diff --git a/package.json b/package.json index 42f8b96655..53c6a693d2 100644 --- a/package.json +++ b/package.json @@ -49,8 +49,9 @@ "rebuild:node": "sumi rebuild", "start": "yarn run rebuild:node && cross-env HOST=127.0.0.1 WS_PATH=ws://127.0.0.1:8000 NODE_ENV=development tsx ./scripts/start", "start:e2e": "yarn start --script=start:e2e", - "start:electron": "cross-env NODE_ENV=development tsx ./scripts/start-electron", - "start:lite": "cross-env NODE_ENV=development tsx ./scripts/start --script=start:lite", + "start:electron": "cross-env NODE_ENV=development tsx ./scripts/start-electron", + "start:lite": "cross-env NODE_ENV=development tsx ./scripts/start --script=start:lite", + "start:client": "cross-env HOST=127.0.0.1 WS_PATH=ws://127.0.0.1:7001 NODE_ENV=development tsx ./scripts/start --script=start:client", "start:pty-service": "KTLOG_SHOW_DEBUG=1 npx tsx packages/terminal-next/src/node/pty.proxy.remote.exec.ts", "start:remote": "yarn run rebuild:node && cross-env NODE_ENV=development tsx ./scripts/start", "test": "node --expose-gc ./node_modules/.bin/jest --forceExit --detectOpenHandles", diff --git a/packages/addons/src/browser/file-search.contribution.ts b/packages/addons/src/browser/file-search.contribution.ts index 102e7c5497..ffa6254049 100644 --- a/packages/addons/src/browser/file-search.contribution.ts +++ b/packages/addons/src/browser/file-search.contribution.ts @@ -366,6 +366,7 @@ export class FileSearchQuickCommandHandler { useGitIgnore: true, noIgnoreParent: true, excludePatterns: this.getPreferenceSearchExcludes(), + followSymlinks: this.preferenceService.get('search.followSymlinks') ?? true, }, token, ); diff --git a/packages/ai-native/__test__/browser/acp/acp-permission-rpc.service.test.ts b/packages/ai-native/__test__/browser/acp/acp-permission-rpc.service.test.ts new file mode 100644 index 0000000000..889e7da744 --- /dev/null +++ b/packages/ai-native/__test__/browser/acp/acp-permission-rpc.service.test.ts @@ -0,0 +1,94 @@ +import { AcpPermissionRpcService } from '../../../lib/browser/acp/acp-permission-rpc.service'; +import { AcpPermissionBridgeService } from '../../../lib/browser/acp/permission-bridge.service'; + +// Mock dependencies +const mockBridgeService = { + showPermissionDialog: jest.fn(), + handleUserDecision: jest.fn(), + handleDialogClose: jest.fn(), + cancelRequest: jest.fn(), + onDidRequestPermission: jest.fn(), + onDidReceivePermissionResult: jest.fn(), + getActiveDialogCount: jest.fn(), + getActiveDialogs: jest.fn(), +}; + +const mockLogger = { + log: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + verbose: jest.fn(), + warn: jest.fn(), +}; + +describe('AcpPermissionRpcService', () => { + let service: AcpPermissionRpcService; + + beforeEach(() => { + jest.clearAllMocks(); + + service = new AcpPermissionRpcService(); + Object.defineProperty(service, 'permissionBridgeService', { value: mockBridgeService, writable: true }); + Object.defineProperty(service, 'logger', { value: mockLogger, writable: true }); + }); + + describe('$showPermissionDialog()', () => { + it('should forward params to bridge service and return decision', async () => { + const params = { + requestId: 'req-001', + title: 'Test title', + kind: 'write', + content: 'Test content', + locations: [{ path: '/workspace/file.txt', line: 10 }], + command: undefined, + options: [{ optionId: 'opt-1', name: 'Allow', kind: 'allow_once' }], + timeout: 30000, + }; + + mockBridgeService.showPermissionDialog.mockResolvedValue({ + type: 'allow', + optionId: 'opt-1', + always: false, + }); + + const result = await service.$showPermissionDialog(params); + + expect(mockBridgeService.showPermissionDialog).toHaveBeenCalledWith({ + requestId: 'req-001', + title: 'Test title', + kind: 'write', + content: 'Test content', + locations: [{ path: '/workspace/file.txt', line: 10 }], + command: undefined, + options: [{ optionId: 'opt-1', name: 'Allow', kind: 'allow_once' }], + timeout: 30000, + }); + expect(result).toEqual({ type: 'allow', optionId: 'opt-1', always: false }); + }); + + it('should return cancelled on error', async () => { + const params = { + requestId: 'req-002', + title: 'Test title', + kind: 'write', + content: 'Test content', + options: [], + timeout: 30000, + }; + + mockBridgeService.showPermissionDialog.mockRejectedValue(new Error('Bridge error')); + + const result = await service.$showPermissionDialog(params); + + expect(result).toEqual({ type: 'cancelled' }); + }); + }); + + describe('$cancelRequest()', () => { + it('should forward cancel request to bridge service', async () => { + await service.$cancelRequest('req-001'); + + expect(mockBridgeService.cancelRequest).toHaveBeenCalledWith('req-001'); + }); + }); +}); diff --git a/packages/ai-native/__test__/browser/acp/permission-bridge.service.test.ts b/packages/ai-native/__test__/browser/acp/permission-bridge.service.test.ts new file mode 100644 index 0000000000..42e9fc449c --- /dev/null +++ b/packages/ai-native/__test__/browser/acp/permission-bridge.service.test.ts @@ -0,0 +1,196 @@ +import { Emitter } from '@opensumi/ide-core-common'; + +import { + AcpPermissionBridgeService, + ShowPermissionDialogParams, +} from '../../../lib/browser/acp/permission-bridge.service'; + +// Mock @opensumi/di to make decorators no-ops +jest.mock('@opensumi/di', () => { + const actual = jest.requireActual('@opensumi/di'); + const noopDecorator = () => () => {}; + return { + ...actual, + Injectable: () => (cls: any) => cls, + Autowired: noopDecorator, + Inject: noopDecorator, + Optional: noopDecorator, + }; +}); + +// Mock dependencies +const mockLogger = { + log: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + verbose: jest.fn(), + warn: jest.fn(), +}; + +const mockMainLayoutService = {}; + +describe('AcpPermissionBridgeService', () => { + let service: AcpPermissionBridgeService; + + const mockParams: ShowPermissionDialogParams = { + requestId: 'req-001', + title: 'Test permission', + kind: 'write', + content: 'Edit file.txt', + locations: [{ path: '/workspace/file.txt' }], + options: [ + { optionId: 'allow_once', name: 'Allow Once', kind: 'allow_once' }, + { optionId: 'reject_once', name: 'Reject', kind: 'reject_once' }, + ], + timeout: 5000, + }; + + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + + service = new AcpPermissionBridgeService(); + Object.defineProperty(service, 'logger', { value: mockLogger, writable: true }); + Object.defineProperty(service, 'mainLayoutService', { value: mockMainLayoutService, writable: true }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('showPermissionDialog()', () => { + it('should return cancelled if dialog already exists for requestId', async () => { + const promise1 = service.showPermissionDialog(mockParams); + const promise2 = service.showPermissionDialog(mockParams); + + expect(await promise2).toEqual({ type: 'cancelled' }); + }); + + it('should fire onDidRequestPermission event', async () => { + const receivedParams: ShowPermissionDialogParams[] = []; + service.onDidRequestPermission((params) => receivedParams.push(params)); + + service.showPermissionDialog(mockParams); + + expect(receivedParams).toHaveLength(1); + expect(receivedParams[0].requestId).toBe('req-001'); + }); + + it('should resolve with allow when user decides allow_once', async () => { + const promise = service.showPermissionDialog(mockParams); + + service.handleUserDecision('req-001', 'allow_once', 'allow_once'); + + const result = await promise; + expect(result).toEqual({ + type: 'allow', + optionId: 'allow_once', + always: false, + }); + }); + + it('should resolve with reject when user decides reject_once', async () => { + const promise = service.showPermissionDialog(mockParams); + + service.handleUserDecision('req-001', 'reject_once', 'reject_once'); + + const result = await promise; + expect(result).toEqual({ + type: 'reject', + optionId: 'reject_once', + always: false, + }); + }); + + it('should resolve with allow and always=true for allow_always', async () => { + const promise = service.showPermissionDialog(mockParams); + + service.handleUserDecision('req-001', 'allow_always', 'allow_always'); + + const result = await promise; + expect(result.type).toBe('allow'); + expect(result.always).toBe(true); + }); + + it('should fire onDidReceivePermissionResult on user decision', async () => { + const results: any[] = []; + service.onDidReceivePermissionResult((result) => results.push(result)); + + const promise = service.showPermissionDialog(mockParams); + service.handleUserDecision('req-001', 'allow_once', 'allow_once'); + await promise; + + expect(results).toHaveLength(1); + expect(results[0].requestId).toBe('req-001'); + expect(results[0].decision.type).toBe('allow'); + }); + }); + + describe('handleDialogClose()', () => { + it('should resolve with timeout when dialog closes', async () => { + const promise = service.showPermissionDialog(mockParams); + + service.handleDialogClose('req-001'); + + const result = await promise; + expect(result).toEqual({ type: 'timeout' }); + }); + + it('should do nothing when no pending decision', () => { + // Should not throw + service.handleDialogClose('non-existent-id'); + }); + + it('should fire onDidReceivePermissionResult with timeout decision', async () => { + const results: any[] = []; + service.onDidReceivePermissionResult((result) => results.push(result)); + + const promise = service.showPermissionDialog(mockParams); + service.handleDialogClose('req-001'); + await promise; + + expect(results).toHaveLength(1); + expect(results[0].decision.type).toBe('timeout'); + }); + }); + + describe('cancelRequest()', () => { + it('should resolve with timeout (same as handleDialogClose)', async () => { + const promise = service.showPermissionDialog(mockParams); + + service.cancelRequest('req-001'); + + const result = await promise; + expect(result).toEqual({ type: 'timeout' }); + }); + }); + + describe('getActiveDialogCount()', () => { + it('should return 0 initially', () => { + expect(service.getActiveDialogCount()).toBe(0); + }); + + it('should return correct count with active dialogs', () => { + service.showPermissionDialog(mockParams); + expect(service.getActiveDialogCount()).toBe(1); + + service.handleUserDecision('req-001', 'allow_once', 'allow_once'); + expect(service.getActiveDialogCount()).toBe(0); + }); + }); + + describe('getActiveDialogs()', () => { + it('should return empty array initially', () => { + expect(service.getActiveDialogs()).toEqual([]); + }); + + it('should return active dialog props', () => { + service.showPermissionDialog(mockParams); + + const dialogs = service.getActiveDialogs(); + expect(dialogs).toHaveLength(1); + expect(dialogs[0].requestId).toBe('req-001'); + expect(dialogs[0].visible).toBe(true); + }); + }); +}); diff --git a/packages/ai-native/__test__/browser/acp/permission-dialog-container.test.ts b/packages/ai-native/__test__/browser/acp/permission-dialog-container.test.ts new file mode 100644 index 0000000000..5437cee63e --- /dev/null +++ b/packages/ai-native/__test__/browser/acp/permission-dialog-container.test.ts @@ -0,0 +1,264 @@ +import { ShowPermissionDialogParams } from '../../../lib/browser/acp/permission-bridge.service'; +import { getAffectedFileName, getSmartTitle } from '../../../lib/browser/acp/permission-dialog-container'; +import { PermissionDialogManager } from '../../../lib/browser/acp/permission-dialog-container'; + +// Mock @opensumi/di to make decorators no-ops +jest.mock('@opensumi/di', () => { + const actual = jest.requireActual('@opensumi/di'); + const noopDecorator = () => () => {}; + return { + ...actual, + Injectable: () => (cls: any) => cls, + Autowired: noopDecorator, + Inject: noopDecorator, + Optional: noopDecorator, + }; +}); + +describe('getAffectedFileName()', () => { + const baseParams: ShowPermissionDialogParams = { + requestId: 'req-1', + title: 'Test', + kind: 'write', + options: [], + timeout: 5000, + }; + + it('should extract filename from locations path', () => { + const params = { + ...baseParams, + locations: [{ path: '/workspace/src/file.ts' }], + }; + + expect(getAffectedFileName(params)).toBe('file.ts'); + }); + + it('should extract filename with nested path', () => { + const params = { + ...baseParams, + locations: [{ path: '/a/b/c/deep/file.json' }], + }; + + expect(getAffectedFileName(params)).toBe('file.json'); + }); + + it('should fallback to "file" when no locations', () => { + const params = { ...baseParams, locations: undefined }; + + expect(getAffectedFileName(params)).toBe('file'); + }); + + it('should fallback to "file" when locations is empty array', () => { + const params = { ...baseParams, locations: [] }; + + expect(getAffectedFileName(params)).toBe('file'); + }); + + it('should handle path without slashes', () => { + const params = { + ...baseParams, + locations: [{ path: 'filename.txt' }], + }; + + expect(getAffectedFileName(params)).toBe('filename.txt'); + }); +}); + +describe('getSmartTitle()', () => { + const baseParams: ShowPermissionDialogParams = { + requestId: 'req-1', + title: 'Default title', + kind: 'write', + locations: [{ path: '/workspace/src/file.ts' }], + options: [], + timeout: 5000, + }; + + it('should generate edit title for edit kind', () => { + const params = { ...baseParams, kind: 'edit', content: 'some content' }; + + expect(getSmartTitle(params)).toBe('Make this edit to file.ts?'); + }); + + it('should generate edit title for write kind', () => { + const params = { ...baseParams, kind: 'write', content: 'some content' }; + + expect(getSmartTitle(params)).toBe('Make this edit to file.ts?'); + }); + + it('should generate bash command title for execute kind', () => { + const params = { ...baseParams, kind: 'execute' }; + + expect(getSmartTitle(params)).toBe('Allow this bash command?'); + }); + + it('should generate bash command title for bash kind', () => { + const params = { ...baseParams, kind: 'bash' }; + + expect(getSmartTitle(params)).toBe('Allow this bash command?'); + }); + + it('should generate read title for read kind', () => { + const params = { ...baseParams, kind: 'read' }; + + expect(getSmartTitle(params)).toBe('Allow read from file.ts?'); + }); + + it('should fallback to params.title for unknown kind', () => { + const params = { ...baseParams, kind: 'unknown' }; + + expect(getSmartTitle(params)).toBe('Default title'); + }); + + it('should fallback to "Permission Required" when no title and unknown kind', () => { + const params = { ...baseParams, kind: 'unknown', title: '' }; + + expect(getSmartTitle(params)).toBe('Permission Required'); + }); + + it('should handle missing kind', () => { + const params = { ...baseParams, kind: undefined }; + + expect(getSmartTitle(params)).toBe('Default title'); + }); +}); + +describe('PermissionDialogManager', () => { + let manager: PermissionDialogManager; + + const mockParams: ShowPermissionDialogParams = { + requestId: 'req-1', + title: 'Test', + kind: 'write', + options: [], + timeout: 5000, + }; + + beforeEach(() => { + manager = new PermissionDialogManager(); + }); + + describe('addDialog()', () => { + it('should add a new dialog', () => { + manager.addDialog(mockParams); + const dialogs = manager.getDialogs(); + + expect(dialogs).toHaveLength(1); + expect(dialogs[0].requestId).toBe('req-1'); + expect(dialogs[0].params).toBe(mockParams); + }); + + it('should not add duplicate dialogs with same requestId', () => { + manager.addDialog(mockParams); + manager.addDialog(mockParams); + + expect(manager.getDialogs()).toHaveLength(1); + }); + + it('should notify listeners when adding dialog', () => { + const listener = jest.fn(); + manager.subscribe(listener); + + manager.addDialog(mockParams); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith(expect.arrayContaining([expect.objectContaining({ requestId: 'req-1' })])); + }); + }); + + describe('removeDialog()', () => { + it('should remove dialog by requestId', () => { + manager.addDialog(mockParams); + manager.removeDialog('req-1'); + + expect(manager.getDialogs()).toEqual([]); + }); + + it('should do nothing when requestId not found', () => { + manager.addDialog(mockParams); + manager.removeDialog('non-existent'); + + expect(manager.getDialogs()).toHaveLength(1); + }); + + it('should notify listeners when removing dialog', () => { + const listener = jest.fn(); + manager.subscribe(listener); + + manager.addDialog(mockParams); + manager.removeDialog('req-1'); + + expect(listener).toHaveBeenCalledTimes(2); + }); + }); + + describe('clearAll()', () => { + it('should remove all dialogs', () => { + manager.addDialog(mockParams); + manager.addDialog({ ...mockParams, requestId: 'req-2' }); + + manager.clearAll(); + + expect(manager.getDialogs()).toEqual([]); + }); + + it('should notify listeners', () => { + const listener = jest.fn(); + manager.subscribe(listener); + + manager.addDialog(mockParams); + manager.clearAll(); + + expect(listener).toHaveBeenCalledTimes(2); + expect(listener).toHaveBeenLastCalledWith([]); + }); + }); + + describe('getDialogs()', () => { + it('should return a copy of dialogs', () => { + manager.addDialog(mockParams); + const dialogs1 = manager.getDialogs(); + const dialogs2 = manager.getDialogs(); + + expect(dialogs1).toEqual(dialogs2); + expect(dialogs1).not.toBe(dialogs2); // should be a copy + }); + + it('should return empty array when no dialogs', () => { + expect(manager.getDialogs()).toEqual([]); + }); + }); + + describe('subscribe()', () => { + it('should return unsubscribe function', () => { + const unsubscribe = manager.subscribe(jest.fn()); + expect(typeof unsubscribe).toBe('function'); + }); + + it('should stop receiving updates after unsubscribe', () => { + const listener = jest.fn(); + const unsubscribe = manager.subscribe(listener); + + manager.addDialog(mockParams); + expect(listener).toHaveBeenCalledTimes(1); + + unsubscribe(); + + manager.addDialog({ ...mockParams, requestId: 'req-2' }); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('should support multiple subscribers', () => { + const listener1 = jest.fn(); + const listener2 = jest.fn(); + + manager.subscribe(listener1); + manager.subscribe(listener2); + + manager.addDialog(mockParams); + + expect(listener1).toHaveBeenCalledTimes(1); + expect(listener2).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/ai-native/__test__/browser/acp/permission.handler.test.ts b/packages/ai-native/__test__/browser/acp/permission.handler.test.ts new file mode 100644 index 0000000000..0cd9d9e6c9 --- /dev/null +++ b/packages/ai-native/__test__/browser/acp/permission.handler.test.ts @@ -0,0 +1,340 @@ +import { AcpPermissionHandler, PermissionDecision } from '../../../lib/browser/acp/permission.handler'; + +// Mock @opensumi/di to make decorators no-ops +jest.mock('@opensumi/di', () => { + const actual = jest.requireActual('@opensumi/di'); + const noopDecorator = () => () => {}; + return { + ...actual, + Injectable: () => (cls: any) => cls, + Autowired: noopDecorator, + Inject: noopDecorator, + Optional: noopDecorator, + }; +}); + +// Mock dependencies +const mockLogger = { + log: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + verbose: jest.fn(), + warn: jest.fn(), + critical: jest.fn(), + dispose: jest.fn(), + getLevel: jest.fn(), + setLevel: jest.fn(), +}; + +const mockStorage = { + get: jest.fn().mockReturnValue('[]'), + set: jest.fn(), + onUpdate: jest.fn(), + dispose: jest.fn(), +}; + +const mockPreferenceService = { + get: jest.fn(), + set: jest.fn(), + onPreferenceChanged: jest.fn(() => ({ dispose: jest.fn() })), +}; + +const mockStorageProvider = jest.fn().mockResolvedValue(mockStorage); + +// Helper to capture the uuid generated by requestPermission +let capturedRequestId: string | undefined; +const originalUuid = jest.requireActual('@opensumi/ide-core-common').uuid; +let uuidCounter = 0; + +jest.mock('@opensumi/ide-core-common', () => { + const actual = jest.requireActual('@opensumi/ide-core-common'); + return { + ...actual, + uuid: () => { + uuidCounter++; + const id = `test-uuid-${uuidCounter}`; + capturedRequestId = id; + return id; + }, + }; +}); + +describe('AcpPermissionHandler', () => { + let handler: AcpPermissionHandler; + + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + mockStorage.get.mockReturnValue('[]'); + capturedRequestId = undefined; + uuidCounter = 0; + + handler = new AcpPermissionHandler(); + Object.defineProperty(handler, 'logger', { value: mockLogger, writable: true }); + Object.defineProperty(handler, 'storageProvider', { value: mockStorageProvider, writable: true }); + Object.defineProperty(handler, 'preferenceService', { value: mockPreferenceService, writable: true }); + Object.defineProperty(handler, 'permissionStorage', { value: mockStorage, writable: true }); + // Prevent initStorage from overwriting mock storage + Object.defineProperty(handler, 'ensureInitialized', { value: () => {}, writable: true }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('buildPermissionResponse()', () => { + it('should build allow response', () => { + const decision: PermissionDecision = { type: 'allow', optionId: 'opt-1', always: false }; + const result = handler.buildPermissionResponse(decision); + + expect(result).toEqual({ + outcome: { outcome: 'selected', optionId: 'opt-1' }, + }); + }); + + it('should build reject response', () => { + const decision: PermissionDecision = { type: 'reject', optionId: 'opt-2', always: true }; + const result = handler.buildPermissionResponse(decision); + + expect(result).toEqual({ + outcome: { outcome: 'selected', optionId: 'opt-2' }, + }); + }); + + it('should build timeout response', () => { + const decision: PermissionDecision = { type: 'timeout' }; + const result = handler.buildPermissionResponse(decision); + + expect(result).toEqual({ + outcome: { outcome: 'cancelled' }, + }); + }); + + it('should build cancelled response', () => { + const decision: PermissionDecision = { type: 'cancelled' }; + const result = handler.buildPermissionResponse(decision); + + expect(result).toEqual({ + outcome: { outcome: 'cancelled' }, + }); + }); + }); + + describe('handleUserResponse()', () => { + it('should resolve with allow for allow_once', async () => { + const decisionPromise = handler.requestPermission({ + sessionId: 'sess-1', + toolCall: { type: 'tool_call_update', title: 'Test tool', kind: 'write', content: '' } as any, + options: [], + timeout: 5000, + }); + + // Use the captured request ID + handler.handleUserResponse(capturedRequestId!, 'allow_once', 'allow_once'); + + jest.advanceTimersByTime(0); + + const result = await decisionPromise; + expect(result).toEqual({ + type: 'allow', + optionId: 'allow_once', + always: false, + }); + }); + + it('should resolve with reject for reject_once', async () => { + const decisionPromise = handler.requestPermission({ + sessionId: 'sess-1', + toolCall: { type: 'tool_call_update', title: 'Test tool', kind: 'write', content: '' } as any, + options: [], + timeout: 5000, + }); + + handler.handleUserResponse(capturedRequestId!, 'reject_once', 'reject_once'); + + jest.advanceTimersByTime(0); + + const result = await decisionPromise; + expect(result).toEqual({ + type: 'reject', + optionId: 'reject_once', + always: false, + }); + }); + + it('should resolve with allow and always=true for allow_always', async () => { + const decisionPromise = handler.requestPermission({ + sessionId: 'sess-1', + toolCall: { type: 'tool_call_update', title: 'Test tool', kind: 'write', content: '' } as any, + options: [], + timeout: 5000, + }); + + handler.handleUserResponse(capturedRequestId!, 'allow_always', 'allow_always'); + + jest.advanceTimersByTime(0); + + const result = await decisionPromise; + expect(result.type).toBe('allow'); + expect(result.always).toBe(true); + }); + + it('should resolve with reject and always=true for reject_always', async () => { + const decisionPromise = handler.requestPermission({ + sessionId: 'sess-1', + toolCall: { type: 'tool_call_update', title: 'Test tool', kind: 'write', content: '' } as any, + options: [], + timeout: 5000, + }); + + handler.handleUserResponse(capturedRequestId!, 'reject_always', 'reject_always'); + + jest.advanceTimersByTime(0); + + const result = await decisionPromise; + expect(result.type).toBe('reject'); + expect(result.always).toBe(true); + }); + + it('should warn when requestId not found', () => { + handler.handleUserResponse('non-existent-id', 'allow_once', 'allow_once'); + + expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('not found')); + }); + + it('should clear timeout when response is handled', async () => { + const decisionPromise = handler.requestPermission({ + sessionId: 'sess-1', + toolCall: { type: 'tool_call_update', title: 'Test tool', kind: 'write', content: '' } as any, + options: [], + timeout: 5000, + }); + + handler.handleUserResponse(capturedRequestId!, 'allow_once', 'allow_once'); + jest.advanceTimersByTime(0); + + await decisionPromise; + + // Advance past the timeout - should not cause issues since timeout was cleared + jest.advanceTimersByTime(6000); + }); + + it('should save rule when always option is chosen', async () => { + const decisionPromise = handler.requestPermission({ + sessionId: 'sess-1', + toolCall: { type: 'tool_call_update', title: 'Test tool', kind: 'write', content: '' } as any, + options: [], + timeout: 5000, + }); + + handler.handleUserResponse(capturedRequestId!, 'allow_always', 'allow_always'); + + jest.advanceTimersByTime(0); + + await decisionPromise; + + expect(mockStorage.set).toHaveBeenCalledWith('acp.permission.rules', expect.stringContaining('allow')); + }); + }); + + describe('cancelRequest()', () => { + it('should resolve with cancelled when request exists', async () => { + const decisionPromise = handler.requestPermission({ + sessionId: 'sess-1', + toolCall: { type: 'tool_call_update', title: 'Test tool', kind: 'write', content: '' } as any, + options: [], + timeout: 5000, + }); + + handler.cancelRequest(capturedRequestId!); + jest.advanceTimersByTime(0); + + const result = await decisionPromise; + expect(result).toEqual({ type: 'cancelled' }); + }); + + it('should do nothing when requestId not found', () => { + handler.cancelRequest('non-existent-id'); + // No error should be thrown + }); + }); + + describe('getRules()', () => { + it('should return a copy of rules', () => { + const rules1 = handler.getRules(); + const rules2 = handler.getRules(); + expect(rules1).toEqual(rules2); + expect(rules1).not.toBe(rules2); // should be a copy + }); + }); + + describe('clearRules()', () => { + it('should clear all rules and save', () => { + handler.clearRules(); + + expect(mockStorage.set).toHaveBeenCalledWith('acp.permission.rules', '[]'); + }); + }); + + describe('removeRule()', () => { + it('should remove a rule by id', async () => { + const decisionPromise = handler.requestPermission({ + sessionId: 'sess-1', + toolCall: { type: 'tool_call_update', title: 'Test tool', kind: 'write', content: '' } as any, + options: [], + timeout: 5000, + }); + handler.handleUserResponse(capturedRequestId!, 'allow_always', 'allow_always'); + + jest.advanceTimersByTime(0); + await decisionPromise; + + const rules = handler.getRules(); + expect(rules.length).toBeGreaterThan(0); + + const ruleId = rules[0].id; + handler.removeRule(ruleId); + + expect(handler.getRules()).toEqual([]); + }); + + it('should do nothing when rule id not found', () => { + handler.removeRule('non-existent-rule-id'); + expect(handler.getRules()).toEqual([]); + }); + }); + + describe('auditLog()', () => { + it('should log audit event', () => { + handler.auditLog('request', { + requestId: 'req-1', + sessionId: 'sess-1', + toolKind: 'write', + toolTitle: 'Test tool', + }); + + expect(mockLogger.log).toHaveBeenCalledWith( + expect.stringContaining('[ACP Permission Audit '), + expect.any(Object), + ); + }); + + it('should log decision event with decision and reason', () => { + handler.auditLog('decision', { + requestId: 'req-1', + sessionId: 'sess-1', + decision: 'allow', + reason: 'User approved', + }); + + expect(mockLogger.log).toHaveBeenCalledWith( + expect.stringContaining('decision'), + expect.objectContaining({ + requestId: 'req-1', + decision: 'allow', + reason: 'User approved', + }), + ); + }); + }); +}); diff --git a/packages/ai-native/__test__/browser/chat/chat-model.test.ts b/packages/ai-native/__test__/browser/chat/chat-model.test.ts index 332b8121b9..f9f1ea9875 100644 --- a/packages/ai-native/__test__/browser/chat/chat-model.test.ts +++ b/packages/ai-native/__test__/browser/chat/chat-model.test.ts @@ -219,7 +219,7 @@ describe('ChatSlashCommandItemModel', () => { const AI_SLASH = '/'; const expectedNameWithSlash = chatCommand.name.startsWith(AI_SLASH) ? chatCommand.name - : `${AI_SLASH} ${chatCommand.name}`; + : `${AI_SLASH}${chatCommand.name}`; expect(chatSlashCommandItemModel.nameWithSlash).toBe(expectedNameWithSlash); }); }); diff --git a/packages/ai-native/__test__/node/acp-agent-request-handler.test.ts b/packages/ai-native/__test__/node/acp-agent-request-handler.test.ts new file mode 100644 index 0000000000..7e22029315 --- /dev/null +++ b/packages/ai-native/__test__/node/acp-agent-request-handler.test.ts @@ -0,0 +1,392 @@ +jest.mock('@opensumi/di', () => { + const actual = jest.requireActual('@opensumi/di'); + const noopDecorator = () => () => {}; + return { + ...actual, + Injectable: () => (cls: any) => cls, + Autowired: noopDecorator, + Inject: noopDecorator, + Optional: noopDecorator, + }; +}); + +import { AcpAgentRequestHandler, AcpAgentRequestHandlerToken } from '../../src/node/acp/handlers/agent-request.handler'; + +const mockLogger = { + log: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + verbose: jest.fn(), + warn: jest.fn(), + critical: jest.fn(), + dispose: jest.fn(), + getLevel: jest.fn(), + setLevel: jest.fn(), +}; + +const mockFileSystemHandler = { + readTextFile: jest.fn(), + writeTextFile: jest.fn(), + getFileMeta: jest.fn(), + listDirectory: jest.fn(), + createDirectory: jest.fn(), +}; + +const mockTerminalHandler = { + createTerminal: jest.fn(), + getTerminalOutput: jest.fn(), + waitForTerminalExit: jest.fn(), + killTerminal: jest.fn(), + releaseTerminal: jest.fn(), + releaseSessionTerminals: jest.fn(), +}; + +const mockPermissionCaller = { + requestPermission: jest.fn(), + cancelRequest: jest.fn(), +}; + +describe('AcpAgentRequestHandler', () => { + let handler: AcpAgentRequestHandler; + + beforeEach(() => { + jest.clearAllMocks(); + + handler = new AcpAgentRequestHandler(); + Object.defineProperty(handler, 'logger', { value: mockLogger, writable: true }); + Object.defineProperty(handler, 'fileSystemHandler', { value: mockFileSystemHandler, writable: true }); + Object.defineProperty(handler, 'terminalHandler', { value: mockTerminalHandler, writable: true }); + Object.defineProperty(handler, 'permissionCaller', { value: mockPermissionCaller, writable: true }); + }); + + describe('initialize()', () => { + it('should set initialized flag', () => { + handler.initialize(); + + expect((handler as any).initialized).toBe(true); + }); + + it('should be idempotent', () => { + handler.initialize(); + handler.initialize(); + + expect((handler as any).initialized).toBe(true); + }); + }); + + describe('handlePermissionRequest()', () => { + it('should delegate to permissionCaller and return response', async () => { + const expected = { outcome: { outcome: 'selected', optionId: 'allow_once' } }; + mockPermissionCaller.requestPermission.mockResolvedValue(expected); + + const result = await handler.handlePermissionRequest({ + sessionId: 'sess-1', + toolCall: { toolCallId: 'tc-1', title: 'Test', kind: 'read', status: 'pending' } as any, + options: [{ optionId: 'allow_once', name: 'Allow', kind: 'allow_once' as const }], + }); + + expect(result).toBe(expected); + expect(mockPermissionCaller.requestPermission).toHaveBeenCalled(); + }); + + it('should return cancelled on error', async () => { + mockPermissionCaller.requestPermission.mockRejectedValue(new Error('RPC failed')); + + const result = await handler.handlePermissionRequest({ + sessionId: 'sess-1', + toolCall: { toolCallId: 'tc-1', title: 'Test', kind: 'read', status: 'pending' } as any, + options: [], + }); + + expect(result.outcome.outcome).toBe('cancelled'); + expect(mockLogger.error).toHaveBeenCalled(); + }); + }); + + describe('handleReadTextFile()', () => { + it('should delegate to fileSystemHandler and return content', async () => { + mockFileSystemHandler.readTextFile.mockResolvedValue({ content: 'Hello World' }); + + const result = await handler.handleReadTextFile({ + sessionId: 'sess-1', + path: 'test.txt', + }); + + expect(result.content).toBe('Hello World'); + expect(mockFileSystemHandler.readTextFile).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'sess-1', + path: 'test.txt', + }), + ); + }); + + it('should pass through line and limit params', async () => { + mockFileSystemHandler.readTextFile.mockResolvedValue({ content: 'line1' }); + + await handler.handleReadTextFile({ + sessionId: 'sess-1', + path: 'test.txt', + line: 5, + limit: 10, + }); + + expect(mockFileSystemHandler.readTextFile).toHaveBeenCalledWith(expect.objectContaining({ line: 5, limit: 10 })); + }); + + it('should throw error when file read fails', async () => { + mockFileSystemHandler.readTextFile.mockResolvedValue({ + error: { code: -32000, message: 'File not found' }, + }); + + await expect(handler.handleReadTextFile({ sessionId: 'sess-1', path: 'nonexistent.txt' })).rejects.toThrow( + 'File not found', + ); + }); + }); + + describe('handleWriteTextFile()', () => { + it('should check permission before writing', async () => { + mockPermissionCaller.requestPermission.mockResolvedValue({ + outcome: { outcome: 'selected', optionId: 'allow_once' }, + }); + mockFileSystemHandler.writeTextFile.mockResolvedValue({}); + + const result = await handler.handleWriteTextFile({ + sessionId: 'sess-1', + path: 'test.txt', + content: 'Hello', + }); + + expect(result).toEqual({}); + expect(mockPermissionCaller.requestPermission).toHaveBeenCalledWith( + expect.objectContaining({ + toolCall: expect.objectContaining({ + title: expect.stringContaining('Write file'), + kind: 'write', + }), + }), + ); + }); + + it('should deny when permission rejected', async () => { + mockPermissionCaller.requestPermission.mockResolvedValue({ + outcome: { outcome: 'selected', optionId: 'reject_once' }, + }); + + await expect( + handler.handleWriteTextFile({ + sessionId: 'sess-1', + path: 'test.txt', + content: 'Hello', + }), + ).rejects.toThrow('Write permission denied'); + }); + + it('should throw when write fails', async () => { + mockPermissionCaller.requestPermission.mockResolvedValue({ + outcome: { outcome: 'selected', optionId: 'allow_once' }, + }); + mockFileSystemHandler.writeTextFile.mockResolvedValue({ + error: { code: -32000, message: 'Disk full' }, + }); + + await expect( + handler.handleWriteTextFile({ sessionId: 'sess-1', path: 'test.txt', content: 'Hello' }), + ).rejects.toThrow('Disk full'); + }); + }); + + describe('handleCreateTerminal()', () => { + it('should check permission before creating', async () => { + mockPermissionCaller.requestPermission.mockResolvedValue({ + outcome: { outcome: 'selected', optionId: 'allow_once' }, + }); + mockTerminalHandler.createTerminal.mockResolvedValue({ terminalId: 'term-1' }); + + const result = await handler.handleCreateTerminal({ + sessionId: 'sess-1', + command: 'bash', + args: ['-c', 'ls'], + }); + + expect(result.terminalId).toBe('term-1'); + expect(mockPermissionCaller.requestPermission).toHaveBeenCalledWith( + expect.objectContaining({ + toolCall: expect.objectContaining({ + title: expect.stringContaining('Run command'), + }), + }), + ); + }); + + it('should pass env and cwd to terminal handler', async () => { + mockPermissionCaller.requestPermission.mockResolvedValue({ + outcome: { outcome: 'selected', optionId: 'allow_once' }, + }); + mockTerminalHandler.createTerminal.mockResolvedValue({ terminalId: 'term-1' }); + + await handler.handleCreateTerminal({ + sessionId: 'sess-1', + command: 'bash', + args: ['-c', 'echo $MY_VAR'], + env: [{ name: 'MY_VAR', value: 'hello' }], + cwd: '/custom', + }); + + expect(mockTerminalHandler.createTerminal).toHaveBeenCalledWith( + expect.objectContaining({ + env: { MY_VAR: 'hello' }, + cwd: '/custom', + }), + ); + }); + + it('should deny when permission rejected', async () => { + mockPermissionCaller.requestPermission.mockResolvedValue({ + outcome: { outcome: 'cancelled' }, + }); + + await expect( + handler.handleCreateTerminal({ sessionId: 'sess-1', command: 'rm', args: ['-rf', '/'] }), + ).rejects.toThrow('permission denied'); + }); + + it('should throw when terminal creation fails', async () => { + mockPermissionCaller.requestPermission.mockResolvedValue({ + outcome: { outcome: 'selected', optionId: 'allow_once' }, + }); + mockTerminalHandler.createTerminal.mockResolvedValue({ + error: { code: -32000, message: 'Shell not found' }, + }); + + await expect(handler.handleCreateTerminal({ sessionId: 'sess-1', command: 'nonexistent' })).rejects.toThrow( + 'Shell not found', + ); + }); + }); + + describe('handleTerminalOutput()', () => { + it('should delegate to terminalHandler', async () => { + mockTerminalHandler.getTerminalOutput.mockResolvedValue({ + output: 'hello\nworld', + truncated: false, + exitStatus: null, + }); + + const result = await handler.handleTerminalOutput({ + sessionId: 'sess-1', + terminalId: 'term-1', + }); + + expect(result.output).toBe('hello\nworld'); + expect(result.truncated).toBe(false); + expect(result.exitStatus).toBe(undefined); + }); + + it('should map exitStatus from exitStatus field', async () => { + mockTerminalHandler.getTerminalOutput.mockResolvedValue({ + output: 'done', + exitStatus: 0, + }); + + const result = await handler.handleTerminalOutput({ + sessionId: 'sess-1', + terminalId: 'term-1', + }); + + expect(result.exitStatus).toEqual({ exitCode: 0 }); + }); + + it('should throw when handler returns error', async () => { + mockTerminalHandler.getTerminalOutput.mockResolvedValue({ + error: { code: -32002, message: 'Terminal not found' }, + }); + + await expect(handler.handleTerminalOutput({ sessionId: 'sess-1', terminalId: 'unknown' })).rejects.toThrow( + 'Terminal not found', + ); + }); + }); + + describe('handleWaitForTerminalExit()', () => { + it('should delegate to terminalHandler', async () => { + mockTerminalHandler.waitForTerminalExit.mockResolvedValue({ + exitCode: 0, + signal: null, + }); + + const result = await handler.handleWaitForTerminalExit({ + sessionId: 'sess-1', + terminalId: 'term-1', + }); + + expect(result.exitCode).toBe(0); + expect(result.signal).toBe(null); + }); + + it('should throw when handler returns error', async () => { + mockTerminalHandler.waitForTerminalExit.mockResolvedValue({ + error: { code: -32002, message: 'Terminal not found' }, + }); + + await expect(handler.handleWaitForTerminalExit({ sessionId: 'sess-1', terminalId: 'unknown' })).rejects.toThrow( + 'Terminal not found', + ); + }); + }); + + describe('handleKillTerminal()', () => { + it('should delegate to terminalHandler', async () => { + mockTerminalHandler.killTerminal.mockResolvedValue({ exitCode: -1 }); + + const result = await handler.handleKillTerminal({ + sessionId: 'sess-1', + terminalId: 'term-1', + }); + + expect(result).toEqual({}); + }); + + it('should throw when handler returns error', async () => { + mockTerminalHandler.killTerminal.mockResolvedValue({ + error: { code: -32002, message: 'Terminal not found' }, + }); + + await expect(handler.handleKillTerminal({ sessionId: 'sess-1', terminalId: 'unknown' })).rejects.toThrow( + 'Terminal not found', + ); + }); + }); + + describe('handleReleaseTerminal()', () => { + it('should delegate to terminalHandler', async () => { + mockTerminalHandler.releaseTerminal.mockResolvedValue({}); + + const result = await handler.handleReleaseTerminal({ + sessionId: 'sess-1', + terminalId: 'term-1', + }); + + expect(result).toEqual({}); + }); + + it('should throw when handler returns error', async () => { + mockTerminalHandler.releaseTerminal.mockResolvedValue({ + error: { code: -32002, message: 'Terminal not found' }, + }); + + await expect(handler.handleReleaseTerminal({ sessionId: 'sess-1', terminalId: 'unknown' })).rejects.toThrow( + 'Terminal not found', + ); + }); + }); + + describe('disposeSession()', () => { + it('should release all session terminals', async () => { + await handler.disposeSession('sess-1'); + + expect(mockTerminalHandler.releaseSessionTerminals).toHaveBeenCalledWith('sess-1'); + }); + }); +}); diff --git a/packages/ai-native/__test__/node/acp-agent.service.test.ts b/packages/ai-native/__test__/node/acp-agent.service.test.ts new file mode 100644 index 0000000000..d5fb5f37b6 --- /dev/null +++ b/packages/ai-native/__test__/node/acp-agent.service.test.ts @@ -0,0 +1,469 @@ +jest.mock('@opensumi/di', () => { + const actual = jest.requireActual('@opensumi/di'); + const noopDecorator = () => () => {}; + return { + ...actual, + Injectable: () => (cls: any) => cls, + Autowired: noopDecorator, + Inject: noopDecorator, + Optional: noopDecorator, + }; +}); + +import { AgentProcessConfig } from '@opensumi/ide-core-common'; +import { INodeLogger } from '@opensumi/ide-core-node'; + +import { AcpAgentService, AcpAgentServiceToken } from '../../src/node/acp/acp-agent.service'; +import { AcpTerminalHandler, AcpTerminalHandlerToken } from '../../src/node/acp/handlers/terminal.handler'; + +// Mock dependencies +const mockCliClientService = { + setTransport: jest.fn(), + initialize: jest.fn().mockResolvedValue(undefined), + newSession: jest.fn().mockResolvedValue({ + sessionId: 'test-session-123', + modes: { availableModes: [{ id: 'code', name: 'Code' }] }, + }), + loadSession: jest.fn().mockResolvedValue({}), + prompt: jest.fn().mockResolvedValue(undefined), + cancel: jest.fn(), + close: jest.fn().mockResolvedValue(undefined), + onNotification: jest.fn(() => jest.fn()) as any, + onDisconnect: jest.fn(() => jest.fn()), + listSessions: jest.fn(), + setSessionMode: jest.fn(), + getSessionModes: jest.fn(), +}; + +const mockProcessManager = { + startAgent: jest.fn().mockResolvedValue({ processId: 'proc-1', stdout: {} as any, stdin: {} as any }), + stopAgent: jest.fn().mockResolvedValue(undefined), + killAgent: jest.fn().mockResolvedValue(undefined), + killAllAgents: jest.fn().mockResolvedValue(undefined), + isRunning: jest.fn(), + getExitCode: jest.fn(), + listRunningAgents: jest.fn(), +}; + +const mockTerminalHandler = { + releaseSessionTerminals: jest.fn().mockResolvedValue(undefined), +}; + +const mockLogger: INodeLogger = { + log: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + verbose: jest.fn(), + warn: jest.fn(), + critical: jest.fn(), + dispose: jest.fn(), + getLevel: jest.fn(), + setLevel: jest.fn(), +} as unknown as INodeLogger; + +const mockAppConfig = {}; + +const mockAgentProcessConfig: AgentProcessConfig = { + command: 'npx', + args: ['@anthropic-ai/claude-code@latest'], + workspaceDir: '/test/workspace', +}; + +function createService(): AcpAgentService { + const service = new AcpAgentService(); + Object.defineProperty(service, 'clientService', { value: mockCliClientService, writable: true }); + Object.defineProperty(service, 'processManager', { value: mockProcessManager, writable: true }); + Object.defineProperty(service, 'terminalHandler', { value: mockTerminalHandler, writable: true }); + Object.defineProperty(service, 'appConfig', { value: mockAppConfig, writable: true }); + Object.defineProperty(service, 'logger', { value: mockLogger, writable: true }); + return service; +} + +beforeEach(() => { + jest.clearAllMocks(); + jest.useRealTimers(); +}); + +describe('AcpAgentService', () => { + describe('getSessionInfo()', () => { + it('should return null initially', () => { + const service = createService(); + expect(service.getSessionInfo()).toBeNull(); + }); + + it('should return session info after initializeAgent', async () => { + const service = createService(); + await service.initializeAgent(mockAgentProcessConfig); + const info = service.getSessionInfo(); + expect(info).not.toBeNull(); + expect(info?.sessionId).toBe('test-session-123'); + expect(info?.processId).toBe('proc-1'); + expect(info?.status).toBe('ready'); + }); + }); + + describe('initializeAgent()', () => { + it('should connect process, create session, and store sessionInfo', async () => { + const service = createService(); + const result = await service.initializeAgent(mockAgentProcessConfig); + + expect(mockProcessManager.startAgent).toHaveBeenCalledWith( + 'npx', + ['@anthropic-ai/claude-code@latest'], + {}, + '/test/workspace', + ); + expect(mockCliClientService.setTransport).toHaveBeenCalled(); + expect(mockCliClientService.initialize).toHaveBeenCalled(); + expect(mockCliClientService.newSession).toHaveBeenCalledWith({ + cwd: '/test/workspace', + mcpServers: [], + }); + expect(result.sessionId).toBe('test-session-123'); + expect(result.status).toBe('ready'); + }); + + it('should return cached sessionInfo if already initialized', async () => { + const service = createService(); + const first = await service.initializeAgent(mockAgentProcessConfig); + const second = await service.initializeAgent(mockAgentProcessConfig); + + expect(first).toBe(second); + expect(mockProcessManager.startAgent).toHaveBeenCalledTimes(1); + expect(mockCliClientService.newSession).toHaveBeenCalledTimes(1); + }); + }); + + describe('sendMessage()', () => { + it('should return stream with error if not initialized', () => { + const service = createService(); + const stream = service.sendMessage({ prompt: 'hello', sessionId: 'sess-1' }); + + const errors: Error[] = []; + stream.onError((e) => errors.push(e)); + + expect(errors.length).toBe(1); + expect(errors[0].message).toBe('Agent process not initialized'); + }); + + it('should build prompt blocks with text and send prompt', async () => { + const service = createService(); + await service.initializeAgent(mockAgentProcessConfig); + + service.sendMessage({ prompt: 'Hello world', sessionId: 'test-session-123' }); + + expect(mockCliClientService.prompt).toHaveBeenCalledWith({ + sessionId: 'test-session-123', + prompt: [{ type: 'text', text: 'Hello world' }], + }); + }); + + it('should handle agent_thought_chunk as thought', async () => { + const service = createService(); + await service.initializeAgent(mockAgentProcessConfig); + + let notificationHandler: any; + mockCliClientService.onNotification.mockImplementation((handler: any) => { + notificationHandler = handler; + return jest.fn(); + }); + + const updates: any[] = []; + const stream = service.sendMessage({ prompt: 'Hello', sessionId: 'test-session-123' }); + stream.onData((data) => updates.push(data)); + + notificationHandler({ + sessionId: 'test-session-123', + update: { + sessionUpdate: 'agent_thought_chunk', + content: { type: 'text', text: 'I am thinking...' }, + }, + }); + + expect(updates).toContainEqual({ type: 'thought', content: 'I am thinking...' }); + }); + + it('should handle agent_message_chunk as message', async () => { + const service = createService(); + await service.initializeAgent(mockAgentProcessConfig); + + let notificationHandler: any; + mockCliClientService.onNotification.mockImplementation((handler: any) => { + notificationHandler = handler; + return jest.fn(); + }); + + const updates: any[] = []; + const stream = service.sendMessage({ prompt: 'Hello', sessionId: 'test-session-123' }); + stream.onData((data) => updates.push(data)); + + notificationHandler({ + sessionId: 'test-session-123', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Here is my answer.' }, + }, + }); + + expect(updates).toContainEqual({ type: 'message', content: 'Here is my answer.' }); + }); + + it('should handle tool_call notifications', async () => { + const service = createService(); + await service.initializeAgent(mockAgentProcessConfig); + + let notificationHandler: any; + mockCliClientService.onNotification.mockImplementation((handler: any) => { + notificationHandler = handler; + return jest.fn(); + }); + + const updates: any[] = []; + const stream = service.sendMessage({ prompt: 'Hello', sessionId: 'test-session-123' }); + stream.onData((data) => updates.push(data)); + + notificationHandler({ + sessionId: 'test-session-123', + update: { + sessionUpdate: 'tool_call', + title: 'ReadFile', + rawInput: { path: '/test/file.ts' }, + }, + }); + + expect(updates).toContainEqual({ + type: 'tool_call', + content: 'ReadFile', + toolCall: { name: 'ReadFile', input: { path: '/test/file.ts' } }, + }); + }); + + it('should handle tool_call_update with diff as tool_result', async () => { + const service = createService(); + await service.initializeAgent(mockAgentProcessConfig); + + let notificationHandler: any; + mockCliClientService.onNotification.mockImplementation((handler: any) => { + notificationHandler = handler; + return jest.fn(); + }); + + const updates: any[] = []; + const stream = service.sendMessage({ prompt: 'Hello', sessionId: 'test-session-123' }); + stream.onData((data) => updates.push(data)); + + notificationHandler({ + sessionId: 'test-session-123', + update: { + sessionUpdate: 'tool_call_update', + content: [{ type: 'diff', path: 'src/index.ts' }], + }, + }); + + expect(updates).toContainEqual({ type: 'tool_result', content: 'Modified src/index.ts' }); + }); + + it('should filter notifications by sessionId', async () => { + const service = createService(); + await service.initializeAgent(mockAgentProcessConfig); + + let notificationHandler: any; + mockCliClientService.onNotification.mockImplementation((handler: any) => { + notificationHandler = handler; + return jest.fn(); + }); + + const updates: any[] = []; + const stream = service.sendMessage({ prompt: 'Hello', sessionId: 'test-session-123' }); + stream.onData((data) => updates.push(data)); + + notificationHandler({ + sessionId: 'other-session', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Should be ignored' }, + }, + }); + + expect(updates).not.toContainEqual({ type: 'message', content: 'Should be ignored' }); + }); + + it('should include images in prompt blocks', async () => { + const service = createService(); + await service.initializeAgent(mockAgentProcessConfig); + + const imageData = 'data:image/png;base64,iVBORw0KGgo='; + service.sendMessage({ prompt: 'Look at this', sessionId: 'test-session-123', images: [imageData] }); + + expect(mockCliClientService.prompt).toHaveBeenCalledWith({ + sessionId: 'test-session-123', + prompt: [ + { type: 'text', text: 'Look at this' }, + { type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' }, + ], + }); + }); + }); + + describe('cancelRequest()', () => { + it('should call clientService.cancel', async () => { + const service = createService(); + await service.initializeAgent(mockAgentProcessConfig); + + await service.cancelRequest('test-session-123'); + + expect(mockCliClientService.cancel).toHaveBeenCalledWith({ sessionId: 'test-session-123' }); + }); + + it('should return early if process not initialized', async () => { + const service = createService(); + await service.cancelRequest('test-session-123'); + + expect(mockCliClientService.cancel).not.toHaveBeenCalled(); + expect(mockLogger.warn).toHaveBeenCalled(); + }); + + it('should swallow errors', async () => { + const service = createService(); + await service.initializeAgent(mockAgentProcessConfig); + + mockCliClientService.cancel.mockRejectedValue(new Error('Cancel failed')); + + await expect(service.cancelRequest('test-session-123')).resolves.toBeUndefined(); + }); + }); + + describe('stopAgent()', () => { + it('should stop process, close client, and clear state', async () => { + const service = createService(); + await service.initializeAgent(mockAgentProcessConfig); + + await service.stopAgent(); + + expect(mockProcessManager.stopAgent).toHaveBeenCalled(); + expect(mockCliClientService.close).toHaveBeenCalled(); + expect(service.getSessionInfo()).toBeNull(); + }); + + it('should be no-op if process not initialized', async () => { + const service = createService(); + await service.stopAgent(); + + expect(mockProcessManager.stopAgent).not.toHaveBeenCalled(); + expect(mockCliClientService.close).not.toHaveBeenCalled(); + }); + }); + + describe('dispose()', () => { + it('should unsubscribe disconnect handler, stop handler, and kill agents', async () => { + const service = createService(); + await service.initializeAgent(mockAgentProcessConfig); + + await service.dispose(); + + expect(mockProcessManager.killAllAgents).toHaveBeenCalled(); + expect(service.getSessionInfo()).toBeNull(); + }); + + it('should be no-op when called twice', async () => { + const service = createService(); + await service.initializeAgent(mockAgentProcessConfig); + + await service.dispose(); + await service.dispose(); + + expect(mockProcessManager.stopAgent).toHaveBeenCalledTimes(1); + }); + }); + + describe('loadSession()', () => { + it('should set sessionInfo after loading', async () => { + const service = createService(); + + mockCliClientService.onNotification.mockReturnValue(jest.fn()); + + await service.loadSession('sess-1', mockAgentProcessConfig); + + const info = service.getSessionInfo(); + expect(info).not.toBeNull(); + expect(info?.sessionId).toBe('sess-1'); + }); + }); + + describe('listSessions()', () => { + it('should delegate to clientService.listSessions', async () => { + const service = createService(); + const expected = { + sessions: [{ sessionId: 's1', cwd: '/test', title: 'Session 1' }], + nextCursor: 'cursor-2', + }; + mockCliClientService.listSessions.mockResolvedValue(expected); + + const result = await service.listSessions({ cwd: '/test' }); + + expect(result).toEqual(expected); + }); + }); + + describe('setSessionMode()', () => { + it('should delegate to clientService.setSessionMode', async () => { + const service = createService(); + + await service.setSessionMode({ sessionId: 'sess-1', modeId: 'code' }); + + expect(mockCliClientService.setSessionMode).toHaveBeenCalledWith({ sessionId: 'sess-1', modeId: 'code' }); + }); + }); + + describe('disposeSession()', () => { + it('should call terminalHandler.releaseSessionTerminals', async () => { + const service = createService(); + + await service.disposeSession('sess-1'); + + expect(mockTerminalHandler.releaseSessionTerminals).toHaveBeenCalledWith('sess-1'); + }); + }); + + describe('getAvailableModes()', () => { + it('should delegate to clientService.getSessionModes', async () => { + const service = createService(); + const expected = { availableModes: [{ id: 'code', name: 'Code' }], defaultModeId: 'code' }; + mockCliClientService.getSessionModes.mockResolvedValue(expected); + + const result = await service.getAvailableModes(); + + expect(result).toEqual(expected); + }); + }); + + describe('parseDataUrl()', () => { + it('should extract mimeType and base64Data from data URLs', () => { + const service = createService(); + const result = (service as any).parseDataUrl('data:image/png;base64,helloWorld'); + expect(result).toEqual({ mimeType: 'image/png', base64Data: 'helloWorld' }); + }); + + it('should return default mimeType for non-data URLs', () => { + const service = createService(); + const result = (service as any).parseDataUrl('not-a-data-url'); + expect(result).toEqual({ mimeType: 'image/jpeg', base64Data: 'not-a-data-url' }); + }); + }); + + describe('disconnect handling', () => { + it('should clear state on disconnect', async () => { + const service = createService(); + await service.initializeAgent(mockAgentProcessConfig); + + const onDisconnectCall = (mockCliClientService.onDisconnect as any).mock.calls[0]; + const disconnectHandler = onDisconnectCall[0]; + + disconnectHandler(); + + expect(service.getSessionInfo()).toBeNull(); + expect(service['currentProcessId']).toBeNull(); + expect(mockLogger.warn).toHaveBeenCalledWith('[AcpAgentService] Connection lost, clearing state'); + }); + }); +}); diff --git a/packages/ai-native/__test__/node/acp-cli-back.test.ts b/packages/ai-native/__test__/node/acp-cli-back.test.ts new file mode 100644 index 0000000000..67c9d291de --- /dev/null +++ b/packages/ai-native/__test__/node/acp-cli-back.test.ts @@ -0,0 +1,606 @@ +import { AgentProcessConfig, CancellationToken, Emitter } from '@opensumi/ide-core-common'; +import { ChatReadableStream, INodeLogger } from '@opensumi/ide-core-node'; +import { SumiReadableStream } from '@opensumi/ide-utils/lib/stream'; + +import { AgentSessionInfo, AgentUpdate, IAcpAgentService } from '../../src/node/acp/acp-agent.service'; +import { AcpCliBackService } from '../../src/node/acp/acp-cli-back.service'; +import { OpenAICompatibleModel } from '../../src/node/openai-compatible/openai-compatible-language-model'; + +// Mock dependencies +jest.mock('../../src/node/openai-compatible/openai-compatible-language-model', () => ({ + OpenAICompatibleModel: jest.fn().mockImplementation(() => ({ + request: jest.fn(), + })), +})); + +describe('AcpCliBackService', () => { + let service: AcpCliBackService; + let mockAgentService: jest.Mocked; + let mockLogger: jest.Mocked; + let mockOpenAIModel: jest.Mocked; + + const mockAgentSessionConfig: AgentProcessConfig = { + command: 'npx', + args: ['@anthropic-ai/claude-code@latest'], + workspaceDir: '/test/workspace', + }; + + const mockSessionInfo: AgentSessionInfo = { + sessionId: 'test-session-123', + processId: 'proc-1', + modes: [{ id: 'code', name: 'Code' }], + status: 'ready', + }; + + beforeEach(() => { + jest.clearAllMocks(); + + mockAgentService = { + createSession: jest.fn(), + initializeAgent: jest.fn(), + sendMessage: jest.fn(), + cancelRequest: jest.fn(), + disposeSession: jest.fn(), + dispose: jest.fn(), + getSessionInfo: jest.fn(), + loadSession: jest.fn(), + listSessions: jest.fn(), + setSessionMode: jest.fn(), + stopAgent: jest.fn(), + getAvailableModes: jest.fn(), + } as unknown as jest.Mocked; + + mockLogger = { + log: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + verbose: jest.fn(), + warn: jest.fn(), + critical: jest.fn(), + dispose: jest.fn(), + getLevel: jest.fn(), + setLevel: jest.fn(), + } as unknown as jest.Mocked; + + mockOpenAIModel = { + request: jest.fn(), + } as unknown as jest.Mocked; + + service = new AcpCliBackService(); + Object.defineProperty(service, 'agentService', { value: mockAgentService, writable: true }); + Object.defineProperty(service, 'logger', { value: mockLogger, writable: true }); + Object.defineProperty(service, 'openAICompatibleModel', { value: mockOpenAIModel, writable: true }); + }); + + describe('ready()', () => { + it('should always return true', async () => { + const result = await service.ready(); + expect(result).toBe(true); + }); + }); + + describe('request()', () => { + it('should return error code -1 indicating not supported', async () => { + const result = await service.request('hello', {}); + expect(result.errorCode).toBe(-1); + expect(result.errorMsg).toContain('not supported'); + }); + }); + + describe('createSession()', () => { + it('should create session via agentService', async () => { + const expected = { sessionId: 'new-session', availableCommands: [{ name: '/help', description: 'Help' }] }; + mockAgentService.createSession.mockResolvedValue(expected); + + const result = await service.createSession(mockAgentSessionConfig); + + expect(result).toEqual(expected); + expect(mockAgentService.createSession).toHaveBeenCalledWith(mockAgentSessionConfig); + }); + + it('should ensure agent initialized before creating session', async () => { + mockAgentService.getSessionInfo.mockReturnValue(mockSessionInfo); + mockAgentService.createSession.mockResolvedValue({ sessionId: 's1', availableCommands: [] }); + + await service.createSession(mockAgentSessionConfig); + + expect(mockAgentService.getSessionInfo).toHaveBeenCalled(); + expect(mockAgentService.initializeAgent).not.toHaveBeenCalled(); + }); + + it('should initialize agent when no existing session', async () => { + mockAgentService.getSessionInfo.mockReturnValue(null); + mockAgentService.initializeAgent.mockResolvedValue(mockSessionInfo); + mockAgentService.createSession.mockResolvedValue({ sessionId: 's1', availableCommands: [] }); + + await service.createSession(mockAgentSessionConfig); + + expect(mockAgentService.initializeAgent).toHaveBeenCalledWith(mockAgentSessionConfig); + }); + }); + + describe('requestStream() - fallback to OpenAI', () => { + it('should use OpenAI stream when agentSessionConfig is not provided', async () => { + (mockOpenAIModel.request as jest.Mock).mockImplementation(async (_input, stream) => { + stream.emitData({ kind: 'content', content: 'hello' }); + stream.end(); + }); + + const stream = await service.requestStream('hello', {}); + + expect(mockOpenAIModel.request).toHaveBeenCalled(); + expect(stream).toBeInstanceOf(ChatReadableStream); + }); + }); + + describe('requestStream() - agent mode', () => { + it('should use agent stream when agentSessionConfig is provided', async () => { + mockAgentService.getSessionInfo.mockReturnValue(mockSessionInfo); + + const agentStream = new SumiReadableStream(); + mockAgentService.sendMessage.mockReturnValue(agentStream); + + const stream = await service.requestStream('prompt', { agentSessionConfig: mockAgentSessionConfig }); + + expect(stream).toBeInstanceOf(SumiReadableStream); + expect(mockAgentService.getSessionInfo).toHaveBeenCalled(); + }); + + it('should forward agent updates to the output stream', async () => { + mockAgentService.getSessionInfo.mockReturnValue(mockSessionInfo); + + const agentStream = new SumiReadableStream(); + mockAgentService.sendMessage.mockReturnValue(agentStream); + + const output = await service.requestStream('prompt', { agentSessionConfig: mockAgentSessionConfig }); + + const receivedData: any[] = []; + output.onData((data) => receivedData.push(data)); + + // Simulate agent sending updates + agentStream.emitData({ type: 'message', content: 'Hello from agent' }); + agentStream.emitData({ type: 'thought', content: 'Thinking...' }); + agentStream.emitData({ type: 'done', content: '' }); + + expect(receivedData.length).toBe(2); // 'done' returns null + expect(receivedData[0]).toEqual({ kind: 'content', content: 'Hello from agent' }); + expect(receivedData[1]).toEqual({ kind: 'reasoning', content: 'Thinking...' }); + }); + + it('should emit error when agent stream fails', async () => { + mockAgentService.getSessionInfo.mockReturnValue(mockSessionInfo); + + const agentStream = new SumiReadableStream(); + mockAgentService.sendMessage.mockReturnValue(agentStream); + + const output = await service.requestStream('prompt', { agentSessionConfig: mockAgentSessionConfig }); + + const receivedError: Error[] = []; + output.onError((err) => receivedError.push(err)); + + agentStream.emitError(new Error('Agent connection lost')); + + expect(receivedError.length).toBe(1); + expect(receivedError[0].message).toBe('Agent connection lost'); + }); + + it('should handle cancellation token', async () => { + mockAgentService.getSessionInfo.mockReturnValue(mockSessionInfo); + + const agentStream = new SumiReadableStream(); + mockAgentService.sendMessage.mockReturnValue(agentStream); + + const cancelEmitter = new Emitter(); + const cancelToken = { + isCancellationRequested: false, + onCancellationRequested: cancelEmitter.event, + } as CancellationToken; + + await service.requestStream('prompt', { agentSessionConfig: mockAgentSessionConfig }, cancelToken); + + cancelEmitter.fire(); + + expect(mockAgentService.cancelRequest).toHaveBeenCalledWith(mockSessionInfo.sessionId); + }); + + it('should use provided sessionId from options instead of sessionInfo', async () => { + mockAgentService.getSessionInfo.mockReturnValue(mockSessionInfo); + + const agentStream = new SumiReadableStream(); + mockAgentService.sendMessage.mockReturnValue(agentStream); + + await service.requestStream('prompt', { + agentSessionConfig: mockAgentSessionConfig, + sessionId: 'override-session-id', + }); + + expect(mockAgentService.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'override-session-id' }), + expect.any(Object), + ); + }); + }); + + describe('convertAgentUpdateToChatProgress()', () => { + it('should convert "thought" update to reasoning progress', async () => { + mockAgentService.getSessionInfo.mockReturnValue(mockSessionInfo); + const agentStream = new SumiReadableStream(); + mockAgentService.sendMessage.mockReturnValue(agentStream); + + const output = await service.requestStream('prompt', { agentSessionConfig: mockAgentSessionConfig }); + const receivedData: any[] = []; + output.onData((data) => receivedData.push(data)); + + agentStream.emitData({ type: 'thought', content: 'I think...' }); + agentStream.emitData({ type: 'done', content: '' }); + + expect(receivedData).toEqual([{ kind: 'reasoning', content: 'I think...' }]); + }); + + it('should convert "message" update to content progress', async () => { + mockAgentService.getSessionInfo.mockReturnValue(mockSessionInfo); + const agentStream = new SumiReadableStream(); + mockAgentService.sendMessage.mockReturnValue(agentStream); + + const output = await service.requestStream('prompt', { agentSessionConfig: mockAgentSessionConfig }); + const receivedData: any[] = []; + output.onData((data) => receivedData.push(data)); + + agentStream.emitData({ type: 'message', content: 'Answer text' }); + agentStream.emitData({ type: 'done', content: '' }); + + expect(receivedData).toEqual([{ kind: 'content', content: 'Answer text' }]); + }); + + it('should convert "tool_result" update to content progress', async () => { + mockAgentService.getSessionInfo.mockReturnValue(mockSessionInfo); + const agentStream = new SumiReadableStream(); + mockAgentService.sendMessage.mockReturnValue(agentStream); + + const output = await service.requestStream('prompt', { agentSessionConfig: mockAgentSessionConfig }); + const receivedData: any[] = []; + output.onData((data) => receivedData.push(data)); + + agentStream.emitData({ type: 'tool_result', content: 'Modified file.ts' }); + agentStream.emitData({ type: 'done', content: '' }); + + expect(receivedData).toEqual([{ kind: 'content', content: 'Modified file.ts' }]); + }); + + it('should ignore "tool_call" and "done" updates', async () => { + mockAgentService.getSessionInfo.mockReturnValue(mockSessionInfo); + const agentStream = new SumiReadableStream(); + mockAgentService.sendMessage.mockReturnValue(agentStream); + + const output = await service.requestStream('prompt', { agentSessionConfig: mockAgentSessionConfig }); + const receivedData: any[] = []; + output.onData((data) => receivedData.push(data)); + + agentStream.emitData({ type: 'tool_call', content: 'read_file' }); + agentStream.emitData({ type: 'done', content: '' }); + + expect(receivedData).toEqual([]); + }); + }); + + describe('loadAgentSession()', () => { + const mockSessionNotifications: any[] = [ + { + sessionId: 'sess-1', + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'Hello agent' }, + }, + }, + { + sessionId: 'sess-1', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Hi there!' }, + }, + }, + ]; + + it('should load session and convert to messages', async () => { + mockAgentService.loadSession.mockResolvedValue({ + sessionId: 'sess-1', + processId: 'proc-1', + modes: [], + status: 'ready', + historyUpdates: mockSessionNotifications, + }); + + const result = await service.loadAgentSession(mockAgentSessionConfig, 'sess-1'); + + expect(result.sessionId).toBe('sess-1'); + expect(result.messages).toEqual([ + { role: 'user', content: 'Hello agent' }, + { role: 'assistant', content: 'Hi there!' }, + ]); + }); + + it('should handle load session error', async () => { + mockAgentService.loadSession.mockRejectedValue(new Error('Session not found')); + + await expect(service.loadAgentSession(mockAgentSessionConfig, 'sess-1')).rejects.toThrow( + 'Failed to load session sess-1: Session not found', + ); + expect(mockLogger.error).toHaveBeenCalled(); + }); + + it('should handle non-Error throw', async () => { + mockAgentService.loadSession.mockRejectedValue('string error'); + + await expect(service.loadAgentSession(mockAgentSessionConfig, 'sess-1')).rejects.toThrow( + 'Failed to load session sess-1: string error', + ); + }); + }); + + describe('disposeSession()', () => { + it('should cancel request then dispose session', async () => { + await service.disposeSession('sess-1'); + + expect(mockAgentService.cancelRequest).toHaveBeenCalledWith('sess-1'); + expect(mockAgentService.disposeSession).toHaveBeenCalledWith('sess-1'); + }); + + it('should still complete even if disposeSession fails', async () => { + mockAgentService.disposeSession.mockRejectedValue(new Error('dispose failed')); + + await service.disposeSession('sess-1'); + + expect(mockAgentService.cancelRequest).toHaveBeenCalledWith('sess-1'); + expect(mockLogger.error).toHaveBeenCalled(); + }); + }); + + describe('cancelSession()', () => { + it('should call agentService.cancelRequest', async () => { + await service.cancelSession('sess-1'); + expect(mockAgentService.cancelRequest).toHaveBeenCalledWith('sess-1'); + }); + }); + + describe('setSessionMode()', () => { + it('should call agentService.setSessionMode with correct params', async () => { + await service.setSessionMode('sess-1', 'code'); + + expect(mockAgentService.setSessionMode).toHaveBeenCalledWith({ + sessionId: 'sess-1', + modeId: 'code', + }); + }); + + it('should re-throw error from agentService', async () => { + const testError = new Error('Mode switch failed'); + mockAgentService.setSessionMode.mockRejectedValue(testError); + + await expect(service.setSessionMode('sess-1', 'code')).rejects.toThrow('Mode switch failed'); + expect(mockLogger.error).toHaveBeenCalled(); + }); + }); + + describe('listSessions()', () => { + it('should initialize agent and list sessions', async () => { + mockAgentService.getSessionInfo.mockReturnValue(mockSessionInfo); + mockAgentService.listSessions.mockResolvedValue({ + sessions: [{ sessionId: 's1', cwd: '/test', title: 'Session 1' }], + nextCursor: 'cursor-2', + }); + + const result = await service.listSessions(mockAgentSessionConfig); + + expect(mockAgentService.getSessionInfo).toHaveBeenCalled(); + expect(mockAgentService.listSessions).toHaveBeenCalledWith({ + cwd: mockAgentSessionConfig.workspaceDir, + }); + expect(result.sessions).toHaveLength(1); + expect(result.nextCursor).toBe('cursor-2'); + }); + + it('should re-throw error from listSessions', async () => { + mockAgentService.getSessionInfo.mockReturnValue(mockSessionInfo); + mockAgentService.listSessions.mockRejectedValue(new Error('List failed')); + + await expect(service.listSessions(mockAgentSessionConfig)).rejects.toThrow('List failed'); + }); + + it('should initialize agent when no existing session', async () => { + mockAgentService.getSessionInfo.mockReturnValue(null); + mockAgentService.initializeAgent.mockResolvedValue(mockSessionInfo); + mockAgentService.listSessions.mockResolvedValue({ sessions: [], nextCursor: undefined }); + + await service.listSessions(mockAgentSessionConfig); + + expect(mockAgentService.initializeAgent).toHaveBeenCalledWith(mockAgentSessionConfig); + }); + }); + + describe('dispose()', () => { + it('should call agentService.dispose', async () => { + await service.dispose(); + expect(mockAgentService.dispose).toHaveBeenCalled(); + }); + + it('should not dispose twice when called multiple times', async () => { + await service.dispose(); + await service.dispose(); + + expect(mockAgentService.dispose).toHaveBeenCalledTimes(1); + }); + }); + + describe('OpenAI error handling', () => { + it('should emit error on stream when OpenAI request fails', async () => { + (mockOpenAIModel.request as jest.Mock).mockRejectedValue(new Error('API error')); + + const stream = await service.requestStream('hello', { apiKey: 'test-key' }); + + const errors: Error[] = []; + stream.onError((e) => errors.push(e)); + + // Wait for async error to propagate + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(errors.length).toBe(1); + expect(errors[0].message).toBe('API error'); + }); + + it('should wrap non-Error rejections into Error', async () => { + (mockOpenAIModel.request as jest.Mock).mockRejectedValue('string error'); + + const stream = await service.requestStream('hello', { apiKey: 'test-key' }); + + const errors: Error[] = []; + stream.onError((e) => errors.push(e)); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(errors.length).toBe(1); + expect(errors[0].message).toBe('string error'); + }); + }); + + describe('requestStream() - with history and images', () => { + it('should forward history to agentService.sendMessage', async () => { + mockAgentService.getSessionInfo.mockReturnValue(mockSessionInfo); + + const agentStream = new SumiReadableStream(); + mockAgentService.sendMessage.mockReturnValue(agentStream); + + const history = [ + { role: 'user' as const, content: 'Previous question' }, + { role: 'assistant' as const, content: 'Previous answer' }, + ]; + + await service.requestStream('new prompt', { + agentSessionConfig: mockAgentSessionConfig, + history: history as any, + }); + + expect(mockAgentService.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + history, + }), + expect.any(Object), + ); + }); + + it('should handle empty history array', async () => { + mockAgentService.getSessionInfo.mockReturnValue(mockSessionInfo); + + const agentStream = new SumiReadableStream(); + mockAgentService.sendMessage.mockReturnValue(agentStream); + + await service.requestStream('prompt', { + agentSessionConfig: mockAgentSessionConfig, + history: [], + }); + + expect(mockAgentService.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ history: [] }), + expect.any(Object), + ); + }); + + it('should forward images to agentService.sendMessage', async () => { + mockAgentService.getSessionInfo.mockReturnValue(mockSessionInfo); + + const agentStream = new SumiReadableStream(); + mockAgentService.sendMessage.mockReturnValue(agentStream); + + const images = ['data:image/png;base64,abc123']; + + await service.requestStream('what is this image?', { + agentSessionConfig: mockAgentSessionConfig, + images, + }); + + expect(mockAgentService.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ images }), + expect.any(Object), + ); + }); + }); + + describe('setupAgentStream error handling', () => { + it('should emit error when ensureAgentInitialized throws', async () => { + mockAgentService.getSessionInfo.mockReturnValue(null); + mockAgentService.initializeAgent.mockRejectedValue(new Error('Init failed')); + + const stream = await service.requestStream('prompt', { + agentSessionConfig: mockAgentSessionConfig, + }); + + const errors: Error[] = []; + stream.onError((e) => errors.push(e)); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(errors.length).toBe(1); + expect(errors[0].message).toBe('Init failed'); + }); + }); + + describe('convertToSimpleMessage helper (indirect)', () => { + it('should convert CoreMessage with array content to SimpleMessage', async () => { + mockAgentService.getSessionInfo.mockReturnValue(mockSessionInfo); + + const agentStream = new SumiReadableStream(); + mockAgentService.sendMessage.mockReturnValue(agentStream); + + const history = [ + { + role: 'user', + content: [ + { type: 'text', text: 'Part one' }, + { type: 'text', text: 'Part two' }, + ], + }, + ]; + + await service.requestStream('prompt', { + agentSessionConfig: mockAgentSessionConfig, + history: history as any, + }); + + expect(mockAgentService.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + history: [{ role: 'user', content: 'Part one\nPart two' }], + }), + expect.any(Object), + ); + }); + + it('should filter non-text content parts from array content', async () => { + mockAgentService.getSessionInfo.mockReturnValue(mockSessionInfo); + + const agentStream = new SumiReadableStream(); + mockAgentService.sendMessage.mockReturnValue(agentStream); + + const history = [ + { + role: 'user', + content: [ + { type: 'text', text: 'Keep this' }, + { type: 'image', url: 'http://example.com/img.png' }, + { type: 'text', text: 'And this' }, + ], + }, + ]; + + await service.requestStream('prompt', { + agentSessionConfig: mockAgentSessionConfig, + history: history as any, + }); + + expect(mockAgentService.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + history: [{ role: 'user', content: 'Keep this\nAnd this' }], + }), + expect.any(Object), + ); + }); + }); +}); diff --git a/packages/ai-native/__test__/node/acp-cli-client.test.ts b/packages/ai-native/__test__/node/acp-cli-client.test.ts new file mode 100644 index 0000000000..b9b192217c --- /dev/null +++ b/packages/ai-native/__test__/node/acp-cli-client.test.ts @@ -0,0 +1,546 @@ +jest.mock('@opensumi/di', () => { + const actual = jest.requireActual('@opensumi/di'); + const noopDecorator = () => () => {}; + return { + ...actual, + Injectable: () => (cls: any) => cls, + Autowired: noopDecorator, + Inject: noopDecorator, + Optional: noopDecorator, + }; +}); + +import { EventEmitter } from 'events'; + +import { ACP_PROTOCOL_VERSION, AcpCliClientService } from '../../src/node/acp/acp-cli-client.service'; +import { AcpAgentRequestHandler } from '../../src/node/acp/handlers/agent-request.handler'; + +// Mock dependencies +const mockLogger = { + log: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + verbose: jest.fn(), + warn: jest.fn(), + critical: jest.fn(), + dispose: jest.fn(), + getLevel: jest.fn(), + setLevel: jest.fn(), +}; + +const mockAgentRequestHandler = { + handleReadTextFile: jest.fn(), + handleWriteTextFile: jest.fn(), + handlePermissionRequest: jest.fn(), + handleCreateTerminal: jest.fn(), + handleTerminalOutput: jest.fn(), + handleWaitForTerminalExit: jest.fn(), + handleKillTerminal: jest.fn(), + handleReleaseTerminal: jest.fn(), +}; + +describe('AcpCliClientService', () => { + let service: AcpCliClientService; + let mockStdin: any; + let mockStdout: any; + + beforeEach(() => { + jest.clearAllMocks(); + + mockStdin = new EventEmitter() as any; + mockStdin.writable = true; + mockStdin.write = jest.fn().mockReturnValue(true); + mockStdin.end = jest.fn(); + + mockStdout = new EventEmitter() as any; + mockStdout.removeAllListeners = jest.fn(); + + service = new AcpCliClientService(); + Object.defineProperty(service, 'logger', { value: mockLogger, writable: true }); + Object.defineProperty(service, 'agentRequestHandler', { value: mockAgentRequestHandler, writable: true }); + }); + + function setTransport() { + service.setTransport(mockStdout, mockStdin); + } + + describe('setTransport()', () => { + it('should set stdin/stdout and transition to connected state', () => { + setTransport(); + expect(service.isConnected()).toBe(true); + }); + + it('should reject pending requests when reconnecting', () => { + setTransport(); + + // Simulate a pending request + (service as any).pendingRequests.set(1, { + resolve: jest.fn(), + reject: jest.fn(), + }); + + // Reconnect + setTransport(); + + expect((service as any).pendingRequests.size).toBe(0); + }); + + it('should clear request queue when reconnecting', () => { + setTransport(); + + (service as any).requestQueue = [{ method: 'test', params: {}, resolve: jest.fn(), reject: jest.fn() }]; + + setTransport(); + + expect((service as any).requestQueue).toEqual([]); + }); + + it('should remove old listeners before attaching new ones', () => { + setTransport(); + // Reset mock count + mockStdout.removeAllListeners.mockClear(); + // Reconnect - this should call removeAllListeners on the OLD stdout + setTransport(); + + expect(mockStdout.removeAllListeners).toHaveBeenCalled(); + }); + + it('should reset protocol and capability state', () => { + setTransport(); + (service as any).negotiatedProtocolVersion = 1; + (service as any).agentCapabilities = { fs: true }; + + setTransport(); + + expect(service.getNegotiatedProtocolVersion()).toBeNull(); + expect(service.getAgentCapabilities()).toBeNull(); + }); + }); + + describe('isConnected()', () => { + it('should return false before transport is set', () => { + expect(service.isConnected()).toBe(false); + }); + + it('should return true after setTransport', () => { + setTransport(); + expect(service.isConnected()).toBe(true); + }); + + it('should return false after close', () => { + setTransport(); + service.close(); + expect(service.isConnected()).toBe(false); + }); + }); + + describe('close()', () => { + it('should clear handlers and streams', () => { + setTransport(); + (service as any).notificationHandlers = [jest.fn()]; + (service as any).disconnectHandlers = [jest.fn()]; + + service.close(); + + expect((service as any).notificationHandlers).toEqual([]); + expect((service as any).disconnectHandlers).toEqual([]); + expect(mockStdout.removeAllListeners).toHaveBeenCalled(); + expect(mockStdin.end).toHaveBeenCalled(); + }); + + it('should not throw when stdin.end fails', () => { + setTransport(); + mockStdin.end.mockImplementation(() => { + throw new Error('already closed'); + }); + + expect(() => service.close()).not.toThrow(); + }); + }); + + describe('handleDisconnect()', () => { + it('should transition to disconnected state', () => { + setTransport(); + service.handleDisconnect(); + expect(service.isConnected()).toBe(false); + }); + + it('should reject all pending requests', () => { + setTransport(); + const reject = jest.fn(); + (service as any).pendingRequests.set(1, { resolve: jest.fn(), reject }); + (service as any).pendingRequests.set(2, { resolve: jest.fn(), reject }); + + service.handleDisconnect(); + + expect(reject).toHaveBeenCalledTimes(2); + expect(reject).toHaveBeenCalledWith(new Error('Not connected to agent process')); + }); + + it('should reject all queued requests', () => { + setTransport(); + const reject = jest.fn(); + (service as any).requestQueue = [{ method: 'test', params: {}, resolve: jest.fn(), reject }]; + + service.handleDisconnect(); + + expect(reject).toHaveBeenCalledWith(new Error('Not connected to agent process')); + }); + + it('should call disconnect handlers', () => { + setTransport(); + const handler = jest.fn(); + service.onDisconnect(handler); + + service.handleDisconnect(); + + expect(handler).toHaveBeenCalled(); + }); + + it('should clear all state', () => { + setTransport(); + (service as any).negotiatedProtocolVersion = 1; + (service as any).agentCapabilities = {}; + (service as any).agentInfo = {}; + (service as any).authMethods = ['oauth']; + (service as any).sessionModes = {}; + + service.handleDisconnect(); + + expect(service.getNegotiatedProtocolVersion()).toBeNull(); + expect(service.getAgentCapabilities()).toBeNull(); + expect(service.getAgentInfo()).toBeNull(); + expect(service.getAuthMethods()).toEqual([]); + expect(service.getSessionModes()).toBeNull(); + }); + + it('should be idempotent - no effect when already disconnected', () => { + setTransport(); + service.handleDisconnect(); + + const handler = jest.fn(); + service.onDisconnect(handler); + service.handleDisconnect(); + + expect(handler).not.toHaveBeenCalled(); + }); + }); + + describe('onDisconnect()', () => { + it('should return unsubscribe function', () => { + setTransport(); + const handler = jest.fn(); + const unsubscribe = service.onDisconnect(handler); + + unsubscribe(); + + service.handleDisconnect(); + expect(handler).not.toHaveBeenCalled(); + }); + }); + + describe('onNotification()', () => { + it('should return unsubscribe function', () => { + const handler = jest.fn(); + const unsubscribe = service.onNotification(handler); + + unsubscribe(); + + expect((service as any).notificationHandlers).not.toContain(handler); + }); + }); + + describe('initialize()', () => { + it('should send initialize request and store protocol version', async () => { + setTransport(); + + const sendRequestSpy = jest.spyOn(service as any, 'sendRequest').mockResolvedValue({ + protocolVersion: ACP_PROTOCOL_VERSION, + agentCapabilities: { fs: true }, + agentInfo: { name: 'test', version: '1.0' }, + }); + + const result = await service.initialize(); + + expect(result.protocolVersion).toBe(ACP_PROTOCOL_VERSION); + expect(service.getNegotiatedProtocolVersion()).toBe(ACP_PROTOCOL_VERSION); + expect(service.getAgentCapabilities()).toEqual({ fs: true }); + expect(service.getAgentInfo()).toEqual({ name: 'test', version: '1.0' }); + sendRequestSpy.mockRestore(); + }); + + it('should throw if protocol version is higher than supported', async () => { + setTransport(); + + jest.spyOn(service as any, 'sendRequest').mockResolvedValue({ + protocolVersion: ACP_PROTOCOL_VERSION + 1, + }); + + jest.spyOn(service as any, 'close').mockResolvedValue(undefined); + + await expect(service.initialize()).rejects.toThrow('Unsupported protocol version'); + }); + + it('should throw if not connected', async () => { + await expect(service.initialize()).rejects.toThrow('Not connected to agent process'); + }); + + it('should accept lower protocol version with warning', async () => { + setTransport(); + + jest.spyOn(service as any, 'sendRequest').mockResolvedValue({ + protocolVersion: ACP_PROTOCOL_VERSION - 1, + }); + + const result = await service.initialize(); + + expect(result.protocolVersion).toBe(ACP_PROTOCOL_VERSION - 1); + expect(mockLogger.warn).toHaveBeenCalled(); + }); + }); + + describe('sendRequest()', () => { + it('should throw if not connected', async () => { + await expect((service as any).sendRequest('test', {})).rejects.toThrow('Not connected to agent process'); + }); + }); + + describe('handleData() - NDJSON parsing', () => { + it('should parse a single JSON-RPC response', () => { + setTransport(); + const resolve = jest.fn(); + (service as any).pendingRequests.set(1, { resolve, reject: jest.fn() }); + + mockStdout.emit('data', Buffer.from('{"jsonrpc":"2.0","id":1,"result":{"ok":true}}\n')); + + expect(resolve).toHaveBeenCalledWith({ ok: true }); + }); + + it('should parse multiple lines in one chunk', () => { + setTransport(); + const resolve1 = jest.fn(); + const resolve2 = jest.fn(); + (service as any).pendingRequests.set(1, { resolve: resolve1, reject: jest.fn() }); + (service as any).pendingRequests.set(2, { resolve: resolve2, reject: jest.fn() }); + + mockStdout.emit( + 'data', + Buffer.from('{"jsonrpc":"2.0","id":1,"result":"a"}\n{"jsonrpc":"2.0","id":2,"result":"b"}\n'), + ); + + expect(resolve1).toHaveBeenCalledWith('a'); + expect(resolve2).toHaveBeenCalledWith('b'); + }); + + it('should handle partial messages across chunks', () => { + setTransport(); + const resolve = jest.fn(); + (service as any).pendingRequests.set(1, { resolve, reject: jest.fn() }); + + // Send partial message + mockStdout.emit('data', Buffer.from('{"jsonrpc":"2.0","id":1,')); + expect(resolve).not.toHaveBeenCalled(); + + // Complete the message + mockStdout.emit('data', Buffer.from('"result":"done"}\n')); + expect(resolve).toHaveBeenCalledWith('done'); + }); + + it('should handle error responses', () => { + setTransport(); + const reject = jest.fn(); + (service as any).pendingRequests.set(1, { resolve: jest.fn(), reject }); + + mockStdout.emit( + 'data', + Buffer.from('{"jsonrpc":"2.0","id":1,"error":{"code":-32600,"message":"Invalid request"}}\n'), + ); + + expect(reject).toHaveBeenCalled(); + const error = reject.mock.calls[0][0]; + expect(error.message).toBe('Invalid request'); + expect((error as any).code).toBe(-32600); + }); + + it('should skip empty lines', () => { + setTransport(); + const resolve = jest.fn(); + (service as any).pendingRequests.set(1, { resolve, reject: jest.fn() }); + + mockStdout.emit('data', Buffer.from('\n\n{"jsonrpc":"2.0","id":1,"result":"ok"}\n\n')); + + expect(resolve).toHaveBeenCalledWith('ok'); + }); + + it('should log error for invalid JSON', () => { + setTransport(); + + mockStdout.emit('data', Buffer.from('not json\n')); + + expect(mockLogger.error).toHaveBeenCalled(); + }); + }); + + describe('handleIncomingNotification()', () => { + it('should dispatch session/update to notification handlers', () => { + setTransport(); + const handler = jest.fn(); + service.onNotification(handler); + + mockStdout.emit( + 'data', + Buffer.from( + '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Hello"}}}}\n', + ), + ); + + expect(handler).toHaveBeenCalledWith({ + sessionId: 's1', + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Hello' } }, + }); + }); + + it('should update currentModeId on current_mode_update', () => { + setTransport(); + (service as any).sessionModes = { currentModeId: 'old' }; + + mockStdout.emit( + 'data', + Buffer.from( + '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s1","update":{"sessionUpdate":"current_mode_update","currentModeId":"code"}}}\n', + ), + ); + + expect((service as any).sessionModes.currentModeId).toBe('code'); + }); + + it('should warn if current_mode_update received but sessionModes not initialized', () => { + setTransport(); + (service as any).sessionModes = null; + + mockStdout.emit( + 'data', + Buffer.from( + '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s1","update":{"sessionUpdate":"current_mode_update","currentModeId":"code"}}}\n', + ), + ); + + expect(mockLogger.warn).toHaveBeenCalled(); + }); + }); + + describe('handleIncomingRequest()', () => { + it('should route fs/read_text_file to handler', async () => { + setTransport(); + mockAgentRequestHandler.handleReadTextFile.mockResolvedValue({ content: 'hello' }); + + const writeSpy = jest.spyOn(mockStdin, 'write'); + + mockStdout.emit( + 'data', + Buffer.from( + '{"jsonrpc":"2.0","id":1,"method":"fs/read_text_file","params":{"sessionId":"s1","path":"test.txt"}}\n', + ), + ); + + await new Promise((r) => setTimeout(r, 10)); + + expect(mockAgentRequestHandler.handleReadTextFile).toHaveBeenCalledWith({ + sessionId: 's1', + path: 'test.txt', + }); + expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining('"result":{"content":"hello"}')); + }); + + it('should return method not found for unknown methods', async () => { + setTransport(); + const writeSpy = jest.spyOn(mockStdin, 'write'); + + mockStdout.emit('data', Buffer.from('{"jsonrpc":"2.0","id":1,"method":"unknown/method","params":{}}\n')); + + await new Promise((r) => setTimeout(r, 10)); + + expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining('"code":-32601')); + }); + + it('should send error response when handler throws', async () => { + setTransport(); + mockAgentRequestHandler.handleReadTextFile.mockRejectedValue(new Error('read failed')); + const writeSpy = jest.spyOn(mockStdin, 'write'); + + mockStdout.emit( + 'data', + Buffer.from( + '{"jsonrpc":"2.0","id":1,"method":"fs/read_text_file","params":{"sessionId":"s1","path":"test.txt"}}\n', + ), + ); + + await new Promise((r) => setTimeout(r, 10)); + + expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining('"error"')); + }); + }); + + describe('handleDisconnect on stdout events', () => { + it('should handle stdout end event', () => { + setTransport(); + const disconnectSpy = jest.spyOn(service, 'handleDisconnect'); + + mockStdout.emit('end'); + + expect(disconnectSpy).toHaveBeenCalled(); + expect(mockLogger.error).toHaveBeenCalled(); + }); + + it('should handle stdout error event', () => { + setTransport(); + const disconnectSpy = jest.spyOn(service, 'handleDisconnect'); + + mockStdout.emit('error', new Error('stream error')); + + expect(disconnectSpy).toHaveBeenCalled(); + expect(mockLogger.error).toHaveBeenCalled(); + }); + }); + + describe('sendNotification()', () => { + it('should send notification without id', () => { + setTransport(); + service.cancel({ sessionId: 's1' }); + + expect(mockStdin.write).toHaveBeenCalledWith(expect.stringContaining('"method":"session/cancel"')); + }); + + it('should not send notification when disconnected', () => { + service.cancel({ sessionId: 's1' }); + expect(mockStdin.write).not.toHaveBeenCalled(); + }); + + it('should handle write errors gracefully', () => { + setTransport(); + mockStdin.write.mockImplementationOnce(() => { + throw new Error('write failed'); + }); + + expect(() => service.cancel({ sessionId: 's1' })).not.toThrow(); + expect(mockLogger.warn).toHaveBeenCalled(); + }); + }); + + describe('getSessionModes()', () => { + it('should return session modes after initialize', async () => { + setTransport(); + jest.spyOn(service as any, 'sendRequest').mockResolvedValue({ + protocolVersion: ACP_PROTOCOL_VERSION, + modes: { currentModeId: 'code', availableModes: [{ id: 'code', name: 'Code' }] }, + }); + + await service.initialize(); + + expect(service.getSessionModes()).toEqual({ + currentModeId: 'code', + availableModes: [{ id: 'code', name: 'Code' }], + }); + }); + }); +}); diff --git a/packages/ai-native/__test__/node/acp-cli-process-manager.test.ts b/packages/ai-native/__test__/node/acp-cli-process-manager.test.ts new file mode 100644 index 0000000000..d3d58e6dfb --- /dev/null +++ b/packages/ai-native/__test__/node/acp-cli-process-manager.test.ts @@ -0,0 +1,227 @@ +jest.mock('@opensumi/di', () => { + const actual = jest.requireActual('@opensumi/di'); + const noopDecorator = () => () => {}; + return { + ...actual, + Injectable: () => (cls: any) => cls, + Autowired: noopDecorator, + Inject: noopDecorator, + Optional: noopDecorator, + }; +}); + +import { EventEmitter } from 'events'; + +// Create a mock child process for each test +function createMockChildProcess(pid = 12345) { + const mock = new EventEmitter() as any; + mock.pid = pid; + mock.killed = false; + mock.exitCode = null; + mock.signalCode = null; + mock.stdio = [new EventEmitter(), new EventEmitter(), new EventEmitter()]; + mock.stderr = new EventEmitter(); + return mock; +} + +const mockSpawn = jest.fn(); + +jest.mock('child_process', () => ({ + spawn: (...args: any[]) => mockSpawn(...args), +})); + +import { CliAgentProcessManager } from '../../src/node/acp/cli-agent-process-manager'; + +const mockLogger = { + log: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + verbose: jest.fn(), + warn: jest.fn(), + critical: jest.fn(), + dispose: jest.fn(), + getLevel: jest.fn(), + setLevel: jest.fn(), +}; + +describe('CliAgentProcessManager', () => { + let manager: CliAgentProcessManager; + let mockChildProcess: ReturnType; + + beforeEach(() => { + mockChildProcess = createMockChildProcess(); + mockSpawn.mockImplementation(() => mockChildProcess); + + jest.spyOn(process, 'kill').mockImplementation((pid: number, signal: number | NodeJS.Signals): any => undefined); + + manager = new CliAgentProcessManager(); + Object.defineProperty(manager, 'logger', { value: mockLogger, writable: true }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('startAgent()', () => { + it('should spawn a new process and return process info', async () => { + const result = await manager.startAgent('npx', ['test'], {}, '/test/workspace'); + + expect(result.processId).toBe('12345'); + expect(mockSpawn).toHaveBeenCalledTimes(1); + }); + }); + + describe('stopAgent()', () => { + it('should do nothing when no process running', async () => { + await manager.stopAgent(); + + expect(mockLogger.warn).toHaveBeenCalled(); + }); + }); + + describe('killAgent()', () => { + it('should clear references when no process', async () => { + await manager.killAgent(); + + expect((manager as any).currentProcess).toBeNull(); + }); + }); + + describe('isRunning()', () => { + it('should return false when no process', () => { + expect(manager.isRunning()).toBe(false); + }); + + it('should return true for running process', async () => { + await manager.startAgent('npx', ['test'], {}, '/test/workspace'); + + expect(manager.isRunning()).toBe(true); + }); + + it('should return false when process killed flag is set', async () => { + await manager.startAgent('npx', ['test'], {}, '/test/workspace'); + + mockChildProcess.killed = true; + expect(manager.isRunning()).toBe(false); + }); + + it('should return false when process has exitCode', async () => { + await manager.startAgent('npx', ['test'], {}, '/test/workspace'); + + mockChildProcess.exitCode = 0; + expect(manager.isRunning()).toBe(false); + }); + }); + + describe('getExitCode()', () => { + it('should return null when no process', () => { + expect(manager.getExitCode()).toBeNull(); + }); + + it('should return exitCode from process', async () => { + await manager.startAgent('npx', ['test'], {}, '/test/workspace'); + + mockChildProcess.exitCode = 42; + expect(manager.getExitCode()).toBe(42); + }); + }); + + describe('listRunningAgents()', () => { + it('should return singleton ID when running', async () => { + await manager.startAgent('npx', ['test'], {}, '/test/workspace'); + + const agents = manager.listRunningAgents(); + + expect(agents).toEqual(['singleton-agent-process']); + }); + + it('should return empty array when not running', () => { + expect(manager.listRunningAgents()).toEqual([]); + }); + }); + + describe('killAllAgents()', () => { + it('should delegate to forceKillInternal', async () => { + const forceKillSpy = jest.spyOn(manager as any, 'forceKillInternal').mockResolvedValue(undefined); + + await manager.killAllAgents(); + + expect(forceKillSpy).toHaveBeenCalled(); + }); + }); + + describe('handleProcessExit()', () => { + it('should clear references on exit', async () => { + await manager.startAgent('npx', ['test'], {}, '/test/workspace'); + + mockChildProcess.emit('exit', 0, null); + + expect((manager as any).currentProcess).toBeNull(); + expect((manager as any).currentCommand).toBeNull(); + expect((manager as any).currentCwd).toBeNull(); + }); + }); + + describe('killProcessGroup()', () => { + it('should try process group kill first', () => { + const result = (manager as any).killProcessGroup(12345, 'SIGTERM'); + + expect(result).toBe(true); + expect(process.kill).toHaveBeenCalledWith(-12345, 'SIGTERM'); + }); + + it('should fallback to single process kill when group kill fails', () => { + const mockKill = process.kill as jest.Mock; + mockKill + .mockImplementationOnce(() => { + throw new Error('group not found'); + }) + .mockImplementation(() => true); + + const result = (manager as any).killProcessGroup(12345, 'SIGTERM'); + + expect(result).toBe(true); + expect(mockKill).toHaveBeenCalledWith(12345, 'SIGTERM'); + }); + + it('should return false when both kills fail', () => { + (process.kill as jest.Mock).mockImplementation(() => { + throw new Error('not found'); + }); + + const result = (manager as any).killProcessGroup(12345, 'SIGTERM'); + + expect(result).toBe(false); + }); + }); + + describe('wrapError()', () => { + it('should return user-friendly message for ENOENT', () => { + const err = new Error('spawn ENOENT'); + (err as any).code = 'ENOENT'; + + const result = (manager as any).wrapError(err, 'npx'); + + expect(result.message).toContain('Command not found'); + expect(result.message).toContain('npx'); + }); + + it('should return user-friendly message for EACCES', () => { + const err = new Error('spawn EACCES'); + (err as any).code = 'EACCES'; + + const result = (manager as any).wrapError(err, 'npx'); + + expect(result.message).toContain('Permission denied'); + }); + + it('should return original error for other codes', () => { + const err = new Error('some error'); + (err as any).code = 'OTHER'; + + const result = (manager as any).wrapError(err, 'npx'); + + expect(result).toBe(err); + }); + }); +}); diff --git a/packages/ai-native/__test__/node/acp-file-system-handler.test.ts b/packages/ai-native/__test__/node/acp-file-system-handler.test.ts new file mode 100644 index 0000000000..c2503909e0 --- /dev/null +++ b/packages/ai-native/__test__/node/acp-file-system-handler.test.ts @@ -0,0 +1,414 @@ +jest.mock('@opensumi/di', () => { + const actual = jest.requireActual('@opensumi/di'); + const noopDecorator = () => () => {}; + return { + ...actual, + Injectable: () => (cls: any) => cls, + Autowired: noopDecorator, + Inject: noopDecorator, + Optional: noopDecorator, + }; +}); + +// Mock fs for realpathSync +const mockFs = { + realpathSync: jest.fn((p: string) => { + // Simulate real path resolution + if (p.includes('..')) { + throw new Error('ENOENT'); + } + return p; + }), +}; + +jest.mock('fs', () => mockFs); + +import * as path from 'path'; + +import { ACPErrorCode } from '../../src/node/acp/handlers/constants'; +import { AcpFileSystemHandler, AcpFileSystemHandlerToken } from '../../src/node/acp/handlers/file-system.handler'; + +const mockLogger = { + log: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + verbose: jest.fn(), + warn: jest.fn(), + critical: jest.fn(), + dispose: jest.fn(), + getLevel: jest.fn(), + setLevel: jest.fn(), +}; + +const mockFileService = { + getFileStat: jest.fn(), + resolveContent: jest.fn(), + setContent: jest.fn(), + createFile: jest.fn(), + createFolder: jest.fn(), +}; + +describe('AcpFileSystemHandler', () => { + let handler: AcpFileSystemHandler; + + beforeEach(() => { + jest.clearAllMocks(); + + handler = new AcpFileSystemHandler(); + Object.defineProperty(handler, 'logger', { value: mockLogger, writable: true }); + Object.defineProperty(handler, 'fileService', { value: mockFileService, writable: true }); + + handler.configure({ workspaceDir: '/test/workspace' }); + }); + + describe('configure()', () => { + it('should set workspaceDir and maxFileSize', () => { + handler.configure({ workspaceDir: '/new/workspace', maxFileSize: 2048 }); + + expect((handler as any).workspaceDir).toBe('/new/workspace'); + expect((handler as any).maxFileSize).toBe(2048); + }); + }); + + describe('resolvePath() security', () => { + it('should reject when workspaceDir is not set', () => { + handler.configure({ workspaceDir: '' }); + + const result = (handler as any).resolvePath('test.txt'); + + expect(result).toBeNull(); + }); + + it('should reject path traversal with ..', () => { + mockFs.realpathSync.mockImplementation((p: string) => { + if (p === '/test/workspace') {return '/test/workspace';} + if (p === '/test/workspace/../etc/passwd') {return '/etc/passwd';} + return p; + }); + + const result = (handler as any).resolvePath('../etc/passwd'); + + expect(result).toBeNull(); + expect(mockLogger.warn).toHaveBeenCalled(); + }); + + it('should resolve relative paths against workspaceDir', () => { + mockFs.realpathSync.mockImplementation((p: string) => p); + + const result = (handler as any).resolvePath('src/index.ts'); + + expect(result).toBe(path.resolve('/test/workspace', 'src/index.ts')); + }); + + it('should pass through absolute paths within workspace', () => { + mockFs.realpathSync.mockImplementation((p: string) => p); + + const result = (handler as any).resolvePath('/test/workspace/src/index.ts'); + + expect(result).toBe('/test/workspace/src/index.ts'); + }); + }); + + describe('readTextFile()', () => { + it('should return content for valid file', async () => { + mockFileService.getFileStat.mockResolvedValue({ size: 100, isDirectory: false }); + mockFileService.resolveContent.mockResolvedValue({ content: 'Hello World' }); + + const result = await handler.readTextFile({ sessionId: 'sess-1', path: 'test.txt' }); + + expect(result.content).toBe('Hello World'); + expect(result.error).toBeUndefined(); + }); + + it('should return error for invalid path', async () => { + handler.configure({ workspaceDir: '' }); + + const result = await handler.readTextFile({ sessionId: 'sess-1', path: 'test.txt' }); + + expect(result.error).toBeDefined(); + expect(result.error?.code).toBe(ACPErrorCode.SERVER_ERROR); + }); + + it('should return error when file not found', async () => { + mockFileService.getFileStat.mockResolvedValue(null); + + const result = await handler.readTextFile({ sessionId: 'sess-1', path: 'nonexistent.txt' }); + + expect(result.error).toBeDefined(); + expect(result.error?.code).toBe(ACPErrorCode.RESOURCE_NOT_FOUND); + }); + + it('should return error when file too large', async () => { + mockFileService.getFileStat.mockResolvedValue({ size: 2 * 1024 * 1024, isDirectory: false }); + + const result = await handler.readTextFile({ sessionId: 'sess-1', path: 'large.txt' }); + + expect(result.error).toBeDefined(); + expect(result.error?.message).toContain('File too large'); + }); + + it('should slice lines when line parameter is provided', async () => { + mockFileService.getFileStat.mockResolvedValue({ size: 100, isDirectory: false }); + mockFileService.resolveContent.mockResolvedValue({ + content: 'line1\nline2\nline3\nline4\nline5', + }); + + const result = await handler.readTextFile({ sessionId: 'sess-1', path: 'test.txt', line: 2, limit: 2 }); + + expect(result.content).toBe('line2\nline3'); + }); + + it('should handle read error', async () => { + mockFileService.getFileStat.mockResolvedValue({ size: 100, isDirectory: false }); + mockFileService.resolveContent.mockRejectedValue(new Error('read error')); + + const result = await handler.readTextFile({ sessionId: 'sess-1', path: 'test.txt' }); + + expect(result.error).toBeDefined(); + expect(result.error?.message).toBe('read error'); + }); + }); + + describe('writeTextFile()', () => { + it('should write content successfully', async () => { + mockFileService.getFileStat + .mockResolvedValueOnce({ isDirectory: true }) // parent exists + .mockResolvedValueOnce(null); // file doesn't exist + + const result = await handler.writeTextFile({ + sessionId: 'sess-1', + path: 'test.txt', + content: 'Hello', + }); + + expect(result.error).toBeUndefined(); + expect(mockFileService.createFile).toHaveBeenCalled(); + }); + + it('should return error for invalid path', async () => { + handler.configure({ workspaceDir: '' }); + + const result = await handler.writeTextFile({ sessionId: 'sess-1', path: 'test.txt', content: 'Hello' }); + + expect(result.error).toBeDefined(); + }); + + it('should return error when content is missing', async () => { + const result = await handler.writeTextFile({ sessionId: 'sess-1', path: 'test.txt' }); + + expect(result.error).toBeDefined(); + expect(result.error?.code).toBe(ACPErrorCode.INVALID_PARAMS); + }); + + it('should create parent directories if needed', async () => { + mockFileService.getFileStat + .mockResolvedValueOnce(null) // parent doesn't exist + .mockResolvedValueOnce(null); // file doesn't exist + + await handler.writeTextFile({ + sessionId: 'sess-1', + path: 'dir/test.txt', + content: 'Hello', + }); + + expect(mockFileService.createFolder).toHaveBeenCalled(); + }); + + it('should check permission callback before writing', async () => { + mockFileService.getFileStat.mockResolvedValueOnce({ isDirectory: true }).mockResolvedValueOnce(null); + + const permitted = await handler.writeTextFile({ + sessionId: 'sess-1', + path: 'test.txt', + content: 'Hello', + }); + + // No permission callback set by default, should proceed + expect(permitted.error).toBeUndefined(); + }); + + it('should deny write when permission callback returns false', async () => { + const denyCallback = jest.fn().mockResolvedValue(false); + handler.setPermissionCallback(denyCallback); + + const result = await handler.writeTextFile({ + sessionId: 'sess-1', + path: 'test.txt', + content: 'Hello', + }); + + expect(result.error).toBeDefined(); + expect(result.error?.code).toBe(ACPErrorCode.FORBIDDEN); + expect(denyCallback).toHaveBeenCalled(); + }); + + it('should update existing file', async () => { + mockFileService.getFileStat + .mockResolvedValueOnce({ isDirectory: true }) + .mockResolvedValueOnce({ isDirectory: false, uri: 'file:///test.txt' }); + + await handler.writeTextFile({ + sessionId: 'sess-1', + path: 'test.txt', + content: 'Updated content', + }); + + expect(mockFileService.setContent).toHaveBeenCalled(); + }); + }); + + describe('getFileMeta()', () => { + it('should return meta for existing file', async () => { + mockFileService.getFileStat.mockResolvedValue({ + size: 1024, + lastModification: 1234567890, + isDirectory: false, + }); + + const result = await handler.getFileMeta({ sessionId: 'sess-1', path: 'test.ts' }); + + expect(result.size).toBe(1024); + expect(result.mtime).toBe(1234567890); + expect(result.isFile).toBe(true); + expect(result.mimeType).toBe('application/typescript'); + }); + + it('should return false for non-existing file', async () => { + mockFileService.getFileStat.mockResolvedValue(null); + + const result = await handler.getFileMeta({ sessionId: 'sess-1', path: 'nonexistent.txt' }); + + expect(result.isFile).toBe(false); + expect(result.size).toBe(0); + expect(result.mtime).toBe(0); + }); + }); + + describe('listDirectory()', () => { + it('should return entries for valid directory', async () => { + mockFileService.getFileStat.mockResolvedValue({ + isDirectory: true, + children: [ + { uri: 'file:///test/workspace/src', isDirectory: true, size: 0 }, + { uri: 'file:///test/workspace/index.ts', isDirectory: false, size: 100 }, + ], + }); + + const result = await handler.listDirectory({ sessionId: 'sess-1', path: '.' }); + + expect(result.entries).toHaveLength(2); + expect(result.entries![0].name).toBe('src'); + expect(result.entries![1].name).toBe('index.ts'); + }); + + it('should return error when path is a file', async () => { + mockFileService.getFileStat.mockResolvedValue({ isDirectory: false }); + + const result = await handler.listDirectory({ sessionId: 'sess-1', path: 'test.txt' }); + + expect(result.error).toBeDefined(); + expect(result.error?.message).toContain('not a directory'); + }); + + it('should return error when directory not found', async () => { + mockFileService.getFileStat.mockResolvedValue(null); + + const result = await handler.listDirectory({ sessionId: 'sess-1', path: 'nonexistent' }); + + expect(result.error).toBeDefined(); + expect(result.error?.code).toBe(ACPErrorCode.RESOURCE_NOT_FOUND); + }); + + it('should include subdirectory entries when recursive', async () => { + mockFileService.getFileStat.mockResolvedValue({ + isDirectory: true, + children: [ + { + uri: 'file:///test/workspace/src', + isDirectory: true, + size: 0, + children: [{ uri: 'file:///test/workspace/src/index.ts', isDirectory: false, size: 200 }], + }, + ], + }); + + const result = await handler.listDirectory({ sessionId: 'sess-1', path: '.', recursive: true }); + + expect(result.entries).toHaveLength(2); + expect(result.entries![1].name).toBe('src/index.ts'); + }); + }); + + describe('createDirectory()', () => { + it('should create directory successfully', async () => { + const result = await handler.createDirectory({ sessionId: 'sess-1', path: 'new-dir' }); + + expect(result.error).toBeUndefined(); + expect(mockFileService.createFolder).toHaveBeenCalled(); + }); + + it('should check permission callback', async () => { + const denyCallback = jest.fn().mockResolvedValue(false); + handler.setPermissionCallback(denyCallback); + + const result = await handler.createDirectory({ sessionId: 'sess-1', path: 'new-dir' }); + + expect(result.error).toBeDefined(); + expect(result.error?.code).toBe(ACPErrorCode.FORBIDDEN); + }); + }); + + describe('detectMimeType()', () => { + const testCases: [string, string][] = [ + ['test.ts', 'application/typescript'], + ['test.js', 'application/javascript'], + ['test.json', 'application/json'], + ['test.md', 'text/markdown'], + ['test.yaml', 'application/yaml'], + ['test.yml', 'application/yaml'], + ['test.py', 'text/x-python'], + ['test.java', 'text/x-java'], + ['test.go', 'text/x-go'], + ['test.rs', 'text/x-rust'], + ['test.c', 'text/x-c'], + ['test.cpp', 'text/x-c++'], + ['test.h', 'text/x-c'], + ['test.hpp', 'text/x-c++'], + ['test.css', 'text/css'], + ['test.html', 'text/html'], + ['test.xml', 'application/xml'], + ['test.jsx', 'text/jsx'], + ['test.tsx', 'text/tsx'], + ['test.txt', 'text/plain'], + ['test.unknown', 'application/octet-stream'], + ]; + + for (const [filename, expected] of testCases) { + it(`should return ${expected} for ${filename}`, () => { + const result = (handler as any).detectMimeType(filename); + expect(result).toBe(expected); + }); + } + }); + + describe('ACPErrorCode', () => { + it('should have correct standard error codes', () => { + expect(ACPErrorCode.PARSE_ERROR).toBe(-32700); + expect(ACPErrorCode.INVALID_REQUEST).toBe(-32600); + expect(ACPErrorCode.METHOD_NOT_FOUND).toBe(-32601); + expect(ACPErrorCode.INVALID_PARAMS).toBe(-32602); + expect(ACPErrorCode.INTERNAL_ERROR).toBe(-32603); + }); + + it('should have correct ACP-specific codes', () => { + expect(ACPErrorCode.SERVER_ERROR).toBe(-32000); + expect(ACPErrorCode.RESOURCE_NOT_FOUND).toBe(-32002); + }); + + it('should have correct application codes', () => { + expect(ACPErrorCode.AUTHENTICATION_REQUIRED).toBe(1000); + expect(ACPErrorCode.SESSION_NOT_FOUND).toBe(1001); + expect(ACPErrorCode.FORBIDDEN).toBe(1003); + }); + }); +}); diff --git a/packages/ai-native/__test__/node/acp-permission-caller.test.ts b/packages/ai-native/__test__/node/acp-permission-caller.test.ts new file mode 100644 index 0000000000..5e6ef45033 --- /dev/null +++ b/packages/ai-native/__test__/node/acp-permission-caller.test.ts @@ -0,0 +1,468 @@ +jest.mock('@opensumi/di', () => { + const actual = jest.requireActual('@opensumi/di'); + const noopDecorator = () => () => {}; + return { + ...actual, + Injectable: () => (cls: any) => cls, + Autowired: noopDecorator, + Inject: noopDecorator, + Optional: noopDecorator, + }; +}); + +import { + AcpPermissionCallerManager, + AcpPermissionCallerManagerToken, +} from '../../src/node/acp/acp-permission-caller.service'; + +const mockLogger = { + log: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + verbose: jest.fn(), + warn: jest.fn(), + critical: jest.fn(), + dispose: jest.fn(), + getLevel: jest.fn(), + setLevel: jest.fn(), +}; + +const mockRpcClient = { + $showPermissionDialog: jest.fn(), + $cancelRequest: jest.fn(), +}; + +describe('AcpPermissionCallerManager', () => { + let manager: AcpPermissionCallerManager; + + beforeEach(() => { + jest.clearAllMocks(); + + (AcpPermissionCallerManager as any).currentRpcClient = null; + + manager = new AcpPermissionCallerManager(); + Object.defineProperty(manager, 'logger', { value: mockLogger, writable: true }); + Object.defineProperty(manager, 'client', { value: mockRpcClient, writable: true }); + }); + + afterEach(() => { + (AcpPermissionCallerManager as any).currentRpcClient = null; + }); + + describe('setConnectionClientId()', () => { + it('should set clientId', () => { + manager.setConnectionClientId('client-1'); + + expect((manager as any).clientId).toBe('client-1'); + }); + + it('should update static currentRpcClient via microtask', async () => { + expect((AcpPermissionCallerManager as any).currentRpcClient).toBeNull(); + + manager.setConnectionClientId('client-1'); + + await Promise.resolve(); + + expect((AcpPermissionCallerManager as any).currentRpcClient).toBe(mockRpcClient); + }); + }); + + describe('removeConnectionClientId()', () => { + it('should clear clientId when matching', () => { + manager.setConnectionClientId('client-1'); + manager.removeConnectionClientId('client-1'); + + expect((manager as any).clientId).toBeUndefined(); + }); + }); + + describe('requestPermission() - skip mode', () => { + const originalEnv = process.env; + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it('should return allow option when SKIP_PERMISSION_CHECK=true', async () => { + process.env.SKIP_PERMISSION_CHECK = 'true'; + + const result = await manager.requestPermission({ + sessionId: 'sess-1', + toolCall: { toolCallId: 'tc-1', title: 'Test', kind: 'read', status: 'pending' } as any, + options: [ + { optionId: 'allow_once', name: 'Allow Once', kind: 'allow_once' as const }, + { optionId: 'allow_always', name: 'Allow Always', kind: 'allow_always' as const }, + { optionId: 'reject_once', name: 'Reject Once', kind: 'reject_once' as const }, + ], + }); + + expect(result.outcome.outcome).toBe('selected'); + expect(mockRpcClient.$showPermissionDialog).not.toHaveBeenCalled(); + }); + + it('should prefer allow_once over allow_always in skip mode', async () => { + process.env.SKIP_PERMISSION_CHECK = 'true'; + + const result = await manager.requestPermission({ + sessionId: 'sess-1', + toolCall: { toolCallId: 'tc-1', title: 'Test', kind: 'read', status: 'pending' } as any, + options: [ + { optionId: 'allow_always', name: 'Always', kind: 'allow_always' as const }, + { optionId: 'allow_once', name: 'Once', kind: 'allow_once' as const }, + ], + }); + + expect((result.outcome as any).optionId).toBe('allow_once'); + }); + + it('should fallback to first option in skip mode when no allow options', async () => { + process.env.SKIP_PERMISSION_CHECK = 'true'; + + const result = await manager.requestPermission({ + sessionId: 'sess-1', + toolCall: { toolCallId: 'tc-1', title: 'Test', kind: 'read', status: 'pending' } as any, + options: [{ optionId: 'custom', name: 'Custom', kind: 'custom' as any }], + }); + + expect((result.outcome as any).optionId).toBe('custom'); + }); + + it('should return empty string in skip mode when no options', async () => { + process.env.SKIP_PERMISSION_CHECK = 'true'; + + const result = await manager.requestPermission({ + sessionId: 'sess-1', + toolCall: { toolCallId: 'tc-1', title: 'Test', kind: 'read', status: 'pending' } as any, + options: [], + }); + + expect((result.outcome as any).optionId).toBe(''); + }); + }); + + describe('findAllowOptionId()', () => { + it('should prefer allow_once', () => { + const options = [ + { optionId: 'allow_always', name: 'Always', kind: 'allow_always' as const }, + { optionId: 'allow_once', name: 'Once', kind: 'allow_once' as const }, + ]; + + const result = (manager as any).findAllowOptionId(options); + expect(result).toBe('allow_once'); + }); + + it('should fallback to allow_always if no allow_once', () => { + const options = [{ optionId: 'allow_always', name: 'Always', kind: 'allow_always' as const }]; + + const result = (manager as any).findAllowOptionId(options); + expect(result).toBe('allow_always'); + }); + + it('should fallback to first option if no allow options', () => { + const options = [{ optionId: 'reject_once', name: 'Reject', kind: 'reject_once' as const }]; + + const result = (manager as any).findAllowOptionId(options); + expect(result).toBe('reject_once'); + }); + + it('should return empty string for empty options', () => { + const result = (manager as any).findAllowOptionId([]); + expect(result).toBe(''); + }); + }); + + describe('sortOptionsByKind()', () => { + it('should sort in correct order', () => { + const options = [ + { optionId: 'reject_once', kind: 'reject_once' as const }, + { optionId: 'allow_always', kind: 'allow_always' as const }, + { optionId: 'reject_always', kind: 'reject_always' as const }, + { optionId: 'allow_once', kind: 'allow_once' as const }, + ]; + + const result = (manager as any).sortOptionsByKind(options); + const kinds = result.map((o: any) => o.kind); + expect(kinds).toEqual(['allow_always', 'allow_once', 'reject_always', 'reject_once']); + }); + + it('should not mutate original array', () => { + const original = [ + { optionId: 'reject_once', kind: 'reject_once' as const }, + { optionId: 'allow_always', kind: 'allow_always' as const }, + ]; + + (manager as any).sortOptionsByKind(original); + + expect(original[0].kind).toBe('reject_once'); + }); + + it('should put unknown kinds at the end', () => { + const options = [ + { optionId: 'unknown', kind: 'unknown' as any }, + { optionId: 'allow_once', kind: 'allow_once' as const }, + ]; + + const result = (manager as any).sortOptionsByKind(options); + expect(result[0].kind).toBe('allow_once'); + expect(result[1].kind).toBe('unknown'); + }); + }); + + describe('requestPermission() - normal RPC flow', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.SKIP_PERMISSION_CHECK; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it('should call $showPermissionDialog with correct params', async () => { + mockRpcClient.$showPermissionDialog.mockResolvedValue({ type: 'allow', optionId: 'allow_once' }); + + const result = await manager.requestPermission({ + sessionId: 'sess-1', + toolCall: { + toolCallId: 'tc-1', + title: 'Run Command', + kind: 'execute', + status: 'pending', + locations: [{ path: '/src/test.ts', line: 10 }], + rawInput: { command: 'npm test' }, + } as any, + options: [{ optionId: 'allow_once', name: 'Allow Once', kind: 'allow_once' as const }], + }); + + expect(mockRpcClient.$showPermissionDialog).toHaveBeenCalledWith( + expect.objectContaining({ + requestId: 'sess-1:tc-1', + sessionId: 'sess-1', + title: 'Run Command', + kind: 'execute', + content: expect.any(String), + locations: [{ path: '/src/test.ts', line: 10 }], + options: [{ optionId: 'allow_once', name: 'Allow Once', kind: 'allow_once' }], + timeout: 60000, + }), + ); + expect(result.outcome.outcome).toBe('selected'); + expect((result.outcome as any).optionId).toBe('allow_once'); + }); + + it('should build content with title, affected files, and command', async () => { + mockRpcClient.$showPermissionDialog.mockResolvedValue({ type: 'allow' }); + + await manager.requestPermission({ + sessionId: 'sess-1', + toolCall: { + toolCallId: 'tc-1', + title: 'Edit File', + kind: 'write', + status: 'pending', + locations: [{ path: '/src/a.ts' }, { path: '/src/b.ts' }], + rawInput: { command: 'write to file' }, + } as any, + options: [{ optionId: 'opt-1', name: 'Allow', kind: 'allow_once' as const }], + }); + + const callArg = mockRpcClient.$showPermissionDialog.mock.calls[0][0]; + expect(callArg.content).toContain('Edit File'); + expect(callArg.content).toContain('Affected files: /src/a.ts, /src/b.ts'); + expect(callArg.content).toContain('Command: `write to file`'); + }); + + it('should throw when no RPC client available', async () => { + (AcpPermissionCallerManager as any).currentRpcClient = null; + Object.defineProperty(manager, 'client', { value: null, writable: true }); + + await expect( + manager.requestPermission({ + sessionId: 'sess-1', + toolCall: { toolCallId: 'tc-1', title: 'Test', kind: 'read', status: 'pending' } as any, + options: [{ optionId: 'opt-1', name: 'Allow', kind: 'allow_once' as const }], + }), + ).rejects.toThrow('[ACP Permission Caller] No active RPC client available'); + }); + + it('should use static currentRpcClient as fallback', async () => { + const staticClient = { + $showPermissionDialog: jest.fn().mockResolvedValue({ type: 'allow' }), + $cancelRequest: jest.fn(), + }; + (AcpPermissionCallerManager as any).currentRpcClient = staticClient; + Object.defineProperty(manager, 'client', { value: null, writable: true }); + + await manager.requestPermission({ + sessionId: 'sess-1', + toolCall: { toolCallId: 'tc-1', title: 'Test', kind: 'read', status: 'pending' } as any, + options: [{ optionId: 'opt-1', name: 'Allow', kind: 'allow_once' as const }], + }); + + expect(staticClient.$showPermissionDialog).toHaveBeenCalled(); + }); + }); + + describe('buildPermissionResponse()', () => { + const options = [ + { optionId: 'allow_once', name: 'Allow Once', kind: 'allow_once' as const }, + { optionId: 'allow_always', name: 'Allow Always', kind: 'allow_always' as const }, + { optionId: 'reject_once', name: 'Reject Once', kind: 'reject_once' as const }, + { optionId: 'reject_always', name: 'Reject Always', kind: 'reject_always' as const }, + ]; + + it('should return selected outcome for allow decision', () => { + const result = (manager as any).buildPermissionResponse({ type: 'allow', optionId: 'allow_once' }, options); + expect(result.outcome.outcome).toBe('selected'); + expect(result.outcome.optionId).toBe('allow_once'); + }); + + it('should return selected outcome for reject decision', () => { + const result = (manager as any).buildPermissionResponse({ type: 'reject', optionId: 'reject_once' }, options); + expect(result.outcome.outcome).toBe('selected'); + expect(result.outcome.optionId).toBe('reject_once'); + }); + + it('should auto-find optionId when not provided in allow decision', () => { + const result = (manager as any).buildPermissionResponse({ type: 'allow' }, options); + expect(result.outcome.outcome).toBe('selected'); + expect(result.outcome.optionId).toBe('allow_once'); + }); + + it('should auto-find optionId when not provided in reject decision', () => { + const result = (manager as any).buildPermissionResponse({ type: 'reject' }, options); + expect(result.outcome.outcome).toBe('selected'); + expect(result.outcome.optionId).toBe('reject_once'); + }); + + it('should return cancelled outcome for timeout decision', () => { + const result = (manager as any).buildPermissionResponse({ type: 'timeout' }, options); + expect(result.outcome.outcome).toBe('cancelled'); + }); + + it('should return cancelled outcome for cancelled decision', () => { + const result = (manager as any).buildPermissionResponse({ type: 'cancelled' }, options); + expect(result.outcome.outcome).toBe('cancelled'); + }); + + it('should return cancelled outcome for unknown decision type', () => { + const result = (manager as any).buildPermissionResponse({ type: 'unknown' as any }, options); + expect(result.outcome.outcome).toBe('cancelled'); + }); + }); + + describe('findOptionId()', () => { + const options = [ + { optionId: 'allow_once', name: 'Allow Once', kind: 'allow_once' as const }, + { optionId: 'allow_always', name: 'Allow Always', kind: 'allow_always' as const }, + { optionId: 'reject_once', name: 'Reject Once', kind: 'reject_once' as const }, + { optionId: 'reject_always', name: 'Reject Always', kind: 'reject_always' as const }, + ]; + + it('should find allow_once for allow decision', () => { + const result = (manager as any).findOptionId('allow', options); + expect(result).toBe('allow_once'); + }); + + it('should find reject_once for reject decision', () => { + const result = (manager as any).findOptionId('reject', options); + expect(result).toBe('reject_once'); + }); + + it('should fallback to allow_always when no allow_once', () => { + const opts = options.filter((o) => o.kind !== 'allow_once'); + const result = (manager as any).findOptionId('allow', opts); + expect(result).toBe('allow_always'); + }); + + it('should fallback to prefix match when no exact kind match', () => { + const opts = [{ optionId: 'allow_custom', name: 'Custom', kind: 'allow_custom' as any }]; + const result = (manager as any).findOptionId('allow', opts); + expect(result).toBe('allow_custom'); + }); + + it('should fallback to first option when no match', () => { + const opts = [{ optionId: 'custom', name: 'Custom', kind: 'custom' as any }]; + const result = (manager as any).findOptionId('allow', opts); + expect(result).toBe('custom'); + }); + + it('should return empty string for empty options', () => { + const result = (manager as any).findOptionId('allow', []); + expect(result).toBe(''); + }); + }); + + describe('cancelRequest()', () => { + it('should call $cancelRequest on rpc client', async () => { + mockRpcClient.$cancelRequest.mockResolvedValue(undefined); + + await manager.cancelRequest('req-123'); + + expect(mockRpcClient.$cancelRequest).toHaveBeenCalledWith('req-123'); + }); + + it('should use static currentRpcClient as fallback', async () => { + const staticClient = { + $showPermissionDialog: jest.fn(), + $cancelRequest: jest.fn().mockResolvedValue(undefined), + }; + (AcpPermissionCallerManager as any).currentRpcClient = staticClient; + Object.defineProperty(manager, 'client', { value: null, writable: true }); + + await manager.cancelRequest('req-456'); + + expect(staticClient.$cancelRequest).toHaveBeenCalledWith('req-456'); + }); + + it('should not throw when rpc client is unavailable', async () => { + (AcpPermissionCallerManager as any).currentRpcClient = null; + Object.defineProperty(manager, 'client', { value: null, writable: true }); + + await expect(manager.cancelRequest('req-789')).resolves.not.toThrow(); + }); + + it('should log error when $cancelRequest fails', async () => { + mockRpcClient.$cancelRequest.mockRejectedValue(new Error('Network error')); + + await manager.cancelRequest('req-123'); + + expect(mockLogger.error).toHaveBeenCalledWith( + '[ACP Permission Caller] Failed to cancel request:', + expect.any(Error), + ); + }); + }); + + describe('removeConnectionClientId() - edge cases', () => { + it('should not clear clientId when mismatched', () => { + manager.setConnectionClientId('client-1'); + manager.removeConnectionClientId('client-2'); + + expect((manager as any).clientId).toBe('client-1'); + }); + + it('should not clear static currentRpcClient when client mismatched', () => { + const otherClient = { $showPermissionDialog: jest.fn(), $cancelRequest: jest.fn() }; + (AcpPermissionCallerManager as any).currentRpcClient = otherClient; + + manager.setConnectionClientId('client-1'); + manager.removeConnectionClientId('client-2'); + + expect((AcpPermissionCallerManager as any).currentRpcClient).toBe(otherClient); + }); + + it('should clear static currentRpcClient when matching', async () => { + manager.setConnectionClientId('client-1'); + await Promise.resolve(); + + expect((AcpPermissionCallerManager as any).currentRpcClient).toBe(mockRpcClient); + + manager.removeConnectionClientId('client-1'); + + expect((AcpPermissionCallerManager as any).currentRpcClient).toBeNull(); + }); + }); +}); diff --git a/packages/ai-native/__test__/node/acp-terminal-handler.test.ts b/packages/ai-native/__test__/node/acp-terminal-handler.test.ts new file mode 100644 index 0000000000..cce1be00d2 --- /dev/null +++ b/packages/ai-native/__test__/node/acp-terminal-handler.test.ts @@ -0,0 +1,491 @@ +jest.mock('@opensumi/di', () => { + const actual = jest.requireActual('@opensumi/di'); + const noopDecorator = () => () => {}; + return { + ...actual, + Injectable: () => (cls: any) => cls, + Autowired: noopDecorator, + Inject: noopDecorator, + Optional: noopDecorator, + }; +}); + +// Mock node-pty +const mockPtyProcess = { + pid: 12345, + onData: jest.fn(), + onExit: jest.fn(), + kill: jest.fn(), +}; + +jest.mock('node-pty', () => ({ + spawn: jest.fn(() => mockPtyProcess), +})); + +import pty from 'node-pty'; + +import { ACPErrorCode } from '../../src/node/acp/handlers/constants'; +import { AcpTerminalHandler, AcpTerminalHandlerToken } from '../../src/node/acp/handlers/terminal.handler'; + +const mockLogger = { + log: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + verbose: jest.fn(), + warn: jest.fn(), + critical: jest.fn(), + dispose: jest.fn(), + getLevel: jest.fn(), + setLevel: jest.fn(), +}; + +describe('AcpTerminalHandler', () => { + let handler: AcpTerminalHandler; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + + mockPtyProcess.onData = jest.fn(); + mockPtyProcess.onExit = jest.fn(); + mockPtyProcess.kill = jest.fn(); + + handler = new AcpTerminalHandler(); + Object.defineProperty(handler, 'logger', { value: mockLogger, writable: true }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('configure()', () => { + it('should set output limit', () => { + handler.configure({ outputLimit: 2048 }); + + expect((handler as any).defaultOutputLimit).toBe(2048); + }); + + it('should not change limit if not provided', () => { + const original = (handler as any).defaultOutputLimit; + handler.configure({}); + + expect((handler as any).defaultOutputLimit).toBe(original); + }); + }); + + describe('setPermissionCallback()', () => { + it('should set the callback', () => { + const cb = jest.fn(); + handler.setPermissionCallback(cb); + + expect((handler as any).permissionCallback).toBe(cb); + }); + }); + + describe('createTerminal()', () => { + const baseRequest = { + sessionId: 'sess-1', + command: 'bash', + args: ['-c', 'echo hello'], + }; + + it('should create terminal and return terminalId', async () => { + const result = await handler.createTerminal(baseRequest); + + expect(result.terminalId).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(pty.spawn).toHaveBeenCalledWith('bash', ['-c', 'echo hello'], expect.any(Object)); + }); + + it('should default to /bin/sh when no command provided', async () => { + await handler.createTerminal({ sessionId: 'sess-1' }); + + expect(pty.spawn).toHaveBeenCalledWith('/bin/sh', [], expect.any(Object)); + }); + + it('should deny creation when permission callback returns false', async () => { + handler.setPermissionCallback(jest.fn().mockResolvedValue(false)); + + const result = await handler.createTerminal(baseRequest); + + expect(result.error).toBeDefined(); + expect(result.error?.code).toBe(ACPErrorCode.FORBIDDEN); + expect(result.error?.message).toContain('permission denied'); + }); + + it('should allow creation when permission callback returns true', async () => { + handler.setPermissionCallback(jest.fn().mockResolvedValue(true)); + + const result = await handler.createTerminal(baseRequest); + + expect(result.error).toBeUndefined(); + expect(result.terminalId).toBeDefined(); + }); + + it('should create directly without permission callback', async () => { + const result = await handler.createTerminal(baseRequest); + + expect(result.error).toBeUndefined(); + expect(pty.spawn).toHaveBeenCalled(); + }); + + it('should merge environment variables', async () => { + await handler.createTerminal({ + sessionId: 'sess-1', + command: 'bash', + env: { MY_VAR: 'test' }, + }); + + const spawnCall = (pty.spawn as jest.Mock).mock.calls[0]; + expect(spawnCall[2].env).toHaveProperty('MY_VAR', 'test'); + expect(spawnCall[2].env).toHaveProperty('PATH', process.env.PATH); + }); + + it('should use custom cwd', async () => { + await handler.createTerminal({ + sessionId: 'sess-1', + command: 'bash', + cwd: '/custom/path', + }); + + const spawnCall = (pty.spawn as jest.Mock).mock.calls[0]; + expect(spawnCall[2].cwd).toBe('/custom/path'); + }); + + it('should use default cwd when not provided', async () => { + await handler.createTerminal({ sessionId: 'sess-1', command: 'bash' }); + + const spawnCall = (pty.spawn as jest.Mock).mock.calls[0]; + expect(spawnCall[2].cwd).toBe(process.cwd()); + }); + + it('should set outputByteLimit from request', async () => { + const result = await handler.createTerminal({ + sessionId: 'sess-1', + command: 'bash', + outputByteLimit: 512, + }); + + const terminalId = result.terminalId!; + const session = (handler as any).terminals.get(terminalId); + expect(session.outputByteLimit).toBe(512); + }); + + it('should use default outputByteLimit when not provided', async () => { + const result = await handler.createTerminal({ sessionId: 'sess-1', command: 'bash' }); + + const terminalId = result.terminalId!; + const session = (handler as any).terminals.get(terminalId); + expect(session.outputByteLimit).toBe((handler as any).defaultOutputLimit); + }); + + it('should handle spawn error', async () => { + (pty.spawn as jest.Mock).mockImplementationOnce(() => { + throw new Error('spawn failed'); + }); + + const result = await handler.createTerminal(baseRequest); + + expect(result.error).toBeDefined(); + expect(result.error?.message).toBe('spawn failed'); + }); + }); + + describe('getTerminalOutput()', () => { + it('should return terminal not found error for unknown terminal', async () => { + const result = await handler.getTerminalOutput({ + sessionId: 'sess-1', + terminalId: 'unknown', + }); + + expect(result.error).toBeDefined(); + expect(result.error?.message).toBe('Terminal not found'); + }); + + it('should return session mismatch error', async () => { + const createResult = await handler.createTerminal({ sessionId: 'sess-1', command: 'bash' }); + const terminalId = createResult.terminalId!; + + const result = await handler.getTerminalOutput({ + sessionId: 'sess-2', + terminalId, + }); + + expect(result.error).toBeDefined(); + expect(result.error?.message).toBe('Session mismatch'); + }); + + it('should return output buffer', async () => { + const createResult = await handler.createTerminal({ sessionId: 'sess-1', command: 'bash' }); + const terminalId = createResult.terminalId!; + + // Simulate output + const session = (handler as any).terminals.get(terminalId); + session.outputBuffer = 'hello world'; + + const result = await handler.getTerminalOutput({ sessionId: 'sess-1', terminalId }); + + expect(result.output).toBe('hello world'); + expect(result.truncated).toBe(false); + }); + + it('should return truncated flag when buffer exceeds limit', async () => { + const createResult = await handler.createTerminal({ + sessionId: 'sess-1', + command: 'bash', + outputByteLimit: 10, + }); + const terminalId = createResult.terminalId!; + + const session = (handler as any).terminals.get(terminalId); + session.outputBuffer = 'This is a long output string that exceeds the limit'; + + const result = await handler.getTerminalOutput({ sessionId: 'sess-1', terminalId }); + + expect(result.truncated).toBe(true); + }); + + it('should return exitStatus when terminal has exited', async () => { + const createResult = await handler.createTerminal({ sessionId: 'sess-1', command: 'bash' }); + const terminalId = createResult.terminalId!; + + const session = (handler as any).terminals.get(terminalId); + session.exited = true; + session.exitCode = 0; + + const result = await handler.getTerminalOutput({ sessionId: 'sess-1', terminalId }); + + expect(result.exitStatus).toBe(0); + }); + + it('should return null exitStatus when still running', async () => { + const createResult = await handler.createTerminal({ sessionId: 'sess-1', command: 'bash' }); + const terminalId = createResult.terminalId!; + + const result = await handler.getTerminalOutput({ sessionId: 'sess-1', terminalId }); + + expect(result.exitStatus).toBe(null); + }); + }); + + describe('waitForTerminalExit()', () => { + it('should return immediately when already exited', async () => { + const createResult = await handler.createTerminal({ sessionId: 'sess-1', command: 'bash' }); + const terminalId = createResult.terminalId!; + + const session = (handler as any).terminals.get(terminalId); + session.exited = true; + session.exitCode = 42; + + const result = await handler.waitForTerminalExit({ sessionId: 'sess-1', terminalId }); + + expect(result.exitCode).toBe(42); + }); + + it('should return terminal not found error', async () => { + const result = await handler.waitForTerminalExit({ + sessionId: 'sess-1', + terminalId: 'unknown', + }); + + expect(result.error).toBeDefined(); + expect(result.error?.message).toBe('Terminal not found'); + }); + + it('should return session mismatch error', async () => { + const createResult = await handler.createTerminal({ sessionId: 'sess-1', command: 'bash' }); + const terminalId = createResult.terminalId!; + + const result = await handler.waitForTerminalExit({ + sessionId: 'sess-2', + terminalId, + }); + + expect(result.error).toBeDefined(); + expect(result.error?.message).toBe('Session mismatch'); + }); + + it('should return null exitStatus on timeout', async () => { + const createResult = await handler.createTerminal({ sessionId: 'sess-1', command: 'bash' }); + const terminalId = createResult.terminalId!; + + const exitPromise = handler.waitForTerminalExit({ + sessionId: 'sess-1', + terminalId, + timeout: 1000, + }); + + jest.advanceTimersByTime(1500); + + const result = await exitPromise; + expect(result.exitStatus).toBe(null); + }); + + it('should return exitCode when terminal exits within timeout', async () => { + let exitCallback: Function | null = null; + mockPtyProcess.onExit.mockImplementation((cb: Function) => { + exitCallback = cb; + }); + + const createResult = await handler.createTerminal({ sessionId: 'sess-1', command: 'bash' }); + const terminalId = createResult.terminalId!; + + const exitPromise = handler.waitForTerminalExit({ + sessionId: 'sess-1', + terminalId, + timeout: 5000, + }); + + // Simulate terminal exit + const session = (handler as any).terminals.get(terminalId); + session.exited = true; + session.exitCode = 0; + + jest.advanceTimersByTime(200); + + const result = await exitPromise; + expect(result.exitCode).toBe(0); + }); + }); + + describe('killTerminal()', () => { + it('should return terminal not found error', async () => { + const result = await handler.killTerminal({ + sessionId: 'sess-1', + terminalId: 'unknown', + }); + + expect(result.error).toBeDefined(); + expect(result.error?.message).toBe('Terminal not found'); + }); + + it('should return session mismatch error', async () => { + const createResult = await handler.createTerminal({ sessionId: 'sess-1', command: 'bash' }); + const terminalId = createResult.terminalId!; + + const result = await handler.killTerminal({ + sessionId: 'sess-2', + terminalId, + }); + + expect(result.error).toBeDefined(); + expect(result.error?.message).toBe('Session mismatch'); + }); + + it('should return exitStatus when already exited', async () => { + const createResult = await handler.createTerminal({ sessionId: 'sess-1', command: 'bash' }); + const terminalId = createResult.terminalId!; + + const session = (handler as any).terminals.get(terminalId); + session.exited = true; + session.exitCode = 1; + + const result = await handler.killTerminal({ sessionId: 'sess-1', terminalId }); + + expect(result.exitStatus).toBe(1); + expect(mockPtyProcess.kill).not.toHaveBeenCalled(); + }); + + it('should kill the PTY process', async () => { + const createResult = await handler.createTerminal({ sessionId: 'sess-1', command: 'bash' }); + const terminalId = createResult.terminalId!; + + const killPromise = handler.killTerminal({ sessionId: 'sess-1', terminalId }); + + // Simulate exit after kill + jest.advanceTimersByTime(50); + const session = (handler as any).terminals.get(terminalId); + session.exited = true; + session.exitCode = -1; + + jest.advanceTimersByTime(200); + + const result = await killPromise; + expect(mockPtyProcess.kill).toHaveBeenCalled(); + }); + }); + + describe('releaseTerminal()', () => { + it('should return empty when terminal does not exist', async () => { + const result = await handler.releaseTerminal({ + sessionId: 'sess-1', + terminalId: 'unknown', + }); + + expect(result).toEqual({}); + }); + + it('should return session mismatch error', async () => { + const createResult = await handler.createTerminal({ sessionId: 'sess-1', command: 'bash' }); + const terminalId = createResult.terminalId!; + + const result = await handler.releaseTerminal({ + sessionId: 'sess-2', + terminalId, + }); + + expect(result.error).toBeDefined(); + expect(result.error?.message).toBe('Session mismatch'); + }); + + it('should remove terminal from tracking map', async () => { + const createResult = await handler.createTerminal({ sessionId: 'sess-1', command: 'bash' }); + const terminalId = createResult.terminalId!; + + await handler.releaseTerminal({ sessionId: 'sess-1', terminalId }); + + expect((handler as any).terminals.has(terminalId)).toBe(false); + }); + + it('should kill PTY process if not exited', async () => { + const createResult = await handler.createTerminal({ sessionId: 'sess-1', command: 'bash' }); + const terminalId = createResult.terminalId!; + + await handler.releaseTerminal({ sessionId: 'sess-1', terminalId }); + + expect(mockPtyProcess.kill).toHaveBeenCalled(); + }); + }); + + describe('releaseSessionTerminals()', () => { + it('should release all terminals for a session', async () => { + const r1 = await handler.createTerminal({ sessionId: 'sess-1', command: 'bash' }); + const r2 = await handler.createTerminal({ sessionId: 'sess-1', command: 'ls' }); + await handler.createTerminal({ sessionId: 'sess-2', command: 'bash' }); + + const termId1 = r1.terminalId!; + const termId2 = r2.terminalId!; + + await handler.releaseSessionTerminals('sess-1'); + + expect((handler as any).terminals.has(termId1)).toBe(false); + expect((handler as any).terminals.has(termId2)).toBe(false); + expect((handler as any).terminals.size).toBe(1); + }); + + it('should do nothing when no terminals exist for session', async () => { + await handler.releaseSessionTerminals('non-existent'); + + expect(mockLogger.log).toHaveBeenCalledWith(expect.stringContaining('Released 0 terminals')); + }); + }); + + describe('getSessionTerminals()', () => { + it('should return terminal IDs for a session', async () => { + const r1 = await handler.createTerminal({ sessionId: 'sess-1', command: 'bash' }); + const r2 = await handler.createTerminal({ sessionId: 'sess-1', command: 'ls' }); + await handler.createTerminal({ sessionId: 'sess-2', command: 'bash' }); + + const ids = handler.getSessionTerminals('sess-1'); + + expect(ids).toContain(r1.terminalId); + expect(ids).toContain(r2.terminalId); + expect(ids).toHaveLength(2); + }); + + it('should return empty array for session with no terminals', () => { + const ids = handler.getSessionTerminals('non-existent'); + expect(ids).toEqual([]); + }); + }); +}); diff --git a/packages/ai-native/__tests__/node/acp/cli-agent-process-manager.test.ts b/packages/ai-native/__tests__/node/acp/cli-agent-process-manager.test.ts new file mode 100644 index 0000000000..dd806d6bf4 --- /dev/null +++ b/packages/ai-native/__tests__/node/acp/cli-agent-process-manager.test.ts @@ -0,0 +1,506 @@ +import { EventEmitter } from 'events'; + +// Mock child_process module before importing the class under test +const mockSpawn = jest.fn(); + +jest.mock('child_process', () => ({ + spawn: (...args: any[]) => mockSpawn(...args), +})); + +import { CliAgentProcessManager } from '../../../src/node/acp/cli-agent-process-manager'; + +// Mock dependencies +const mockLogger = { + log: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + info: jest.fn(), +}; + +jest.mock('@opensumi/di', () => ({ + Injectable: () => jest.fn(), + Autowired: () => jest.fn(), +})); + +jest.mock('@opensumi/ide-core-node', () => ({ + INodeLogger: Symbol('INodeLogger'), +})); + +// Helper: create a mock ChildProcess with controllable behavior +function createMockChildProcess(opts?: { pid?: number; killed?: boolean; exitCode?: number | null }): any { + const mock = new EventEmitter() as any; + mock.pid = opts?.pid ?? 12345; + mock.killed = opts?.killed ?? false; + mock.exitCode = opts?.exitCode ?? null; + mock.signalCode = null; + mock.stdin = { write: jest.fn(), on: jest.fn(), pipe: jest.fn() }; + mock.stdout = new EventEmitter(); + mock.stderr = new EventEmitter(); + mock.kill = jest.fn().mockReturnValue(true); + mock.stdio = [mock.stdin, mock.stdout, mock.stderr]; + return mock; +} + +describe('CliAgentProcessManager', () => { + let manager: CliAgentProcessManager; + let mockProcessKill: jest.SpyInstance; + + const defaultCommand = '/usr/bin/agent'; + const defaultArgs = ['--mode', 'cli']; + const defaultEnv = { KEY: 'value' }; + const defaultCwd = '/tmp/workspace'; + + beforeEach(() => { + jest.useFakeTimers(); + mockSpawn.mockClear(); + + mockProcessKill = jest.spyOn(process, 'kill').mockImplementation(() => true as any); + + manager = new CliAgentProcessManager(); + (manager as any).logger = mockLogger; + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + // ==================== startAgent ==================== + + describe('startAgent', () => { + it('should create a new process when none exists', async () => { + const mockChild = createMockChildProcess(); + mockSpawn.mockReturnValue(mockChild); + + const startPromise = manager.startAgent(defaultCommand, defaultArgs, defaultEnv, defaultCwd); + jest.advanceTimersByTime(100); + const result = await startPromise; + + expect(mockSpawn).toHaveBeenCalledWith(defaultCommand, defaultArgs, { + cwd: defaultCwd, + stdio: ['pipe', 'pipe', 'pipe'], + detached: false, + shell: false, + env: expect.objectContaining({ KEY: 'value' }), + }); + expect(result.processId).toBe('12345'); + expect(result.stdout).toBe(mockChild.stdio[1]); + expect(result.stdin).toBe(mockChild.stdio[0]); + }); + + it('should reject with wrapped error when command not found (ENOENT)', async () => { + const mockChild = createMockChildProcess(); + mockSpawn.mockReturnValue(mockChild); + + const promise = manager.startAgent('nonexistent', [], {}, '/tmp'); + + // Emit error event (simulates spawn failing immediately) + const err: any = new Error('spawn ENOENT'); + err.code = 'ENOENT'; + mockChild.emit('error', err); + + jest.advanceTimersByTime(100); + + await expect(promise).rejects.toThrow( + 'Command not found: nonexistent. Please ensure the CLI agent is installed.', + ); + }); + + it('should reject with wrapped error when permission denied (EACCES)', async () => { + const mockChild = createMockChildProcess(); + mockSpawn.mockReturnValue(mockChild); + + const promise = manager.startAgent('/bin/restricted', [], {}, '/tmp'); + + const err: any = new Error('spawn EACCES'); + err.code = 'EACCES'; + mockChild.emit('error', err); + + jest.advanceTimersByTime(100); + + await expect(promise).rejects.toThrow('Permission denied when executing: /bin/restricted'); + }); + + it('should reject when child process has no PID', async () => { + const mockChild = createMockChildProcess({ pid: 0 }); + mockSpawn.mockReturnValue(mockChild); + + const promise = manager.startAgent(defaultCommand, defaultArgs, defaultEnv, defaultCwd); + jest.advanceTimersByTime(100); + + await expect(promise).rejects.toThrow('Failed to get PID for agent process'); + }); + + it('should reuse existing process when config is the same', async () => { + const mockChild = createMockChildProcess(); + mockSpawn.mockReturnValue(mockChild); + + const p1 = manager.startAgent(defaultCommand, defaultArgs, defaultEnv, defaultCwd); + jest.advanceTimersByTime(100); + const result1 = await p1; + + mockSpawn.mockClear(); + const p2 = manager.startAgent(defaultCommand, defaultArgs, defaultEnv, defaultCwd); + const result2 = await p2; + + expect(mockSpawn).not.toHaveBeenCalled(); + expect(result2.processId).toBe(result1.processId); + }); + + it('should clean up exited process and create new one', async () => { + const mockChild1 = createMockChildProcess(); + mockSpawn.mockReturnValue(mockChild1); + + const p1 = manager.startAgent(defaultCommand, defaultArgs, defaultEnv, defaultCwd); + jest.advanceTimersByTime(100); + await p1; + + // Simulate process exit + mockChild1.killed = true; + mockChild1.exitCode = 0; + mockChild1.emit('exit', 0, null); + + const mockChild2 = createMockChildProcess({ pid: 99999 }); + mockSpawn.mockReturnValue(mockChild2); + mockSpawn.mockClear(); + + const p2 = manager.startAgent(defaultCommand, defaultArgs, defaultEnv, defaultCwd); + jest.advanceTimersByTime(100); + const result = await p2; + + expect(result.processId).toBe('99999'); + }); + + it('should use SUMI_ACP_AGENT_PATH env var to override command', async () => { + const originalEnv = process.env.SUMI_ACP_AGENT_PATH; + process.env.SUMI_ACP_AGENT_PATH = '/custom/agent/path'; + + const mockChild = createMockChildProcess(); + mockSpawn.mockReturnValue(mockChild); + + const p = manager.startAgent(defaultCommand, defaultArgs, defaultEnv, defaultCwd); + jest.advanceTimersByTime(100); + await p; + + expect(mockSpawn).toHaveBeenCalledWith('/custom/agent/path', defaultArgs, expect.any(Object)); + + if (originalEnv !== undefined) { + process.env.SUMI_ACP_AGENT_PATH = originalEnv; + } else { + delete process.env.SUMI_ACP_AGENT_PATH; + } + }); + + it('should set NODE and PATH in env based on SUMI_ACP_NODE_PATH', async () => { + const originalNodePath = process.env.SUMI_ACP_NODE_PATH; + process.env.SUMI_ACP_NODE_PATH = '/opt/node/v18/bin/node'; + + const mockChild = createMockChildProcess(); + mockSpawn.mockReturnValue(mockChild); + + const p = manager.startAgent(defaultCommand, defaultArgs, defaultEnv, defaultCwd); + jest.advanceTimersByTime(100); + await p; + + const spawnOpts = mockSpawn.mock.calls[0][2]; + expect(spawnOpts.env.NODE).toBe('/opt/node/v18/bin/node'); + expect(spawnOpts.env.PATH).toContain('/opt/node/v18'); + + if (originalNodePath !== undefined) { + process.env.SUMI_ACP_NODE_PATH = originalNodePath; + } else { + delete process.env.SUMI_ACP_NODE_PATH; + } + }); + }); + + // ==================== isRunning ==================== + + describe('isRunning', () => { + it('should return false when no process exists', () => { + expect(manager.isRunning()).toBe(false); + }); + + it('should return false when process is killed', () => { + const mockChild = createMockChildProcess({ killed: true }); + (manager as any).currentProcess = mockChild; + + expect(manager.isRunning()).toBe(false); + }); + + it('should return false when process has exit code', () => { + const mockChild = createMockChildProcess({ exitCode: 1 }); + (manager as any).currentProcess = mockChild; + + expect(manager.isRunning()).toBe(false); + }); + + it('should return false when process has no pid', () => { + const mockChild = createMockChildProcess({ pid: 0 }); + (manager as any).currentProcess = mockChild; + + expect(manager.isRunning()).toBe(false); + }); + + it('should return true when process exists and is alive', () => { + const mockChild = createMockChildProcess(); + (manager as any).currentProcess = mockChild; + + expect(manager.isRunning()).toBe(true); + }); + + it('should return false when process.kill(pid, 0) throws', () => { + const mockChild = createMockChildProcess(); + (manager as any).currentProcess = mockChild; + + mockProcessKill.mockImplementation(() => { + throw new Error('kill ESRCH'); + }); + + expect(manager.isRunning()).toBe(false); + }); + }); + + // ==================== getExitCode ==================== + + describe('getExitCode', () => { + it('should return null when no process exists', () => { + expect(manager.getExitCode()).toBeNull(); + }); + + it('should return exit code when process has one', () => { + const mockChild = createMockChildProcess({ exitCode: 42 }); + (manager as any).currentProcess = mockChild; + + expect(manager.getExitCode()).toBe(42); + }); + + it('should return null when process has no exit code yet', () => { + const mockChild = createMockChildProcess(); + (manager as any).currentProcess = mockChild; + + expect(manager.getExitCode()).toBeNull(); + }); + }); + + // ==================== listRunningAgents ==================== + + describe('listRunningAgents', () => { + it('should return empty array when no process', () => { + expect(manager.listRunningAgents()).toEqual([]); + }); + + it('should return singleton ID when process is running', () => { + const mockChild = createMockChildProcess(); + (manager as any).currentProcess = mockChild; + + expect(manager.listRunningAgents()).toEqual(['singleton-agent-process']); + }); + }); + + // ==================== stopAgent ==================== + + describe('stopAgent', () => { + it('should return immediately when no process exists', async () => { + await manager.stopAgent(); + expect(mockProcessKill).not.toHaveBeenCalled(); + }); + + it('should send SIGTERM to process group and wait for graceful exit', async () => { + const mockChild = createMockChildProcess(); + (manager as any).currentProcess = mockChild; + + const stopPromise = manager.stopAgent(); + + expect(mockProcessKill).toHaveBeenCalledWith(-12345, 'SIGTERM'); + + mockChild.emit('exit', 0, null); + + await stopPromise; + }); + + it('should force kill after graceful shutdown timeout', async () => { + const mockChild = createMockChildProcess(); + (manager as any).currentProcess = mockChild; + + const stopPromise = manager.stopAgent(); + + expect(mockProcessKill).toHaveBeenCalledWith(-12345, 'SIGTERM'); + + jest.advanceTimersByTime(5000); + + expect(mockProcessKill).toHaveBeenCalledWith(-12345, 'SIGKILL'); + + await stopPromise; + }); + }); + + // ==================== killAgent ==================== + + describe('killAgent', () => { + it('should send SIGKILL to process group immediately', async () => { + const mockChild = createMockChildProcess(); + (manager as any).currentProcess = mockChild; + + const killPromise = manager.killAgent(); + + expect(mockProcessKill).toHaveBeenCalledWith(-12345, 'SIGKILL'); + + mockChild.emit('exit', null, 'SIGKILL'); + + await killPromise; + }); + + it('should resolve after timeout even if process does not exit', async () => { + const mockChild = createMockChildProcess(); + (manager as any).currentProcess = mockChild; + + const killPromise = manager.killAgent(); + + jest.advanceTimersByTime(3000); + + await killPromise; + + expect((manager as any).currentProcess).toBeNull(); + }); + + it('should resolve immediately when no process', async () => { + await manager.killAgent(); + expect(mockProcessKill).not.toHaveBeenCalled(); + }); + }); + + // ==================== killAllAgents ==================== + + describe('killAllAgents', () => { + it('should delegate to forceKillInternal', async () => { + const mockChild = createMockChildProcess(); + (manager as any).currentProcess = mockChild; + + const killPromise = manager.killAllAgents(); + + expect(mockProcessKill).toHaveBeenCalledWith(-12345, 'SIGKILL'); + + mockChild.emit('exit', null, 'SIGKILL'); + + await killPromise; + }); + }); + + // ==================== killProcessGroup ==================== + + describe('killProcessGroup', () => { + it('should try process group kill first', () => { + const mockChild = createMockChildProcess(); + (manager as any).currentProcess = mockChild; + + (manager as any).killProcessGroup(12345, 'SIGTERM'); + + expect(mockProcessKill).toHaveBeenNthCalledWith(1, -12345, 'SIGTERM'); + }); + + it('should fallback to single process kill when group kill fails', () => { + const mockChild = createMockChildProcess(); + (manager as any).currentProcess = mockChild; + + let callCount = 0; + mockProcessKill.mockImplementation(() => { + callCount++; + if (callCount === 1) { + throw new Error('ESRCH'); + } + return true as any; + }); + + const result = (manager as any).killProcessGroup(12345, 'SIGTERM'); + + expect(mockProcessKill).toHaveBeenNthCalledWith(1, -12345, 'SIGTERM'); + expect(mockProcessKill).toHaveBeenNthCalledWith(2, 12345, 'SIGTERM'); + expect(result).toBe(true); + }); + + it('should return false when both kills fail', () => { + const mockChild = createMockChildProcess(); + (manager as any).currentProcess = mockChild; + + mockProcessKill.mockImplementation(() => { + throw new Error('ESRCH'); + }); + + const result = (manager as any).killProcessGroup(12345, 'SIGTERM'); + + expect(result).toBe(false); + }); + }); + + // ==================== handleProcessExit ==================== + + describe('handleProcessExit', () => { + it('should clear all state on exit', async () => { + const mockChild = createMockChildProcess(); + (manager as any).currentProcess = mockChild; + (manager as any).currentCommand = defaultCommand; + (manager as any).currentCwd = defaultCwd; + + // Directly call the private method + (manager as any).handleProcessExit(1, null); + + expect((manager as any).currentProcess).toBeNull(); + expect((manager as any).currentCommand).toBeNull(); + expect((manager as any).currentCwd).toBeNull(); + }); + + it('should clear state even with null code and signal', () => { + const mockChild = createMockChildProcess(); + (manager as any).currentProcess = mockChild; + (manager as any).currentCommand = defaultCommand; + (manager as any).currentCwd = defaultCwd; + + (manager as any).handleProcessExit(null, null); + + expect((manager as any).currentProcess).toBeNull(); + expect((manager as any).currentCommand).toBeNull(); + expect((manager as any).currentCwd).toBeNull(); + }); + }); + + // ==================== wrapError ==================== + + describe('wrapError', () => { + it('should wrap ENOENT error', () => { + const err: any = new Error('spawn ENOENT'); + err.code = 'ENOENT'; + + const wrapped = (manager as any).wrapError(err, 'my-agent'); + + expect(wrapped.message).toBe('Command not found: my-agent. Please ensure the CLI agent is installed.'); + }); + + it('should wrap EACCES error', () => { + const err: any = new Error('spawn EACCES'); + err.code = 'EACCES'; + + const wrapped = (manager as any).wrapError(err, 'my-agent'); + + expect(wrapped.message).toBe('Permission denied when executing: my-agent'); + }); + + it('should wrap EPERM error', () => { + const err: any = new Error('spawn EPERM'); + err.code = 'EPERM'; + + const wrapped = (manager as any).wrapError(err, 'my-agent'); + + expect(wrapped.message).toBe('Permission denied when executing: my-agent'); + }); + + it('should return original error for other codes', () => { + const err = new Error('some other error'); + + const wrapped = (manager as any).wrapError(err, 'my-agent'); + + expect(wrapped).toBe(err); + }); + }); +}); diff --git a/packages/ai-native/package.json b/packages/ai-native/package.json index f07d762da4..ff209caffa 100644 --- a/packages/ai-native/package.json +++ b/packages/ai-native/package.json @@ -19,6 +19,7 @@ "url": "git@github.com:opensumi/core.git" }, "dependencies": { + "@agentclientprotocol/sdk": "^0.16.1", "@ai-sdk/anthropic": "^1.1.9", "@ai-sdk/deepseek": "^0.1.11", "@ai-sdk/openai": "^1.1.9", @@ -53,6 +54,7 @@ "diff": "^7.0.0", "dom-align": "^1.7.0", "eventsource": "^3.0.5", + "node-pty": "1.0.0", "rc-collapse": "^4.0.0", "react-chat-elements": "^12.0.10", "react-highlight": "^0.15.0", diff --git a/packages/ai-native/src/browser/acp/acp-permission-rpc.service.ts b/packages/ai-native/src/browser/acp/acp-permission-rpc.service.ts new file mode 100644 index 0000000000..10acb0b3cc --- /dev/null +++ b/packages/ai-native/src/browser/acp/acp-permission-rpc.service.ts @@ -0,0 +1,64 @@ +import { Autowired, Injectable } from '@opensumi/di'; +import { RPCService } from '@opensumi/ide-connection/lib/common/rpc-service'; +import { + AcpPermissionDecision, + AcpPermissionDialogParams, + IAcpPermissionService, + ILogger, +} from '@opensumi/ide-core-common'; + +import { AcpPermissionBridgeService } from './permission-bridge.service'; + +/** + * Browser-side RPC service for ACP permission requests. + * This service is called from the Node layer to show permission dialogs in the browser. + * + * @description + * This RPC service bridges the Node.js ACP agent process with the browser UI. + * When the agent needs user permission for a tool call (file write, command execution, etc.), + * it calls this service which shows a dialog in the browser and returns the user's decision. + */ +@Injectable() +export class AcpPermissionRpcService extends RPCService implements IAcpPermissionService { + @Autowired(AcpPermissionBridgeService) + private permissionBridgeService: AcpPermissionBridgeService; + + @Autowired(ILogger) + private logger: ILogger; + + constructor() { + super(); + } + + /** + * Show permission dialog and wait for user response + * Called from Node layer via RPC + */ + async $showPermissionDialog(params: AcpPermissionDialogParams): Promise { + try { + // Call the browser-side permission bridge service + const decision = await this.permissionBridgeService.showPermissionDialog({ + requestId: params.requestId, + title: params.title, + kind: params.kind, + content: params.content, + locations: params.locations, + command: params.command, + options: params.options, + timeout: params.timeout, + }); + + return decision; + } catch (error) { + return { type: 'cancelled' }; + } + } + + /** + * Cancel a pending permission request + * Called from Node layer via RPC + */ + async $cancelRequest(requestId: string): Promise { + this.permissionBridgeService.cancelRequest(requestId); + } +} diff --git a/packages/ai-native/src/browser/acp/components/AcpChatHistory.tsx b/packages/ai-native/src/browser/acp/components/AcpChatHistory.tsx new file mode 100644 index 0000000000..1037068365 --- /dev/null +++ b/packages/ai-native/src/browser/acp/components/AcpChatHistory.tsx @@ -0,0 +1,285 @@ +import cls from 'classnames'; +import React, { FC, memo, useCallback, useEffect, useRef, useState } from 'react'; + +import { Icon, Input, Loading, Popover, PopoverPosition, PopoverTriggerType, getIcon } from '@opensumi/ide-components'; +import { localize } from '@opensumi/ide-core-browser'; +import { EnhanceIcon } from '@opensumi/ide-core-browser/lib/components/ai-native'; + +import styles from '../../components/acp/chat-history.module.less'; + +export interface IChatHistoryItem { + id: string; + title: string; + updatedAt: number; + loading: boolean; +} + +export interface IChatHistoryProps { + title: string; + historyList: IChatHistoryItem[]; + currentId?: string; + className?: string; + historyLoading?: boolean; + disabled?: boolean; + onNewChat: () => void; + onHistoryItemSelect: (item: IChatHistoryItem) => void; + onHistoryItemDelete?: (item: IChatHistoryItem) => void; + onHistoryItemChange: (item: IChatHistoryItem, title: string) => void; + onHistoryPopoverVisibleChange?: (visible: boolean) => void; +} + +// 最大历史记录数 +const MAX_HISTORY_LIST = 100; + +/** + * ACP 专属的 ChatHistory 组件 + * 与原版区别:移除了删除按钮(ACP 模式下由服务端管理会话生命周期) + */ +const AcpChatHistory: FC = memo( + ({ + title, + historyList, + currentId, + onNewChat, + onHistoryItemSelect, + onHistoryItemChange, + onHistoryPopoverVisibleChange, + historyLoading, + disabled, + className, + }) => { + const [historyTitleEditable, setHistoryTitleEditable] = useState<{ + [key: string]: boolean; + } | null>(null); + const [searchValue, setSearchValue] = useState(''); + const inputRef = useRef(null); + + // 处理搜索输入变化 + const handleSearchChange = useCallback((event: React.ChangeEvent) => { + setSearchValue(event.target.value); + }, []); + + // 处理历史记录项选择 + const handleHistoryItemSelect = useCallback( + (item: IChatHistoryItem) => { + if (disabled) { + return; + } + onHistoryItemSelect(item); + setSearchValue(''); + }, + [onHistoryItemSelect, disabled], + ); + + // 处理标题编辑 + const handleTitleEdit = useCallback((item: IChatHistoryItem) => { + setHistoryTitleEditable({ + [item.id]: true, + }); + }, []); + + // 处理标题编辑完成 + const handleTitleEditComplete = useCallback( + (item: IChatHistoryItem, newTitle: string) => { + setHistoryTitleEditable({ + [item.id]: false, + }); + onHistoryItemChange(item, newTitle); + }, + [onHistoryItemChange], + ); + + // 处理标题编辑取消 + const handleTitleEditCancel = useCallback((item: IChatHistoryItem) => { + setHistoryTitleEditable({ + [item.id]: false, + }); + }, []); + + // 处理新建聊天 + const handleNewChat = useCallback(() => { + if (disabled) { + return; + } + onNewChat(); + }, [onNewChat, disabled]); + + useEffect(() => { + if (historyTitleEditable) { + inputRef.current?.focus({ cursor: 'end' }); + } + }, [historyTitleEditable]); + + // 获取时间标签 + const getTimeKey = useCallback((diff: number): string => { + if (diff < 60 * 60 * 1000) { + const minutes = Math.floor(diff / (60 * 1000)); + return minutes === 0 ? 'Just now' : `${minutes}m ago`; + } else if (diff < 24 * 60 * 60 * 1000) { + const hours = Math.floor(diff / (60 * 60 * 1000)); + return `${hours}h ago`; + } else if (diff < 7 * 24 * 60 * 60 * 1000) { + const days = Math.floor(diff / (24 * 60 * 60 * 1000)); + return `${days}d ago`; + } else if (diff < 30 * 24 * 60 * 60 * 1000) { + const weeks = Math.floor(diff / (7 * 24 * 60 * 60 * 1000)); + return `${weeks}w ago`; + } else if (diff < 365 * 24 * 60 * 60 * 1000) { + const months = Math.floor(diff / (30 * 24 * 60 * 60 * 1000)); + return `${months}mo ago`; + } + const years = Math.floor(diff / (365 * 24 * 60 * 60 * 1000)); + return `${years}y ago`; + }, []); + + // 格式化历史记录 + const formatHistory = useCallback( + (list: IChatHistoryItem[]) => { + const now = new Date(); + const result = [] as { key: string; items: typeof list }[]; + + list.forEach((item: IChatHistoryItem) => { + const updatedAt = new Date(item.updatedAt); + const diff = now.getTime() - updatedAt.getTime(); + const key = getTimeKey(diff); + + const existingGroup = result.find((group) => group.key === key); + if (existingGroup) { + existingGroup.items.push(item); + } else { + result.push({ key, items: [item] }); + } + }); + + return result; + }, + [getTimeKey], + ); + + // 渲染历史记录项 + const renderHistoryItem = useCallback( + (item: IChatHistoryItem) => ( +
handleHistoryItemSelect(item)} + > +
+ {item.loading ? ( + + ) : ( + + )} + {!historyTitleEditable?.[item.id] ? ( + + {item.title} + + ) : ( + { + handleTitleEditComplete(item, e.target.value); + }} + onBlur={() => handleTitleEditCancel(item)} + /> + )} +
+ {/* ACP 模式:不显示删除按钮,会话由服务端管理 */} +
+ ), + [ + historyTitleEditable, + handleHistoryItemSelect, + handleTitleEditComplete, + handleTitleEditCancel, + currentId, + inputRef, + ], + ); + + // 渲染历史记录列表 + const renderHistory = useCallback(() => { + const filteredList = historyList + .slice(-MAX_HISTORY_LIST) + .reverse() + .filter((item) => item.title && item.title.includes(searchValue)); + + const groupedHistoryList = formatHistory(filteredList); + + return ( +
+ +
+ {historyLoading ? ( +
+ +
+ ) : ( + groupedHistoryList.map((group) => ( +
+ {group.items.map(renderHistoryItem)} +
+ )) + )} +
+
+ ); + }, [historyList, searchValue, formatHistory, handleSearchChange, renderHistoryItem, historyLoading, disabled]); + + // getPopupContainer 处理函数 + const getPopupContainer = useCallback((triggerNode: HTMLElement) => triggerNode.parentElement!, []); + + return ( +
+
+ {title} +
+
+ +
+ +
+
+ + {disabled ? ( +
+ +
+ ) : ( + + )} +
+
+
+ ); + }, +); + +export default AcpChatHistory; diff --git a/packages/ai-native/src/browser/acp/components/AcpChatInput.tsx b/packages/ai-native/src/browser/acp/components/AcpChatInput.tsx new file mode 100644 index 0000000000..be081892a7 --- /dev/null +++ b/packages/ai-native/src/browser/acp/components/AcpChatInput.tsx @@ -0,0 +1,540 @@ +import cls from 'classnames'; +import React, { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; + +import { AINativeConfigService, useInjectable, useLatest } from '@opensumi/ide-core-browser'; +import { Icon, Popover, PopoverPosition, getIcon } from '@opensumi/ide-core-browser/lib/components'; +import { EnhanceIcon } from '@opensumi/ide-core-browser/lib/components/ai-native'; +import { InteractiveInput } from '@opensumi/ide-core-browser/lib/components/ai-native/interactive-input/index'; +import { + ChatAgentViewServiceToken, + ChatFeatureRegistryToken, + MessageType, + localize, + runWhenIdle, +} from '@opensumi/ide-core-common'; +import { CommandService } from '@opensumi/ide-core-common/lib/command'; +import { MonacoCommandRegistry } from '@opensumi/ide-editor/lib/browser/monaco-contrib/command/command.service'; +import { IDialogService } from '@opensumi/ide-overlay'; + +import { + AT_SIGN_SYMBOL, + IChatAgentService, + IChatInternalService, + SLASH_SYMBOL, + TokenMCPServerProxyService, +} from '../../../common'; +import { ChatAgentViewService } from '../../chat/chat-agent.view.service'; +import { ChatSlashCommandItemModel } from '../../chat/chat-model'; +import { ChatProxyService } from '../../chat/chat-proxy.service'; +import { ChatFeatureRegistry } from '../../chat/chat.feature.registry'; +import { AcpChatInternalService } from '../../chat/chat.internal.service.acp'; +import styles from '../../components/components.module.less'; +import { MCPConfigCommands } from '../../mcp/config/mcp-config.commands'; +import { MCPServerProxyService } from '../../mcp/mcp-server-proxy.service'; +import { MCPToolsDialog } from '../../mcp/mcp-tools-dialog.view'; +import { IChatSlashCommandItem } from '../../types'; + +const INSTRUCTION_BOTTOM = 8; +const EXPAND_CRITICAL_HEIGHT = 68; + +interface IBlockProps extends IChatSlashCommandItem { + command?: string; + agentId?: string; +} + +const Block = ({ + icon, + name, + description, + agentId, + command, + selectedAgentId, +}: IBlockProps & { selectedAgentId?: string }) => { + const renderAgent = useMemo(() => { + if (!selectedAgentId && agentId && agentId !== ChatProxyService.AGENT_ID && command) { + return @{agentId}; + } + return null; + }, []); + + return ( +
+ {icon && } + {name && {name}} + {description && {description}} + {renderAgent} +
+ ); +}; + +const InstructionOptions = ({ onClick, bottom, trigger, agentId: selectedAgentId }) => { + const chatAgentService = useInjectable(IChatAgentService); + const chatAgentViewService = useInjectable(ChatAgentViewServiceToken); + + const options = useMemo(() => { + if (trigger === AT_SIGN_SYMBOL) { + return chatAgentViewService.getRenderAgents().map( + (a) => + new ChatSlashCommandItemModel( + { + icon: '', + name: `${AT_SIGN_SYMBOL}${a.id} `, + description: a.metadata.description, + }, + '', + a.id, + ), + ); + } else { + return chatAgentService + .getCommands() + .map( + (c) => + new ChatSlashCommandItemModel( + { + icon: '', + name: `${SLASH_SYMBOL} ${c.name} `, + description: c.description, + }, + c.name, + c.agentId, + ), + ) + .filter((item) => !selectedAgentId || item.agentId === selectedAgentId); + } + }, [trigger, chatAgentService]); + + const handleClick = useCallback( + (name: string | undefined, agentId?: string, command?: string) => { + if (onClick) { + onClick(name || '', agentId, command); + } + }, + [onClick], + ); + + if (options.length === 0) { + return null; + } + + return ( +
+
+
    + {options.map(({ icon, name, nameWithSlash, description, agentId, command }) => ( +
  • handleClick(nameWithSlash, agentId, command)}> + +
  • + ))} +
+
+
+ ); +}; + +const ThemeWidget = ({ themeBlock }) => ( +
+
{themeBlock}
+
+); + +const AgentWidget = ({ agentId, command }) => ( +
+ {agentId !== ChatProxyService.AGENT_ID && ( +
+ @{agentId} +
+ )} + {command && ( +
+ {SLASH_SYMBOL} {command} +
+ )} +
+); + +export interface IAcpChatInputProps { + onSend: (value: string, images?: string[], agentId?: string, command?: string) => void; + onValueChange?: (value: string) => void; + onExpand?: (value: boolean) => void; + placeholder?: string; + enableOptions?: boolean; + disabled?: boolean; + sendBtnClassName?: string; + defaultHeight?: number; + value?: string; + autoFocus?: boolean; + theme?: string | null; + setTheme: (theme: string | null) => void; + agentId: string; + setAgentId: (id: string) => void; + defaultAgentId?: string; + command: string; + setCommand: (command: string) => void; +} + +export const AcpChatInput = React.forwardRef((props: IAcpChatInputProps, ref) => { + const { + onSend, + onValueChange, + enableOptions = false, + disabled = false, + defaultHeight = 32, + autoFocus, + setTheme, + theme, + setAgentId, + agentId: propsAgentId, + defaultAgentId, + setCommand, + command, + sendBtnClassName, + } = props; + const agentId = propsAgentId || defaultAgentId; + + const textareaRef = useRef(null); + const instructionRef = useRef(null); + + const [value, setValue] = useState(props.value || ''); + const [isShowOptions, setIsShowOptions] = useState(false); + const [inputHeight, setInputHeight] = useState(defaultHeight); + const [focus, setFocus] = useState(false); + const [showExpand, setShowExpand] = useState(false); + const [isExpand, setIsExpand] = useState(false); + const [placeholder, setPlaceHolder] = useState(localize('aiNative.chat.input.placeholder.default')); + const aiChatService = useInjectable(IChatInternalService); + const dialogService = useInjectable(IDialogService); + const aiNativeConfigService = useInjectable(AINativeConfigService); + const mcpServerProxyService = useInjectable(TokenMCPServerProxyService); + const monacoCommandRegistry = useInjectable(MonacoCommandRegistry); + const chatAgentService = useInjectable(IChatAgentService); + const chatFeatureRegistry = useInjectable(ChatFeatureRegistryToken); + const commandService = useInjectable(CommandService); + + const currentAgentIdRef = useLatest(agentId); + + const handleShowMCPConfig = React.useCallback(() => { + commandService.executeCommand(MCPConfigCommands.OPEN_MCP_CONFIG.id); + }, [commandService]); + + const handleShowMCPTools = React.useCallback(async () => { + const tools = await mcpServerProxyService.getAllMCPTools(); + dialogService.open({ + message: , + type: MessageType.Empty, + buttons: [localize('dialog.file.close')], + }); + }, [mcpServerProxyService, dialogService]); + + useImperativeHandle(ref, () => ({ + setInputValue: (v: string) => { + setValue(v); + runWhenIdle(() => { + textareaRef.current?.focus(); + }, 120); + }, + })); + + useEffect(() => { + if (props.value !== value) { + setValue(props.value || ''); + } + }, [props.value]); + + useEffect(() => { + textareaRef.current?.focus(); + const defaultPlaceholder = localize('aiNative.chat.input.placeholder.default'); + + const findCommandHandler = chatFeatureRegistry.getSlashCommandHandler(command); + if (findCommandHandler && findCommandHandler.providerInputPlaceholder) { + const editor = monacoCommandRegistry.getActiveCodeEditor(); + const placeholder = findCommandHandler.providerInputPlaceholder(value, editor); + setPlaceHolder(placeholder || defaultPlaceholder); + } else { + setPlaceHolder(defaultPlaceholder); + } + }, [chatFeatureRegistry, command]); + + useEffect(() => { + acquireOptionsCheck(theme || '', agentId, command); + }, [theme, agentId, command]); + + useEffect(() => { + if (textareaRef && autoFocus) { + textareaRef.current?.focus(); + } + }, [textareaRef, autoFocus, props.value]); + + useEffect(() => { + if (enableOptions) { + if ( + (value === SLASH_SYMBOL || (value === AT_SIGN_SYMBOL && chatAgentService.getAgents().length > 0)) && + !isExpand + ) { + setIsShowOptions(true); + } else { + setIsShowOptions(false); + } + } + + if (value.startsWith(SLASH_SYMBOL)) { + const { value: newValue, nameWithSlash } = chatFeatureRegistry.parseSlashCommand(value); + + if (nameWithSlash) { + const commandModel = chatFeatureRegistry.getSlashCommandBySlashName(nameWithSlash); + setValue(newValue); + setTheme(nameWithSlash); + if (commandModel) { + setAgentId(commandModel.agentId!); + setCommand(commandModel.command!); + } + return; + } + } + + if (chatAgentService.getAgents().length) { + const parsedInfo = chatAgentService.parseMessage(value, currentAgentIdRef.current); + if (parsedInfo.agentId || parsedInfo.command) { + setTheme(''); + setValue(parsedInfo.message); + if (parsedInfo.agentId) { + setAgentId(parsedInfo.agentId); + } + if (parsedInfo.command) { + setCommand(parsedInfo.command); + } + } + } + }, [textareaRef, value, enableOptions, chatFeatureRegistry]); + + useEffect(() => { + if (!value) { + setInputHeight(defaultHeight); + setShowExpand(false); + setIsExpand(false); + } + }, [value]); + + const handleInputChange = useCallback((value: string) => { + setValue(value); + if (onValueChange) { + onValueChange(value); + } + }, []); + + const handleStop = useCallback(() => { + aiChatService.cancelRequest(); + }, []); + + const handleSend = useCallback(async () => { + if (disabled) { + return; + } + + const handleSendLogic = (newValue: string = value) => { + onSend(newValue, [], agentId, command); + setValue(''); + setTheme(''); + setAgentId(''); + setCommand(''); + }; + + if (command) { + const chatCommandHandler = chatFeatureRegistry.getSlashCommandHandler(command); + if (chatCommandHandler && chatCommandHandler.execute) { + const editor = monacoCommandRegistry.getActiveCodeEditor(); + await chatCommandHandler.execute(value, (newValue: string) => handleSendLogic(newValue), editor); + return; + } + } + + handleSendLogic(); + }, [onSend, value, agentId, command, chatFeatureRegistry]); + + const acquireOptionsCheck = useCallback( + (themeValue: string, agentId?: string, command?: string) => { + if (agentId) { + setIsShowOptions(false); + setTheme(''); + setAgentId(agentId); + setCommand(command || ''); + if (textareaRef?.current) { + const inputValue = textareaRef.current.value; + if (inputValue === AT_SIGN_SYMBOL || (command && inputValue === SLASH_SYMBOL)) { + setValue(''); + } + runWhenIdle(() => textareaRef.current!.focus()); + } + } else if (themeValue) { + setIsShowOptions(false); + setAgentId(''); + setCommand(''); + + const findCommand = chatFeatureRegistry.getSlashCommandBySlashName(themeValue); + if (findCommand) { + setTheme(findCommand.nameWithSlash); + } else { + setTheme(''); + } + + if (textareaRef && textareaRef.current) { + const inputValue = textareaRef.current.value; + if (inputValue.length === 1 && inputValue.startsWith(SLASH_SYMBOL)) { + setValue(''); + } + runWhenIdle(() => textareaRef.current!.focus()); + } + } + }, + [textareaRef, chatFeatureRegistry], + ); + + const optionsBottomPosition = useMemo(() => { + const customBottom = INSTRUCTION_BOTTOM + inputHeight; + if (isExpand) { + setIsShowOptions(false); + } + return customBottom; + }, [inputHeight]); + + const handleKeyDown = (event) => { + if (event.key === 'Backspace') { + if (textareaRef.current?.selectionEnd === 0 && textareaRef.current?.selectionStart === 0) { + setTheme(''); + + if (agentId === ChatProxyService.AGENT_ID) { + setCommand(''); + setAgentId(''); + return; + } + + if (agentId) { + if (command) { + setCommand(''); + } else { + setAgentId(''); + } + } + } + } + }; + + const handleHeightChange = useCallback((height: number) => { + setInputHeight(height); + + if (height > EXPAND_CRITICAL_HEIGHT) { + setShowExpand(true); + } else { + setShowExpand(false); + } + }, []); + + const handleBlur = useCallback(() => { + setFocus(false); + setIsShowOptions(false); + }, [textareaRef]); + + const handleFocus = useCallback(() => { + setFocus(true); + }, [textareaRef]); + + const handleExpandClick = useCallback(() => { + const expand = isExpand; + setIsExpand(!expand); + if (!expand) { + const ele = document.querySelector('#ai_chat_left_container'); + const maxHeight = ele!.clientHeight - 68 - (theme ? 32 : 0) - 16; + setInputHeight(maxHeight); + } else { + setInputHeight(defaultHeight); + setShowExpand(false); + } + }, [isExpand]); + + return ( +
+ {isShowOptions && ( +
+ +
+ )} + {theme && } + {agentId && } + {showExpand && ( +
handleExpandClick()}> + + + +
+ )} + +
+ {aiNativeConfigService.capabilities.supportsMCP && ( +
+ + + + + + +
+ )} +
+
+ ); +}); diff --git a/packages/ai-native/src/browser/acp/components/AcpChatMentionInput.tsx b/packages/ai-native/src/browser/acp/components/AcpChatMentionInput.tsx new file mode 100644 index 0000000000..bae3097df3 --- /dev/null +++ b/packages/ai-native/src/browser/acp/components/AcpChatMentionInput.tsx @@ -0,0 +1,823 @@ +import { DataContent } from 'ai'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { Image } from '@opensumi/ide-components/lib/image'; +import { + AINativeConfigService, + LabelService, + PreferenceService, + getSymbolIcon, + useInjectable, +} from '@opensumi/ide-core-browser'; +import { Icon, getIcon } from '@opensumi/ide-core-browser/lib/components'; +import { + AINativeSettingSectionsId, + ChatFeatureRegistryToken, + ChatRenderRegistryToken, + RulesServiceToken, + URI, + localize, +} from '@opensumi/ide-core-common'; +import { CommandService } from '@opensumi/ide-core-common/lib/command'; +import { defaultFilesWatcherExcludes } from '@opensumi/ide-core-common/lib/preferences/file-watch'; +import { WorkbenchEditorService } from '@opensumi/ide-editor'; +import { MonacoCommandRegistry } from '@opensumi/ide-editor/lib/browser/monaco-contrib/command/command.service'; +import { FileSearchServicePath, IFileSearchService } from '@opensumi/ide-file-search'; +import { IFileServiceClient } from '@opensumi/ide-file-service/lib/common'; +import { OutlineCompositeTreeNode, OutlineTreeNode } from '@opensumi/ide-outline/lib/browser/outline-node.define'; +import { OutlineTreeService } from '@opensumi/ide-outline/lib/browser/services/outline-tree.service'; +import { IMessageService } from '@opensumi/ide-overlay'; +import { IconType } from '@opensumi/ide-theme'; +import { IconService } from '@opensumi/ide-theme/lib/browser'; +import { IWorkspaceService } from '@opensumi/ide-workspace'; + +import { IChatInternalService } from '../../../common'; +import { LLMContextService } from '../../../common/llm-context'; +import { ChatFeatureRegistry } from '../../chat/chat.feature.registry'; +import { AcpChatInternalService } from '../../chat/chat.internal.service.acp'; +import { ChatRenderRegistry } from '../../chat/chat.render.registry'; +import { MentionInput } from '../../components/acp/MentionInput'; +import { ModeOption } from '../../components/acp/types'; +import styles from '../../components/components.module.less'; +import { FooterButtonPosition, FooterConfig, MentionItem, MentionType } from '../../components/mention-input/types'; +import { MCPConfigCommands } from '../../mcp/config/mcp-config.commands'; +import { RulesCommands } from '../../rules/rules.contribution'; +import { RulesService } from '../../rules/rules.service'; + +export interface IChatMentionInputProps { + onSend: ( + value: string, + images?: string[], + agentId?: string, + command?: string, + option?: { model: string; [key: string]: any }, + ) => void; + onValueChange?: (value: string) => void; + onExpand?: (value: boolean) => void; + placeholder?: string; + enableOptions?: boolean; + disabled?: boolean; + sendBtnClassName?: string; + defaultHeight?: number; + value?: string; + images?: Array; + autoFocus?: boolean; + theme?: string | null; + setTheme: (theme: string | null) => void; + agentId: string; + setAgentId: (id: string) => void; + defaultAgentId?: string; + command: string; + setCommand: (command: string) => void; + disableModelSelector?: boolean; + sessionModelId?: string; + contextService?: LLMContextService; + agentModes?: Array<{ id: string; name: string; description?: string }>; + agentCwd?: string; +} + +/** + * ACP 专属的 ChatMentionInput 组件 + * 与原版区别: + * - 文件选择器:无搜索词时递归加载工作区文件(限制 50 个) + * - 文件夹选择器:无搜索词时加载工作区根目录下的文件夹 + */ +export const AcpChatMentionInput = (props: IChatMentionInputProps) => { + const { onSend, disabled = false, contextService, agentCwd } = props; + + const [value, setValue] = useState(props.value || ''); + const [images, setImages] = useState(props.images || []); + const [currentMode, setCurrentMode] = useState(props.agentModes?.[0]?.id || 'default'); + const aiChatService = useInjectable(IChatInternalService); + const aiNativeConfigService = useInjectable(AINativeConfigService); + const commandService = useInjectable(CommandService); + const searchService = useInjectable(FileSearchServicePath); + const fileServiceClient = useInjectable(IFileServiceClient); + const workspaceService = useInjectable(IWorkspaceService); + const editorService = useInjectable(WorkbenchEditorService); + const labelService = useInjectable(LabelService); + const iconService = useInjectable(IconService); + const messageService = useInjectable(IMessageService); + const chatFeatureRegistry = useInjectable(ChatFeatureRegistryToken); + const chatRenderRegistry = useInjectable(ChatRenderRegistryToken); + const monacoCommandRegistry = useInjectable(MonacoCommandRegistry); + const outlineTreeService = useInjectable(OutlineTreeService); + const prevOutlineItems = useRef([]); + const [placeholder, setPlaceholder] = useState( + props.placeholder || localize('aiNative.chat.input.placeholder.default'), + ); + const [defaultInput, setDefaultInput] = useState(''); + const preferenceService = useInjectable(PreferenceService); + const rulesService = useInjectable(RulesServiceToken); + + const handleShowMCPConfig = React.useCallback(() => { + commandService.executeCommand(MCPConfigCommands.OPEN_MCP_CONFIG.id); + }, [commandService]); + + const handleShowRules = React.useCallback(() => { + commandService.executeCommand(RulesCommands.OPEN_RULES_FILE.id); + }, [commandService]); + + // 监听 ACP Agent 模式切换成功事件,同步更新 UI + useEffect(() => { + const disposable = aiChatService.onModeChange((modeId) => { + setCurrentMode(modeId); + }); + return () => disposable.dispose(); + }, [aiChatService]); + + // 当 agentModes 变化时,更新 currentMode 为第一个 mode + useEffect(() => { + if (props.agentModes?.length && !props.agentModes.find((m) => m.id === currentMode)) { + setCurrentMode(props.agentModes[0].id); + } + }, [props.agentModes]); + + // 当 slash command 变化时,更新 placeholder 和 defaultInput + useEffect(() => { + const defaultPlaceholder = props.placeholder || localize('aiNative.chat.input.placeholder.default'); + const findCommandHandler = chatFeatureRegistry.getSlashCommandHandler(props.command); + if (findCommandHandler && findCommandHandler.providerInputPlaceholder) { + const editor = monacoCommandRegistry.getActiveCodeEditor(); + const customPlaceholder = findCommandHandler.providerInputPlaceholder(value, editor); + setPlaceholder(customPlaceholder || defaultPlaceholder); + } else { + setPlaceholder(defaultPlaceholder); + } + + if (findCommandHandler?.providerDefaultInput) { + const editor = monacoCommandRegistry.getActiveCodeEditor(); + Promise.resolve(findCommandHandler.providerDefaultInput(value, editor)).then((input) => { + if (input) { + setDefaultInput(input); + } + }); + } + }, [chatFeatureRegistry, props.command]); + + useEffect(() => { + if (props.value !== value) { + setValue(props.value || ''); + } + }, [props.value]); + + const resolveSymbols = useCallback( + async (parent?: OutlineCompositeTreeNode, symbols: (OutlineTreeNode | OutlineCompositeTreeNode)[] = []) => { + if (!parent) { + parent = (await outlineTreeService.resolveChildren())[0] as OutlineCompositeTreeNode; + } + const children = (await outlineTreeService.resolveChildren(parent)) as ( + | OutlineTreeNode + | OutlineCompositeTreeNode + )[]; + for (const child of children) { + symbols.push(child); + if (OutlineCompositeTreeNode.is(child)) { + await resolveSymbols(child, symbols); + } + } + return symbols; + }, + [outlineTreeService], + ); + + // 拆分目录路径为多个层级的辅助函数 + const expandFolderPaths = useCallback( + async (folderPaths: string[], workspaceRootPath: string): Promise => { + const expandedPaths = new Set(); + const workspaceUri = new URI(workspaceRootPath); + + // 将所有路径展开为多层级 + for (const folderPath of folderPaths) { + const uri = new URI(folderPath); + const relativePath = await workspaceService.asRelativePath(uri); + + if (relativePath?.path) { + const pathSegments = relativePath.path.split('/').filter(Boolean); + + // 为每个层级创建路径 + for (let i = 0; i < pathSegments.length; i++) { + const segmentPath = pathSegments.slice(0, i + 1).join('/'); + const fullPath = workspaceUri.resolve(segmentPath).codeUri.fsPath; + + // 避免添加工作区本身或其上级目录 + if (fullPath !== workspaceRootPath && !workspaceRootPath.startsWith(fullPath)) { + expandedPaths.add(fullPath); + } + } + } else { + // 如果无法获取相对路径,直接添加(但仍要过滤工作区路径) + if (folderPath !== workspaceRootPath && !workspaceRootPath.startsWith(folderPath)) { + expandedPaths.add(folderPath); + } + } + } + + // 转换为 MentionItem 格式 + return Promise.all( + Array.from(expandedPaths).map(async (folderPath) => { + const uri = new URI(folderPath); + const relativePath = await workspaceService.asRelativePath(uri); + return { + id: uri.codeUri.fsPath, + type: MentionType.FOLDER, + text: uri.displayName, + value: uri.codeUri.fsPath, + description: relativePath?.root ? relativePath.path : '', + contextId: uri.codeUri.fsPath, + icon: getIcon('folder'), + }; + }), + ); + }, + [workspaceService], + ); + + // ACP 专属:递归加载工作区文件 + const loadWorkspaceFiles = useCallback(async (): Promise => { + const files: MentionItem[] = []; + const collectFiles = async (dirUri: string, limit: number) => { + if (files.length >= limit) { + return; + } + const stat = await fileServiceClient.getFileStat(dirUri, true); + if (!stat?.children) { + return; + } + for (const child of stat.children) { + if (files.length >= limit) { + break; + } + if (child.isDirectory) { + await collectFiles(child.uri, limit); + } else { + const uri = new URI(child.uri); + const relativePath = (await workspaceService.asRelativePath(uri.parent))?.path; + files.push({ + id: uri.codeUri.fsPath, + type: MentionType.FILE, + text: uri.displayName, + value: uri.codeUri.fsPath, + description: relativePath || '', + contextId: uri.codeUri.fsPath, + icon: labelService.getIcon(uri), + }); + } + } + }; + const rootUri = agentCwd ? URI.file(agentCwd).toString() : workspaceService.workspace?.uri; + if (rootUri) { + await collectFiles(rootUri, 50); + } + return files; + }, [fileServiceClient, workspaceService, labelService, agentCwd]); + + // ACP 专属:加载工作区根目录下的文件夹 + const loadWorkspaceFolders = async (): Promise => { + const rootUri = agentCwd ? URI.file(agentCwd).toString() : workspaceService.workspace?.uri; + if (!rootUri) { + return []; + } + const stat = await fileServiceClient.getFileStat(rootUri, true); + if (!stat?.children) { + return []; + } + return Promise.all( + stat.children + .filter((child) => child.isDirectory) + .map(async (child) => { + const uri = new URI(child.uri); + const relativePath = await workspaceService.asRelativePath(uri); + return { + id: uri.codeUri.fsPath, + type: MentionType.FOLDER, + text: uri.displayName, + value: uri.codeUri.fsPath, + description: relativePath?.root ? relativePath.path : '', + contextId: uri.codeUri.fsPath, + icon: getIcon('folder'), + }; + }), + ); + }; + + // 默认菜单项(ACP 专属版本) + const defaultMenuItems: MentionItem[] = [ + { + id: MentionType.FILE, + type: MentionType.FILE, + text: 'File', + icon: getIcon('file'), + getHighestLevelItems: () => { + const currentEditor = editorService.currentEditor; + const currentUri = currentEditor?.currentUri; + if (!currentUri) { + return []; + } + return [ + { + id: currentUri.codeUri.fsPath, + type: MentionType.FILE, + text: currentUri.displayName, + value: currentUri.codeUri.fsPath, + description: `(${localize('aiNative.chat.defaultContextFile')})`, + contextId: currentUri.codeUri.fsPath, + icon: labelService.getIcon(currentUri), + }, + ]; + }, + getItems: async (searchText: string) => { + if (!searchText) { + // ACP 专属:无搜索词时递归加载工作区文件 + try { + return await loadWorkspaceFiles(); + } catch (_e) { + return []; + } + } else { + const rootUris = agentCwd + ? [agentCwd] + : (await workspaceService.roots).map((root) => new URI(root.uri).codeUri.fsPath.toString()); + const results = await searchService.find(searchText, { + rootUris, + useGitIgnore: true, + noIgnoreParent: true, + fuzzyMatch: true, + limit: 10, + }); + return Promise.all( + results.map(async (file) => { + const uri = new URI(file); + const relatveParentPath = (await workspaceService.asRelativePath(uri.parent))?.path; + return { + id: uri.codeUri.fsPath, + type: MentionType.FILE, + text: uri.displayName, + value: uri.codeUri.fsPath, + description: relatveParentPath || '', + contextId: uri.codeUri.fsPath, + icon: labelService.getIcon(uri), + }; + }), + ); + } + }, + }, + { + id: MentionType.FOLDER, + type: MentionType.FOLDER, + text: 'Folder', + icon: getIcon('folder'), + getHighestLevelItems: () => { + const currentEditor = editorService.currentEditor; + const currentFolderUri = currentEditor?.currentUri?.parent; + if (!currentFolderUri) { + return []; + } + const rootUri = agentCwd ? URI.file(agentCwd).toString() : workspaceService.workspace?.uri; + if (currentFolderUri.toString() === rootUri) { + return []; + } + return [ + { + id: currentFolderUri.codeUri.fsPath, + type: MentionType.FOLDER, + text: currentFolderUri.displayName, + value: currentFolderUri.codeUri.fsPath, + description: `(${localize('aiNative.chat.defaultContextFolder')})`, + contextId: currentFolderUri.codeUri.fsPath, + icon: getIcon('folder'), + }, + ]; + }, + getItems: async (searchText: string) => { + if (!searchText) { + // ACP 专属:无搜索词时加载工作区根目录下的文件夹 + try { + return await loadWorkspaceFolders(); + } catch (_e) { + return []; + } + } else { + const rootUris = agentCwd + ? [agentCwd] + : (await workspaceService.roots).map((root) => new URI(root.uri).codeUri.fsPath.toString()); + const files = await searchService.find(searchText, { + rootUris, + useGitIgnore: true, + noIgnoreParent: true, + fuzzyMatch: true, + excludePatterns: Object.keys(defaultFilesWatcherExcludes), + limit: 10, + }); + const rootWorkspaceUri = agentCwd + ? URI.file(agentCwd).toString() + : workspaceService.workspace?.uri?.toString() || ''; + const folders = Array.from( + new Set( + files.map((file) => new URI(file).parent.toString()).filter((folder) => folder !== rootWorkspaceUri), + ), + ); + return await expandFolderPaths(folders, rootWorkspaceUri); + } + }, + }, + { + id: 'code', + type: 'code', + text: 'Code', + icon: getIcon('codebraces'), + getHighestLevelItems: () => [], + getItems: async (searchText: string) => { + if (!searchText || prevOutlineItems.current.length === 0) { + const uri = outlineTreeService.currentUri; + if (!uri) { + return []; + } + const treeNodes = await resolveSymbols(); + prevOutlineItems.current = await Promise.all( + treeNodes.map(async (treeNode) => { + const relativePath = await workspaceService.asRelativePath(uri); + return { + id: treeNode.raw.id, + type: MentionType.CODE, + text: treeNode.raw.name, + symbol: treeNode.raw, + value: treeNode.raw.id, + description: `${relativePath?.root ? relativePath.path : ''}:L${treeNode.raw.range.startLineNumber}-${ + treeNode.raw.range.endLineNumber + }`, + kind: treeNode.raw.kind, + contextId: `${outlineTreeService.currentUri?.codeUri.fsPath}:L${treeNode.raw.range.startLineNumber}-${treeNode.raw.range.endLineNumber}`, + icon: getSymbolIcon(treeNode.raw.kind) + ' outline-icon', + }; + }), + ); + return prevOutlineItems.current; + } else { + searchText = searchText.toLocaleLowerCase(); + return prevOutlineItems.current.sort((a, b) => { + if (a.text.toLocaleLowerCase().includes(searchText) && b.text.toLocaleLowerCase().includes(searchText)) { + return 0; + } + if (a.text.toLocaleLowerCase().includes(searchText)) { + return -1; + } else if (b.text.toLocaleLowerCase().includes(searchText)) { + return 1; + } + return 0; + }); + } + }, + }, + { + id: MentionType.RULE, + type: MentionType.RULE, + text: 'Rule', + icon: getIcon('rules'), + getHighestLevelItems: () => [], + getItems: async (searchText: string) => { + const rules = await rulesService.projectRules; + const mappedRules = rules.map((rule) => { + const uri = new URI(rule.path); + return { + id: uri.codeUri.fsPath, + type: MentionType.RULE, + text: uri.displayName, + value: uri.codeUri.fsPath, + contextId: uri.codeUri.fsPath, + description: rule.description, + icon: getIcon('rules'), + }; + }); + + if (!searchText) { + return mappedRules.slice(0, 10); + } + + const lowerSearchText = searchText.toLocaleLowerCase(); + return mappedRules + .filter((rule) => rule.text.toLocaleLowerCase().includes(lowerSearchText)) + .sort((a, b) => { + const aTextLower = a.text.toLocaleLowerCase(); + const bTextLower = b.text.toLocaleLowerCase(); + const aDescLower = a.description?.toLocaleLowerCase() || ''; + const bDescLower = b.description?.toLocaleLowerCase() || ''; + + const aTextMatch = aTextLower.includes(lowerSearchText); + const bTextMatch = bTextLower.includes(lowerSearchText); + const aDescMatch = aDescLower.includes(lowerSearchText); + const bDescMatch = bDescLower.includes(lowerSearchText); + + if (aTextMatch && bTextMatch) { + return aTextLower.localeCompare(bTextLower); + } + if (aTextMatch && !bTextMatch) { + return -1; + } + if (!aTextMatch && bTextMatch) { + return 1; + } + + if (aDescMatch && bDescMatch) { + return aTextLower.localeCompare(bTextLower); + } + if (aDescMatch && !bDescMatch) { + return -1; + } + if (!aDescMatch && bDescMatch) { + return 1; + } + + return aTextLower.localeCompare(bTextLower); + }) + .slice(0, 10); + }, + }, + ]; + + // Mode 选项 + const modeOptions: ModeOption[] = useMemo( + () => + props.agentModes?.length + ? props.agentModes + : [{ id: 'default', name: 'Default', description: 'Require approval for edits' }], + [props.agentModes], + ); + + const slashCommands = useMemo( + () => + chatFeatureRegistry.getAllSlashCommand().map((cmd) => ({ + nameWithSlash: cmd.nameWithSlash, + icon: cmd.icon, + name: cmd.name, + description: cmd.description, + })), + [chatFeatureRegistry], + ); + + const [acpSlashCommands, setAcpSlashCommands] = useState< + Array<{ nameWithSlash: string; icon?: string; name?: string; description?: string }> + >(() => + aiChatService.getAvailableCommands().map((cmd) => ({ + nameWithSlash: `/${cmd.name}`, + icon: undefined, + name: cmd.name, + description: cmd.description || '', + })), + ); + + useEffect(() => { + const disposable = aiChatService.onAvailableCommandsChange((commands) => { + setAcpSlashCommands( + commands.map((cmd) => ({ + nameWithSlash: `/${cmd.name}`, + icon: undefined, + name: cmd.name, + description: cmd.description || '', + })), + ); + }); + return () => disposable.dispose(); + }, [aiChatService]); + + const defaultMentionInputFooterOptions: FooterConfig = useMemo( + () => ({ + modeOptions, + defaultMode: modeOptions[0]?.id || 'default', + currentMode, + showModeSelector: modeOptions.length > 1, + modelOptions: [ + { + value: 'qwen-plus-latest', + label: 'Qwen 3', + iconClass: iconService.fromIcon( + '', + 'https://img.alicdn.com/imgextra/i3/O1CN01LFMrZj28YrnrzeebY_!!6000000007945-55-tps-16-16.svg', + IconType.Background, + ), + tags: ['思考链', '擅长代码'], + description: '高性能代码模型,支持思考链', + }, + { + label: 'Claude 4 Sonnet', + value: 'claude_sonnet4', + iconClass: iconService.fromIcon( + '', + 'https://img.alicdn.com/imgextra/i3/O1CN01p0mziz1Nsl40lp1HO_!!6000000001626-55-tps-92-65.svg', + IconType.Background, + ), + tags: ['多模态', '长上下文理解', '思考模式'], + description: '高性能模型,支持多模态输入', + }, + { + label: 'DeepSeek R1', + value: 'DeepSeek-R1-0528', + iconClass: iconService.fromIcon( + '', + 'https://img.alicdn.com/imgextra/i3/O1CN01ClcK2w1JwdxcbAB3a_!!6000000001093-55-tps-30-30.svg', + IconType.Background, + ), + tags: ['思考模式', '长上下文理解'], + description: '专业创作,支持多模态输入', + }, + ], + defaultModel: + props.sessionModelId || preferenceService.get(AINativeSettingSectionsId.ModelID) || 'deepseek-r1', + buttons: aiNativeConfigService.capabilities.supportsAgentMode + ? [] + : [ + { + id: 'mcp-server', + icon: 'mcp', + title: 'MCP Server', + onClick: handleShowMCPConfig, + position: FooterButtonPosition.LEFT, + }, + { + id: 'rules', + icon: 'rules', + title: 'Rules', + onClick: handleShowRules, + position: FooterButtonPosition.LEFT, + }, + { + id: 'upload-image', + icon: 'image', + title: localize('aiNative.chat.imageUpload'), + onClick: () => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.onchange = (e) => { + const files = (e.target as HTMLInputElement).files; + if (files?.length) { + handleImageUpload(Array.from(files)); + } + }; + input.click(); + }, + position: FooterButtonPosition.LEFT, + }, + ], + showModelSelector: aiNativeConfigService.capabilities.supportsAgentMode ? false : true, + disableModelSelector: props.disableModelSelector, + }), + [ + iconService, + handleShowMCPConfig, + handleShowRules, + props.disableModelSelector, + props.sessionModelId, + currentMode, + modeOptions, + aiNativeConfigService.capabilities.supportsAgentMode, + preferenceService, + ], + ); + + const handleStop = useCallback(() => { + aiChatService.cancelRequest(); + }, []); + + const handleSend = useCallback( + async (content: string, option?: { model: string; [key: string]: any }) => { + if (disabled) { + return; + } + + const currentCommand = props.command; + const currentAgentId = props.agentId; + + const doSend = (newValue: string = content) => { + onSend( + newValue, + images.map((image) => image.toString()), + currentAgentId, + currentCommand, + option, + ); + // 发送后重置 slash command 状态 + props.setTheme(null); + props.setAgentId(''); + props.setCommand(''); + setImages(props.images || []); + }; + + // 如果有 slash command,调用其 execute handler + if (currentCommand) { + const chatCommandHandler = chatFeatureRegistry.getSlashCommandHandler(currentCommand); + if (chatCommandHandler && chatCommandHandler.execute) { + const editor = monacoCommandRegistry.getActiveCodeEditor(); + await chatCommandHandler.execute(content, (newValue: string) => doSend(newValue), editor); + return; + } + } + + doSend(); + }, + [onSend, images, disabled, props.agentId, props.command, chatFeatureRegistry], + ); + + const handleImageUpload = useCallback( + async (files: File[]) => { + const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif']; + + const invalidFiles = files.filter((file) => !allowedTypes.includes(file.type)); + if (invalidFiles.length > 0) { + messageService.error('Only JPG, PNG, WebP and GIF images are supported'); + return; + } + + const imageUploadProvider = chatFeatureRegistry.getImageUploadProvider(); + if (!imageUploadProvider) { + messageService.error('No image upload provider found'); + return; + } + + const uploadedData = await Promise.all(files.map((file) => imageUploadProvider.imageUpload(file))); + + const newImages = [...images, ...uploadedData]; + setImages(newImages); + }, + [images], + ); + + const handleModeChange = useCallback( + async (modeId: string) => { + try { + await aiChatService.setSessionMode(modeId); + } catch (error) { + messageService.error('Failed to switch mode: ' + (error instanceof Error ? error.message : String(error))); + } + }, + [aiChatService, messageService], + ); + + const handleDeleteImage = useCallback( + (index: number) => { + setImages(images.filter((_, i) => i !== index)); + }, + [images], + ); + + const handleSlashSelect = useCallback( + (nameWithSlash: string) => { + const commandModel = chatFeatureRegistry.getSlashCommandBySlashName(nameWithSlash); + if (commandModel) { + props.setTheme(nameWithSlash); + props.setAgentId(commandModel.agentId!); + props.setCommand(commandModel.command!); + } + }, + [chatFeatureRegistry], + ); + + return ( +
+ {images.length > 0 && } + chatRenderRegistry.enabledMentionTypes!.includes(item.id)) + : defaultMenuItems + } + slashCommands={[...slashCommands, ...acpSlashCommands]} + onSend={handleSend} + onStop={handleStop} + loading={disabled} + labelService={labelService} + workspaceService={workspaceService} + placeholder={placeholder} + footerConfig={defaultMentionInputFooterOptions} + onImageUpload={handleImageUpload} + contextService={contextService} + onModeChange={handleModeChange} + defaultInput={defaultInput} + onDefaultInputConsumed={() => setDefaultInput('')} + onSlashSelect={handleSlashSelect} + /> +
+ ); +}; + +const ImagePreviewer = ({ + images, + onDelete, +}: { + images: Array; + onDelete: (index: number) => void; +}) => ( +
+
+ {images.map((image, index) => ( +
+ + +
+ ))} +
+
+); diff --git a/packages/ai-native/src/browser/acp/components/AcpChatViewHeader.tsx b/packages/ai-native/src/browser/acp/components/AcpChatViewHeader.tsx new file mode 100644 index 0000000000..5f6b4b7ffc --- /dev/null +++ b/packages/ai-native/src/browser/acp/components/AcpChatViewHeader.tsx @@ -0,0 +1,262 @@ +import React from 'react'; + +import { QuickPickService, getIcon, useInjectable } from '@opensumi/ide-core-browser'; +import { Popover, PopoverPosition } from '@opensumi/ide-core-browser/lib/components'; +import { EnhanceIcon } from '@opensumi/ide-core-browser/lib/components/ai-native'; +import { + ChatMessageRole, + DisposableCollection, + IDisposable, + formatLocalize, + localize, +} from '@opensumi/ide-core-common'; +import { IMessageService } from '@opensumi/ide-overlay'; +import { IWorkspaceService } from '@opensumi/ide-workspace'; + +import { IChatInternalService } from '../../../common'; +import { cleanAttachedTextWrapper } from '../../../common/utils'; +import { ChatInternalService } from '../../chat/chat.internal.service'; +import { AcpChatInternalService } from '../../chat/chat.internal.service.acp'; +import styles from '../../chat/chat.module.less'; +import { getCachedWorkspaceDir, switchWorkspaceDir } from '../../chat/pick-workspace-dir'; + +import AcpChatHistory, { IChatHistoryItem } from './AcpChatHistory'; + +const MAX_TITLE_LENGTH = 100; + +/** + * ACP 专属的 ChatViewHeader + * 与 DefaultChatViewHeader 的区别: + * - 使用 session.title(服务端返回的标题)构建 historyList,而非从消息内容推导 + * - 不显示删除按钮(ACP 模式下由服务端管理会话生命周期) + */ +export function AcpChatViewHeader({ + handleClear, + handleCloseChatView, +}: { + handleClear: () => any; + handleCloseChatView: () => any; +}) { + const aiChatService = useInjectable(IChatInternalService); + const messageService = useInjectable(IMessageService); + const workspaceService = useInjectable(IWorkspaceService); + const quickPick = useInjectable(QuickPickService); + + const [historyList, setHistoryList] = React.useState([]); + const [currentTitle, setCurrentTitle] = React.useState(''); + const [historyLoading, setHistoryLoading] = React.useState(false); + const [sessionSwitching, setSessionSwitching] = React.useState(false); + const isMultiRoot = workspaceService.isMultiRootWorkspaceOpened; + + const [currentWorkspaceDir, setCurrentWorkspaceDir] = React.useState(getCachedWorkspaceDir()); + + // Sync state when cache is updated externally (e.g. by session provider on first init) + React.useEffect(() => { + const cached = getCachedWorkspaceDir(); + if (cached && cached !== currentWorkspaceDir) { + setCurrentWorkspaceDir(cached); + } + }); + + const handleSwitchWorkspaceDir = React.useCallback(async () => { + const oldDir = getCachedWorkspaceDir(); + const newDir = await switchWorkspaceDir(workspaceService, quickPick, messageService); + setCurrentWorkspaceDir(newDir); + // Create new session with new cwd if path actually changed + if (newDir && newDir !== oldDir) { + try { + aiChatService.createSessionModel(); + } catch (error) { + messageService.error(error.message); + } + } + }, [workspaceService, quickPick, messageService, aiChatService]); + + React.useEffect(() => { + const dispose = aiChatService.onSessionLoadingChange((loading) => { + setSessionSwitching(loading); + }); + return () => dispose.dispose(); + }, [aiChatService]); + + const handleNewChat = React.useCallback(() => { + if (sessionSwitching) { + return; + } + if (aiChatService.sessionModel && aiChatService.sessionModel.history.getMessages().length > 0) { + try { + aiChatService.createSessionModel(); + } catch (error) { + messageService.error(error.message); + } + } + }, [aiChatService, sessionSwitching]); + + const handleHistoryItemSelect = React.useCallback( + (item: IChatHistoryItem) => { + if (sessionSwitching) { + return; + } + aiChatService.activateSession(item.id); + }, + [aiChatService, sessionSwitching], + ); + + const handleHistoryItemChange = React.useCallback(() => {}, []); + + /** + * 构建 ACP 历史列表 + * 优先使用 session.title(服务端元数据),降级使用第一条消息内容 + */ + const getHistoryList = React.useCallback(async () => { + const sessions = aiChatService.getSessions(); + + // 当前会话标题 + const currentMessages = aiChatService.sessionModel?.history.getMessages() || []; + const latestUserMessage = [...currentMessages].find((m) => m.role === ChatMessageRole.User); + const title = latestUserMessage + ? cleanAttachedTextWrapper(latestUserMessage.content).slice(0, MAX_TITLE_LENGTH) + : ''; + setCurrentTitle(title); + + setHistoryList( + sessions.map((session) => { + const messages = session.history.getMessages(); + + // ACP 关键区别:优先使用 session.title + let sessionTitle = ''; + if (session.title) { + sessionTitle = session.title.slice(0, MAX_TITLE_LENGTH); + } else if (messages.length > 0) { + sessionTitle = cleanAttachedTextWrapper(messages[0].content).slice(0, MAX_TITLE_LENGTH); + } + + const updatedAt = messages.length > 0 ? messages[messages.length - 1].replyStartTime || 0 : 0; + + return { + id: session.sessionId, + title: sessionTitle, + updatedAt, + loading: false, + }; + }), + ); + }, [aiChatService]); + + // 监听 popover 打开时刷新列表 + const handleHistoryPopoverVisibleChange = React.useCallback( + async (visible: boolean) => { + if (visible) { + setHistoryLoading(true); + try { + await aiChatService.getSessionsByAcp(); + await getHistoryList(); + } finally { + setHistoryLoading(false); + } + } + }, + [aiChatService, getHistoryList], + ); + + React.useEffect(() => { + getHistoryList(); + + const toDispose = new DisposableCollection(); + let previousMessageChangeDisposable: IDisposable | undefined; + + toDispose.push( + aiChatService.onChangeSession(() => { + getHistoryList(); + previousMessageChangeDisposable?.dispose(); + if (aiChatService.sessionModel) { + previousMessageChangeDisposable = aiChatService.sessionModel.history.onMessageChange(() => { + getHistoryList(); + }); + } + }), + ); + + toDispose.push({ dispose: () => previousMessageChangeDisposable?.dispose() }); + + if (aiChatService.sessionModel) { + toDispose.push( + aiChatService.sessionModel.history.onMessageChange(() => { + getHistoryList(); + }), + ); + } + + return () => { + toDispose.dispose(); + }; + }, [aiChatService]); + + return ( +
+ {}} + onHistoryItemChange={handleHistoryItemChange} + onHistoryPopoverVisibleChange={handleHistoryPopoverVisibleChange} + /> + {isMultiRoot && ( + + + + )} + + + + + + +
+ ); +} diff --git a/packages/ai-native/src/browser/acp/components/AcpChatViewWrapper.tsx b/packages/ai-native/src/browser/acp/components/AcpChatViewWrapper.tsx new file mode 100644 index 0000000000..3c602782d5 --- /dev/null +++ b/packages/ai-native/src/browser/acp/components/AcpChatViewWrapper.tsx @@ -0,0 +1,213 @@ +/** + * ACP ChatView Wrapper + * + * 为 ACP 模式提供包装层,封装: + * - ACP 初始化逻辑(等待 Agent 准备) + * - 等待 sessionModel 准备好 + * - Loading/Error 状态处理 + * - 权限弹窗 + * + * 非 ACP 模式下直接渲染子组件 + */ +import React, { useEffect, useRef, useState } from 'react'; + +import { AINativeConfigService, useInjectable } from '@opensumi/ide-core-browser'; +import { Progress } from '@opensumi/ide-core-browser/lib/progress/progress-bar'; +import { AIBackSerivcePath, IAIBackService, localize } from '@opensumi/ide-core-common'; + +import { ChatProxyServiceToken, IChatManagerService } from '../../../common'; +import { ChatManagerService } from '../../chat/chat-manager.service'; +import { AcpChatManagerService } from '../../chat/chat-manager.service.acp'; +import { ChatProxyService } from '../../chat/chat-proxy.service'; +import { AcpChatProxyService } from '../../chat/chat-proxy.service.acp'; +import { ChatInternalService } from '../../chat/chat.internal.service'; +import { AcpChatInternalService } from '../../chat/chat.internal.service.acp'; +import styles from '../../chat/chat.module.less'; + +interface AcpChatViewWrapperProps { + children: React.ReactNode; + aiChatService: ChatInternalService; +} + +export function AcpChatViewWrapper({ children, aiChatService }: AcpChatViewWrapperProps) { + const aiNativeConfigService = useInjectable(AINativeConfigService); + const aiBackService = useInjectable(AIBackSerivcePath); + const chatManagerService = useInjectable(IChatManagerService); + const chatProxyService = useInjectable(ChatProxyServiceToken); + + // ACP 模式初始化状态 + const [initState, setInitState] = useState<{ + initialized: boolean; + }>({ + initialized: false, + }); + + // ACP 模式:等待 sessionModel 准备好 + const [sessionReady, setSessionReady] = useState(false); + + // 初始化超时状态:超过 30s 未完成时展示重试按钮 + const [timedOut, setTimedOut] = useState(false); + + // 重试 key:变化时触发重新初始化 + const [retryKey, setRetryKey] = useState(0); + + // 用于取消上一轮初始化的 cancelled flag + const cancelledRef = useRef(false); + + // ACP 模式:只在第一次渲染或重试时触发初始化 + useEffect(() => { + // 非 ACP 模式不需要延迟初始化 + if (!aiNativeConfigService.capabilities.supportsAgentMode) { + setInitState({ initialized: true }); + setSessionReady(true); + return; + } + + // 取消上一轮初始化,重置状态 + cancelledRef.current = false; + setInitState({ initialized: false }); + setSessionReady(false); + setTimedOut(false); + + const cancelled = () => cancelledRef.current; + + const initializeACP = async () => { + try { + // 等待 acp-cli-back 的 default agent 初始化完成 + let ready = false; + let retries = 0; + const maxRetries = 10; // 最多重试 10 次,每次 1s,总共 10 秒 + + while (!ready && retries < maxRetries) { + if (cancelled()) { + return; + } + const isReady = await aiBackService.ready?.(); + ready = !!isReady; + + if (!ready) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + retries++; + } + } + + if (cancelled()) { + return; + } + + if (!ready) { + throw new Error('ACP backend service is not ready after maximum retries'); + } + + // 先调用 aiChatService.init() 注册 onStorageInit 监听器 + aiChatService.init(); + // 创建新会话 + await aiChatService.createSessionModel(); + + if (cancelled()) { + return; + } + + // 加载历史会话列表(用于 history 下拉展示) + await chatManagerService.loadSessionList(); + + if (cancelled()) { + return; + } + + setInitState({ initialized: true }); + } catch (error) { + if (cancelled()) { + return; + } + // Fallback to default agent when ACP is unavailable + chatManagerService.fallbackToLocal(); + chatProxyService.registerFallbackAgent(); + // Re-create session model using the local provider + await aiChatService.createSessionModel(); + setInitState({ initialized: true }); + } + }; + + // 30s 超时 timer + const timeoutTimer = window.setTimeout(() => { + setTimedOut(true); + }, 30000); + + initializeACP(); + + return () => { + cancelledRef.current = true; + clearTimeout(timeoutTimer); + }; + }, [retryKey]); + + const handleRetry = () => { + setRetryKey((k) => k + 1); + }; + + // 等待 sessionModel 准备好 + useEffect(() => { + if (!aiNativeConfigService.capabilities.supportsAgentMode) { + setSessionReady(true); + return; + } + + if (!initState.initialized) { + return; + } + + // 检查 sessionModel 是否已准备好 + if (aiChatService.sessionModel) { + setSessionReady(true); + return; + } + + // 轮询检查 sessionModel,直到就绪 + let pollCount = 0; + const MAX_POLL_COUNT = 12000; // 1200s at 100ms intervals + + const interval = window.setInterval(() => { + pollCount++; + if (aiChatService.sessionModel) { + setSessionReady(true); + clearInterval(interval); + return; + } + if (pollCount >= MAX_POLL_COUNT) { + clearInterval(interval); + setInitState({ initialized: true }); + } + }, 100); + + return () => { + clearInterval(interval); + }; + }, [initState.initialized, retryKey]); + if (!aiNativeConfigService.capabilities.supportsAgentMode) { + return children; + } + + // ACP 模式或初始化完成且 session 准备好,渲染子组件 + if (initState.initialized && sessionReady) { + return <>{children}; + } + + // 初始化中或等待 session + return ( +
+ +
{localize('aiNative.chat.acp.initializing.text', 'Initializing ACP service...')}
+ {timedOut && ( + <> +
+ {localize('aiNative.chat.acp.timeout.hint', 'Initialization is taking longer than expected')} +
+ + + )} +
+ ); +} diff --git a/packages/ai-native/src/browser/acp/components/AcpFooterButtons.tsx b/packages/ai-native/src/browser/acp/components/AcpFooterButtons.tsx new file mode 100644 index 0000000000..ed86e5192d --- /dev/null +++ b/packages/ai-native/src/browser/acp/components/AcpFooterButtons.tsx @@ -0,0 +1,29 @@ +import React, { useMemo } from 'react'; + +import { useInjectable } from '@opensumi/ide-core-browser'; +import { ChatFeatureRegistryToken } from '@opensumi/ide-core-common'; + +import { ChatFeatureRegistry } from '../../chat/chat.feature.registry'; +import styles from '../../components/components.module.less'; + +export function AcpSlashCommandFooter() { + const chatFeatureRegistry = useInjectable(ChatFeatureRegistryToken); + + const slashCommands = useMemo(() => chatFeatureRegistry.getAllSlashCommand(), [chatFeatureRegistry]); + + const handleTriggerClick = () => { + window.dispatchEvent(new CustomEvent('opensumi-chat-input-open-slash-panel')); + }; + + if (slashCommands.length === 0) { + return null; + } + + return ( +
+ + / + +
+ ); +} diff --git a/packages/ai-native/src/browser/acp/components/AcpFooterContribution.ts b/packages/ai-native/src/browser/acp/components/AcpFooterContribution.ts new file mode 100644 index 0000000000..f4f3f43342 --- /dev/null +++ b/packages/ai-native/src/browser/acp/components/AcpFooterContribution.ts @@ -0,0 +1,34 @@ +import { Autowired, Injectable } from '@opensumi/di'; +import { ClientAppContribution, Domain, IDisposable } from '@opensumi/ide-core-browser'; + +import { + ChatInputFooterRegistry, + ChatInputFooterRegistryToken, + FooterButtonPosition, +} from '../../chat/chat-input-footer.registry'; + +import { AcpSlashCommandFooter } from './AcpFooterButtons'; + +@Injectable() +@Domain(ClientAppContribution) +export class AcpFooterContribution implements ClientAppContribution { + @Autowired(ChatInputFooterRegistryToken) + private readonly footerRegistry: ChatInputFooterRegistry; + + private registrationDisposables: IDisposable[] = []; + + initialize(): void { + this.registrationDisposables.push( + this.footerRegistry.registerFooterItem('slash-commands', { + component: AcpSlashCommandFooter, + order: 20, + position: FooterButtonPosition.LEFT, + }), + ); + } + + dispose(): void { + this.registrationDisposables.forEach((d) => d.dispose()); + this.registrationDisposables = []; + } +} diff --git a/packages/ai-native/src/browser/acp/index.ts b/packages/ai-native/src/browser/acp/index.ts new file mode 100644 index 0000000000..78c39d5487 --- /dev/null +++ b/packages/ai-native/src/browser/acp/index.ts @@ -0,0 +1,5 @@ +export { AcpPermissionHandler } from './permission.handler'; +export { AcpPermissionBridgeService, ShowPermissionDialogParams } from './permission-bridge.service'; +export { AcpPermissionRpcService } from './acp-permission-rpc.service'; +export { PermissionDialog, PermissionDialogProps } from './permission-dialog.view'; +export { default as PermissionDialogStyles } from './permission-dialog.module.less'; diff --git a/packages/ai-native/src/browser/acp/permission-bridge.service.ts b/packages/ai-native/src/browser/acp/permission-bridge.service.ts new file mode 100644 index 0000000000..e646d67798 --- /dev/null +++ b/packages/ai-native/src/browser/acp/permission-bridge.service.ts @@ -0,0 +1,160 @@ +import { Autowired, Injectable } from '@opensumi/di'; +import { Emitter, Event, ILogger } from '@opensumi/ide-core-common'; +import { IMainLayoutService } from '@opensumi/ide-main-layout'; + +import { PermissionDialogProps } from './permission-dialog.view'; +import { PermissionDecision } from './permission.handler'; + +import type { PermissionOption, PermissionOptionKind } from '@opensumi/ide-core-common/lib/types/ai-native/acp-types'; + +export interface ShowPermissionDialogParams { + requestId: string; + title: string; + kind?: string; + content?: string; + locations?: Array<{ path: string; line?: number }>; + command?: string; + options: PermissionOption[]; + timeout: number; +} + +@Injectable() +export class AcpPermissionBridgeService { + @Autowired(ILogger) + private logger: ILogger; + + @Autowired(IMainLayoutService) + private mainLayoutService: IMainLayoutService; + + private activeDialogs = new Map(); + private pendingDecisions = new Map< + string, + { + resolve: (decision: PermissionDecision) => void; + timeout: NodeJS.Timeout; + } + >(); + + private readonly onPermissionRequest = new Emitter(); + readonly onDidRequestPermission: Event = this.onPermissionRequest.event; + + private readonly onPermissionResult = new Emitter<{ + requestId: string; + decision: PermissionDecision; + }>(); + readonly onDidReceivePermissionResult: Event<{ + requestId: string; + decision: PermissionDecision; + }> = this.onPermissionResult.event; + + /** + * Show permission dialog and wait for user response + */ + async showPermissionDialog(params: ShowPermissionDialogParams): Promise { + const requestId = params.requestId; + + // Check if dialog already exists for this request + if (this.activeDialogs.has(requestId)) { + return { type: 'cancelled' }; + } + + // Create dialog props + const dialogProps: PermissionDialogProps = { + visible: true, + requestId, + title: params.title, + kind: params.kind, + content: params.content, + locations: params.locations, + command: params.command, + options: params.options, + timeout: params.timeout, + onSelect: this.handleUserDecision.bind(this), + onClose: this.handleDialogClose.bind(this), + }; + + this.activeDialogs.set(requestId, dialogProps); + + // Emit event to show dialog + this.onPermissionRequest.fire(params); + + // Set up timeout + const timeout = setTimeout(() => { + this.handleDialogClose(requestId); + }, params.timeout); + + // Wait for decision + return new Promise((resolve) => { + this.pendingDecisions.set(requestId, { + resolve, + timeout, + }); + }); + } + + /** + * Handle user decision on permission request + */ + handleUserDecision(requestId: string, optionId: string, optionKind: PermissionOptionKind): void { + const pending = this.pendingDecisions.get(requestId); + if (!pending) { + return; + } + + clearTimeout(pending.timeout); + this.pendingDecisions.delete(requestId); + + const always = optionKind === 'allow_always' || optionKind === 'reject_always'; + const allow = optionKind === 'allow_once' || optionKind === 'allow_always'; + + const decision: PermissionDecision = { + type: allow ? 'allow' : 'reject', + optionId, + always, + }; + + this.activeDialogs.delete(requestId); + this.onPermissionResult.fire({ requestId, decision }); + pending.resolve(decision); + } + + /** + * Handle dialog close/timeout + */ + handleDialogClose(requestId: string): void { + const pending = this.pendingDecisions.get(requestId); + if (!pending) { + return; + } + + clearTimeout(pending.timeout); + this.pendingDecisions.delete(requestId); + + const decision: PermissionDecision = { type: 'timeout' }; + + this.activeDialogs.delete(requestId); + this.onPermissionResult.fire({ requestId, decision }); + pending.resolve(decision); + } + + /** + * Cancel a pending permission request + */ + cancelRequest(requestId: string): void { + this.handleDialogClose(requestId); + } + + /** + * Get active dialog count + */ + getActiveDialogCount(): number { + return this.activeDialogs.size; + } + + /** + * Get active dialogs (for debugging) + */ + getActiveDialogs(): PermissionDialogProps[] { + return Array.from(this.activeDialogs.values()); + } +} diff --git a/packages/ai-native/src/browser/acp/permission-dialog-container.module.less b/packages/ai-native/src/browser/acp/permission-dialog-container.module.less new file mode 100644 index 0000000000..61abceeba2 --- /dev/null +++ b/packages/ai-native/src/browser/acp/permission-dialog-container.module.less @@ -0,0 +1,13 @@ +.dialogContainer { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 1000; + pointer-events: none; + + > * { + pointer-events: auto; + } +} diff --git a/packages/ai-native/src/browser/acp/permission-dialog-container.tsx b/packages/ai-native/src/browser/acp/permission-dialog-container.tsx new file mode 100644 index 0000000000..697228747f --- /dev/null +++ b/packages/ai-native/src/browser/acp/permission-dialog-container.tsx @@ -0,0 +1,441 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; + +import { Autowired, Injectable } from '@opensumi/di'; +import { ComponentContribution, ComponentRegistry, Domain, useInjectable } from '@opensumi/ide-core-browser'; +import { getIcon } from '@opensumi/ide-core-browser/lib/components'; + +import { AcpPermissionBridgeService, ShowPermissionDialogParams } from './permission-bridge.service'; + +import type { PermissionOptionKind } from '@opensumi/ide-core-common/lib/types/ai-native/acp-types'; + +// Module load logging for debugging + +// 默认权限选项(仅作为类型参考,实际选项由后端传入) +// 后端传入的选项可能包含:allow_always, allow_once, reject_once 等 + +/** + * 简化的全局对话框状态管理 + */ +@Injectable() +class PermissionDialogManager { + private listeners: Array<(dialogs: DialogState[]) => void> = []; + private dialogs: DialogState[] = []; + + addDialog(params: ShowPermissionDialogParams) { + const exists = this.dialogs.find((d) => d.requestId === params.requestId); + + if (!exists) { + this.dialogs.push({ + requestId: params.requestId, + params, + }); + this.notifyListeners(); + } + } + + removeDialog(requestId: string) { + const index = this.dialogs.findIndex((d) => d.requestId === requestId); + if (index !== -1) { + this.dialogs.splice(index, 1); + this.notifyListeners(); + } + } + + clearAll() { + this.dialogs = []; + this.notifyListeners(); + } + + getDialogs(): DialogState[] { + return [...this.dialogs]; + } + + subscribe(listener: (dialogs: DialogState[]) => void) { + this.listeners.push(listener); + return () => { + this.listeners = this.listeners.filter((l) => l !== listener); + }; + } + + private notifyListeners() { + this.listeners.forEach((listener) => listener([...this.dialogs])); + } +} + +interface DialogState { + requestId: string; + params: ShowPermissionDialogParams; +} + +/** + * 智能文件名提取工具函数 + */ +export const getAffectedFileName = (params: ShowPermissionDialogParams): string => { + // 优先从 locations 获取文件名 + const fromLocations = params.locations?.[0]?.path; + if (fromLocations) { + return fromLocations.split('/').pop() || fromLocations; + } + + return 'file'; +}; + +/** + * 智能标题生成工具函数 + */ +export const getSmartTitle = (params: ShowPermissionDialogParams): string => { + const kind = params.kind; + + if (kind === 'edit' || kind === 'write') { + const fileName = getAffectedFileName(params); + return `Make this edit to ${fileName}?`; + } + + if (kind === 'execute' || kind === 'bash') { + return 'Allow this bash command?'; + } + + if (kind === 'read') { + const fileName = getAffectedFileName(params); + return `Allow read from ${fileName}?`; + } + + return params.title || 'Permission Required'; +}; + +@Injectable() +@Domain(ComponentContribution) +export class AcpPermissionDialogContribution implements ComponentContribution { + @Autowired(AcpPermissionBridgeService) + private permissionBridgeService!: AcpPermissionBridgeService; + + @Autowired(PermissionDialogManager) + private dialogManager!: PermissionDialogManager; + + constructor() { + // 监听权限请求事件 - 添加对话框 + this.permissionBridgeService.onDidRequestPermission((params: ShowPermissionDialogParams) => { + this.dialogManager.addDialog(params); + }); + + // 监听权限结果事件 - 处理超时等结果 + this.permissionBridgeService.onDidReceivePermissionResult((result) => { + // 超时或取消时关闭对话框 + if (result.decision.type === 'timeout' || result.decision.type === 'cancelled') { + this.dialogManager.removeDialog(result.requestId); + } + }); + } + + registerComponent(registry: ComponentRegistry) { + registry.register('acp-permission-dialog-container', { + id: 'acp-permission-dialog-container', + component: AcpPermissionDialogContainer, + }); + } +} + +/** + * 函数组件形式的权限对话框容器 + */ +const AcpPermissionDialogContainer: React.FC = () => { + // 状态管理 + const [dialogs, setDialogs] = useState([]); + const [focusedIndex, setFocusedIndex] = useState(0); + + const functionComponentDialogManager = useInjectable(PermissionDialogManager); + const permissionBridgeService = useInjectable(AcpPermissionBridgeService); + + // Ref 管理 + const containerRef = useRef(null); + + // 组件挂载:订阅对话框状态变化 + useEffect(() => { + const unsubscribe = functionComponentDialogManager.subscribe((newDialogs) => { + setDialogs(newDialogs); + setFocusedIndex(0); // 重置焦点索引 + }); + + // 初始化当前 dialogs + setDialogs(functionComponentDialogManager.getDialogs()); + + return unsubscribe; + }, []); + + // 键盘导航处理函数(使用 useCallback 优化性能) + const handleKeyboardNavigation = useCallback( + (e: KeyboardEvent) => { + const options = dialogs[0]?.params.options || []; + + if (dialogs.length === 0) { + return; + } + + // 数字键 1-9 支持快捷选择 + const numMatch = e.key.match(/^[1-9]$/); + if (numMatch) { + const index = parseInt(e.key, 10) - 1; + if (index < options.length) { + e.preventDefault(); + handleDialogSelect(options[index].optionId || ''); + } + return; + } + + // 箭头键导航 + if (e.key === 'ArrowDown') { + e.preventDefault(); + setFocusedIndex((prev) => Math.min(prev + 1, options.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setFocusedIndex((prev) => Math.max(prev - 1, 0)); + } + + // 回车键选择 + if (e.key === 'Enter') { + e.preventDefault(); + if (focusedIndex < options.length) { + handleDialogSelect(options[focusedIndex].optionId || ''); + } + } + + // ESC 键取消 + if (e.key === 'Escape') { + e.preventDefault(); + handleDialogClose(); + } + }, + [dialogs, focusedIndex], + ); + + // 组件更新:动态添加/移除键盘监听 + useEffect(() => { + if (dialogs.length > 0) { + window.addEventListener('keydown', handleKeyboardNavigation); + // 添加焦点 + if (containerRef.current) { + containerRef.current.focus(); + } + } else { + window.removeEventListener('keydown', handleKeyboardNavigation); + } + + return () => { + window.removeEventListener('keydown', handleKeyboardNavigation); + }; + }, [dialogs.length, handleKeyboardNavigation]); + + // 处理用户选择 + const handleDialogSelect = useCallback( + (_optionId: string) => { + if (dialogs.length === 0) { + return; + } + const requestId = dialogs[0].requestId; + const params = dialogs[0].params; + + // Find the selected option to get its kind + const selectedOption = params.options.find((opt) => opt.optionId === _optionId); + if (!selectedOption) { + return; + } + + // PermissionOption has 'kind' field which is PermissionOptionKind + const optionKind: PermissionOptionKind = selectedOption.kind || 'allow_once'; + + // Notify the permission bridge service with the decision + permissionBridgeService.handleUserDecision(requestId, _optionId, optionKind); + + // Close dialog + functionComponentDialogManager.removeDialog(requestId); + }, + [dialogs, permissionBridgeService], + ); + + // 处理对话框关闭 + const handleDialogClose = useCallback(() => { + if (dialogs.length === 0) { + return; + } + const requestId = dialogs[0].requestId; + // Notify the permission bridge service that the dialog was cancelled + permissionBridgeService.handleDialogClose(requestId); + // Close dialog + functionComponentDialogManager.removeDialog(requestId); + }, [dialogs, permissionBridgeService]); + + // 如果没有对话框,返回null + if (dialogs.length === 0) { + return null; + } + + const currentDialog = dialogs[0]; + const params = currentDialog.params; + const smartTitle = getSmartTitle(params); + const shouldShowDescription = + ['edit', 'write', 'read', 'execute', 'bash'].includes(params.kind || '') && params.content; + + return ( +
+
+ {/* 头部:标题和关闭按钮 */} +
+
+ + ! + + {smartTitle} +
+ +
+ + {/* 描述内容 */} + {shouldShowDescription && params.content && ( +
+ {params.content} +
+ )} + + {/* 选项按钮 */} +
+ {(params.options || []).map((option, index) => { + const isFocused = focusedIndex === index; + const buttonStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: 8, + padding: '6px 10px', + textAlign: 'left', + width: '100%', + border: 0, + borderRadius: 4, + fontSize: '0.85em', + fontWeight: isFocused ? 600 : 'normal', + cursor: 'pointer', + backgroundColor: isFocused ? 'var(--app-list-active-background)' : 'transparent', + color: isFocused ? 'var(--app-list-active-foreground)' : 'var(--app-primary-foreground)', + outline: 'none', + transition: 'background-color 0.15s', + }; + + return ( + + ); + })} +
+
+
+ ); +}; + +export default AcpPermissionDialogContainer; +export { PermissionDialogManager }; diff --git a/packages/ai-native/src/browser/acp/permission-dialog.module.less b/packages/ai-native/src/browser/acp/permission-dialog.module.less new file mode 100644 index 0000000000..fece0812c5 --- /dev/null +++ b/packages/ai-native/src/browser/acp/permission-dialog.module.less @@ -0,0 +1,121 @@ +.permissionContent { + display: flex; + flex-direction: column; + gap: 16px; +} + +.permissionDetails { + display: flex; + flex-direction: column; + gap: 12px; + background: var(--kt-panel-background); + padding: 12px; + border-radius: 4px; + border: 1px solid var(--kt-panel-border); +} + +.detailRow { + display: flex; + flex-direction: column; + gap: 4px; +} + +.detailLabel { + font-size: 12px; + color: var(--kt-text-subtoken); + font-weight: 500; +} + +.detailValue { + display: flex; + align-items: center; + gap: 8px; + font-weight: 500; +} + +.commandCode { + background: var(--kt-code-background); + padding: 8px 12px; + border-radius: 4px; + font-family: inherit; + font-size: 13px; + color: var(--kt-text-highlight); + word-break: break-all; + margin: 0; +} + +.locationList { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.locationItem { + display: flex; + align-items: center; + gap: 4px; + background: var(--kt-badge-background); + padding: 2px 8px; + border-radius: 3px; + font-size: 12px; + color: var(--kt-text-secondary); +} + +.contentPreview { + background: var(--kt-code-background); + padding: 8px; + border-radius: 4px; + max-height: 150px; + overflow: auto; + + pre { + margin: 0; + font-size: 12px; + font-family: var(--kt-code-font-family); + white-space: pre-wrap; + word-break: break-all; + } +} + +.timeoutSection { + background: var(--kt-panel-background); + padding: 12px; + border-radius: 4px; +} + +.timeoutHeader { + display: flex; + justify-content: space-between; + margin-bottom: 8px; + font-size: 12px; + color: var(--kt-text-subtoken); +} + +.timeoutValue { + font-weight: 600; + color: var(--kt-text-highlight); + min-width: 40px; + text-align: right; +} + +.warningMessage { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 8px 12px; + background: var(--kt-noticeInfo-background); + border-left: 3px solid var(--kt-noticeInfo-foreground); + border-radius: 0 4px 4px 0; + font-size: 12px; + color: var(--kt-text-secondary); + + span { + line-height: 1.4; + } +} + +.dialogFooter { + display: flex; + justify-content: flex-end; + gap: 8px; +} diff --git a/packages/ai-native/src/browser/acp/permission-dialog.view.tsx b/packages/ai-native/src/browser/acp/permission-dialog.view.tsx new file mode 100644 index 0000000000..17cbda6f5d --- /dev/null +++ b/packages/ai-native/src/browser/acp/permission-dialog.view.tsx @@ -0,0 +1,180 @@ +import React, { useEffect, useState } from 'react'; + +import { Button, Dialog, Icon } from '@opensumi/ide-components'; + +import styles from './permission-dialog.module.less'; + +import type { PermissionOptionKind, ToolCallLocation } from '@opensumi/ide-core-common/lib/types/ai-native/acp-types'; + +export interface PermissionDialogProps { + visible: boolean; + requestId: string; + title: string; + kind?: string; + content?: string; + locations?: ToolCallLocation[]; + command?: string; + options: Array<{ + optionId: string; + name: string; + kind: PermissionOptionKind; + }>; + timeout: number; + onSelect: (requestId: string, optionId: string, kind: PermissionOptionKind) => void; + onClose: (requestId: string) => void; +} + +export const PermissionDialog: React.FC = ({ + visible, + requestId, + title, + kind, + content, + locations, + command, + options, + timeout, + onSelect, + onClose, +}) => { + const [remainingTime, setRemainingTime] = useState(timeout); + // const [theme] = useDesignTheme(); + + // Countdown timer + useEffect(() => { + if (!visible || remainingTime <= 0) { + return; + } + + const interval = setInterval(() => { + setRemainingTime((prev) => { + if (prev <= 100) { + clearInterval(interval); + onClose(requestId); + return 0; + } + return prev - 100; + }); + }, 100); + + return () => clearInterval(interval); + }, [visible, remainingTime, requestId, onClose]); + + const handleOptionSelect = (optionId: string, kind: PermissionOptionKind) => { + onSelect(requestId, optionId, kind); + }; + + const getIconForKind = (kind?: string) => { + switch (kind) { + case 'write': + case 'edit': + return 'edit'; + case 'read': + return 'eye'; + case 'command': + return 'terminal'; + case 'search': + return 'search'; + default: + return 'file'; + } + }; + + // const progressPercent = (remainingTime / timeout) * 100; + + return ( + onClose(requestId)} + footer={ +
+ {options.map((option) => ( + + ))} +
+ } + message={undefined} + > +
+ {/* Permission details */} +
+ {kind && ( +
+ Operation: + + + {kind.charAt(0).toUpperCase() + kind.slice(1)} + +
+ )} + + {/* Show command if present */} + {command && ( +
+ Command: + {command} +
+ )} + + {/* Show affected files/paths */} + {locations && locations.length > 0 && ( +
+ Affected: +
+ {locations.map((loc, idx) => ( + + + {loc.path} + {loc.line && `:${loc.line}`} + + ))} +
+
+ )} + + {/* Show diff/content preview if available */} + {content && ( +
+ Preview: +
+
+                  {content.substring(0, 500)}
+                  {content.length > 500 ? '...' : ''}
+                
+
+
+ )} +
+ + {/* Timeout progress */} +
+
+ Auto-reject in + {Math.ceil(remainingTime / 1000)}s +
+ {/* */} +
+ + {/* Warning message */} +
+ + This operation was requested by the AI agent. Please review carefully. +
+
+
+ ); +}; + +export default PermissionDialog; diff --git a/packages/ai-native/src/browser/acp/permission.handler.ts b/packages/ai-native/src/browser/acp/permission.handler.ts new file mode 100644 index 0000000000..0a278118fc --- /dev/null +++ b/packages/ai-native/src/browser/acp/permission.handler.ts @@ -0,0 +1,330 @@ +import { Autowired, Injectable } from '@opensumi/di'; +import { PreferenceService } from '@opensumi/ide-core-browser/lib/preferences'; +import { Disposable, ILogger, IStorage, STORAGE_NAMESPACE, StorageProvider, uuid } from '@opensumi/ide-core-common'; + +import type { + PermissionOption, + PermissionOptionKind, + RequestPermissionResponse, + ToolCallUpdate, +} from '@opensumi/ide-core-common/lib/types/ai-native/acp-types'; + +export interface PermissionRequest { + sessionId: string; + toolCall: ToolCallUpdate; + options: PermissionOption[]; + timeout?: number; +} + +export type PermissionDecision = + | { type: 'allow'; optionId: string; always: boolean } + | { type: 'reject'; optionId: string; always: boolean } + | { type: 'timeout' } + | { type: 'cancelled' }; + +interface PermissionRule { + id: string; + pattern: string; + kind: ToolKind; + decision: 'allow' | 'reject'; + always: boolean; + createdAt: number; +} + +type ToolKind = 'read' | 'write' | 'edit' | 'command' | 'search'; + +@Injectable() +export class AcpPermissionHandler extends Disposable { + @Autowired(ILogger) + private logger: ILogger; + + @Autowired(StorageProvider) + private storageProvider: StorageProvider; + + @Autowired(PreferenceService) + private preferenceService: PreferenceService; + + private pendingRequests = new Map< + string, + { + resolve: (decision: PermissionDecision) => void; + timeout: NodeJS.Timeout; + } + >(); + + private rules: PermissionRule[] = []; + private defaultTimeout = 60000; // 60 seconds + + private permissionStorage: IStorage; + private initialized = false; + + private ensureInitialized(): void { + if (this.initialized) { + return; + } + this.initialized = true; + this.initStorage(); + } + + private async initStorage(): Promise { + this.permissionStorage = await this.storageProvider(STORAGE_NAMESPACE.AI_NATIVE); + this.loadRules(); + } + + /** + * Request permission for a tool operation + */ + async requestPermission(request: PermissionRequest): Promise { + this.ensureInitialized(); + const requestId = uuid(); + + // Check existing rules first + const autoDecision = this.checkRules(request); + if (autoDecision) { + return autoDecision; + } + + return new Promise((resolve) => { + // Set up timeout + const timeout = setTimeout(() => { + this.pendingRequests.delete(requestId); + this.logger.warn(`Permission request timed out: ${request.toolCall.title}`); + resolve({ type: 'timeout' }); + }, request.timeout ?? this.defaultTimeout); + + this.pendingRequests.set(requestId, { + resolve, + timeout, + }); + + // Show permission dialog + this.showPermissionDialog(requestId, request); + }); + } + + /** + * Handle user response to permission request + */ + handleUserResponse(requestId: string, optionId: string, optionKind: PermissionOptionKind): void { + const pending = this.pendingRequests.get(requestId); + if (!pending) { + this.logger.warn(`Permission request ${requestId} not found (maybe timed out)`); + return; + } + + clearTimeout(pending.timeout); + this.pendingRequests.delete(requestId); + + const always = optionKind === 'allow_always' || optionKind === 'reject_always'; + const allow = optionKind === 'allow_once' || optionKind === 'allow_always'; + + // Save rule if "always" + if (always) { + this.addRule(requestId, optionId, allow ? 'allow' : 'reject'); + } + + if (allow) { + pending.resolve({ + type: 'allow', + optionId, + always, + }); + } else { + pending.resolve({ + type: 'reject', + optionId, + always, + }); + } + } + + /** + * Cancel a pending permission request + */ + cancelRequest(requestId: string): void { + const pending = this.pendingRequests.get(requestId); + if (!pending) { + return; + } + + clearTimeout(pending.timeout); + this.pendingRequests.delete(requestId); + pending.resolve({ type: 'cancelled' }); + } + + /** + * Build permission response for the agent + */ + buildPermissionResponse(decision: PermissionDecision): RequestPermissionResponse { + switch (decision.type) { + case 'allow': + return { + outcome: { + outcome: 'selected', + optionId: decision.optionId, + }, + }; + case 'reject': + return { + outcome: { + outcome: 'selected', + optionId: decision.optionId, + }, + }; + case 'timeout': + case 'cancelled': + return { + outcome: { + outcome: 'cancelled', + }, + }; + } + } + + /** + * Get all saved permission rules + */ + getRules(): PermissionRule[] { + this.ensureInitialized(); + return [...this.rules]; + } + + /** + * Remove a permission rule + */ + removeRule(ruleId: string): void { + this.ensureInitialized(); + const index = this.rules.findIndex((r) => r.id === ruleId); + if (index !== -1) { + this.rules.splice(index, 1); + this.saveRules(); + } + } + + /** + * Clear all permission rules + */ + clearRules(): void { + this.ensureInitialized(); + this.rules = []; + this.saveRules(); + } + + private showPermissionDialog(requestId: string, request: PermissionRequest): void { + // This will be implemented to show a UI dialog + // For now, log the request + // TODO: Implement actual dialog UI component + // - Show tool call details + // - Show affected files/directories + // - Show command preview for terminal operations + // - Provide Allow/Allow Always/Reject/Reject Always buttons + // - Show countdown timer + } + + private checkRules(request: PermissionRequest): PermissionDecision | null { + const toolKind = request.toolCall.kind || 'read'; + + // Build pattern from tool call + let pattern = ''; + if (request.toolCall.locations && request.toolCall.locations.length > 0) { + pattern = request.toolCall.locations.map((l) => l.path).join(','); + } else { + pattern = request.toolCall.title || ''; + } + + for (const rule of this.rules) { + // Check if kind matches + if (rule.kind !== toolKind) { + continue; + } + + // Check if pattern matches (exact or glob) + if (this.matchPattern(pattern, rule.pattern)) { + return { + type: rule.decision, + optionId: rule.decision === 'allow' ? 'allow_always' : 'reject_always', + always: true, + }; + } + } + + return null; + } + + private matchPattern(value: string, pattern: string): boolean { + // Simple glob matching + if (pattern.includes('*')) { + const regex = new RegExp('^' + pattern.replace(/\*/g, '.*').replace(/\?/g, '.') + '$'); + return regex.test(value); + } + return value === pattern || value.startsWith(pattern); + } + + private addRule(requestId: string, pattern: string, decision: 'allow' | 'reject'): void { + // Extract pattern from request + // This is a placeholder - actual implementation should extract from the request + const rule: PermissionRule = { + id: uuid(), + pattern, + kind: 'write', // Should be extracted from actual request + decision, + always: true, + createdAt: Date.now(), + }; + + // Remove conflicting rules + this.rules = this.rules.filter((r) => r.pattern !== pattern || r.kind !== rule.kind); + + this.rules.push(rule); + this.saveRules(); + } + + private loadRules(): void { + try { + const saved = this.permissionStorage.get('acp.permission.rules', '[]'); + if (saved && saved !== '[]') { + this.rules = JSON.parse(saved); + } + } catch (e) { + this.logger.error('Failed to load permission rules:', e); + this.rules = []; + } + } + + private saveRules(): void { + try { + this.permissionStorage.set('acp.permission.rules', JSON.stringify(this.rules)); + } catch (e) { + this.logger.error('Failed to save permission rules:', e); + } + } + + /** + * Log permission audit event + */ + auditLog( + event: 'request' | 'decision', + data: { + requestId: string; + sessionId: string; + toolKind?: ToolKind; + toolTitle?: string; + decision?: string; + reason?: string; + }, + ): void { + const timestamp = new Date().toISOString(); + + // Log to console (could be extended to server-side logging) + this.logger.log(`[ACP Permission Audit ${timestamp}] ${event}:`, { + requestId: data.requestId, + sessionId: data.sessionId, + toolKind: data.toolKind, + toolTitle: data.toolTitle, + decision: data.decision, + reason: data.reason, + }); + + // TODO: Send audit logs to server + } +} diff --git a/packages/ai-native/src/browser/ai-core.contribution.ts b/packages/ai-native/src/browser/ai-core.contribution.ts index d180b117fa..bb6273d098 100644 --- a/packages/ai-native/src/browser/ai-core.contribution.ts +++ b/packages/ai-native/src/browser/ai-core.contribution.ts @@ -1,3 +1,5 @@ +import React from 'react'; + import { Autowired, INJECTOR_TOKEN, Injector } from '@opensumi/di'; import { AINativeConfigService, @@ -27,6 +29,7 @@ import { TabbarBehaviorConfig, getIcon, localize, + useInjectable, } from '@opensumi/ide-core-browser'; import { AI_CHAT_VISIBLE, @@ -49,7 +52,11 @@ import { IBrowserCtxMenu } from '@opensumi/ide-core-browser/lib/menu/next/render import { AI_NATIVE_SETTING_GROUP_TITLE, ChatFeatureRegistryToken, + ChatHistoryRegistryToken, + ChatInputRegistryToken, ChatRenderRegistryToken, + ChatServiceToken, + ChatViewRegistryToken, CommandService, IDisposable, InlineChatFeatureRegistryToken, @@ -62,6 +69,7 @@ import { STORAGE_NAMESPACE, StorageProvider, TerminalRegistryToken, + URI, isUndefined, runWhenIdle, } from '@opensumi/ide-core-common'; @@ -97,15 +105,26 @@ import { deepSeekModels, openAiNativeModels, } from '../common'; +import { LLMContextService, LLMContextServiceToken } from '../common/llm-context'; import { MCPServerDescription, MCPServersDisabledKey } from '../common/mcp-server-manager'; import { MCP_SERVER_TYPE } from '../common/types'; +import { AcpChatInput } from './acp/components/AcpChatInput'; +import { AcpChatMentionInput } from './acp/components/AcpChatMentionInput'; import { ChatEditSchemeDocumentProvider } from './chat/chat-edit-resource'; import { ChatManagerService } from './chat/chat-manager.service'; import { ChatMultiDiffResolver } from './chat/chat-multi-diff-source'; import { ChatProxyService } from './chat/chat-proxy.service'; +import { ChatService } from './chat/chat.api.service'; +import { IChatHistoryRegistry } from './chat/chat.history.registry'; +import { IChatInputRegistry } from './chat/chat.input.registry'; import { ChatInternalService } from './chat/chat.internal.service'; import { AIChatView } from './chat/chat.view'; +import { AIChatViewACP } from './chat/chat.view.acp'; +import { IChatViewRegistry } from './chat/chat.view.registry'; +import ChatHistoryACP from './components/ChatHistory.acp'; +import { ChatInput } from './components/ChatInput'; +import { ChatMentionInput } from './components/ChatMentionInput'; import { CodeActionSingleHandler } from './contrib/code-action/code-action.handler'; import { AIInlineCompletionsProvider } from './contrib/inline-completions/completeProvider'; import { InlineCompletionsController } from './contrib/inline-completions/inline-completions.controller'; @@ -149,6 +168,15 @@ import { SumiLightBulbWidget } from './widget/light-bulb'; export const INLINE_DIFF_MANAGER_WIDGET_ID = 'inline-diff-manager-widget'; +const DynamicChatViewWrapper: React.FC = () => { + const chatViewRegistry = useInjectable(ChatViewRegistryToken); + const activeView = chatViewRegistry.getActiveChatView(); + if (!activeView) { + return null; + } + return React.createElement(activeView.component); +}; + @Domain( ClientAppContribution, BrowserEditorContribution, @@ -199,6 +227,15 @@ export class AINativeBrowserContribution @Autowired(ChatRenderRegistryToken) private readonly chatRenderRegistry: IChatRenderRegistry; + @Autowired(ChatInputRegistryToken) + private readonly chatInputRegistry: IChatInputRegistry; + + @Autowired(ChatViewRegistryToken) + private readonly chatViewRegistry: IChatViewRegistry; + + @Autowired(ChatHistoryRegistryToken) + private readonly chatHistoryRegistry: IChatHistoryRegistry; + @Autowired(ResolveConflictRegistryToken) private readonly resolveConflictRegistry: IResolveConflictRegistry; @@ -280,6 +317,12 @@ export class AINativeBrowserContribution @Autowired(StorageProvider) private readonly storageProvider: StorageProvider; + @Autowired(ChatServiceToken) + private readonly chatService: ChatService; + + @Autowired(LLMContextServiceToken) + private readonly llmContextService: LLMContextService; + @Autowired() private readonly chatEditResourceProvider: ChatEditSchemeDocumentProvider; @@ -299,14 +342,19 @@ export class AINativeBrowserContribution } async initialize() { - const { supportsChatAssistant } = this.aiNativeConfigService.capabilities; + const { supportsChatAssistant, supportsAgentMode } = this.aiNativeConfigService.capabilities; if (supportsChatAssistant) { ComponentRegistryImpl.addLayoutModule(this.appConfig.layoutConfig, AI_CHAT_VIEW_ID, AI_CHAT_CONTAINER_ID); ComponentRegistryImpl.addLayoutModule(this.appConfig.layoutConfig, DESIGN_MENU_BAR_RIGHT, AI_CHAT_LOGO_AVATAR_ID); this.chatProxyService.registerDefaultAgent(); - this.chatInternalService.init(); - await this.chatManagerService.init(); + + // Local 模式:立即初始化 + // ACP 模式:延迟到面板打开时初始化 + if (!supportsAgentMode) { + this.chatInternalService.init(); + this.chatManagerService.init(); + } } } @@ -536,12 +584,96 @@ export class AINativeBrowserContribution contribution.registerChatAgentPromptProvider?.(); }); + // 注册默认输入组件 + this.registerDefaultInputs(); + + // 注册默认聊天视图和历史记录组件 + this.registerChatViews(); + + // 注册内置的 "Chat" 按钮,将选中代码添加到 Chat 面板的 context 中 + if (this.aiNativeConfigService.capabilities.supportsChatAssistant) { + this.inlineChatFeatureRegistry.registerEditorInlineChat( + { + id: 'ai-chat', + name: 'Chat', + title: 'Add to Chat', + renderType: 'button', + }, + { + execute: async (editor, selection) => { + const model = editor.getModel(); + if (!model) { + return; + } + const uri = model.uri; + const [startLine, endLine] = [selection.selectionStartLineNumber, selection.positionLineNumber].sort( + (a, b) => a - b, + ); + + this.llmContextService.addFileToContext(new URI(uri.toString()), [startLine, endLine], true); + this.chatService.sendMessage({ message: '', immediate: false }); + }, + }, + ); + } + // 注册 Opensumi 框架提供的 MCP Server Tools 能力 (此时的 Opensumi 作为 MCP Server) this.mcpServerContributions.getContributions().forEach((contribution) => { contribution.registerMCPServer(this.mcpServerRegistry); }); } + private registerDefaultInputs() { + this.chatInputRegistry.registerChatInput({ + id: 'acp-mention-input', + component: AcpChatMentionInput, + priority: 200, + when: () => this.aiNativeConfigService.capabilities.supportsAgentMode, + }); + + this.chatInputRegistry.registerChatInput({ + id: 'acp-chat-input', + component: AcpChatInput, + priority: 150, + when: () => this.aiNativeConfigService.capabilities.supportsAgentMode, + }); + + this.chatInputRegistry.registerChatInput({ + id: 'mention-input', + component: ChatMentionInput, + priority: 100, + when: () => this.aiNativeConfigService.capabilities.supportsMCP, + }); + + this.chatInputRegistry.registerChatInput({ + id: 'chat-input', + component: ChatInput, + priority: 50, + }); + } + + private registerChatViews() { + this.chatViewRegistry.registerChatView({ + id: 'acp-chat-view', + component: AIChatViewACP, + priority: 200, + when: () => this.aiNativeConfigService.capabilities.supportsAgentMode, + }); + + this.chatHistoryRegistry.registerChatHistory({ + id: 'acp-chat-history', + component: ChatHistoryACP, + priority: 200, + when: () => this.aiNativeConfigService.capabilities.supportsAgentMode, + }); + + this.chatViewRegistry.registerChatView({ + id: 'default-chat-view', + component: AIChatView, + priority: 50, + }); + } + registerSetting(registry: ISettingRegistry) { registry.registerSettingGroup({ id: AI_NATIVE_SETTING_GROUP_ID, @@ -676,6 +808,23 @@ export class AINativeBrowserContribution }); } + // Register Agent configs settings + if (this.aiNativeConfigService.capabilities.supportsAgentMode) { + registry.registerSettingSection(AI_NATIVE_SETTING_GROUP_ID, { + title: localize('preference.ai.native.agent.configs.title'), + preferences: [ + { + id: AINativeSettingSectionsId.AgentConfigs, + localized: 'preference.ai.native.agent.configs', + }, + { + id: AINativeSettingSectionsId.DefaultAgentType, + localized: 'preference.ai.native.agent.defaultType', + }, + ], + }); + } + if (this.aiNativeConfigService.capabilities.supportsInlineChat) { registry.registerSettingSection(AI_NATIVE_SETTING_GROUP_ID, { title: localize('preference.ai.native.inlineChat.title'), @@ -842,7 +991,7 @@ export class AINativeBrowserContribution registerComponent(registry: ComponentRegistry): void { registry.register(AI_CHAT_CONTAINER_ID, [], { - component: AIChatView, + component: DynamicChatViewWrapper, title: localize('aiNative.chat.ai.assistant.name'), iconClass: getIcon('magic-wand'), containerId: AI_CHAT_CONTAINER_ID, diff --git a/packages/ai-native/src/browser/chat/acp-chat-agent.ts b/packages/ai-native/src/browser/chat/acp-chat-agent.ts new file mode 100644 index 0000000000..9a79b39817 --- /dev/null +++ b/packages/ai-native/src/browser/chat/acp-chat-agent.ts @@ -0,0 +1,203 @@ +import { Autowired, Injectable } from '@opensumi/di'; +import { PreferenceService } from '@opensumi/ide-core-browser'; +import { + AIBackSerivcePath, + CancellationToken, + ChatFeatureRegistryToken, + Deferred, + IACPConfigProvider, + IAIBackService, + IAIReporter, + IApplicationService, + IChatProgress, + MCPConfigServiceToken, +} from '@opensumi/ide-core-common'; +import { AINativeSettingSectionsId } from '@opensumi/ide-core-common/lib/settings/ai-native'; +import { MonacoCommandRegistry } from '@opensumi/ide-editor/lib/browser/monaco-contrib/command/command.service'; +import { IMessageService } from '@opensumi/ide-overlay'; +import { listenReadable } from '@opensumi/ide-utils/lib/stream'; + +import { + CoreMessage, + IChatAgent, + IChatAgentCommand, + IChatAgentMetadata, + IChatAgentRequest, + IChatAgentResult, + IChatAgentService, + IChatAgentWelcomeMessage, +} from '../../common/index'; +import { MCPConfigService } from '../mcp/config/mcp-config.service'; + +import { ChatFeatureRegistry } from './chat.feature.registry'; + +/** + * ACP Chat Agent - 实现默认的聊天代理 + */ +@Injectable() +export class AcpChatAgent implements IChatAgent { + static readonly AGENT_ID = 'Default_Chat_Agent'; + + @Autowired(IChatAgentService) + protected readonly chatAgentService: IChatAgentService; + + @Autowired(AIBackSerivcePath) + protected readonly aiBackService: IAIBackService; + + @Autowired(PreferenceService) + protected readonly preferenceService: PreferenceService; + + @Autowired(IApplicationService) + protected readonly applicationService: IApplicationService; + + @Autowired(MonacoCommandRegistry) + protected readonly monacoCommandRegistry: MonacoCommandRegistry; + + @Autowired(ChatFeatureRegistryToken) + protected readonly chatFeatureRegistry: ChatFeatureRegistry; + + @Autowired(IAIReporter) + protected readonly aiReporter: IAIReporter; + + @Autowired(IMessageService) + protected readonly messageService: IMessageService; + + @Autowired(MCPConfigServiceToken) + protected readonly mcpConfigService: MCPConfigService; + + @Autowired(IACPConfigProvider) + protected readonly configProvider: IACPConfigProvider; + + public id = AcpChatAgent.AGENT_ID; + + public get metadata(): IChatAgentMetadata { + return { + systemPrompt: this.preferenceService.get(AINativeSettingSectionsId.SystemPrompt, ''), + }; + } + + public set metadata(_) { + // 不处理 + } + + protected async getRequestOptions() { + const model = this.preferenceService.get(AINativeSettingSectionsId.LLMModelSelection); + const modelId = this.preferenceService.get(AINativeSettingSectionsId.ModelID); + let apiKey: string = ''; + let baseURL: string = ''; + if (model === 'deepseek') { + apiKey = this.preferenceService.get(AINativeSettingSectionsId.DeepseekApiKey, ''); + } else if (model === 'openai') { + apiKey = this.preferenceService.get(AINativeSettingSectionsId.OpenaiApiKey, ''); + } else if (model === 'anthropic') { + apiKey = this.preferenceService.get(AINativeSettingSectionsId.AnthropicApiKey, ''); + } else { + // openai-compatible 为兜底 + apiKey = this.preferenceService.get(AINativeSettingSectionsId.OpenaiApiKey, ''); + baseURL = this.preferenceService.get(AINativeSettingSectionsId.OpenaiBaseURL, ''); + } + const maxTokens = this.preferenceService.get(AINativeSettingSectionsId.MaxTokens); + const agent = this.chatAgentService.getAgent(AcpChatAgent.AGENT_ID); + const disabledTools = await this.mcpConfigService.getDisabledTools(); + + return { + clientId: this.applicationService.clientId, + model, + modelId, + apiKey, + baseURL, + maxTokens, + system: agent?.metadata.systemPrompt, + disabledTools, + }; + } + + async invoke( + request: IChatAgentRequest, + progress: (part: IChatProgress) => void, + history: CoreMessage[], + token: CancellationToken, + ): Promise { + const chatDeferred = new Deferred(); + const { message, command } = request; + let prompt: string = message; + if (command) { + const commandHandler = this.chatFeatureRegistry.getSlashCommandHandler(command); + if (commandHandler && commandHandler.providerPrompt) { + const editor = this.monacoCommandRegistry.getActiveCodeEditor(); + const slashCommandPrompt = await commandHandler.providerPrompt(message, editor); + prompt = slashCommandPrompt; + } + } + + // Slash command 自定义路由:handler 有 invoke 时跳过 ACP,由 handler 自行处理 + if (command) { + const commandHandler = this.chatFeatureRegistry.getSlashCommandHandler(command); + if (commandHandler?.invoke) { + await commandHandler.invoke(prompt, progress, token); + chatDeferred.resolve(); + return {}; + } + } + + let sessionId = request.sessionId; + // 去掉 acp: 前缀(Agent 使用纯 UUID) + if (sessionId.startsWith('acp:')) { + // 【优化】等待后台 ACP Session 初始化完成 + // createSession 时已经异步初始化,正常情况下应该立即可用 + sessionId = sessionId.substring(4); + } + // agent 模式只需要发送最后一条数据 + const lastmessage = history[history.length - 1]; + + try { + const config = await this.configProvider.resolveConfig(); + const stream = await this.aiBackService.requestStream( + prompt, + { + requestId: request.requestId, + sessionId, + history: [lastmessage], + images: request.images, + ...(await this.getRequestOptions()), + agentSessionConfig: config, + }, + token, + ); + + listenReadable(stream, { + onData: (data) => { + progress(data); + }, + onEnd: () => { + chatDeferred.resolve(); + }, + onError: (error) => { + this.messageService.error(error.message); + this.aiReporter.end(sessionId + '_' + request.requestId, { + message: error.message, + success: false, + command, + }); + chatDeferred.reject(error); + }, + }); + + await chatDeferred.promise; + } catch (e) { + this.messageService.error(e.message); + chatDeferred.reject(e); + } + return {}; + } + + async provideSlashCommands(): Promise { + return this.chatFeatureRegistry + .getAllSlashCommand() + .map((s) => ({ ...s, name: s.name, description: s.description || '' })); + } + + async provideChatWelcomeMessage(): Promise { + return undefined; + } +} diff --git a/packages/ai-native/src/browser/chat/acp-session-provider.ts b/packages/ai-native/src/browser/chat/acp-session-provider.ts new file mode 100644 index 0000000000..9e71afdd0a --- /dev/null +++ b/packages/ai-native/src/browser/chat/acp-session-provider.ts @@ -0,0 +1,191 @@ +import { Autowired, Injectable } from '@opensumi/di'; +import { AIBackSerivcePath, Domain, IACPConfigProvider, IAIBackService } from '@opensumi/ide-core-common'; +import { IMessageService } from '@opensumi/ide-overlay'; + +import { ISessionModel, ISessionModelExtension, ISessionProvider, SessionProviderDomain } from './session-provider'; + +/** + * ACP Session Provider + * 通过 RPC 调用 Node 层加载 ACP Agent 的 Session + */ +@Domain(SessionProviderDomain) +@Injectable() +export class ACPSessionProvider implements ISessionProvider { + readonly id = 'ACPSessionProvider'; + + @Autowired(AIBackSerivcePath) + private aiBackService: IAIBackService; + + @Autowired(IACPConfigProvider) + private configProvider: IACPConfigProvider; + + @Autowired(IMessageService) + protected messageService: IMessageService; + + private loadedSessionMap: Map = new Map(); + + private loadedSessionsResult: ISessionModel[] | null = null; + + canHandle(mode: string): boolean { + return mode.startsWith('acp'); + } + + async createSession(title?: string): Promise { + if (!this.aiBackService?.createSession) { + throw new Error('aiBackService.createSession is not available'); + } + + try { + const config = await this.configProvider.resolveConfig(); + const result = await this.aiBackService.createSession(config); + + if (!result?.sessionId) { + throw new Error('createSession did not return a valid sessionId'); + } + + // 构造本地 Session ID(添加 acp: 前缀) + const sessionId = `acp:${result.sessionId}`; + + // 构造空壳会话模型 + const sessionModel: ISessionModel & { extension?: ISessionModelExtension } = { + sessionId, + history: { + additional: {}, + messages: [], + }, + requests: [], + title: title || '', + ...(result.availableCommands?.length ? { extension: { availableCommands: result.availableCommands } } : {}), + }; + + // 新创建的 Session 不需要 load,直接加入缓存 + this.loadedSessionMap.set(sessionId, sessionModel); + + return sessionModel; + } catch (e) { + this.messageService.error(e.message); + throw e; + } + } + + async loadSessions(): Promise { + if (this.loadedSessionsResult) { + return this.loadedSessionsResult; + } + + if (!this.aiBackService?.listSessions) { + return []; + } + + try { + const config = await this.configProvider.resolveConfig(); + const result = await this.aiBackService!.listSessions(config); + + if (!result?.sessions?.length) { + return []; + } + + // 只返回会话列表的元数据,不加载完整数据 + // 完整数据在 getSession 时通过 loadSession 按需加载 + const sessionModels = result.sessions + .slice(0, 20) + .reverse() + .map((sessionMeta) => ({ + ...sessionMeta, + sessionId: `acp:${sessionMeta.sessionId}`, + history: { + additional: {}, + messages: [], + }, + requests: [], + title: sessionMeta.title, + })); + + if (sessionModels.length === 0) { + return []; + } + this.loadedSessionsResult = sessionModels as unknown as ISessionModel[]; + + return this.loadedSessionsResult ?? []; + } catch (e) { + this.messageService.error(e.message); + return []; + } + } + + async loadSession(sessionId: string): Promise { + if (!sessionId) { + return undefined; + } + + if (!this.aiBackService?.loadAgentSession) { + return undefined; + } + + // 解析 sessionId,提取 agentSessionId(去掉 'acp:' 前缀) + const agentSessionId = sessionId.startsWith('acp:') ? sessionId.slice(4) : sessionId; + + try { + const config = await this.configProvider.resolveConfig(); + const agentSession = await this.aiBackService.loadAgentSession(config, agentSessionId); + + if (!agentSession) { + return undefined; + } + + // 将 Agent Session 转换为 ISessionModel 格式 + const sessionModel = this.convertAgentSessionToModel(sessionId, agentSession); + + // 缓存加载的 Session + this.loadedSessionMap.set(sessionId, sessionModel); + + return sessionModel; + } catch (error) { + // 不在 provider 层弹错误提示,将异常抛给调用方统一处理(如 activateSession 会自动创建新会话) + throw error; + } + } + + private convertAgentSessionToModel( + sessionId: string, + agentSession: { + sessionId: string; + messages: Array<{ + role: 'user' | 'assistant'; + content: string; + timestamp?: number; + }>; + }, + ): ISessionModel { + // 过滤掉包含 的系统消息 + const filteredMessages = agentSession.messages.filter((msg, index) => { + // 如果内容包含系统命令的 XML 标签,则过滤掉 + if (msg.content.includes('') || msg.content.includes('')) { + return false; + } + return true; + }); + + // 转换消息格式 + const messages = filteredMessages.map((msg, index) => ({ + id: `${sessionId}-msg-${index}`, + role: msg.role === 'user' ? 1 : 2, // ChatMessageRole.User = 1, Assistant = 2 + content: msg.content, + order: index, + timestamp: msg.timestamp, + })); + + const result = { + sessionId, + history: { + additional: {}, + messages, + }, + requests: [], + }; + + return result; + } + + async saveSessions(sessions: ISessionModel[]): Promise {} +} diff --git a/packages/ai-native/src/browser/chat/chat-agent.service.ts b/packages/ai-native/src/browser/chat/chat-agent.service.ts index 3eaede5263..8ef8b25e32 100644 --- a/packages/ai-native/src/browser/chat/chat-agent.service.ts +++ b/packages/ai-native/src/browser/chat/chat-agent.service.ts @@ -145,7 +145,7 @@ export class ChatAgentService extends Disposable implements IChatAgentService { ): Promise { const data = this.agents.get(id); if (!data) { - throw new Error(`No agent with id ${id}`); + throw new Error(`No agent with id ${id},this.agents ${this.agents}`); } // 发送第一条消息时携带初始 context diff --git a/packages/ai-native/src/browser/chat/chat-input-footer.registry.ts b/packages/ai-native/src/browser/chat/chat-input-footer.registry.ts new file mode 100644 index 0000000000..93ae4887e7 --- /dev/null +++ b/packages/ai-native/src/browser/chat/chat-input-footer.registry.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@opensumi/di'; +import { + ChatInputFooterItem, + ChatInputFooterRegistryToken, + Disposable, + Emitter, + Event, + FooterButtonPosition, + IChatInputFooterRegistry, + IDisposable, +} from '@opensumi/ide-core-common'; + +export { ChatInputFooterRegistryToken, FooterButtonPosition }; + +export interface ChatInputFooterContribution extends ChatInputFooterItem { + id: string; +} + +@Injectable() +export class ChatInputFooterRegistry extends Disposable implements IChatInputFooterRegistry { + private contributions: ChatInputFooterContribution[] = []; + private readonly onDidChangeEmitter = new Emitter(); + readonly onDidChange: Event = this.onDidChangeEmitter.event; + + registerFooterItem(id: string, item: ChatInputFooterItem): IDisposable { + const existing = this.contributions.findIndex((c) => c.id === id); + if (existing !== -1) { + this.contributions.splice(existing, 1); + } + + const entry: ChatInputFooterContribution = { + id, + ...item, + order: item.order ?? 100, + position: item.position ?? FooterButtonPosition.LEFT, + }; + this.contributions.push(entry); + this.contributions.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); + + const disposable = Disposable.create(() => { + const idx = this.contributions.indexOf(entry); + if (idx !== -1) { + this.contributions.splice(idx, 1); + this.onDidChangeEmitter.fire(); + } + }); + this.addDispose(disposable); + this.onDidChangeEmitter.fire(); + return disposable; + } + + getItems(): ChatInputFooterContribution[] { + return this.contributions.filter((c) => !c.visible || c.visible()); + } +} diff --git a/packages/ai-native/src/browser/chat/chat-manager.service.acp.ts b/packages/ai-native/src/browser/chat/chat-manager.service.acp.ts new file mode 100644 index 0000000000..83847b128c --- /dev/null +++ b/packages/ai-native/src/browser/chat/chat-manager.service.acp.ts @@ -0,0 +1,188 @@ +import { Autowired, Injectable } from '@opensumi/di'; +import { AINativeConfigService } from '@opensumi/ide-core-browser'; +import { AvailableCommand, debounce } from '@opensumi/ide-core-common'; + +import { MsgHistoryManager } from '../model/msg-history-manager'; + +import { ChatManagerService } from './chat-manager.service'; +import { ChatModel, ChatRequestModel, ChatResponseModel } from './chat-model'; +import { ChatFeatureRegistry } from './chat.feature.registry'; +import { ISessionModel, ISessionProvider } from './session-provider'; +import { ISessionProviderRegistry } from './session-provider-registry'; + +const MAX_SESSION_COUNT = 20; + +@Injectable() +export class AcpChatManagerService extends ChatManagerService { + @Autowired(AINativeConfigService) + protected readonly aiNativeConfig: AINativeConfigService; + + @Autowired(ISessionProviderRegistry) + private sessionProviderRegistry: ISessionProviderRegistry; + + private mainProvider: ISessionProvider | null = null; + + private availableCommands: AvailableCommand[] = []; + + constructor() { + super(); + const mode = this.aiNativeConfig.capabilities.supportsAgentMode ? 'acp' : 'local'; + const allProviders = this.sessionProviderRegistry.getAllProviders(); + const p = allProviders.filter((provider) => provider.canHandle(mode))[0]; + this.mainProvider = p; + } + + override async init() { + await this.loadSessionList(); + } + + async loadSessionList() { + if (!this.mainProvider) { + await this.storageInitEmitter.fireAndAwait(); + return; + } + + try { + const sessionsModelData = await this.mainProvider.loadSessions(); + const recentSessionsData = sessionsModelData.slice(-MAX_SESSION_COUNT); + + const activeKeys = new Set(this.sessionModels.keys()); + const filteredData = recentSessionsData.filter((item) => !activeKeys.has(item.sessionId)); + const maxIncoming = MAX_SESSION_COUNT - activeKeys.size; + + if (maxIncoming > 0) { + const savedSessions = this.fromAcpJSON(filteredData.slice(-maxIncoming)); + savedSessions.forEach((session) => { + this.sessionModels.set(session.sessionId, session); + }); + } + } catch (error) { + this.sessionModels.clear(); + } + + await this.storageInitEmitter.fireAndAwait(); + } + + override getSessions() { + return Array.from(this.sessionModels.values()); + } + + getAvailableCommands(): AvailableCommand[] { + return this.availableCommands; + } + + override async startSession(): Promise { + if (this.aiNativeConfig.capabilities.supportsAgentMode && this.mainProvider?.createSession) { + const sessionData = await this.mainProvider.createSession(); + if (sessionData.extension?.availableCommands) { + this.availableCommands = sessionData.extension.availableCommands; + } + const models = this.fromAcpJSON([sessionData]); + if (models.length > 0) { + const model = models[0]; + this.sessionModels.set(model.sessionId, model); + this.listenSession(model); + return model; + } + } + + const model = new ChatModel(this.chatFeatureRegistry); + this.sessionModels.set(model.sessionId, model); + this.listenSession(model); + return model; + } + + async loadSession(sessionId: string) { + if (this.aiNativeConfig.capabilities.supportsAgentMode) { + const existingSession = this.sessionModels.get(sessionId); + if (existingSession?.history?.getMessages()?.length) { + return; + } + + if (this.mainProvider?.loadSession && sessionId) { + return this.mainProvider.loadSession(sessionId).then((sessionData) => { + if (sessionData) { + const sessions = this.fromAcpJSON([sessionData]); + if (sessions.length > 0) { + const session = sessions[0]; + this.sessionModels.set(sessionId, session); + this.listenSession(session); + } + } + }); + } + } + } + + fallbackToLocal(): void { + const localProvider = this.sessionProviderRegistry.getProvider('local'); + if (!localProvider) { + return; + } + this.mainProvider = localProvider; + this.sessionModels.clear(); + this.loadSessionList(); + } + + private toSessionData(model: ChatModel): ISessionModel { + return { + sessionId: model.sessionId, + modelId: model.modelId, + history: model.history.toJSON(), + title: model.title, + requests: model.getRequests().map((request) => ({ + requestId: request.requestId, + message: request.message, + response: { + isCanceled: request.response.isCanceled, + responseText: request.response.responseText, + responseContents: request.response.responseContents, + responseParts: request.response.responseParts, + errorDetails: request.response.errorDetails, + followups: request.response.followups, + }, + })), + }; + } + + protected fromAcpJSON(data: ISessionModel[]) { + return data + .filter((item) => item.history.messages.length > 0 || item.sessionId.startsWith('acp:')) + .map((item) => { + const model = new ChatModel(this.chatFeatureRegistry, { + sessionId: item.sessionId, + history: new MsgHistoryManager(this.chatFeatureRegistry, item.history), + modelId: item.modelId, + title: item?.title, + }); + const requests = item.requests.map( + (request) => + new ChatRequestModel( + request.requestId, + model, + request.message, + new ChatResponseModel(request.requestId, model, request.message.agentId, { + responseContents: request.response.responseContents, + isComplete: true, + responseText: request.response.responseText, + responseParts: request.response.responseParts, + errorDetails: request.response.errorDetails, + followups: request.response.followups, + isCanceled: request.response.isCanceled, + }), + ), + ); + model.restoreRequests(requests); + return model; + }); + } + + @debounce(1000) + protected override async saveSessions() { + if (!this.mainProvider?.saveSessions) { + return; + } + const sessionsData = this.getSessions().map((model) => this.toSessionData(model)); + await this.mainProvider.saveSessions(sessionsData); + } +} diff --git a/packages/ai-native/src/browser/chat/chat-manager.service.ts b/packages/ai-native/src/browser/chat/chat-manager.service.ts index 85a6599dab..e63009aa1a 100644 --- a/packages/ai-native/src/browser/chat/chat-manager.service.ts +++ b/packages/ai-native/src/browser/chat/chat-manager.service.ts @@ -62,9 +62,10 @@ class DisposableLRUCache extends LRUCach @Injectable() export class ChatManagerService extends Disposable { - #sessionModels = this.registerDispose(new DisposableLRUCache(MAX_SESSION_COUNT)); + // Exposed as protected so AcpChatManagerService subclass can access it + protected sessionModels = this.registerDispose(new DisposableLRUCache(MAX_SESSION_COUNT)); #pendingRequests = this.registerDispose(new DisposableMap()); - private storageInitEmitter = new Emitter(); + protected storageInitEmitter = new Emitter(); public onStorageInit = this.storageInitEmitter.event; @Autowired(INJECTOR_TOKEN) @@ -80,7 +81,7 @@ export class ChatManagerService extends Disposable { private preferenceService: PreferenceService; @Autowired(ChatFeatureRegistryToken) - private chatFeatureRegistry: ChatFeatureRegistry; + protected chatFeatureRegistry: ChatFeatureRegistry; private _chatStorage: IStorage; @@ -88,14 +89,11 @@ export class ChatManagerService extends Disposable { return data .filter((item) => item.history.messages.length > 0) .map((item) => { - const model = new ChatModel( - this.chatFeatureRegistry, - { - sessionId: item.sessionId, - history: new MsgHistoryManager(this.chatFeatureRegistry, item.history), - modelId: item.modelId, - }, - ); + const model = new ChatModel(this.chatFeatureRegistry, { + sessionId: item.sessionId, + history: new MsgHistoryManager(this.chatFeatureRegistry, item.history), + modelId: item.modelId, + }); const requests = item.requests.map( (request) => new ChatRequestModel( @@ -127,35 +125,33 @@ export class ChatManagerService extends Disposable { const sessionsModelData = this._chatStorage.get('sessionModels', []); const savedSessions = this.fromJSON(sessionsModelData); savedSessions.forEach((session) => { - this.#sessionModels.set(session.sessionId, session); + this.sessionModels.set(session.sessionId, session); this.listenSession(session); }); await this.storageInitEmitter.fireAndAwait(); } getSessions() { - return Array.from(this.#sessionModels.values()); + return Array.from(this.sessionModels.values()); } - startSession() { - const model = new ChatModel( - this.chatFeatureRegistry, - ); - this.#sessionModels.set(model.sessionId, model); + async startSession(): Promise { + const model = new ChatModel(this.chatFeatureRegistry); + this.sessionModels.set(model.sessionId, model); this.listenSession(model); return model; } getSession(sessionId: string): ChatModel | undefined { - return this.#sessionModels.get(sessionId); + return this.sessionModels.get(sessionId); } clearSession(sessionId: string) { - const model = this.#sessionModels.get(sessionId) as ChatModel; + const model = this.sessionModels.get(sessionId) as ChatModel; if (!model) { throw new Error(`Unknown session: ${sessionId}`); } - this.#sessionModels.disposeKey(sessionId); + this.sessionModels.disposeKey(sessionId); this.#pendingRequests.get(sessionId)?.cancel(); this.#pendingRequests.disposeKey(sessionId); this.saveSessions(); diff --git a/packages/ai-native/src/browser/chat/chat-model.ts b/packages/ai-native/src/browser/chat/chat-model.ts index a365010f44..df311d1fa3 100644 --- a/packages/ai-native/src/browser/chat/chat-model.ts +++ b/packages/ai-native/src/browser/chat/chat-model.ts @@ -300,12 +300,18 @@ export class ChatModel extends Disposable implements IChatModel { constructor( private chatFeatureRegistry: ChatFeatureRegistry, - initParams?: { sessionId?: string; history?: MsgHistoryManager; modelId?: string }, + initParams?: { sessionId?: string; history?: MsgHistoryManager; modelId?: string; title?: string }, ) { super(); this.#sessionId = initParams?.sessionId ?? uuid(); this.history = initParams?.history ?? new MsgHistoryManager(this.chatFeatureRegistry); this.#modelId = initParams?.modelId; + this.#title = initParams?.title ?? ''; + } + + #title: string; + get title(): string { + return this.#title; } #sessionId: string; @@ -415,7 +421,6 @@ export class ChatModel extends Disposable implements IChatModel { try { return JSON.parse(jsonString); } catch (e) { - console.error(`[ChatModel] Failed to parse ${context}:`, e); return {}; } } @@ -502,13 +507,16 @@ export class ChatModel extends Disposable implements IChatModel { if (basicKind.includes(kind)) { request.response.updateContent(progress, quiet); } else { - console.error(`Couldn't handle progress: ${JSON.stringify(progress)}`); + // Couldn't handle progress } } getRequest(requestId: string): ChatRequestModel | undefined { return this.#requests.get(requestId); } + getRequests(): ChatRequestModel[] { + return Array.from(this.#requests.values()); + } override dispose(): void { super.dispose(); @@ -575,6 +583,6 @@ export class ChatSlashCommandItemModel extends Disposable implements IChatSlashC } get nameWithSlash() { - return this.name.startsWith(SLASH_SYMBOL) ? this.name : `${SLASH_SYMBOL} ${this.name}`; + return this.name.startsWith(SLASH_SYMBOL) ? this.name : `${SLASH_SYMBOL}${this.name}`; } } diff --git a/packages/ai-native/src/browser/chat/chat-proxy.service.acp.ts b/packages/ai-native/src/browser/chat/chat-proxy.service.acp.ts new file mode 100644 index 0000000000..13ff178b1a --- /dev/null +++ b/packages/ai-native/src/browser/chat/chat-proxy.service.acp.ts @@ -0,0 +1,62 @@ +import { Autowired, Injectable } from '@opensumi/di'; +import { AINativeConfigService, PreferenceService } from '@opensumi/ide-core-browser'; +import { + ChatAgentViewServiceToken, + Disposable, + IApplicationService, + IDisposable, + MCPConfigServiceToken, +} from '@opensumi/ide-core-common'; +import { AINativeSettingSectionsId } from '@opensumi/ide-core-common/lib/settings/ai-native'; + +import { DefaultChatAgentToken, IChatAgentService } from '../../common'; +import { ChatToolRender } from '../components/ChatToolRender'; +import { MCPConfigService } from '../mcp/config/mcp-config.service'; +import { IChatAgentViewService } from '../types'; + +import { AcpChatAgent } from './acp-chat-agent'; +import { ChatProxyService } from './chat-proxy.service'; +import { DefaultChatAgent } from './default-chat-agent'; + +@Injectable() +export class AcpChatProxyService extends ChatProxyService { + @Autowired(AINativeConfigService) + private readonly aiNativeConfigService: AINativeConfigService; + + @Autowired(DefaultChatAgentToken) + private readonly defaultChatAgent: DefaultChatAgent; + + @Autowired(AcpChatAgent) + private readonly acpChatAgent: AcpChatAgent; + + private agentDisposable: IDisposable | null = null; + + override registerDefaultAgent() { + this.chatAgentViewService.registerChatComponent({ + id: 'toolCall', + component: ChatToolRender, + initialProps: {}, + }); + + this.applicationService.getBackendOS().then(() => { + const agentToRegister = this.aiNativeConfigService.capabilities.supportsAgentMode + ? this.acpChatAgent + : this.defaultChatAgent; + + const disposable = this.chatAgentService.registerAgent(agentToRegister); + this.agentDisposable = disposable; + this.addDispose(disposable); + queueMicrotask(() => { + this.chatAgentService.updateAgent(ChatProxyService.AGENT_ID, {}); + }); + }); + } + + registerFallbackAgent(): void { + this.agentDisposable?.dispose(); + this.addDispose(this.chatAgentService.registerAgent(this.defaultChatAgent)); + queueMicrotask(() => { + this.chatAgentService.updateAgent(ChatProxyService.AGENT_ID, {}); + }); + } +} diff --git a/packages/ai-native/src/browser/chat/chat-proxy.service.ts b/packages/ai-native/src/browser/chat/chat-proxy.service.ts index 6c131fd445..cf589006bf 100644 --- a/packages/ai-native/src/browser/chat/chat-proxy.service.ts +++ b/packages/ai-native/src/browser/chat/chat-proxy.service.ts @@ -42,7 +42,7 @@ export class ChatProxyService extends Disposable { static readonly AGENT_ID = 'Default_Chat_Agent'; @Autowired(IChatAgentService) - private readonly chatAgentService: IChatAgentService; + protected readonly chatAgentService: IChatAgentService; @Autowired(AIBackSerivcePath) private readonly aiBackService: IAIBackService; @@ -57,13 +57,13 @@ export class ChatProxyService extends Disposable { private readonly aiReporter: IAIReporter; @Autowired(ChatAgentViewServiceToken) - private readonly chatAgentViewService: IChatAgentViewService; + protected readonly chatAgentViewService: IChatAgentViewService; @Autowired(PreferenceService) private readonly preferenceService: PreferenceService; @Autowired(IApplicationService) - private readonly applicationService: IApplicationService; + protected readonly applicationService: IApplicationService; @Autowired(IMessageService) private readonly messageService: IMessageService; diff --git a/packages/ai-native/src/browser/chat/chat.history.registry.ts b/packages/ai-native/src/browser/chat/chat.history.registry.ts new file mode 100644 index 0000000000..5eeea993ea --- /dev/null +++ b/packages/ai-native/src/browser/chat/chat.history.registry.ts @@ -0,0 +1,52 @@ +import React from 'react'; + +import { Injectable } from '@opensumi/di'; +import { ChatHistoryRegistryToken, Disposable, IDisposable } from '@opensumi/ide-core-common'; + +export interface ChatHistoryContribution { + id: string; + component: React.ComponentType; + /** Higher value = higher priority. Default 0. */ + priority?: number; + /** Optional condition. History component is selected only when this returns true. */ + when?: () => boolean; +} + +export interface IChatHistoryRegistry { + registerChatHistory(contribution: ChatHistoryContribution): IDisposable; + getChatHistoryContributions(): ChatHistoryContribution[]; + getActiveChatHistory(): ChatHistoryContribution | null; +} + +@Injectable() +export class ChatHistoryRegistry extends Disposable implements IChatHistoryRegistry { + private contributions: ChatHistoryContribution[] = []; + + registerChatHistory(contribution: ChatHistoryContribution): IDisposable { + const entry = { ...contribution, priority: contribution.priority ?? 0 }; + this.contributions.push(entry); + this.contributions.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)); + + const disposable = Disposable.create(() => { + const idx = this.contributions.indexOf(entry); + if (idx !== -1) { + this.contributions.splice(idx, 1); + } + }); + this.addDispose(disposable); + return disposable; + } + + getChatHistoryContributions(): ChatHistoryContribution[] { + return [...this.contributions]; + } + + getActiveChatHistory(): ChatHistoryContribution | null { + for (const c of this.contributions) { + if (!c.when || c.when()) { + return c; + } + } + return null; + } +} diff --git a/packages/ai-native/src/browser/chat/chat.input.registry.ts b/packages/ai-native/src/browser/chat/chat.input.registry.ts new file mode 100644 index 0000000000..7b4897dd86 --- /dev/null +++ b/packages/ai-native/src/browser/chat/chat.input.registry.ts @@ -0,0 +1,95 @@ +import { DataContent } from 'ai'; +import React from 'react'; + +import { Injectable } from '@opensumi/di'; +import { Disposable, IDisposable } from '@opensumi/ide-core-common'; + +import { LLMContextService } from '../../common/llm-context'; + +/** + * Props interface for chat input components. + * Based on AcpChatMentionInput's prop surface — all registered inputs must satisfy this contract. + */ +export interface IChatInputProps { + onSend: ( + value: string, + images?: string[], + agentId?: string, + command?: string, + option?: { model: string; [key: string]: any }, + ) => void; + onValueChange?: (value: string) => void; + onExpand?: (value: boolean) => void; + placeholder?: string; + enableOptions?: boolean; + disabled?: boolean; + sendBtnClassName?: string; + defaultHeight?: number; + value?: string; + images?: Array; + autoFocus?: boolean; + theme?: string | null; + setTheme: (theme: string | null) => void; + agentId: string; + setAgentId: (id: string) => void; + defaultAgentId?: string; + command: string; + setCommand: (command: string) => void; + disableModelSelector?: boolean; + sessionModelId?: string; + contextService?: LLMContextService; + agentModes?: Array<{ id: string; name: string; description?: string }>; + agentCwd?: string; +} + +export interface ChatInputContribution { + id: string; + component: React.ComponentType; + /** Higher value = higher priority. Default 0. */ + priority?: number; + /** Optional condition. Input is selected only when this returns true. */ + when?: () => boolean; +} + +export interface IChatInputRegistry { + registerChatInput(contribution: ChatInputContribution): IDisposable; + getChatInputContributions(): ChatInputContribution[]; + /** Get the highest-priority input whose `when()` condition passes, or null. */ + getActiveChatInput(): ChatInputContribution | null; +} + +@Injectable() +export class ChatInputRegistry extends Disposable implements IChatInputRegistry { + private contributions: ChatInputContribution[] = []; + + registerChatInput(contribution: ChatInputContribution): IDisposable { + const entry: ChatInputContribution = { + ...contribution, + priority: contribution.priority ?? 0, + }; + this.contributions.push(entry); + this.contributions.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)); + + const disposable = Disposable.create(() => { + const idx = this.contributions.indexOf(entry); + if (idx !== -1) { + this.contributions.splice(idx, 1); + } + }); + this.addDispose(disposable); + return disposable; + } + + getChatInputContributions(): ChatInputContribution[] { + return [...this.contributions]; + } + + getActiveChatInput(): ChatInputContribution | null { + for (const c of this.contributions) { + if (!c.when || c.when()) { + return c; + } + } + return null; + } +} diff --git a/packages/ai-native/src/browser/chat/chat.internal.service.acp.ts b/packages/ai-native/src/browser/chat/chat.internal.service.acp.ts new file mode 100644 index 0000000000..96179877d7 --- /dev/null +++ b/packages/ai-native/src/browser/chat/chat.internal.service.acp.ts @@ -0,0 +1,145 @@ +import { Autowired, Injectable } from '@opensumi/di'; +import { AINativeConfigService } from '@opensumi/ide-core-browser'; +import { AvailableCommand, Emitter, Event } from '@opensumi/ide-core-common'; +import { IMessageService } from '@opensumi/ide-overlay'; + +import { AcpChatManagerService } from './chat-manager.service.acp'; +import { ChatModel } from './chat-model'; +import { ChatInternalService } from './chat.internal.service'; + +@Injectable() +export class AcpChatInternalService extends ChatInternalService { + @Autowired(AINativeConfigService) + protected aiNativeConfigService: AINativeConfigService; + + @Autowired(IMessageService) + private messageService: IMessageService; + + private readonly _onModeChange = new Emitter(); + public readonly onModeChange: Event = this._onModeChange.event; + + private readonly _onSessionLoadingChange = new Emitter(); + public readonly onSessionLoadingChange: Event = this._onSessionLoadingChange.event; + + private readonly _onSessionModelChange = new Emitter(); + public readonly onSessionModelChange: Event = this._onSessionModelChange.event; + + private readonly _onAvailableCommandsChange = new Emitter(); + public readonly onAvailableCommandsChange: Event = this._onAvailableCommandsChange.event; + + private availableCommands: AvailableCommand[] = []; + + getAvailableCommands(): AvailableCommand[] { + return this.availableCommands; + } + + setAvailableCommands(commands: AvailableCommand[]) { + this.availableCommands = commands; + this._onAvailableCommandsChange.fire(commands); + } + + public get onStorageInit() { + return this.chatManagerService.onStorageInit; + } + + override init() { + this.chatManagerService.onStorageInit(async () => { + if (this.aiNativeConfigService.capabilities.supportsAgentMode) { + return; + } + const sessions = this.chatManagerService.getSessions(); + if (sessions.length > 0) { + await this.activateSession(sessions[sessions.length - 1].sessionId); + } else { + await this.createSessionModel(); + } + }); + } + + async setSessionMode(modeId: string): Promise { + const sessionId = this._sessionModel?.sessionId; + if (!sessionId) { + throw new Error('No active session'); + } + + try { + await this.aiBackService.setSessionMode?.(sessionId, modeId); + this._onModeChange.fire(modeId); + } catch (e) { + this.messageService.error((e as Error).message); + } + } + + override async createSessionModel() { + this._onSessionLoadingChange.fire(true); + this._sessionModel = await this.chatManagerService.startSession(); + const acpManager = this.chatManagerService as AcpChatManagerService; + this.setAvailableCommands(acpManager.getAvailableCommands()); + this._onSessionModelChange.fire(this._sessionModel); + this._onChangeSession.fire(this._sessionModel.sessionId); + this._onSessionLoadingChange.fire(false); + } + + override async clearSessionModel(sessionId?: string) { + sessionId = sessionId || this._sessionModel?.sessionId; + if (!sessionId) { + throw new Error('No active session'); + } + this._onWillClearSession.fire(sessionId); + this.chatManagerService.clearSession(sessionId); + if (this._sessionModel && sessionId === this._sessionModel.sessionId) { + this._sessionModel = await this.chatManagerService.startSession(); + const acpManager = this.chatManagerService as AcpChatManagerService; + this.setAvailableCommands(acpManager.getAvailableCommands()); + this._onSessionModelChange.fire(this._sessionModel); + } + if (this._sessionModel) { + this._onChangeSession.fire(this._sessionModel.sessionId); + } + } + + override getSessions() { + return this.chatManagerService.getSessions(); + } + + async getSessionsByAcp() { + const acpManager = this.chatManagerService as AcpChatManagerService; + await acpManager.loadSessionList(); + if (acpManager.getSessions().length === 0) { + await new Promise((resolve) => setTimeout(resolve, 1000 * 3)); + await acpManager.loadSessionList(); + } + return this.chatManagerService.getSessions(); + } + + override async activateSession(sessionId: string) { + this._onSessionLoadingChange.fire(true); + try { + const acpManager = this.chatManagerService as AcpChatManagerService; + await acpManager.loadSession(sessionId); + const updatedSession = this.chatManagerService.getSession(sessionId); + if (!updatedSession) { + this.messageService.info(`Session ${sessionId} not found, creating a new session.`); + await this.createSessionModel(); + return; + } + this._sessionModel = updatedSession; + this.setAvailableCommands(acpManager.getAvailableCommands()); + this._onSessionModelChange.fire(this._sessionModel); + this._onChangeSession.fire(this._sessionModel.sessionId); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.messageService.info(`Failed to load session, creating a new session. (${errorMessage})`); + await this.createSessionModel(); + } finally { + this._onSessionLoadingChange.fire(false); + } + } + + override dispose(): void { + this._onModeChange.dispose(); + this._onSessionLoadingChange.dispose(); + this._onSessionModelChange.dispose(); + super.dispose(); + } +} diff --git a/packages/ai-native/src/browser/chat/chat.internal.service.ts b/packages/ai-native/src/browser/chat/chat.internal.service.ts index d8196dea6c..4b4cbbf243 100644 --- a/packages/ai-native/src/browser/chat/chat.internal.service.ts +++ b/packages/ai-native/src/browser/chat/chat.internal.service.ts @@ -18,22 +18,23 @@ export class ChatInternalService extends Disposable { @Autowired(PreferenceService) protected preferenceService: PreferenceService; + // Exposed as protected so AcpChatInternalService subclass can access it @Autowired(IChatManagerService) - private chatManagerService: ChatManagerService; + protected chatManagerService: ChatManagerService; private readonly _onChangeRequestId = new Emitter(); public readonly onChangeRequestId: Event = this._onChangeRequestId.event; - private readonly _onChangeSession = new Emitter(); + protected readonly _onChangeSession = new Emitter(); public readonly onChangeSession: Event = this._onChangeSession.event; private readonly _onCancelRequest = new Emitter(); public readonly onCancelRequest: Event = this._onCancelRequest.event; - private readonly _onWillClearSession = new Emitter(); + protected readonly _onWillClearSession = new Emitter(); public readonly onWillClearSession: Event = this._onWillClearSession.event; - private readonly _onRegenerateRequest = new Emitter(); + protected readonly _onRegenerateRequest = new Emitter(); public readonly onRegenerateRequest: Event = this._onRegenerateRequest.event; private _latestRequestId: string; @@ -41,18 +42,19 @@ export class ChatInternalService extends Disposable { return this._latestRequestId; } - #sessionModel: ChatModel; + // Exposed as protected so AcpChatInternalService subclass can access it + protected _sessionModel: ChatModel; get sessionModel() { - return this.#sessionModel; + return this._sessionModel; } init() { - this.chatManagerService.onStorageInit(() => { + this.chatManagerService.onStorageInit(async () => { const sessions = this.chatManagerService.getSessions(); if (sessions.length > 0) { - this.activateSession(sessions[sessions.length - 1].sessionId); + await this.activateSession(sessions[sessions.length - 1].sessionId); } else { - this.createSessionModel(); + await this.createSessionModel(); } }); } @@ -63,11 +65,11 @@ export class ChatInternalService extends Disposable { } createRequest(input: string, agentId: string, images?: string[], command?: string) { - return this.chatManagerService.createRequest(this.#sessionModel.sessionId, input, agentId, command, images); + return this.chatManagerService.createRequest(this._sessionModel.sessionId, input, agentId, command, images); } sendRequest(request: ChatRequestModel, regenerate = false) { - const result = this.chatManagerService.sendRequest(this.#sessionModel.sessionId, request, regenerate); + const result = this.chatManagerService.sendRequest(this._sessionModel.sessionId, request, regenerate); if (regenerate) { this._onRegenerateRequest.fire(); } @@ -75,23 +77,23 @@ export class ChatInternalService extends Disposable { } cancelRequest() { - this.chatManagerService.cancelRequest(this.#sessionModel.sessionId); + this.chatManagerService.cancelRequest(this._sessionModel.sessionId); this._onCancelRequest.fire(); } - createSessionModel() { - this.#sessionModel = this.chatManagerService.startSession(); - this._onChangeSession.fire(this.#sessionModel.sessionId); + async createSessionModel() { + this._sessionModel = await this.chatManagerService.startSession(); + this._onChangeSession.fire(this._sessionModel.sessionId); } - clearSessionModel(sessionId?: string) { - sessionId = sessionId || this.#sessionModel.sessionId; + async clearSessionModel(sessionId?: string) { + sessionId = sessionId || this._sessionModel.sessionId; this._onWillClearSession.fire(sessionId); this.chatManagerService.clearSession(sessionId); - if (sessionId === this.#sessionModel.sessionId) { - this.#sessionModel = this.chatManagerService.startSession(); + if (sessionId === this._sessionModel.sessionId) { + this._sessionModel = await this.chatManagerService.startSession(); } - this._onChangeSession.fire(this.#sessionModel.sessionId); + this._onChangeSession.fire(this._sessionModel.sessionId); } getSessions() { @@ -107,12 +109,12 @@ export class ChatInternalService extends Disposable { if (!targetSession) { throw new Error(`There is no session with session id ${sessionId}`); } - this.#sessionModel = targetSession; - this._onChangeSession.fire(this.#sessionModel.sessionId); + this._sessionModel = targetSession; + this._onChangeSession.fire(this._sessionModel.sessionId); } override dispose(): void { - this.#sessionModel?.dispose(); + this._sessionModel?.dispose(); super.dispose(); } } diff --git a/packages/ai-native/src/browser/chat/chat.module.less b/packages/ai-native/src/browser/chat/chat.module.less index 8188ba348c..9aace560e3 100644 --- a/packages/ai-native/src/browser/chat/chat.module.less +++ b/packages/ai-native/src/browser/chat/chat.module.less @@ -292,3 +292,70 @@ width: calc(100% - 40px); color: var(--design-text-foreground); } + +.loading_container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + gap: 12px; + color: var(--tab-inactiveForeground); + font-size: 12px; +} + +.timeout_hint { + color: var(--design-text-secondary); + font-size: 12px; + margin-top: 4px; +} + +.retry_button { + margin-top: 4px; + padding: 4px 16px; + font-size: 12px; + color: var(--button-foreground); + background-color: var(--button-background); + border: none; + border-radius: 4px; + cursor: pointer; + + &:hover { + background-color: var(--button-hoverBackground); + } +} + +.acp_error_container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + padding: 24px; + text-align: center; + + .acp_error_icon { + font-size: 48px; + margin-bottom: 16px; + } + + .acp_error_title { + font-size: 18px; + font-weight: 600; + margin-bottom: 8px; + color: var(--design-text-foreground); + } + + .acp_error_message { + font-size: 14px; + color: var(--design-text-secondary); + margin-bottom: 16px; + max-width: 400px; + word-break: break-all; + } + + .acp_error_hint { + font-size: 12px; + color: var(--design-text-secondary); + } +} diff --git a/packages/ai-native/src/browser/chat/chat.render.registry.ts b/packages/ai-native/src/browser/chat/chat.render.registry.ts index 4dd8a07fc3..0842d751ee 100644 --- a/packages/ai-native/src/browser/chat/chat.render.registry.ts +++ b/packages/ai-native/src/browser/chat/chat.render.registry.ts @@ -1,14 +1,17 @@ import { Injectable } from '@opensumi/di'; -import { Disposable } from '@opensumi/ide-core-common'; +import { Disposable, Emitter, IDisposable } from '@opensumi/ide-core-common'; import { ChatAIRoleRender, + ChatHistoryRender, ChatInputRender, ChatThinkingRender, ChatThinkingResultRender, ChatUserRoleRender, ChatViewHeaderRender, + ChatWelcomePageRender, ChatWelcomeRender, + IChatMessageProcessor, IChatRenderRegistry, } from '../types'; @@ -21,6 +24,33 @@ export class ChatRenderRegistry extends Disposable implements IChatRenderRegistr public chatInputRender?: ChatInputRender; public chatThinkingResultRender?: ChatThinkingResultRender; public chatViewHeaderRender?: ChatViewHeaderRender; + public chatHistoryRender?: ChatHistoryRender; + + private messageProcessors: IChatMessageProcessor[] = []; + + private readonly _onDidChangeProcessors = new Emitter(); + readonly onDidChangeProcessors = this._onDidChangeProcessors.event; + + registerMessageProcessor(processor: IChatMessageProcessor): IDisposable { + const p = { priority: 100, ...processor }; + this.messageProcessors.push(p); + this.messageProcessors.sort((a, b) => a.priority! - b.priority!); + this._onDidChangeProcessors.fire(); + + const disposable = Disposable.create(() => { + const idx = this.messageProcessors.indexOf(p); + if (idx !== -1) { + this.messageProcessors.splice(idx, 1); + this._onDidChangeProcessors.fire(); + } + }); + this.addDispose(disposable); + return disposable; + } + + getMessageProcessors(): IChatMessageProcessor[] { + return [...this.messageProcessors]; + } registerWelcomeRender(render: ChatWelcomeRender): void { this.chatWelcomeRender = render; @@ -42,6 +72,12 @@ export class ChatRenderRegistry extends Disposable implements IChatRenderRegistr this.chatInputRender = render; } + public enabledMentionTypes?: string[]; + + registerEnabledMentionTypes(types: string[]): void { + this.enabledMentionTypes = types; + } + registerThinkingResultRender(render: ChatThinkingResultRender): void { this.chatThinkingResultRender = render; } @@ -49,4 +85,14 @@ export class ChatRenderRegistry extends Disposable implements IChatRenderRegistr registerChatViewHeaderRender(render: ChatViewHeaderRender): void { this.chatViewHeaderRender = render; } + + registerChatHistoryRender(render: ChatHistoryRender): void { + this.chatHistoryRender = render; + } + + public chatWelcomePageRender?: ChatWelcomePageRender; + + registerChatWelcomePageRender(render: ChatWelcomePageRender): void { + this.chatWelcomePageRender = render; + } } diff --git a/packages/ai-native/src/browser/chat/chat.view.acp.tsx b/packages/ai-native/src/browser/chat/chat.view.acp.tsx new file mode 100644 index 0000000000..bfccf6c5ac --- /dev/null +++ b/packages/ai-native/src/browser/chat/chat.view.acp.tsx @@ -0,0 +1,1180 @@ +import debounce from 'lodash/debounce'; +import * as React from 'react'; +import { MessageList } from 'react-chat-elements'; + +import { + AINativeConfigService, + AppConfig, + LabelService, + getIcon, + useInjectable, + useUpdateOnEvent, +} from '@opensumi/ide-core-browser'; +import { Popover, PopoverPosition } from '@opensumi/ide-core-browser/lib/components'; +import { EnhanceIcon } from '@opensumi/ide-core-browser/lib/components/ai-native'; +import { + AIServiceType, + ActionSourceEnum, + ActionTypeEnum, + CancellationToken, + CancellationTokenSource, + ChatFeatureRegistryToken, + ChatHistoryRegistryToken, + ChatInputRegistryToken, + ChatMessageRole, + ChatRenderRegistryToken, + ChatServiceToken, + CommandService, + Disposable, + DisposableCollection, + IAIReporter, + IChatComponent, + IChatContent, + URI, + formatLocalize, + localize, + path, + uuid, +} from '@opensumi/ide-core-common'; +import { WorkbenchEditorService } from '@opensumi/ide-editor'; +import { IMainLayoutService } from '@opensumi/ide-main-layout'; +import { IMessageService } from '@opensumi/ide-overlay'; +import 'react-chat-elements/dist/main.css'; +import { IWorkspaceService } from '@opensumi/ide-workspace'; + +import { AI_CHAT_VIEW_ID, IChatAgentService, IChatInternalService, IChatMessageStructure } from '../../common'; +import { + LLMContextService, + LLMContextServiceToken, + LLM_CONTEXT_KEY, + LLM_CONTEXT_KEY_REGEX, +} from '../../common/llm-context'; +import { CodeBlockData } from '../../common/types'; +import { cleanAttachedTextWrapper } from '../../common/utils'; +import { AcpChatViewWrapper } from '../acp/components/AcpChatViewWrapper'; +import { FileChange, FileListDisplay } from '../components/ChangeList'; +import { CodeBlockWrapperInput } from '../components/ChatEditor'; +import ChatHistory, { IChatHistoryItem } from '../components/ChatHistory'; +import { ChatInput } from '../components/ChatInput'; +import { ChatMarkdown } from '../components/ChatMarkdown'; +import { ChatNotify, ChatReply } from '../components/ChatReply'; +import { SlashCustomRender } from '../components/SlashCustomRender'; +import { MessageData, createMessageByAI, createMessageByUser } from '../components/utils'; +import { WelcomeMessage } from '../components/WelcomeMsg'; +import { BaseApplyService } from '../mcp/base-apply.service'; +import { ChatViewHeaderRender, IMCPServerRegistry, TSlashCommandCustomRender, TokenMCPServerRegistry } from '../types'; + +import { ChatRequestModel, ChatSlashCommandItemModel } from './chat-model'; +import { ChatProxyService } from './chat-proxy.service'; +import { ChatService } from './chat.api.service'; +import { ChatFeatureRegistry } from './chat.feature.registry'; +import { IChatHistoryRegistry } from './chat.history.registry'; +import { ChatInputRegistry } from './chat.input.registry'; +import { ChatInternalService } from './chat.internal.service'; +import { AcpChatInternalService } from './chat.internal.service.acp'; +import styles from './chat.module.less'; +import { ChatRenderRegistry } from './chat.render.registry'; + +const SCROLL_CLASSNAME = 'chat_scroll'; + +interface TDispatchAction { + type: 'add' | 'clear' | 'init'; + payload?: MessageData[]; +} + +const MAX_TITLE_LENGTH = 100; + +const getFileChanges = (codeBlocks: CodeBlockData[]) => + codeBlocks + .map((block) => { + const rangesFromDiffHunk = block.applyResult?.diff.split('\n').reduce( + ([del, add], line) => { + if (line.startsWith('-')) { + del += 1; + } else if (line.startsWith('+')) { + add += 1; + } + return [del, add]; + }, + [0, 0], + ) || [0, 0]; + return { + path: block.relativePath, + additions: rangesFromDiffHunk[1], + deletions: rangesFromDiffHunk[0], + status: block.status, + }; + }) + .reduce((acc, curr) => { + const existingFile = acc.find((file) => file.path === curr.path); + if (existingFile) { + existingFile.additions += curr.additions; + existingFile.deletions += curr.deletions; + // 使用最新的状态 + existingFile.status = curr.status; + } else { + acc.push(curr); + } + return acc; + }, [] as FileChange[]); + +export const AIChatViewACP = () => { + const aiChatService = useInjectable(IChatInternalService); + return ( + + + + ); +}; + +export const AIChatViewACPContent = () => { + const aiChatService = useInjectable(IChatInternalService); + const chatApiService = useInjectable(ChatServiceToken); + const aiReporter = useInjectable(IAIReporter); + const chatAgentService = useInjectable(IChatAgentService); + const chatFeatureRegistry = useInjectable(ChatFeatureRegistryToken); + const chatRenderRegistry = useInjectable(ChatRenderRegistryToken); + const chatInputRegistry = useInjectable(ChatInputRegistryToken); + const mcpServerRegistry = useInjectable(TokenMCPServerRegistry); + const aiNativeConfigService = useInjectable(AINativeConfigService); + const llmContextService = useInjectable(LLMContextServiceToken); + + const layoutService = useInjectable(IMainLayoutService); + const msgHistoryManager = aiChatService.sessionModel?.history; + if (!msgHistoryManager) { + return null; + } + const containerRef = React.useRef(null); + const autoScroll = React.useRef(true); + const chatInputRef = React.useRef<{ setInputValue: (v: string) => void } | null>(null); + const editorService = useInjectable(WorkbenchEditorService); + const appConfig = useInjectable(AppConfig); + const applyService = useInjectable(BaseApplyService); + const labelService = useInjectable(LabelService); + const workspaceService = useInjectable(IWorkspaceService); + const commandService = useInjectable(CommandService); + const [shortcutCommands, setShortcutCommands] = React.useState([]); + const [sessionModelId, setSessionModelId] = React.useState(aiChatService.sessionModel?.modelId); + const [hasUserSentMessage, setHasUserSentMessage] = React.useState(false); + + const [changeList, setChangeList] = React.useState( + getFileChanges(applyService.getSessionCodeBlocks() || []), + ); + + const [messageListData, dispatchMessage] = React.useReducer((state: MessageData[], action: TDispatchAction) => { + switch (action.type) { + case 'add': + return [...state, ...(action.payload || [])]; + case 'clear': + return []; + case 'init': + return Array.isArray(action.payload) ? action.payload : []; + default: + return state; + } + }, []); + + const [loading, setLoading] = React.useState(false); + const [sessionLoading, setSessionLoading] = React.useState(false); + const [agentId, setAgentId] = React.useState(''); + const [defaultAgentId, setDefaultAgentId] = React.useState(''); + const [command, setCommand] = React.useState(''); + const [theme, setTheme] = React.useState(null); + // 切换session或Agent输出状态变化时 + React.useEffect(() => { + setSessionModelId(aiChatService.sessionModel?.modelId); + }, [loading, aiChatService.sessionModel]); + + React.useEffect(() => { + const dispose = aiChatService.onSessionLoadingChange((isLoading) => { + setSessionLoading(isLoading); + }); + return () => dispose.dispose(); + }, [aiChatService]); + + React.useEffect(() => { + const disposer = new Disposable(); + const doUpdate = () => { + const fileChanges = getFileChanges(applyService.getSessionCodeBlocks() || []); + setChangeList(fileChanges); + }; + disposer.addDispose(aiChatService.onChangeSession(doUpdate)); + // TODO: 全量获取性能不好 + disposer.addDispose(applyService.onCodeBlockUpdate(doUpdate)); + return () => disposer.dispose(); + }, []); + + React.useEffect(() => { + const featureSlashCommands = chatFeatureRegistry.getAllShortcutSlashCommand(); + + const dispose = chatAgentService.onDidChangeAgents(() => { + const agentSlashCommands = chatAgentService + .getCommands() + .filter((c) => c.isShortcut) + .map( + (c) => + new ChatSlashCommandItemModel( + { + icon: '', + name: `${c.name} `, + description: c.description, + isShortcut: c.isShortcut, + }, + c.name, + c.agentId, + ), + ); + + setShortcutCommands(featureSlashCommands.concat(agentSlashCommands)); + }); + + setShortcutCommands(featureSlashCommands); + + return () => dispose.dispose(); + }, [chatFeatureRegistry, chatAgentService]); + + useUpdateOnEvent(aiChatService.onChangeSession); + + const ChatInputWrapperRender = React.useMemo(() => { + // 1. 优先使用 ChatInputRegistry 注册的输入组件(按优先级 + when 条件匹配) + const activeInput = chatInputRegistry.getActiveChatInput(); + if (activeInput) { + return activeInput.component; + } + // 2. 向后兼容:使用 registerInputRender 注册的 + if (chatRenderRegistry.chatInputRender) { + return chatRenderRegistry.chatInputRender; + } + // 3. 最降级 + return ChatInput; + }, [chatInputRegistry, chatRenderRegistry.chatInputRender]); + + const firstMsg = React.useMemo( + () => + createMessageByAI({ + id: uuid(6), + relationId: '', + text: , + }), + [], + ); + + const onDidWheel = React.useCallback( + (e: WheelEvent) => { + // 向上滚动 + if (e.deltaY < 0) { + autoScroll.current = false; + } else { + autoScroll.current = true; + } + }, + [autoScroll], + ); + + React.useEffect(() => { + if (containerRef.current) { + containerRef.current.addEventListener('wheel', onDidWheel); + return () => { + containerRef.current?.removeEventListener('wheel', onDidWheel); + }; + } + }, [autoScroll]); + + const scrollToBottom = React.useCallback(() => { + if (containerRef && containerRef.current && autoScroll.current) { + const lastElement = containerRef.current.lastElementChild; + if (lastElement) { + lastElement.scrollIntoView({ behavior: 'smooth', block: 'end' }); + } + // 出现滚动条时出现分割线 + if (containerRef.current.scrollHeight > containerRef.current.clientHeight) { + containerRef.current.classList.add(SCROLL_CLASSNAME); + } + } + }, [containerRef, autoScroll]); + + const handleDispatchMessage = React.useCallback( + (dispatch: TDispatchAction) => { + dispatchMessage(dispatch); + requestAnimationFrame(() => { + scrollToBottom(); + }); + }, + [dispatchMessage, scrollToBottom], + ); + + React.useEffect(() => { + handleDispatchMessage({ type: 'init', payload: [firstMsg] }); + }, []); + + React.useEffect(() => { + const disposer = new Disposable(); + + disposer.addDispose( + chatApiService.onScrollToBottom(() => { + requestAnimationFrame(() => { + // scrollToBottom(); + }); + }), + ); + + disposer.addDispose( + chatApiService.onChatMessageLaunch(async (message) => { + if (message.immediate !== false) { + if (loading) { + return; + } + await handleSend(message.message, message.images, message.agentId, message.command); + } else { + if (message.agentId) { + setAgentId(message.agentId); + } + if (message.command) { + setCommand(message.command); + } + chatInputRef?.current?.setInputValue(message.message); + } + }), + ); + + disposer.addDispose( + chatApiService.onChatReplyMessageLaunch((data) => { + if (data.kind === 'content') { + const relationId = aiReporter.start(AIServiceType.CustomReply, { + message: data.content, + sessionId: aiChatService.sessionModel?.sessionId, + }); + msgHistoryManager.addAssistantMessage({ + content: data.content, + relationId, + }); + renderSimpleMarkdownReply({ chunk: data.content, relationId }); + } else { + const relationId = aiReporter.start(AIServiceType.CustomReply, { + message: 'component#' + data.component, + sessionId: aiChatService.sessionModel?.sessionId, + }); + msgHistoryManager.addAssistantMessage({ + componentId: data.component, + componentValue: data.value, + content: '', + relationId, + }); + renderCustomComponent({ chunk: data, relationId }); + } + }), + ); + + disposer.addDispose( + chatApiService.onChatMessageListLaunch((list) => { + const messageList: MessageData[] = []; + + list.forEach((item) => { + const { role } = item; + + const relationId = aiReporter.start(AIServiceType.Chat, { + message: '', + sessionId: aiChatService.sessionModel?.sessionId, + }); + + if (role === 'assistant') { + const newChunk = item as IChatComponent | IChatContent; + + messageList.push( + createMessageByAI( + { + id: uuid(6), + relationId, + text: , + }, + styles.chat_notify, + ), + ); + } + + if (role === 'user') { + const { message } = item; + const agentId = ChatProxyService.AGENT_ID; + const ChatUserRoleRender = chatRenderRegistry.chatUserRoleRender; + const visibleAgentId = agentId === ChatProxyService.AGENT_ID ? '' : agentId; + + messageList.push( + createMessageByUser( + { + id: uuid(6), + relationId, + text: ChatUserRoleRender ? ( + + ) : ( + + ), + }, + styles.chat_message_code, + ), + ); + } + }); + + handleDispatchMessage({ type: 'add', payload: messageList }); + + setTimeout(scrollToBottom, 0); + }), + ); + + return () => disposer.dispose(); + }, [chatApiService, chatRenderRegistry.chatAIRoleRender, msgHistoryManager]); + + React.useEffect(() => { + const disposer = new Disposable(); + + disposer.addDispose( + chatAgentService.onDidSendMessage((chunk) => { + const newChunk = chunk as IChatComponent | IChatContent; + const relationId = aiReporter.start(AIServiceType.Agent, { + message: '', + }); + + const notifyMessage = createMessageByAI( + { + id: uuid(6), + relationId, + text: , + }, + styles.chat_notify, + ); + + handleDispatchMessage({ type: 'add', payload: [notifyMessage] }); + }), + ); + + disposer.addDispose( + chatAgentService.onDidChangeAgents(async () => { + const newDefaultAgentId = chatAgentService.getDefaultAgentId(); + setDefaultAgentId(newDefaultAgentId ?? ''); + }), + ); + + return () => disposer.dispose(); + }, [chatAgentService, msgHistoryManager, aiChatService]); + + const handleSlashCustomRender = React.useCallback( + async (value: { + userMessage: string; + render: TSlashCommandCustomRender; + relationId: string; + requestId: string; + startTime: number; + command?: string; + agentId?: string; + }) => { + const { userMessage, relationId, requestId, render, startTime, command, agentId } = value; + + msgHistoryManager.addAssistantMessage({ + type: 'component', + content: '', + }); + + const aiMessage = createMessageByAI({ + id: uuid(6), + relationId, + className: styles.chat_with_more_actions, + text: ( + + ), + }); + + handleDispatchMessage({ type: 'add', payload: [aiMessage] }); + }, + [containerRef, msgHistoryManager], + ); + + const renderUserMessage = React.useCallback( + async (renderModel: { + message: string; + images?: string[]; + agentId?: string; + relationId: string; + command?: string; + }) => { + const ChatUserRoleRender = chatRenderRegistry.chatUserRoleRender; + + const { message, images, agentId, relationId, command } = renderModel; + + const visibleAgentId = agentId === ChatProxyService.AGENT_ID ? '' : agentId; + + const userMessage = createMessageByUser( + { + id: uuid(6), + relationId, + text: ChatUserRoleRender ? ( + + ) : ( + + ), + }, + styles.chat_message_code, + ); + + handleDispatchMessage({ type: 'add', payload: [userMessage] }); + }, + [chatRenderRegistry, chatRenderRegistry.chatUserRoleRender, msgHistoryManager, scrollToBottom], + ); + + const renderReply = React.useCallback( + async (renderModel: { + message: string; + agentId?: string; + request: ChatRequestModel; + relationId: string; + command?: string; + startTime: number; + msgId: string; + }) => { + const { message, agentId, request, relationId, command, startTime, msgId } = renderModel; + + const visibleAgentId = agentId === ChatProxyService.AGENT_ID ? '' : agentId; + + if (agentId === ChatProxyService.AGENT_ID && command) { + const commandHandler = chatFeatureRegistry.getSlashCommandHandler(command); + if (commandHandler && commandHandler.providerRender) { + setLoading(false); + return handleSlashCustomRender({ + userMessage: message, + render: commandHandler.providerRender, + relationId, + requestId: request.requestId, + startTime, + agentId, + command, + }); + } + } + + const aiMessage = createMessageByAI({ + id: uuid(6), + relationId, + className: styles.chat_with_more_actions, + text: ( + { + scrollToBottom(); + }} + history={msgHistoryManager} + onDone={() => { + setLoading(false); + }} + onRegenerate={() => { + if (request) { + aiChatService.sendRequest(request, true); + } + }} + msgId={msgId} + /> + ), + }); + handleDispatchMessage({ type: 'add', payload: [aiMessage] }); + }, + [chatRenderRegistry, msgHistoryManager, scrollToBottom], + ); + + const renderSimpleMarkdownReply = React.useCallback( + (renderModel: { chunk: string; relationId: string }) => { + const { chunk, relationId } = renderModel; + let renderContent = ; + + if (chatRenderRegistry.chatAIRoleRender) { + const ChatAIRoleRender = chatRenderRegistry.chatAIRoleRender; + renderContent = ; + } + + const aiMessage = createMessageByAI({ + id: uuid(6), + relationId, + text: renderContent, + className: styles.chat_with_more_actions, + }); + + handleDispatchMessage({ type: 'add', payload: [aiMessage] }); + }, + [chatRenderRegistry, msgHistoryManager, scrollToBottom], + ); + + const renderCustomComponent = React.useCallback( + (renderModel: { chunk: IChatComponent; relationId: string }) => { + const { chunk, relationId } = renderModel; + + const aiMessage = createMessageByAI( + { + id: uuid(6), + relationId, + text: , + }, + styles.chat_notify, + ); + handleDispatchMessage({ type: 'add', payload: [aiMessage] }); + }, + [chatRenderRegistry, msgHistoryManager, scrollToBottom], + ); + + const handleAgentReply = React.useCallback( + async (value: IChatMessageStructure) => { + const { message, images, agentId, command, reportExtra } = value; + const { actionType, actionSource } = reportExtra || {}; + + const request = aiChatService.createRequest( + message.replaceAll(LLM_CONTEXT_KEY_REGEX, ''), + agentId!, + images, + command, + ); + if (!request) { + return; + } + + setLoading(true); + aiChatService.setLatestRequestId(request.requestId); + + const startTime = Date.now(); + const reportType = ChatProxyService.AGENT_ID === agentId ? AIServiceType.Chat : AIServiceType.Agent; + + const relationId = aiReporter.start( + command || reportType, + { + agentId, + userMessage: message, + actionType, + actionSource, + sessionId: aiChatService.sessionModel?.sessionId, + }, + // 由于涉及 tool 调用,超时时间设置长一点 + 600 * 1000, + ); + msgHistoryManager.addUserMessage({ + content: message, + images: images || [], + agentId: agentId!, + agentCommand: command!, + relationId, + }); + + await renderUserMessage({ + relationId, + message, + images, + command, + agentId, + }); + + aiChatService.sendRequest(request); + + const msgId = msgHistoryManager.addAssistantMessage({ + content: '', + relationId, + requestId: request.requestId, + replyStartTime: startTime, + }); + + // 创建消息时,设置当前活跃的消息信息,便于toolCall打点 + mcpServerRegistry.activeMessageInfo = { + messageId: msgId, + sessionId: aiChatService.sessionModel?.sessionId, + }; + + await renderReply({ + startTime, + relationId, + message, + agentId, + command, + request, + msgId, + }); + }, + [chatRenderRegistry, chatRenderRegistry.chatUserRoleRender, msgHistoryManager, scrollToBottom, loading], + ); + + const handleSend = React.useCallback( + async (message: string, images?: string[], agentId?: string, command?: string) => { + const reportExtra = { + actionSource: ActionSourceEnum.Chat, + actionType: ActionTypeEnum.Send, + }; + agentId = agentId ? agentId : ChatProxyService.AGENT_ID; + // 提取并替换 {{@file:xxx}} 中的文件内容 + let processedContent = message; + const filePattern = /\{\{@file:(.*?)\}\}/g; + const fileMatches = message.match(filePattern); + if (fileMatches) { + for (const match of fileMatches) { + const filePath = match.replace(/\{\{@file:(.*?)\}\}/, '$1'); + const fileUri = new URI(filePath); + const relativePath = (await workspaceService.asRelativePath(fileUri))?.path || fileUri.displayName; + processedContent = processedContent.replace(match, `\`${LLM_CONTEXT_KEY.AttachedFile}${relativePath}\``); + } + } + + const folderPattern = /\{\{@folder:(.*?)\}\}/g; + const folderMatches = processedContent.match(folderPattern); + if (folderMatches) { + for (const match of folderMatches) { + const folderPath = match.replace(/\{\{@folder:(.*?)\}\}/, '$1'); + const folderUri = new URI(folderPath); + const relativePath = (await workspaceService.asRelativePath(folderUri))?.path || folderUri.displayName; + processedContent = processedContent.replace(match, `\`${LLM_CONTEXT_KEY.AttachedFolder}${relativePath}\``); + } + } + const codePattern = /\{\{@code:(.*?)\}\}/g; + const codeMatches = processedContent.match(codePattern); + if (codeMatches) { + for (const match of codeMatches) { + const filePathWithLineRange = match.replace(/\{\{@code:(.*?)\}\}/, '$1'); + const [filePath, lineRange] = filePathWithLineRange.split(':'); + let range: [number, number] = [0, 0]; + if (lineRange) { + const [startLine, endLine] = lineRange.slice(1).split('-'); + range = [parseInt(startLine, 10), parseInt(endLine, 10)]; + } + const fileUri = new URI(filePath); + const relativePath = (await workspaceService.asRelativePath(fileUri))?.path || fileUri.displayName; + processedContent = processedContent.replace( + match, + `\`${LLM_CONTEXT_KEY.AttachedFile}${relativePath}:L${range[0]}-${range[1]}\``, + ); + } + } + const rulePattern = /\{\{@rule:(.*?)\}\}/g; + const ruleMatches = processedContent.match(rulePattern); + if (ruleMatches) { + for (const match of ruleMatches) { + const ruleName = match.replace(/\{\{@rule:(.*?)\}\}/, '$1'); + const ruleUri = new URI(ruleName); + processedContent = processedContent.replace( + match, + `\`${LLM_CONTEXT_KEY.AttachedFile}${ruleUri.displayName}\``, + ); + } + } + return handleAgentReply({ message: processedContent, images, agentId, command, reportExtra }).finally(() => { + setHasUserSentMessage(true); + }); + }, + [handleAgentReply, setHasUserSentMessage], + ); + + const handleClear = React.useCallback(() => { + aiChatService.clearSessionModel(); + chatApiService.clearHistoryMessages(); + clearChatContent(); + setHasUserSentMessage(false); + }, [messageListData]); + + const clearChatContent = React.useCallback(() => { + containerRef?.current?.classList.remove(SCROLL_CLASSNAME); + handleDispatchMessage({ type: 'init', payload: [firstMsg] }); + }, [messageListData]); + + const handleShortcutCommandClick = (commandModel: ChatSlashCommandItemModel) => { + if (loading) { + return; + } + setTheme(commandModel.nameWithSlash); + setAgentId(commandModel.agentId!); + setCommand(commandModel.command!); + }; + + const handleCloseChatView = React.useCallback(() => { + layoutService.toggleSlot(AI_CHAT_VIEW_ID); + }, [layoutService]); + + const HeaderRender: ChatViewHeaderRender = chatRenderRegistry.chatViewHeaderRender || DefaultChatViewHeaderACP; + + const recover = React.useCallback( + async (cancellationToken: CancellationToken) => { + for (const msg of msgHistoryManager.getMessages()) { + if (cancellationToken.isCancellationRequested) { + return; + } + if (msg.role === ChatMessageRole.User) { + await renderUserMessage({ + relationId: msg.relationId!, + message: msg.content, + agentId: msg.agentId, + command: msg.agentCommand, + images: msg.images, + }); + } else if (msg.role === ChatMessageRole.Assistant && msg.requestId) { + const request = aiChatService.sessionModel?.getRequest(msg.requestId)!; + // 从storage恢复时,request为undefined + if (request && !request.response.isComplete) { + setLoading(true); + } + await renderReply({ + msgId: msg.id, + relationId: msg.relationId!, + message: msg.content, + agentId: msg.agentId, + command: msg.agentCommand, + startTime: msg.replyStartTime!, + request, + }); + } else if (msg.role === ChatMessageRole.Assistant && msg.content) { + await renderSimpleMarkdownReply({ + relationId: msg.relationId!, + chunk: msg.content, + }); + } else if (msg.role === ChatMessageRole.Assistant && msg.componentId) { + await renderCustomComponent({ + relationId: msg.relationId!, + chunk: { + kind: 'component', + component: msg.componentId, + value: msg.componentValue, + }, + }); + } + } + }, + [renderReply], + ); + + React.useEffect(() => { + // 尝试重新渲染历史记录 + clearChatContent(); + setHasUserSentMessage(false); + const cancellationTokenSource = new CancellationTokenSource(); + setLoading(false); + recover(cancellationTokenSource.token); + return () => { + cancellationTokenSource.cancel(); + }; + }, [aiChatService.sessionModel]); + + return ( +
+
+ +
+
+
+
+ {!hasUserSentMessage && chatRenderRegistry.chatWelcomePageRender ? ( + React.createElement(chatRenderRegistry.chatWelcomePageRender, { + onSend: handleSend, + agentId, + setAgentId, + command, + setCommand, + }) + ) : ( + + )} +
+ {aiChatService.sessionModel?.slicedMessageCount ? ( +
+
+ {formatLocalize( + 'aiNative.chat.ai.assistant.limit.message', + aiChatService.sessionModel?.slicedMessageCount, + )} +
+
+ ) : null} +
+
+ {/* 定制需求。不需要透出shortcut*/} + {/*
+ {shortcutCommands.map((command) => ( + +
handleShortcutCommandClick(command)}> + {command.name} +
+
+ ))} +
*/} +
+ {changeList.length > 0 && ( + { + editorService.open(URI.file(path.join(appConfig.workspaceDir, filePath))); + }} + onRejectAll={() => { + applyService.processAll('reject'); + }} + onAcceptAll={() => { + applyService.processAll('accept'); + }} + /> + )} + +
+
+
+
+ ); +}; + +export function DefaultChatViewHeaderACP({ + handleClear, + handleCloseChatView, +}: { + handleClear: () => any; + handleCloseChatView: () => any; +}) { + const aiChatService = useInjectable(IChatInternalService); + const messageService = useInjectable(IMessageService); + const chatFeatureRegistry = useInjectable(ChatFeatureRegistryToken); + const chatRenderRegistry = useInjectable(ChatRenderRegistryToken); + const chatHistoryRegistry = useInjectable(ChatHistoryRegistryToken); + + const [historyList, setHistoryList] = React.useState([]); + const [currentTitle, setCurrentTitle] = React.useState(''); + const handleNewChat = React.useCallback(() => { + if (aiChatService.sessionModel?.history.getMessages().length > 0) { + try { + aiChatService.createSessionModel(); + } catch (error) { + messageService.error(error.message); + } + } + }, [aiChatService]); + const handleHistoryItemSelect = React.useCallback( + (item: IChatHistoryItem) => { + aiChatService.activateSession(item.id); + }, + [aiChatService], + ); + const handleHistoryItemDelete = React.useCallback( + (item: IChatHistoryItem) => { + aiChatService.clearSessionModel(item.id); + }, + [aiChatService], + ); + + // 生成摘要 + const getSummary = React.useCallback( + async ( + messages: { role: ChatMessageRole; content: string }[], + currentTitle: string, + summaryProvider: any, + ): Promise => { + if (!summaryProvider) { + return currentTitle; + } + + try { + const summary = await summaryProvider.getMessageSummary(messages); + return summary ? summary.slice(0, MAX_TITLE_LENGTH) : currentTitle; + } catch (error) { + return currentTitle; + } + }, + [], + ); + + // 使用 ref 来跟踪最新的请求 + const latestSummaryRequestRef = React.useRef(0); + + React.useEffect(() => { + const getHistoryList = async () => { + const currentMessages = aiChatService.sessionModel?.history.getMessages(); + const latestUserMessage = [...currentMessages].find((m) => m.role === ChatMessageRole.User); + const currentTitle = latestUserMessage + ? cleanAttachedTextWrapper(latestUserMessage.content).slice(0, MAX_TITLE_LENGTH) + : ''; + + // 设置初始标题 + setCurrentTitle(currentTitle); + + const messages = currentMessages.map((msg) => ({ + role: msg.role, + content: msg.content, + })); + + // 只有当消息数量超过阈值时才生成摘要 + if (messages.length > 2) { + const requestId = Date.now(); + latestSummaryRequestRef.current = requestId; + + const summaryProvider = chatFeatureRegistry.getMessageSummaryProvider(); + const summary = await getSummary(messages, currentTitle, summaryProvider); + + // 检查是否是最新请求 + if (requestId === latestSummaryRequestRef.current && summary) { + setCurrentTitle(summary); + } + } + + setHistoryList( + aiChatService.getSessions().map((session) => { + const history = session.history; + const messages = history.getMessages(); + const title = + messages.length > 0 ? cleanAttachedTextWrapper(messages[0].content).slice(0, MAX_TITLE_LENGTH) : ''; + const updatedAt = messages.length > 0 ? messages[messages.length - 1].replyStartTime || 0 : 0; + // const loading = session.requests[session.requests.length - 1]?.response.isComplete; + return { + id: session.sessionId, + title, + updatedAt, + // TODO: 后续支持 + loading: false, + }; + }), + ); + }; + getHistoryList(); + const toDispose = new DisposableCollection(); + const sessionListenIds = new Set(); + toDispose.push( + aiChatService.onChangeSession((sessionId) => { + getHistoryList(); + if (sessionListenIds.has(sessionId)) { + return; + } + sessionListenIds.add(sessionId); + toDispose.push( + aiChatService.sessionModel?.history.onMessageChange(() => { + getHistoryList(); + }), + ); + }), + ); + toDispose.push( + aiChatService.sessionModel?.history.onMessageChange(() => { + getHistoryList(); + }), + ); + return () => { + toDispose.dispose(); + }; + }, [aiChatService]); + + return ( +
+ {(() => { + // 1. 优先使用 ChatHistoryRegistry 注册的历史组件(按优先级 + when 条件匹配) + const activeHistory = chatHistoryRegistry.getActiveChatHistory(); + if (activeHistory) { + const ChatHistoryComponent = activeHistory.component; + return ( + {}} + /> + ); + } + // 2. 降级使用默认 ChatHistory 组件 + return ( + {}} + /> + ); + })()} + + + + + + +
+ ); +} diff --git a/packages/ai-native/src/browser/chat/chat.view.registry.ts b/packages/ai-native/src/browser/chat/chat.view.registry.ts new file mode 100644 index 0000000000..e6d7ee3351 --- /dev/null +++ b/packages/ai-native/src/browser/chat/chat.view.registry.ts @@ -0,0 +1,53 @@ +import React from 'react'; + +import { Injectable } from '@opensumi/di'; +import { ChatViewRegistryToken, Disposable, IDisposable } from '@opensumi/ide-core-common'; + +export interface ChatViewContribution { + id: string; + component: React.ComponentType; + /** Higher value = higher priority. Default 0. */ + priority?: number; + /** Optional condition. View is selected only when this returns true. */ + when?: () => boolean; +} + +export interface IChatViewRegistry { + registerChatView(contribution: ChatViewContribution): IDisposable; + getChatViewContributions(): ChatViewContribution[]; + /** Get the highest-priority contribution whose `when()` condition passes, or null. */ + getActiveChatView(): ChatViewContribution | null; +} + +@Injectable() +export class ChatViewRegistry extends Disposable implements IChatViewRegistry { + private contributions: ChatViewContribution[] = []; + + registerChatView(contribution: ChatViewContribution): IDisposable { + const entry = { ...contribution, priority: contribution.priority ?? 0 }; + this.contributions.push(entry); + this.contributions.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)); + + const disposable = Disposable.create(() => { + const idx = this.contributions.indexOf(entry); + if (idx !== -1) { + this.contributions.splice(idx, 1); + } + }); + this.addDispose(disposable); + return disposable; + } + + getChatViewContributions(): ChatViewContribution[] { + return [...this.contributions]; + } + + getActiveChatView(): ChatViewContribution | null { + for (const c of this.contributions) { + if (!c.when || c.when()) { + return c; + } + } + return null; + } +} diff --git a/packages/ai-native/src/browser/chat/default-acp-config-provider.ts b/packages/ai-native/src/browser/chat/default-acp-config-provider.ts new file mode 100644 index 0000000000..4222f67b58 --- /dev/null +++ b/packages/ai-native/src/browser/chat/default-acp-config-provider.ts @@ -0,0 +1,37 @@ +import { Autowired, Injectable } from '@opensumi/di'; +import { PreferenceService, QuickPickService } from '@opensumi/ide-core-browser'; +import { AgentProcessConfig, IACPConfigProvider } from '@opensumi/ide-core-common'; +import { IMessageService } from '@opensumi/ide-overlay'; +import { IWorkspaceService } from '@opensumi/ide-workspace'; + +import { getAgentConfig, getDefaultAgentType } from './get-default-agent-type'; +import { pickWorkspaceDir } from './pick-workspace-dir'; + +/** + * Default implementation of IACPConfigProvider. + * Builds AgentProcessConfig from preferences and workspace context. + * Downstream projects can extend this class to customize config construction + * (e.g., inject custom env vars, override command paths, add validation). + */ +@Injectable() +export class DefaultACPConfigProvider implements IACPConfigProvider { + @Autowired(PreferenceService) + protected readonly preferenceService: PreferenceService; + + @Autowired(IWorkspaceService) + protected readonly workspaceService: IWorkspaceService; + + @Autowired(QuickPickService) + protected readonly quickPick: QuickPickService; + + @Autowired(IMessageService) + protected readonly messageService: IMessageService; + + async resolveConfig(): Promise { + await this.workspaceService.whenReady; + const agentType = getDefaultAgentType(this.preferenceService); + const agentConfig = getAgentConfig(this.preferenceService, agentType); + const workspaceDir = await pickWorkspaceDir(this.workspaceService, this.quickPick, this.messageService); + return { ...agentConfig, workspaceDir }; + } +} diff --git a/packages/ai-native/src/browser/chat/default-chat-agent.ts b/packages/ai-native/src/browser/chat/default-chat-agent.ts new file mode 100644 index 0000000000..7d1abff365 --- /dev/null +++ b/packages/ai-native/src/browser/chat/default-chat-agent.ts @@ -0,0 +1,181 @@ +/** + * DefaultChatAgent - 默认聊天 Agent 实现 + * + * 作为 AI 后端服务和聊天界面之间的代理: + * - 处理聊天请求 + * - 调用 AI 后端服务进行流式请求 + * - 管理请求配置(模型、API Key、系统提示等) + */ +import { Autowired, Injectable } from '@opensumi/di'; +import { PreferenceService } from '@opensumi/ide-core-browser'; +import { + AIBackSerivcePath, + CancellationToken, + ChatFeatureRegistryToken, + Deferred, + IAIBackService, + IAIReporter, + IApplicationService, + IChatProgress, + MCPConfigServiceToken, +} from '@opensumi/ide-core-common'; +import { AINativeSettingSectionsId } from '@opensumi/ide-core-common/lib/settings/ai-native'; +import { MonacoCommandRegistry } from '@opensumi/ide-editor/lib/browser/monaco-contrib/command/command.service'; +import { IMessageService } from '@opensumi/ide-overlay'; +import { listenReadable } from '@opensumi/ide-utils/lib/stream'; + +import { + CoreMessage, + IChatAgent, + IChatAgentCommand, + IChatAgentMetadata, + IChatAgentRequest, + IChatAgentResult, + IChatAgentWelcomeMessage, +} from '../../common'; +import { DEFAULT_SYSTEM_PROMPT } from '../../common/prompts/system-prompt'; +import { MCPConfigService } from '../mcp/config/mcp-config.service'; + +import { ChatFeatureRegistry } from './chat.feature.registry'; + +@Injectable() +export class DefaultChatAgent implements IChatAgent { + static readonly AGENT_ID = 'Default_Chat_Agent'; + + public readonly id: string = DefaultChatAgent.AGENT_ID; + + @Autowired(AIBackSerivcePath) + private readonly aiBackService: IAIBackService; + + @Autowired(MonacoCommandRegistry) + private readonly monacoCommandRegistry: MonacoCommandRegistry; + + @Autowired(IAIReporter) + private readonly aiReporter: IAIReporter; + + @Autowired(ChatFeatureRegistryToken) + private readonly chatFeatureRegistry: ChatFeatureRegistry; + + @Autowired(MCPConfigServiceToken) + private readonly mcpConfigService: MCPConfigService; + + @Autowired(PreferenceService) + private readonly preferenceService: PreferenceService; + + @Autowired(IApplicationService) + private readonly applicationService: IApplicationService; + + @Autowired(IMessageService) + private readonly messageService: IMessageService; + + public get metadata(): IChatAgentMetadata { + return { + systemPrompt: this.preferenceService.get(AINativeSettingSectionsId.SystemPrompt, DEFAULT_SYSTEM_PROMPT), + }; + } + + public set metadata(_) { + // no-op + } + + async invoke( + request: IChatAgentRequest, + progress: (part: IChatProgress) => void, + history: CoreMessage[], + token: CancellationToken, + ): Promise { + const chatDeferred = new Deferred(); + const { message, command } = request; + let prompt: string = message; + + if (command) { + const commandHandler = this.chatFeatureRegistry.getSlashCommandHandler(command); + if (commandHandler && commandHandler.providerPrompt) { + const editor = this.monacoCommandRegistry.getActiveCodeEditor(); + const slashCommandPrompt = await commandHandler.providerPrompt(message, editor); + prompt = slashCommandPrompt; + } + // Slash command 自定义路由:handler 有 invoke 时跳过默认 agent,由 handler 自行处理 + if (commandHandler?.invoke) { + await commandHandler.invoke(prompt, progress, token); + chatDeferred.resolve(); + return {}; + } + } + + const stream = await this.aiBackService.requestStream( + prompt, + { + requestId: request.requestId, + sessionId: request.sessionId, + history, + images: request.images, + ...(await this.getRequestOptions()), + }, + token, + ); + + listenReadable(stream, { + onData: (data) => { + progress(data); + }, + onEnd: () => { + chatDeferred.resolve(); + }, + onError: (error) => { + this.messageService.error(error.message); + this.aiReporter.end(request.sessionId + '_' + request.requestId, { + message: error.message, + success: false, + command, + }); + chatDeferred.reject(error); + }, + }); + + await chatDeferred.promise; + return {}; + } + + async provideSlashCommands(_token: CancellationToken): Promise { + return this.chatFeatureRegistry.getAllSlashCommand().map((s) => ({ + ...s, + name: s.name, + description: s.description || '', + })); + } + + async provideChatWelcomeMessage(_token: CancellationToken): Promise { + return undefined; + } + + public async getRequestOptions() { + const model = this.preferenceService.get(AINativeSettingSectionsId.LLMModelSelection); + const modelId = this.preferenceService.get(AINativeSettingSectionsId.ModelID); + let apiKey: string = ''; + let baseURL: string = ''; + if (model === 'deepseek') { + apiKey = this.preferenceService.get(AINativeSettingSectionsId.DeepseekApiKey, ''); + } else if (model === 'openai') { + apiKey = this.preferenceService.get(AINativeSettingSectionsId.OpenaiApiKey, ''); + } else if (model === 'anthropic') { + apiKey = this.preferenceService.get(AINativeSettingSectionsId.AnthropicApiKey, ''); + } else { + // openai-compatible 为兜底 + apiKey = this.preferenceService.get(AINativeSettingSectionsId.OpenaiApiKey, ''); + baseURL = this.preferenceService.get(AINativeSettingSectionsId.OpenaiBaseURL, ''); + } + const maxTokens = this.preferenceService.get(AINativeSettingSectionsId.MaxTokens); + const disabledTools = await this.mcpConfigService.getDisabledTools(); + return { + clientId: this.applicationService.clientId, + model, + modelId, + apiKey, + baseURL, + maxTokens, + system: this.metadata.systemPrompt, + disabledTools, + }; + } +} diff --git a/packages/ai-native/src/browser/chat/get-default-agent-type.ts b/packages/ai-native/src/browser/chat/get-default-agent-type.ts new file mode 100644 index 0000000000..6786a01fcd --- /dev/null +++ b/packages/ai-native/src/browser/chat/get-default-agent-type.ts @@ -0,0 +1,33 @@ +import { PreferenceService } from '@opensumi/ide-core-browser'; +import { ACPAgentType, AgentConfig, DEFAULT_AGENT_TYPE } from '@opensumi/ide-core-common'; +import { AINativeSettingSectionsId } from '@opensumi/ide-core-common/lib/settings/ai-native'; + +export const DEFAULT_AGENT_CONFIGS: Record = { + qwen: { + command: 'qwen', + args: ['--acp', '--channel=ACP', '--input-format=stream-json', '--output-format=stream-json'], + streaming: true, + description: 'Qwen CLI Agent', + }, + 'claude-agent-acp': { + command: 'claude-agent-acp', + args: [], + streaming: true, + description: 'Claude Code ACP Agent', + }, +}; + +/** + * Get the default agent type from user preferences + */ +export function getDefaultAgentType(preferenceService: PreferenceService): ACPAgentType { + return preferenceService.get('ai.native.agent.defaultType', DEFAULT_AGENT_TYPE); +} + +/** + * Get agent config (command + args) for a given type, preferring user preferences over defaults + */ +export function getAgentConfig(preferenceService: PreferenceService, agentType: ACPAgentType): AgentConfig { + const configs = preferenceService.get>(AINativeSettingSectionsId.AgentConfigs, {}); + return configs[agentType] || DEFAULT_AGENT_CONFIGS[agentType]; +} diff --git a/packages/ai-native/src/browser/chat/local-storage-provider.ts b/packages/ai-native/src/browser/chat/local-storage-provider.ts new file mode 100644 index 0000000000..74f1687e64 --- /dev/null +++ b/packages/ai-native/src/browser/chat/local-storage-provider.ts @@ -0,0 +1,64 @@ +import { Autowired, Injectable } from '@opensumi/di'; +import { Domain, IStorage, STORAGE_NAMESPACE, StorageProvider } from '@opensumi/ide-core-common'; + +import { ISessionModel, ISessionProvider, SessionProviderDomain } from './session-provider'; + +/** + * LocalStorage Session Provider + * 负责从浏览器 LocalStorage 加载和保存 Session + */ +@Domain(SessionProviderDomain) +@Injectable() +export class LocalStorageProvider implements ISessionProvider { + readonly id = 'local-storage'; + + @Autowired(StorageProvider) + private storageProvider: StorageProvider; + + private _chatStorage: IStorage | null = null; + + /** + * 获取 storage 实例(延迟初始化) + */ + private async getStorage(): Promise { + if (!this._chatStorage) { + this._chatStorage = await this.storageProvider(STORAGE_NAMESPACE.CHAT); + } + return this._chatStorage; + } + + /** + * 判断是否支持处理该来源 + * 支持:'local' 前缀或无前缀(兼容旧数据) + */ + canHandle(mode: string): boolean { + return mode === 'local'; + } + + /** + * 加载所有本地 Session + */ + async loadSessions(): Promise { + const storage = await this.getStorage(); + const sessionsModelData = storage.get('sessionModels', []); + // 过滤掉空消息历史的会话 + return sessionsModelData.filter((item) => item.history?.messages?.length > 0); + } + + /** + * 加载指定 Session + */ + async loadSession(sessionId: string): Promise { + const storage = await this.getStorage(); + const sessionsModelData = storage.get('sessionModels', []); + return sessionsModelData.find((item) => item.sessionId === sessionId); + } + + /** + * 保存 Session 到 localStorage + */ + async saveSessions(sessions: ISessionModel[]): Promise { + const storage = await this.getStorage(); + storage.set('sessionModels', sessions); + } +} diff --git a/packages/ai-native/src/browser/chat/pick-workspace-dir.ts b/packages/ai-native/src/browser/chat/pick-workspace-dir.ts new file mode 100644 index 0000000000..b39a4615f0 --- /dev/null +++ b/packages/ai-native/src/browser/chat/pick-workspace-dir.ts @@ -0,0 +1,77 @@ +import { QuickPickService } from '@opensumi/ide-core-browser'; +import { URI, formatLocalize, localize } from '@opensumi/ide-core-common'; +import { IMessageService } from '@opensumi/ide-overlay'; +import { IWorkspaceService } from '@opensumi/ide-workspace'; + +let cachedWorkspaceDir: string | null = null; + +/** + * Resolve the workspace directory for ACP operations. + * In multi-root workspace mode, prompts the user to select a workspace root via QuickPick on first call, + * then returns the cached result on subsequent calls. + * In single workspace mode, returns the workspace root directly. + */ +export async function pickWorkspaceDir( + workspaceService: IWorkspaceService, + quickPick: QuickPickService, + messageService: IMessageService, +): Promise { + if (cachedWorkspaceDir !== null) { + return cachedWorkspaceDir; + } + + const dir = await doPickWorkspaceDir(workspaceService, quickPick, messageService); + cachedWorkspaceDir = dir; + return dir; +} + +/** + * Force re-pick the workspace directory (clears cache and shows QuickPick). + * Called from the UI button to switch workspace path. + */ +export async function switchWorkspaceDir( + workspaceService: IWorkspaceService, + quickPick: QuickPickService, + messageService: IMessageService, +): Promise { + cachedWorkspaceDir = null; + const dir = await doPickWorkspaceDir(workspaceService, quickPick, messageService); + cachedWorkspaceDir = dir; + return dir; +} + +/** + * Get the current cached workspace directory, or empty string if not yet selected. + */ +export function getCachedWorkspaceDir(): string { + return cachedWorkspaceDir ?? ''; +} + +async function doPickWorkspaceDir( + workspaceService: IWorkspaceService, + quickPick: QuickPickService, + messageService: IMessageService, +): Promise { + await workspaceService.whenReady; + + if (workspaceService.isMultiRootWorkspaceOpened) { + const roots = workspaceService.tryGetRoots(); + const choose = await quickPick.show( + roots.map((file) => new URI(file.uri).codeUri.fsPath), + { placeholder: localize('chat.selectCWDForACP') }, + ); + if (choose) { + return choose; + } + // User cancelled: fall back to first root and notify + const fallback = new URI(roots[0].uri).codeUri.fsPath; + messageService.info(formatLocalize('chat.defaultCWDSelected', fallback)); + return fallback; + } + + if (workspaceService.workspace) { + return new URI(workspaceService.workspace.uri).codeUri.fsPath; + } + + return ''; +} diff --git a/packages/ai-native/src/browser/chat/session-provider-registry.ts b/packages/ai-native/src/browser/chat/session-provider-registry.ts new file mode 100644 index 0000000000..140297285c --- /dev/null +++ b/packages/ai-native/src/browser/chat/session-provider-registry.ts @@ -0,0 +1,130 @@ +import { Autowired, INJECTOR_TOKEN, Injectable, Injector } from '@opensumi/di'; +import { Disposable, IDisposable } from '@opensumi/ide-core-common'; + +import { ISessionProvider, SessionProviderDomain } from './session-provider'; + +/** + * Session Provider Registry Token(用于 DI) + */ +export const ISessionProviderRegistry = Symbol('ISessionProviderRegistry'); + +/** + * Session Provider Registry 接口 + * 管理所有注册的 Session Provider,提供 Provider 路由功能 + */ +export interface ISessionProviderRegistry { + /** + * 注册 Provider + * @param provider Session Provider 实例 + * @returns 注销句柄 + */ + registerProvider(provider: ISessionProvider): IDisposable; + + /** + * 根据 source 前缀获取 Provider + * @param source 来源标识(如 'local', 'acp') + * @returns 对应的 Provider,未找到返回 undefined + */ + getProvider(source: string): ISessionProvider | undefined; + + /** + * 根据 Session ID 获取 Provider + * 解析 Session ID 的 source 前缀,路由到对应 Provider + * @param sessionId 本地 Session ID(如 'local:uuid', 'acp:sess_123') + * @returns 对应的 Provider,未找到返回 undefined + */ + getProviderBySessionId(sessionId: string): ISessionProvider | undefined; + + /** + * 获取所有已注册的 Provider + * @returns Provider 列表 + */ + getAllProviders(): ISessionProvider[]; +} + +/** + * Session Provider Registry 实现 + * 轻量级路由,不负责加载逻辑,只负责 Provider 注册和查找 + */ +@Injectable() +export class SessionProviderRegistry extends Disposable implements ISessionProviderRegistry { + @Autowired(INJECTOR_TOKEN) + private injector: Injector; + + private providers: Map = new Map(); + private initialized = false; + + constructor() { + super(); + this.initialize(); + } + + /** + * 初始化:从 DI 收集所有标注了 @Domain(SessionProviderDomain) 的 Provider + */ + initialize(): void { + if (this.initialized) { + return; + } + + // 从 DI 获取所有 SessionProviderDomain 的实例 + const domainProviders = this.injector.getFromDomain(SessionProviderDomain) as ISessionProvider[]; + + for (const provider of domainProviders) { + this.registerProvider(provider); + } + + this.initialized = true; + } + + /** + * 注册 Provider + */ + registerProvider(provider: ISessionProvider): IDisposable { + if (this.providers.has(provider.id)) { + // Provider 已存在,将被覆盖 + } + + this.providers.set(provider.id, provider); + + return { + dispose: () => { + this.providers.delete(provider.id); + }, + }; + } + + /** + * 根据 source 前缀获取 Provider + */ + getProvider(source: string): ISessionProvider | undefined { + // 先尝试直接匹配 source + const providers = Array.from(this.providers.values()); + for (const provider of providers) { + try { + const canHandleResult = provider.canHandle(source); + if (canHandleResult) { + return provider; + } + } catch (error) { + // Provider canHandle() threw error + } + } + return undefined; + } + + /** + * 根据 Session ID 获取 Provider + */ + getProviderBySessionId(sessionId: string): ISessionProvider | undefined { + const provider = this.getProvider(sessionId); + return provider; + } + + /** + * 获取所有已注册的 Provider + */ + getAllProviders(): ISessionProvider[] { + return Array.from(this.providers.values()); + } +} diff --git a/packages/ai-native/src/browser/chat/session-provider.ts b/packages/ai-native/src/browser/chat/session-provider.ts new file mode 100644 index 0000000000..45773e7e69 --- /dev/null +++ b/packages/ai-native/src/browser/chat/session-provider.ts @@ -0,0 +1,116 @@ +import { AvailableCommand, IHistoryChatMessage } from '@opensumi/ide-core-common/lib/types/ai-native'; + +import { IChatFollowup, IChatRequestMessage, IChatResponseErrorDetails } from '../../common'; + +import { IChatProgressResponseContent } from './chat-model'; + +/** + * Session 模型数据结构(用于持久化) + */ +export interface ISessionModel { + sessionId: string; + modelId?: string; + history: { additional: Record; messages: IHistoryChatMessage[] }; + requests: { + requestId: string; + message: IChatRequestMessage; + response: { + isCanceled: boolean; + responseText: string; + responseContents: IChatProgressResponseContent[]; + responseParts: IChatProgressResponseContent[]; + errorDetails: IChatResponseErrorDetails | undefined; + followups: IChatFollowup[] | undefined; + }; + }[]; + lastLoadedAt?: number; + title?: string; +} + +/** + * Session 模型扩展字段(非持久化,来自 Agent) + */ +export interface ISessionModelExtension { + availableCommands: AvailableCommand[]; +} + +/** + * Session Provider 接口 + * 抽象不同数据源的 Session 加载逻辑 + */ +export interface ISessionProvider { + /** Provider 唯一标识 */ + readonly id: string; + + /** + * 判断是否支持处理该来源的 Session + * @param source Session 来源标识(如 'local', 'acp', 'acp:sess_123') + */ + canHandle(source: string): boolean; + + /** + * 创建新会话 + * @param title 可选的会话标题 + * @returns 创建的 Session 数据 + */ + createSession?(): Promise; + + /** + * 加载所有可用会话 + * @returns Session 数据列表 + */ + loadSessions(): Promise; + + /** + * 加载指定会话 + * @param sessionId 本地 Session ID + * @returns Session 数据,不存在时返回 undefined + */ + loadSession(sessionId: string): Promise; + + /** + * 保存会话(可选实现) + * @param sessions Session 数据列表 + */ + saveSessions?(sessions: ISessionModel[]): Promise; +} + +/** + * Session Provider Token(用于 DI) + */ +export const ISessionProvider = Symbol('ISessionProvider'); + +/** + * Session Provider Domain(用于 DI 多实例注入) + */ +export const SessionProviderDomain = Symbol('SessionProviderDomain'); + +/** + * Session 加载状态枚举 + */ +export enum SessionLoadState { + /** 正在从远程加载 */ + LOADING = 'loading', + /** 完整数据已加载 */ + LOADED = 'loaded', + /** 加载失败 */ + ERROR = 'error', +} + +/** + * Session 来源类型 + */ +export type SessionSource = 'local' | 'acp'; + +/** + * 解析 Session ID,提取来源和原始 ID + * @param sessionId 本地 Session ID(如 'local:uuid', 'acp:sess_123') + * @returns 来源标识和原始 ID + */ +export function parseSessionId(sessionId: string): { source: SessionSource; originalId: string } { + if (sessionId.startsWith('acp:')) { + return { source: 'acp', originalId: sessionId.slice(4) }; + } + // 默认视为 local 来源(兼容旧数据) + return { source: 'local', originalId: sessionId }; +} diff --git a/packages/ai-native/src/browser/components/ChatEditor.tsx b/packages/ai-native/src/browser/components/ChatEditor.tsx index 3b59327a21..c427e57228 100644 --- a/packages/ai-native/src/browser/components/ChatEditor.tsx +++ b/packages/ai-native/src/browser/components/ChatEditor.tsx @@ -421,7 +421,7 @@ export const CodeBlockWrapperInput = ({ @{agentId} )} - {command &&
/ {command}
} + {command && !tag &&
/ {command}
} void; + onHistoryItemSelect: (item: IChatHistoryItem) => void; + onHistoryItemDelete: (item: IChatHistoryItem) => void; + onHistoryItemChange: (item: IChatHistoryItem, title: string) => void; + onHistoryPopoverVisibleChange?: (visible: boolean) => void; +} + +// 最大历史记录数 +const MAX_HISTORY_LIST = 100; + +const ChatHistoryACP: FC = memo( + ({ + title, + historyList, + currentId, + onNewChat, + onHistoryItemSelect, + onHistoryItemChange, + onHistoryItemDelete, + onHistoryPopoverVisibleChange, + historyLoading, + className, + }) => { + const [historyTitleEditable, setHistoryTitleEditable] = useState<{ + [key: string]: boolean; + } | null>(null); + const [searchValue, setSearchValue] = useState(''); + const inputRef = useRef(null); + + // 处理搜索输入变化 + const handleSearchChange = useCallback( + (event: React.ChangeEvent) => { + setSearchValue(event.target.value); + }, + [searchValue], + ); + + // 处理历史记录项选择 + const handleHistoryItemSelect = useCallback( + (item: IChatHistoryItem) => { + onHistoryItemSelect(item); + setSearchValue(''); + }, + [onHistoryItemSelect, searchValue], + ); + + // 处理标题编辑 + const handleTitleEdit = useCallback( + (item: IChatHistoryItem) => { + setHistoryTitleEditable({ + [item.id]: true, + }); + }, + [historyTitleEditable], + ); + + // 处理标题编辑完成 + const handleTitleEditComplete = useCallback( + (item: IChatHistoryItem, newTitle: string) => { + setHistoryTitleEditable({ + [item.id]: false, + }); + onHistoryItemChange(item, newTitle); + }, + [onHistoryItemChange, historyTitleEditable], + ); + + // 处理标题编辑取消 + const handleTitleEditCancel = useCallback( + (item: IChatHistoryItem) => { + setHistoryTitleEditable({ + [item.id]: false, + }); + }, + [historyTitleEditable], + ); + + // 处理新建聊天 + const handleNewChat = useCallback(() => { + onNewChat(); + }, [onNewChat]); + + useEffect(() => { + if (historyTitleEditable) { + inputRef.current?.focus({ cursor: 'end' }); + } + }, [historyTitleEditable]); + + // 处理删除历史记录 + const handleHistoryItemDelete = useCallback( + (item: IChatHistoryItem) => { + onHistoryItemDelete(item); + }, + [onHistoryItemDelete], + ); + + // 获取时间标签 + const getTimeKey = useCallback((diff: number): string => { + if (diff < 60 * 60 * 1000) { + const minutes = Math.floor(diff / (60 * 1000)); + return minutes === 0 ? 'Just now' : `${minutes}m ago`; + } else if (diff < 24 * 60 * 60 * 1000) { + const hours = Math.floor(diff / (60 * 60 * 1000)); + return `${hours}h ago`; + } else if (diff < 7 * 24 * 60 * 60 * 1000) { + const days = Math.floor(diff / (24 * 60 * 60 * 1000)); + return `${days}d ago`; + } else if (diff < 30 * 24 * 60 * 60 * 1000) { + const weeks = Math.floor(diff / (7 * 24 * 60 * 60 * 1000)); + return `${weeks}w ago`; + } else if (diff < 365 * 24 * 60 * 60 * 1000) { + const months = Math.floor(diff / (30 * 24 * 60 * 60 * 1000)); + return `${months}mo ago`; + } + const years = Math.floor(diff / (365 * 24 * 60 * 60 * 1000)); + return `${years}y ago`; + }, []); + + // 格式化历史记录 + const formatHistory = useCallback( + (list: IChatHistoryItem[]) => { + const now = new Date(); + const result = [] as { key: string; items: typeof list }[]; + + list.forEach((item: IChatHistoryItem) => { + const updatedAt = new Date(item.updatedAt); + const diff = now.getTime() - updatedAt.getTime(); + const key = getTimeKey(diff); + + const existingGroup = result.find((group) => group.key === key); + if (existingGroup) { + existingGroup.items.push(item); + } else { + result.push({ key, items: [item] }); + } + }); + + return result; + }, + [getTimeKey], + ); + + // 渲染历史记录项 + const renderHistoryItem = useCallback( + (item: IChatHistoryItem) => ( +
handleHistoryItemSelect(item)} + > +
+ {item.loading ? ( + + ) : ( + + )} + {!historyTitleEditable?.[item.id] ? ( + + {item.title} + + ) : ( + { + handleTitleEditComplete(item, e.target.value); + }} + onBlur={() => handleTitleEditCancel(item)} + /> + )} +
+
+ { + e.preventDefault(); + e.stopPropagation(); + handleHistoryItemDelete(item); + }} + ariaLabel={localize('aiNative.operate.chatHistory.delete')} + /> +
+
+ ), + [ + historyTitleEditable, + handleHistoryItemSelect, + handleTitleEditComplete, + handleTitleEditCancel, + handleTitleEdit, + handleHistoryItemDelete, + currentId, + inputRef, + ], + ); + + // 渲染历史记录列表 + const renderHistory = useCallback(() => { + const filteredList = historyList + .slice(-MAX_HISTORY_LIST) + .reverse() + .filter((item) => item.title && item.title.includes(searchValue)); + + const groupedHistoryList = formatHistory(filteredList); + + return ( +
+ +
+ {historyLoading ? ( +
+ +
+ ) : ( + groupedHistoryList.map((group) => ( +
+ {group.items.map(renderHistoryItem)} +
+ )) + )} +
+
+ ); + }, [historyList, searchValue, formatHistory, handleSearchChange, renderHistoryItem, historyLoading]); + + // getPopupContainer 处理函数 + const getPopupContainer = useCallback((triggerNode: HTMLElement) => triggerNode.parentElement!, []); + + return ( +
+
+ {title} +
+
+ +
+ +
+
+ + + +
+
+ ); + }, +); + +export default ChatHistoryACP; diff --git a/packages/ai-native/src/browser/components/ChatMentionInput.acp.tsx b/packages/ai-native/src/browser/components/ChatMentionInput.acp.tsx new file mode 100644 index 0000000000..f4247a1271 --- /dev/null +++ b/packages/ai-native/src/browser/components/ChatMentionInput.acp.tsx @@ -0,0 +1,644 @@ +import { DataContent } from 'ai'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { Image } from '@opensumi/ide-components/lib/image'; +import { + AINativeConfigService, + LabelService, + PreferenceService, + RecentFilesManager, + getSymbolIcon, + useInjectable, +} from '@opensumi/ide-core-browser'; +import { Icon, getIcon } from '@opensumi/ide-core-browser/lib/components'; +import { + AINativeSettingSectionsId, + ChatFeatureRegistryToken, + RulesServiceToken, + URI, + localize, +} from '@opensumi/ide-core-common'; +import { CommandService } from '@opensumi/ide-core-common/lib/command'; +import { defaultFilesWatcherExcludes } from '@opensumi/ide-core-common/lib/preferences/file-watch'; +import { WorkbenchEditorService } from '@opensumi/ide-editor'; +import { FileSearchServicePath, IFileSearchService } from '@opensumi/ide-file-search'; +import { OutlineCompositeTreeNode, OutlineTreeNode } from '@opensumi/ide-outline/lib/browser/outline-node.define'; +import { OutlineTreeService } from '@opensumi/ide-outline/lib/browser/services/outline-tree.service'; +import { IMessageService } from '@opensumi/ide-overlay'; +import { IconType } from '@opensumi/ide-theme'; +import { IconService } from '@opensumi/ide-theme/lib/browser'; +import { IWorkspaceService } from '@opensumi/ide-workspace'; + +import { IChatInternalService } from '../../common'; +import { LLMContextService } from '../../common/llm-context'; +import { ChatFeatureRegistry } from '../chat/chat.feature.registry'; +import { ChatInternalService } from '../chat/chat.internal.service'; +import { AcpChatInternalService } from '../chat/chat.internal.service.acp'; +import { MCPConfigCommands } from '../mcp/config/mcp-config.commands'; +import { RulesCommands } from '../rules/rules.contribution'; +import { RulesService } from '../rules/rules.service'; + +import { MentionInput } from './acp/MentionInput'; +import { ModeOption } from './acp/types'; +import styles from './components.module.less'; +import { FooterButtonPosition, FooterConfig, MentionItem, MentionType } from './mention-input/types'; + +export interface IChatMentionInputProps { + onSend: ( + value: string, + images?: string[], + agentId?: string, + command?: string, + option?: { model: string; [key: string]: any }, + ) => void; + onValueChange?: (value: string) => void; + onExpand?: (value: boolean) => void; + placeholder?: string; + enableOptions?: boolean; + disabled?: boolean; + sendBtnClassName?: string; + defaultHeight?: number; + value?: string; + images?: Array; + autoFocus?: boolean; + theme?: string | null; + setTheme: (theme: string | null) => void; + agentId: string; + setAgentId: (id: string) => void; + defaultAgentId?: string; + command: string; + setCommand: (command: string) => void; + disableModelSelector?: boolean; + sessionModelId?: string; + contextService?: LLMContextService; + agentModes?: Array<{ id: string; name: string; description?: string }>; +} + +export const ChatMentionInputACP = (props: IChatMentionInputProps) => { + const { onSend, disabled = false, contextService } = props; + + const [value, setValue] = useState(props.value || ''); + const [images, setImages] = useState(props.images || []); + const [currentMode, setCurrentMode] = useState(props.agentModes?.[0]?.id || 'default'); + const aiChatService = useInjectable(IChatInternalService); + const aiNativeConfigService = useInjectable(AINativeConfigService); + const commandService = useInjectable(CommandService); + const searchService = useInjectable(FileSearchServicePath); + const recentFilesManager = useInjectable(RecentFilesManager); + const workspaceService = useInjectable(IWorkspaceService); + const editorService = useInjectable(WorkbenchEditorService); + const labelService = useInjectable(LabelService); + const iconService = useInjectable(IconService); + const messageService = useInjectable(IMessageService); + const chatFeatureRegistry = useInjectable(ChatFeatureRegistryToken); + const outlineTreeService = useInjectable(OutlineTreeService); + const prevOutlineItems = useRef([]); + const preferenceService = useInjectable(PreferenceService); + const rulesService = useInjectable(RulesServiceToken); + const handleShowMCPConfig = React.useCallback(() => { + commandService.executeCommand(MCPConfigCommands.OPEN_MCP_CONFIG.id); + }, [commandService]); + + const handleShowRules = React.useCallback(() => { + commandService.executeCommand(RulesCommands.OPEN_RULES_FILE.id); + }, [commandService]); + + // 监听 ACP Agent 模式切换成功事件,同步更新 UI + useEffect(() => { + const disposable = aiChatService.onModeChange((modeId) => { + setCurrentMode(modeId); + }); + return () => disposable.dispose(); + }, [aiChatService]); + + // 当 agentModes 变化时,更新 currentMode 为第一个 mode + useEffect(() => { + if (props.agentModes?.length && !props.agentModes.find((m) => m.id === currentMode)) { + setCurrentMode(props.agentModes[0].id); + } + }, [props.agentModes]); + + useEffect(() => { + if (props.value !== value) { + setValue(props.value || ''); + } + }, [props.value]); + + const resolveSymbols = useCallback( + async (parent?: OutlineCompositeTreeNode, symbols: (OutlineTreeNode | OutlineCompositeTreeNode)[] = []) => { + if (!parent) { + parent = (await outlineTreeService.resolveChildren())[0] as OutlineCompositeTreeNode; + } + const children = (await outlineTreeService.resolveChildren(parent)) as ( + | OutlineTreeNode + | OutlineCompositeTreeNode + )[]; + for (const child of children) { + symbols.push(child); + if (OutlineCompositeTreeNode.is(child)) { + await resolveSymbols(child, symbols); + } + } + return symbols; + }, + [outlineTreeService], + ); + + // 拆分目录路径为多个层级的辅助函数 + const expandFolderPaths = async (folderPaths: string[], workspaceRootPath: string): Promise => { + const expandedPaths = new Set(); + const workspaceUri = new URI(workspaceRootPath); + + // 将所有路径展开为多层级 + for (const folderPath of folderPaths) { + const uri = new URI(folderPath); + const relativePath = await workspaceService.asRelativePath(uri); + + if (relativePath?.path) { + const pathSegments = relativePath.path.split('/').filter(Boolean); + + // 为每个层级创建路径 + for (let i = 0; i < pathSegments.length; i++) { + const segmentPath = pathSegments.slice(0, i + 1).join('/'); + const fullPath = workspaceUri.resolve(segmentPath).codeUri.fsPath; + + // 避免添加工作区本身或其上级目录 + if (fullPath !== workspaceRootPath && !workspaceRootPath.startsWith(fullPath)) { + expandedPaths.add(fullPath); + } + } + } else { + // 如果无法获取相对路径,直接添加(但仍要过滤工作区路径) + if (folderPath !== workspaceRootPath && !workspaceRootPath.startsWith(folderPath)) { + expandedPaths.add(folderPath); + } + } + } + + // 转换为 MentionItem 格式 + return Promise.all( + Array.from(expandedPaths).map(async (folderPath) => { + const uri = new URI(folderPath); + const relativePath = await workspaceService.asRelativePath(uri); + return { + id: uri.codeUri.fsPath, + type: MentionType.FOLDER, + text: uri.displayName, + value: uri.codeUri.fsPath, + description: relativePath?.root ? relativePath.path : '', + contextId: uri.codeUri.fsPath, + icon: getIcon('folder'), + }; + }), + ); + }; + + // 默认菜单项 + const defaultMenuItems: MentionItem[] = [ + { + id: MentionType.FILE, + type: MentionType.FILE, + text: 'File', + icon: getIcon('file'), + getHighestLevelItems: () => { + const currentEditor = editorService.currentEditor; + const currentUri = currentEditor?.currentUri; + if (!currentUri) { + return []; + } + return [ + { + id: currentUri.codeUri.fsPath, + type: MentionType.FILE, + text: currentUri.displayName, + value: currentUri.codeUri.fsPath, + description: `(${localize('aiNative.chat.defaultContextFile')})`, + contextId: currentUri.codeUri.fsPath, + icon: labelService.getIcon(currentUri), + }, + ]; + }, + getItems: async (searchText: string) => { + if (!searchText) { + const recentFile = await recentFilesManager.getMostRecentlyOpenedFiles(); + return Promise.all( + recentFile.map(async (file) => { + const uri = new URI(file); + const relatveParentPath = (await workspaceService.asRelativePath(uri.parent))?.path; + return { + id: uri.codeUri.fsPath, + type: MentionType.FILE, + text: uri.displayName, + value: uri.codeUri.fsPath, + description: relatveParentPath || '', + contextId: uri.codeUri.fsPath, + icon: labelService.getIcon(uri), + }; + }), + ); + } else { + const rootUris = (await workspaceService.roots).map((root) => new URI(root.uri).codeUri.fsPath.toString()); + const results = await searchService.find(searchText, { + rootUris, + useGitIgnore: true, + noIgnoreParent: true, + fuzzyMatch: true, + limit: 10, + }); + return Promise.all( + results.map(async (file) => { + const uri = new URI(file); + const relatveParentPath = (await workspaceService.asRelativePath(uri.parent))?.path; + return { + id: uri.codeUri.fsPath, + type: MentionType.FILE, + text: uri.displayName, + value: uri.codeUri.fsPath, + description: relatveParentPath || '', + contextId: uri.codeUri.fsPath, + icon: labelService.getIcon(uri), + }; + }), + ); + } + }, + }, + { + id: MentionType.FOLDER, + type: MentionType.FOLDER, + text: 'Folder', + icon: getIcon('folder'), + getHighestLevelItems: () => { + const currentEditor = editorService.currentEditor; + const currentFolderUri = currentEditor?.currentUri?.parent; + if (!currentFolderUri) { + return []; + } + if (currentFolderUri.toString() === workspaceService.workspace?.uri) { + return []; + } + return [ + { + id: currentFolderUri.codeUri.fsPath, + type: MentionType.FOLDER, + text: currentFolderUri.displayName, + value: currentFolderUri.codeUri.fsPath, + description: `(${localize('aiNative.chat.defaultContextFolder')})`, + contextId: currentFolderUri.codeUri.fsPath, + icon: getIcon('folder'), + }, + ]; + }, + getItems: async (searchText: string) => { + let folders: MentionItem[] = []; + if (!searchText) { + const recentFile = await recentFilesManager.getMostRecentlyOpenedFiles(); + const recentFolder = Array.from( + new Set( + recentFile + .map((file) => new URI(file).parent.codeUri.fsPath) + .filter((folder) => folder !== workspaceService.workspace?.uri.toString() && folder !== '/'), + ), + ); + folders = await expandFolderPaths(recentFolder, workspaceService.workspace?.uri.toString() || ''); + } else { + const rootUris = (await workspaceService.roots).map((root) => new URI(root.uri).codeUri.fsPath.toString()); + const files = await searchService.find(searchText, { + rootUris, + useGitIgnore: true, + noIgnoreParent: true, + fuzzyMatch: true, + excludePatterns: Object.keys(defaultFilesWatcherExcludes), + limit: 10, + }); + const folderPaths = Array.from( + new Set( + files + .map((file) => new URI(file).parent.toString()) + .filter((folder) => folder !== workspaceService.workspace?.uri.toString()), + ), + ); + folders = await expandFolderPaths(folderPaths, workspaceService.workspace?.uri.toString() || ''); + } + return folders + .filter(Boolean) + .filter((folder) => folder.id !== new URI(workspaceService.workspace?.uri).codeUri.fsPath); + }, + }, + { + id: 'code', + type: 'code', + text: 'Code', + icon: getIcon('codebraces'), + getHighestLevelItems: () => [], + getItems: async (searchText: string) => { + if (!searchText || prevOutlineItems.current.length === 0) { + const uri = outlineTreeService.currentUri; + if (!uri) { + return []; + } + const treeNodes = await resolveSymbols(); + prevOutlineItems.current = await Promise.all( + treeNodes.map(async (treeNode) => { + const relativePath = await workspaceService.asRelativePath(uri); + return { + id: treeNode.raw.id, + type: MentionType.CODE, + text: treeNode.raw.name, + symbol: treeNode.raw, + value: treeNode.raw.id, + description: `${relativePath?.root ? relativePath.path : ''}:L${treeNode.raw.range.startLineNumber}-${ + treeNode.raw.range.endLineNumber + }`, + kind: treeNode.raw.kind, + contextId: `${outlineTreeService.currentUri?.codeUri.fsPath}:L${treeNode.raw.range.startLineNumber}-${treeNode.raw.range.endLineNumber}`, + icon: getSymbolIcon(treeNode.raw.kind) + ' outline-icon', + }; + }), + ); + return prevOutlineItems.current; + } else { + searchText = searchText.toLocaleLowerCase(); + return prevOutlineItems.current.sort((a, b) => { + if (a.text.toLocaleLowerCase().includes(searchText) && b.text.toLocaleLowerCase().includes(searchText)) { + return 0; + } + if (a.text.toLocaleLowerCase().includes(searchText)) { + return -1; + } else if (b.text.toLocaleLowerCase().includes(searchText)) { + return 1; + } + return 0; + }); + } + }, + }, + { + id: MentionType.RULE, + type: MentionType.RULE, + text: 'Rule', + icon: getIcon('rules'), + getHighestLevelItems: () => [], + getItems: async (searchText: string) => { + const rules = await rulesService.projectRules; + const mappedRules = rules.map((rule) => { + const uri = new URI(rule.path); + return { + id: uri.codeUri.fsPath, + type: MentionType.RULE, + text: uri.displayName, + value: uri.codeUri.fsPath, + contextId: uri.codeUri.fsPath, + description: rule.description, + icon: getIcon('rules'), + }; + }); + + if (!searchText) { + return mappedRules.slice(0, 10); + } + + const lowerSearchText = searchText.toLocaleLowerCase(); + return mappedRules + .filter((rule) => rule.text.toLocaleLowerCase().includes(lowerSearchText)) + .sort((a, b) => { + const aTextLower = a.text.toLocaleLowerCase(); + const bTextLower = b.text.toLocaleLowerCase(); + const aDescLower = a.description?.toLocaleLowerCase() || ''; + const bDescLower = b.description?.toLocaleLowerCase() || ''; + + // 优先级:文件名包含搜索文本 > 描述包含搜索文本 + const aTextMatch = aTextLower.includes(lowerSearchText); + const bTextMatch = bTextLower.includes(lowerSearchText); + const aDescMatch = aDescLower.includes(lowerSearchText); + const bDescMatch = bDescLower.includes(lowerSearchText); + + if (aTextMatch && bTextMatch) { + // 如果都匹配文件名,按文件名字母序排序 + return aTextLower.localeCompare(bTextLower); + } + if (aTextMatch && !bTextMatch) { + return -1; + } + if (!aTextMatch && bTextMatch) { + return 1; + } + + // 如果文件名都不匹配,比较描述 + if (aDescMatch && bDescMatch) { + return aTextLower.localeCompare(bTextLower); + } + if (aDescMatch && !bDescMatch) { + return -1; + } + if (!aDescMatch && bDescMatch) { + return 1; + } + + // 如果都不匹配,按文件名字母序排序 + return aTextLower.localeCompare(bTextLower); + }) + .slice(0, 10); + }, + }, + ]; + // Mode 选项:优先使用 Agent 初始化时返回的真实 modes,降级为硬编码默认值 + const modeOptions: ModeOption[] = useMemo( + () => + props.agentModes?.length + ? props.agentModes + : [{ id: 'default', name: 'Default', description: 'Require approval for edits' }], + [props.agentModes], + ); + + const defaultMentionInputFooterOptions: FooterConfig = useMemo( + () => ({ + modeOptions, + defaultMode: modeOptions[0]?.id || 'default', + currentMode, + showModeSelector: modeOptions.length > 1, + modelOptions: [ + { + value: 'qwen-plus-latest', + label: 'Qwen 3', + iconClass: iconService.fromIcon( + '', + 'https://img.alicdn.com/imgextra/i3/O1CN01LFMrZj28YrnrzeebY_!!6000000007945-55-tps-16-16.svg', + IconType.Background, + ), + tags: ['思考链', '擅长代码'], + description: '高性能代码模型,支持思考链', + }, + { + label: 'Claude 4 Sonnet', + value: 'claude_sonnet4', + iconClass: iconService.fromIcon( + '', + 'https://img.alicdn.com/imgextra/i3/O1CN01p0mziz1Nsl40lp1HO_!!6000000001626-55-tps-92-65.svg', + IconType.Background, + ), + tags: ['多模态', '长上下文理解', '思考模式'], + description: '高性能模型,支持多模态输入', + }, + { + label: 'DeepSeek R1', + value: 'DeepSeek-R1-0528', + iconClass: iconService.fromIcon( + '', + 'https://img.alicdn.com/imgextra/i3/O1CN01ClcK2w1JwdxcbAB3a_!!6000000001093-55-tps-30-30.svg', + IconType.Background, + ), + tags: ['思考模式', '长上下文理解'], + description: '专业创作,支持多模态输入', + }, + ], + defaultModel: + props.sessionModelId || preferenceService.get(AINativeSettingSectionsId.ModelID) || 'deepseek-r1', + buttons: aiNativeConfigService.capabilities.supportsAgentMode + ? [] + : [ + { + id: 'mcp-server', + icon: 'mcp', + title: 'MCP Server', + onClick: handleShowMCPConfig, + position: FooterButtonPosition.LEFT, + }, + { + id: 'rules', + icon: 'rules', + title: 'Rules', + onClick: handleShowRules, + position: FooterButtonPosition.LEFT, + }, + { + id: 'upload-image', + icon: 'image', + title: localize('aiNative.chat.imageUpload'), + onClick: () => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.onchange = (e) => { + const files = (e.target as HTMLInputElement).files; + if (files?.length) { + handleImageUpload(Array.from(files)); + } + }; + input.click(); + }, + position: FooterButtonPosition.LEFT, + }, + ], + showModelSelector: aiNativeConfigService.capabilities.supportsAgentMode ? false : true, // agnet 模式不支持选择模型 + disableModelSelector: props.disableModelSelector, + }), + [iconService, handleShowMCPConfig, handleShowRules, props.disableModelSelector, props.sessionModelId], + ); + + const handleStop = useCallback(() => { + aiChatService.cancelRequest(); + }, []); + + const handleSend = useCallback( + async (content: string, option?: { model: string; [key: string]: any }) => { + if (disabled) { + return; + } + onSend( + content, + images.map((image) => image.toString()), + undefined, + undefined, + option, + ); + setImages(props.images || []); + }, + [onSend, images, disabled], + ); + + const handleImageUpload = useCallback( + async (files: File[]) => { + const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif']; + + // Validate file types + const invalidFiles = files.filter((file) => !allowedTypes.includes(file.type)); + if (invalidFiles.length > 0) { + messageService.error('Only JPG, PNG, WebP and GIF images are supported'); + return; + } + + const imageUploadProvider = chatFeatureRegistry.getImageUploadProvider(); + if (!imageUploadProvider) { + messageService.error('No image upload provider found'); + return; + } + + // Upload all files + const uploadedData = await Promise.all(files.map((file) => imageUploadProvider.imageUpload(file))); + + const newImages = [...images, ...uploadedData]; + setImages(newImages); + }, + [images], + ); + + const handleModeChange = useCallback( + async (modeId: string) => { + try { + await aiChatService.setSessionMode(modeId); + } catch (error) { + // console.error('Failed to switch mode:', error); + messageService.error('Failed to switch mode: ' + (error instanceof Error ? error.message : String(error))); + } + }, + [aiChatService, messageService], + ); + + const handleDeleteImage = useCallback( + (index: number) => { + setImages(images.filter((_, i) => i !== index)); + }, + [images], + ); + + return ( +
+ {images.length > 0 && } + +
+ ); +}; + +const ImagePreviewer = ({ + images, + onDelete, +}: { + images: Array; + onDelete: (index: number) => void; +}) => ( +
+
+ {images.map((image, index) => ( +
+ + +
+ ))} +
+
+); diff --git a/packages/ai-native/src/browser/components/acp/ChatReply.tsx b/packages/ai-native/src/browser/components/acp/ChatReply.tsx new file mode 100644 index 0000000000..c479713569 --- /dev/null +++ b/packages/ai-native/src/browser/components/acp/ChatReply.tsx @@ -0,0 +1,462 @@ +import cls from 'classnames'; +import React, { + Fragment, + ReactNode, + startTransition, + useCallback, + useEffect, + useMemo, + useReducer, + useRef, + useState, +} from 'react'; + +import { Button } from '@opensumi/ide-components/lib/button'; +import { BasicRecycleTree, IBasicRecycleTreeHandle, IBasicTreeData } from '@opensumi/ide-components/lib/recycle-tree'; +import { + BasicCompositeTreeNode, + BasicTreeNode, +} from '@opensumi/ide-components/lib/recycle-tree/basic/tree-node.define'; +import { Tooltip } from '@opensumi/ide-components/lib/tooltip'; +import { + CommandService, + DisposableCollection, + EDITOR_COMMANDS, + IContextKeyService, + LabelService, + useInjectable, +} from '@opensumi/ide-core-browser'; +import { Icon, getIcon } from '@opensumi/ide-core-browser/lib/components'; +import { Loading } from '@opensumi/ide-core-browser/lib/components/ai-native'; +import { + ActionSourceEnum, + ActionTypeEnum, + ChatAgentViewServiceToken, + ChatRenderRegistryToken, + ChatServiceToken, + FileType, + IAIReporter, + IChatComponent, + IChatContent, + IChatResponseProgressFileTreeData, + IChatToolContent, + URI, + localize, +} from '@opensumi/ide-core-common'; +import { IIconService } from '@opensumi/ide-theme'; +import { IMarkdownString, MarkdownString } from '@opensumi/monaco-editor-core/esm/vs/base/common/htmlContent'; + +import { IChatAgentService, IChatInternalService } from '../../../common'; +import { ChatRequestModel } from '../../chat/chat-model'; +import { ChatService } from '../../chat/chat.api.service'; +import { ChatInternalService } from '../../chat/chat.internal.service'; +import { ChatRenderRegistry } from '../../chat/chat.render.registry'; +import { MsgHistoryManager } from '../../model/msg-history-manager'; +import { IChatAgentViewService } from '../../types'; +import { ChatMarkdown } from '../ChatMarkdown'; +import { ChatThinking, ChatThinkingResult } from '../ChatThinking'; +import styles from '../components.module.less'; + +interface IChatReplyProps { + relationId: string; + request: ChatRequestModel; + history: MsgHistoryManager; + startTime?: number; + agentId?: string; + command?: string; + onRegenerate?: () => void; + onDidChange?: () => void; + onDone?: () => void; + msgId: string; +} + +const TreeRenderer = (props: { treeData: IChatResponseProgressFileTreeData }) => { + const labelService = useInjectable(LabelService); + const commandService = useInjectable(CommandService); + + const getIconClassName = (uri: URI, isDirectory: boolean, expanded: boolean) => { + // getIcon 没有处理 isOpenedDirectory + let iconClassName = labelService.getIcon(uri, { isDirectory }); + if (isDirectory && expanded) { + iconClassName += ' expanded'; + } + return iconClassName; + }; + + const recycleTreeData = useMemo(() => { + const transform = (item: IChatResponseProgressFileTreeData): IBasicTreeData => { + const isDirectory = typeof item.type === 'number' ? item.type === FileType.Directory : !!item.children; + const uri = new URI(item.uri); + return { + label: item.label, + iconClassName: getIconClassName(uri, isDirectory, isDirectory), + expandable: true, + expanded: true, + children: isDirectory ? (item.children || []).map(transform) : null, + uri, + }; + }; + return (props.treeData.children || []).map(transform); + }, [props.treeData]); + + const [height, setHeight] = useState(22); + + const fileHandle = useRef(null); + + const onReady = (handle: IBasicRecycleTreeHandle) => { + fileHandle.current = handle; + const calcHeight = () => { + let size = handle.getModel().root.branchSize; + if (size < 1) { + size = 1; + } else if (size > 20) { + size = 20; + } + setHeight(size * 22); + }; + calcHeight(); + handle.onDidUpdate(calcHeight); + }; + + if (!recycleTreeData.length) { + return null; + } + + return ( +
+ e.preventDefault()} + onClick={(e, item: BasicCompositeTreeNode | BasicTreeNode) => { + if (!fileHandle.current || !item) { + return; + } + if (!BasicCompositeTreeNode.is(item)) { + commandService.executeCommand(EDITOR_COMMANDS.OPEN_RESOURCE.id, item.raw.uri, { + disableNavigate: true, + preview: true, + }); + } else { + item.raw.iconClassName = getIconClassName(item.raw.uri, true, item.expanded); + } + }} + onReady={onReady} + treeName={props.treeData.label} + leaveBottomBlank={false} + baseIndent={0} + /> +
+ ); +}; + +const ToolCallRender = (props: { toolCall: IChatToolContent['content']; messageId?: string }) => { + const { toolCall, messageId } = props; + const chatAgentViewService = useInjectable(ChatAgentViewServiceToken); + const [node, setNode] = useState(null); + + useEffect(() => { + const config = chatAgentViewService.getChatComponent('toolCall'); + if (config) { + const { component: Component, initialProps } = config; + setNode(); + return; + } + setNode( +
+ + 正在加载组件 +
, + ); + const deferred = chatAgentViewService.getChatComponentDeferred('toolCall')!; + deferred.promise.then(({ component: Component, initialProps }) => { + setNode(); + }); + }, [toolCall]); + + return node; +}; + +const ComponentRender = (props: { component: string; value?: unknown; messageId?: string }) => { + const chatAgentViewService = useInjectable(ChatAgentViewServiceToken); + const [node, setNode] = useState(null); + + useEffect(() => { + const config = chatAgentViewService.getChatComponent(props.component); + if (config) { + const { component: Component, initialProps } = config; + setNode(); + return; + } + setNode( +
+ + 正在加载组件 +
, + ); + const deferred = chatAgentViewService.getChatComponentDeferred(props.component)!; + deferred.promise.then(({ component: Component, initialProps }) => { + setNode(); + }); + }, [props.component, props.value]); + + return node; +}; + +export const ChatReply = (props: IChatReplyProps) => { + const { + relationId, + request, + startTime = 0, + onRegenerate, + onDidChange, + onDone, + agentId, + command, + history, + msgId, + } = props; + + const [, update] = useReducer((num) => (num + 1) % 1_000_000, 0); + const aiReporter = useInjectable(IAIReporter); + const iconService = useInjectable(IIconService); + const contextKeyService = useInjectable(IContextKeyService); + const aiChatService = useInjectable(IChatInternalService); + const chatApiService = useInjectable(ChatServiceToken); + const chatAgentService = useInjectable(IChatAgentService); + const chatRenderRegistry = useInjectable(ChatRenderRegistryToken); + const [collapseThinkingIndexSet, setCollapseThinkingIndexSet] = useState>( + !request.response.isComplete + ? new Set() + : new Set( + request.response.responseContents + .map((item, index) => (item.kind === 'reasoning' ? index : -1)) + .filter((item) => item !== -1), + ), + ); + + useEffect(() => { + if (request.response.isComplete) { + setCollapseThinkingIndexSet( + new Set( + request.response.responseContents + .map((item, index) => (item.kind === 'reasoning' ? index : -1)) + .filter((item) => item !== -1), + ), + ); + } + }, [request.response.isComplete]); + + useEffect(() => { + const disposableCollection = new DisposableCollection(); + + disposableCollection.push( + request.response.onDidChange(() => { + history.updateAssistantMessage(msgId, { content: request.response.responseText }); + + if (request.response.isComplete) { + if (onDone) { + onDone(); + } + // 模型消息返回结束,上报消息(包含toolCall等全部结束) + aiReporter.end(relationId, { + assistantMessage: request.response.responseText, + replytime: Date.now() - startTime, + success: true, + isStop: false, + command, + agentId, + messageId: msgId, + sessionId: aiChatService.sessionModel?.sessionId, + }); + } + + startTransition(() => { + onDidChange?.(); + update(); + }); + }), + ); + + return () => disposableCollection.dispose(); + }, [relationId, onDidChange, onDone]); + + const handleRegenerate = useCallback(() => { + request.response.reset(); + onRegenerate?.(); + }, [onRegenerate]); + + const renderMarkdown = useCallback( + (markdown: IMarkdownString) => { + if (chatRenderRegistry.chatAIRoleRender) { + const Render = chatRenderRegistry.chatAIRoleRender; + return ; + } + + return ; + }, + [chatRenderRegistry, chatRenderRegistry.chatAIRoleRender], + ); + + const renderTreeData = (treeData: IChatResponseProgressFileTreeData) => ; + + const renderPlaceholder = (markdown: IMarkdownString) => ( +
+ +
{renderMarkdown(markdown)}
+
+ ); + + const contentNode = React.useMemo( + () => + request.response.responseContents.map((item, index) => { + let node: ReactNode; + if (item.kind === 'asyncContent') { + node = renderPlaceholder(new MarkdownString(item.content)); + } else if (item.kind === 'treeData') { + node = renderTreeData(item.treeData); + } else if (item.kind === 'component') { + node = ; + } else if (item.kind === 'toolCall') { + node = ; + } else if (item.kind === 'reasoning') { + // 思考中必然为最后一条 + const isThinking = index === request.response.responseContents.length - 1 && !request.response.isComplete; + node = ( +
+ + {!collapseThinkingIndexSet.has(index) ? ( +
{renderMarkdown(new MarkdownString(item.content))}
+ ) : null} +
+ ); + } else { + node = renderMarkdown(item.content); + } + return {node}; + }), + [request.response.responseContents, collapseThinkingIndexSet], + ); + + const followupNode = React.useMemo(() => { + if (!request.response.followups) { + return null; + } + return request.response.followups.map((item, index) => { + let node: React.ReactNode = null; + if (item.kind === 'reply') { + const a = ( + { + chatApiService.sendMessage({ + ...chatAgentService.parseMessage(item.message), + reportExtra: { + actionSource: ActionSourceEnum.Chat, + actionType: ActionTypeEnum.Followup, + }, + }); + }} + > + {item.title || item.message} + + ); + node = item.tooltip ? {a} : a; + } else { + if (!item.when || contextKeyService.match(item.when)) { + node = ; + } else { + node = null; + } + } + return node && {node}; + }); + }, [request.response.followups]); + + if (!request.response.isComplete) { + return {contentNode}; + } + + return ( + 0 || + request.response.responseContents.length > 0 || + !!request.response.errorDetails?.message + } + onRegenerate={handleRegenerate} + requestId={request.requestId} + > +
+ {request.response.errorDetails?.message ? ( +
+ + {request.response.errorDetails.message} +
+ ) : ( + <> + {contentNode} + {followupNode?.length !== 0 &&
{followupNode}
} + + )} +
+
+ ); +}; + +interface IChatNotifyProps { + requestId: string; + chunk: IChatContent | IChatComponent; +} +export const ChatNotify = (props: IChatNotifyProps) => { + const { chunk } = props; + const chatRenderRegistry = useInjectable(ChatRenderRegistryToken); + + const contentNode = React.useMemo(() => { + let node: ReactNode; + + if (chunk.kind === 'component') { + node = ; + } else { + let renderContent = ; + + if (chatRenderRegistry.chatAIRoleRender) { + const ChatAIRoleRender = chatRenderRegistry.chatAIRoleRender; + renderContent = ; + } + + node = renderContent; + } + return node; + }, [chunk]); + + return ( + +
{contentNode}
+
+ ); +}; diff --git a/packages/ai-native/src/browser/components/acp/MentionInput.tsx b/packages/ai-native/src/browser/components/acp/MentionInput.tsx new file mode 100644 index 0000000000..7b9ed6ef2b --- /dev/null +++ b/packages/ai-native/src/browser/components/acp/MentionInput.tsx @@ -0,0 +1,1702 @@ +import cls from 'classnames'; +import * as React from 'react'; + +import { getSymbolIcon, localize, useInjectable } from '@opensumi/ide-core-browser'; +import { Icon, Popover, PopoverPosition, getIcon } from '@opensumi/ide-core-browser/lib/components'; +import { EnhanceIcon } from '@opensumi/ide-core-browser/lib/components/ai-native'; +import { FooterButtonPosition } from '@opensumi/ide-core-common'; +import { URI } from '@opensumi/ide-utils'; + +import { FileContext } from '../../../common/llm-context'; +import { ProjectRule } from '../../../common/types'; +import { PermissionDialogManager } from '../../acp/permission-dialog-container'; +import { + ChatInputFooterContribution, + ChatInputFooterRegistry, + ChatInputFooterRegistryToken, +} from '../../chat/chat-input-footer.registry'; +import { MentionPanel } from '../mention-input/mention-panel'; +import { ExtendedModelOption, MentionSelect } from '../mention-input/mention-select'; +import { MENTION_KEYWORD, MentionInputProps, MentionItem, MentionState, MentionType } from '../mention-input/types'; +import { PermissionDialogWidget } from '../permission-dialog-widget'; + +import styles from './mention-input.module.less'; +import { ModeOption } from './types'; + +export const WHITE_SPACE_TEXT = ' '; + +export const MentionInput: React.FC< + MentionInputProps & { + defaultInput?: string; + onDefaultInputConsumed?: () => void; + onModeChange?: (modeId: string) => void; + onAgentChange?: (agentId: string) => void; + modeOptions?: ModeOption[]; + currentMode?: string; + slashCommands?: Array<{ nameWithSlash: string; icon?: string; name?: string; description?: string }>; + } +> = ({ + mentionItems = [], + onSend, + onStop, + loading = false, + mentionKeyword = MENTION_KEYWORD, + onSelectionChange, + onImageUpload, + onSlashSelect, + labelService, + workspaceService, + placeholder = 'Ask anything, @ to mention', + footerConfig = { + buttons: [], + showModelSelector: false, + }, + contextService, + defaultInput, + onDefaultInputConsumed, + onModeChange, + modeOptions, + currentMode, + slashCommands = [], +}) => { + const editorRef = React.useRef(null); + const mentionPanelContainerRef = React.useRef(null); + const [mentionState, setMentionState] = React.useState({ + active: false, + startPos: null, + filter: '', + position: { top: 0, left: 0 }, + activeIndex: 0, + level: 0, // 0: 一级菜单, 1: 二级菜单 + parentType: null, // 二级菜单的父类型 + secondLevelFilter: '', // 二级菜单的筛选文本 + inlineSearchActive: false, // 是否在输入框中进行二级搜索 + inlineSearchStartPos: null, // 内联搜索的起始位置 + loading: false, // 添加加载状态 + trigger: '@', + }); + + // 添加模型选择状态 + const [selectedModel, setSelectedModel] = React.useState(footerConfig.defaultModel || ''); + + // 添加 Mode 选择状态,从 currentMode prop 或首个 modeOption 初始化 + const [selectedMode, setSelectedMode] = React.useState( + currentMode || (modeOptions && modeOptions.length > 0 ? modeOptions[0].id : ''), + ); + + // 添加缓存状态,用于存储二级菜单项 + const [secondLevelCache, setSecondLevelCache] = React.useState>({}); + + // 添加历史记录状态 + const [history, setHistory] = React.useState([]); + const [historyIndex, setHistoryIndex] = React.useState(-1); + const [currentInput, setCurrentInput] = React.useState(''); + const [isNavigatingHistory, setIsNavigatingHistory] = React.useState(false); + const [attachedFiles, setAttachedFiles] = React.useState<{ + files: FileContext[]; + folders: FileContext[]; + rules: ProjectRule[]; + }>({ + files: [], + folders: [], + rules: [], + }); + + // 权限弹窗服务 + const permissionDialogManager = useInjectable(PermissionDialogManager); + const footerRegistry = useInjectable(ChatInputFooterRegistryToken); + const [footerItems, setFooterItems] = React.useState([]); + const [optionsBottomPosition, setOptionsBottomPosition] = React.useState(0); + + // 添加用于跟踪 mention_tag 的状态 + const prevMentionTagsRef = React.useRef< + Array<{ + id: string; + type: string; + contextId: string; + }> + >([]); + + const getCurrentItems = (): MentionItem[] => { + if (mentionState.level === 0) { + return mentionItems; + } else if (mentionState.parentType) { + // 如果正在加载,返回缓存的项目 + if (mentionState.loading) { + return secondLevelCache[mentionState.parentType] || []; + } + + // 返回缓存的项目 + return secondLevelCache[mentionState.parentType] || []; + } + return []; + }; + + const getSlashItems = (): MentionItem[] => { + const filterText = mentionState.filter.substring(1).toLowerCase(); + return slashCommands + .filter((cmd) => cmd.nameWithSlash.toLowerCase().includes(filterText)) + .map((cmd) => ({ + id: cmd.nameWithSlash, + type: 'slash', + text: cmd.nameWithSlash, + description: cmd.description, + icon: cmd.icon, + })); + }; + + const useDebounce = (value: T, delay: number): T => { + const [debouncedValue, setDebouncedValue] = React.useState(value); + + React.useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; + }; + + const debouncedSecondLevelFilter = useDebounce(mentionState.secondLevelFilter, 300); + + React.useEffect(() => { + setSelectedModel(footerConfig.defaultModel || ''); + }, [footerConfig.defaultModel]); + + // 外部受控模式:当 footerConfig.currentMode 变化时(如 ACP Mention 切换通知),同步更新选择器 + React.useEffect(() => { + if (currentMode) { + setSelectedMode(currentMode); + } + }, [currentMode]); + + // 当 currentMode 或 modeOptions 变化时(如 mentionModes 异步加载完成),更新 selectedMode + React.useEffect(() => { + if (currentMode) { + setSelectedMode(currentMode); + } else if (modeOptions && modeOptions.length > 0 && !selectedMode) { + setSelectedMode(modeOptions[0].id); + } + }, [currentMode, modeOptions]); + + // 当 defaultInput 变化时,填充输入框并将光标置于末尾 + React.useEffect(() => { + if (defaultInput && editorRef.current) { + editorRef.current.textContent = defaultInput; + // 将光标放到末尾 + const range = document.createRange(); + const selection = window.getSelection(); + range.selectNodeContents(editorRef.current); + range.collapse(false); + selection?.removeAllRanges(); + selection?.addRange(range); + editorRef.current.focus(); + onDefaultInputConsumed?.(); + } + }, [defaultInput]); + + React.useEffect(() => { + if (mentionState.level === 1 && mentionState.parentType && debouncedSecondLevelFilter !== undefined) { + // 查找父级菜单项 + const parentItem = mentionItems.find((item) => item.id === mentionState.parentType); + if (!parentItem) { + return; + } + + // 设置加载状态 + setMentionState((prev) => ({ ...prev, loading: true })); + + // 异步加载 + const fetchItems = async () => { + try { + // 首先显示高优先级项目(如果有) + const items: MentionItem[] = []; + if (parentItem.getHighestLevelItems) { + const highestLevelItems = parentItem.getHighestLevelItems(); + for (const item of highestLevelItems) { + if (!items.some((i) => i.id === item.id)) { + items.push(item); + } + } + // 立即更新缓存,显示高优先级项目 + setSecondLevelCache((prev) => ({ + ...prev, + [mentionState.parentType!]: highestLevelItems, + })); + } + + // 然后异步加载更多项目 + if (parentItem.getItems) { + try { + // 获取子菜单项 + const newItems = await parentItem.getItems(debouncedSecondLevelFilter); + + // 去重合并 + const combinedItems: MentionItem[] = [...items]; + + for (const item of newItems) { + if (!combinedItems.some((i) => i.id === item.id)) { + combinedItems.push(item); + } + } + + // 更新缓存 + setSecondLevelCache((prev) => ({ + ...prev, + [mentionState.parentType!]: combinedItems, + })); + } catch (error) { + // 如果异步加载失败,至少保留高优先级项目 + setMentionState((prev) => ({ ...prev, loading: false })); + } + } + + // 最后清除加载状态 + setMentionState((prev) => ({ ...prev, loading: false })); + } catch (error) { + setMentionState((prev) => ({ ...prev, loading: false })); + } + }; + + fetchItems(); + } + }, [debouncedSecondLevelFilter, mentionState.level, mentionState.parentType]); + + React.useEffect(() => { + const disposable = contextService?.onDidContextFilesChangeEvent(({ attached, attachedFolders, attachedRules }) => { + setAttachedFiles({ files: attached, folders: attachedFolders, rules: attachedRules }); + }); + + return () => { + disposable?.dispose(); + }; + }, []); + + React.useEffect(() => { + setFooterItems(footerRegistry.getItems()); + const disposable = footerRegistry.onDidChange(() => { + setFooterItems(footerRegistry.getItems()); + }); + return () => { + disposable.dispose(); + }; + }, [footerRegistry]); + + React.useEffect(() => { + const handleInsertSlash = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (!editorRef.current || !detail?.nameWithSlash) { + return; + } + + const selection = window.getSelection(); + if (!selection || !selection.rangeCount) { + return; + } + + const range = selection.getRangeAt(0); + range.deleteContents(); + + // 创建 slash 标签 + const slashTag = document.createElement('span'); + slashTag.className = styles.slash_command_tag; + slashTag.dataset.command = detail.nameWithSlash; + slashTag.contentEditable = 'false'; + slashTag.textContent = detail.nameWithSlash; + + range.insertNode(slashTag); + + // 在标签后插入空格 + const spaceNode = document.createTextNode(' '); + const newRange = document.createRange(); + newRange.setStartAfter(slashTag); + newRange.insertNode(spaceNode); + newRange.setStartAfter(spaceNode); + newRange.collapse(true); + selection.removeAllRanges(); + selection.addRange(newRange); + + editorRef.current.focus(); + }; + + window.addEventListener('opensumi-chat-input-insert-slash', handleInsertSlash); + return () => { + window.removeEventListener('opensumi-chat-input-insert-slash', handleInsertSlash); + }; + }, []); + + // 监听外部打开 slash panel 的事件(如 footer "/" 按钮点击) + React.useEffect(() => { + const handleOpenSlashPanel = () => { + if (!editorRef.current) { + return; + } + + // 确保编辑器聚焦 + editorRef.current.focus(); + + // 在光标位置插入 "/" + const selection = window.getSelection(); + if (!selection || !selection.rangeCount) { + return; + } + + const range = selection.getRangeAt(0); + const textNode = document.createTextNode('/'); + range.insertNode(textNode); + + // 将光标放到 "/" 之后 + const newRange = document.createRange(); + newRange.setStartAfter(textNode); + newRange.collapse(true); + selection.removeAllRanges(); + selection.addRange(newRange); + + // 打开 slash panel + const cursorPos = getCursorPosition(editorRef.current); + setMentionState({ + active: true, + startPos: cursorPos, + filter: '/', + position: { top: 0, left: 0 }, + activeIndex: 0, + level: 0, + parentType: null, + secondLevelFilter: '', + inlineSearchActive: false, + inlineSearchStartPos: null, + loading: false, + trigger: '/', + }); + }; + + window.addEventListener('opensumi-chat-input-open-slash-panel', handleOpenSlashPanel); + return () => { + window.removeEventListener('opensumi-chat-input-open-slash-panel', handleOpenSlashPanel); + }; + }, []); + + // 获取光标位置 + const getCursorPosition = (element: HTMLElement): number => { + const selection = window.getSelection(); + if (!selection || !selection.rangeCount) { + return 0; + } + + const range = selection.getRangeAt(0); + const preCaretRange = range.cloneRange(); + preCaretRange.selectNodeContents(element); + preCaretRange.setEnd(range.endContainer, range.endOffset); + return preCaretRange.toString().length; + }; + + const handleInput = () => { + // 如果用户开始输入,退出历史导航模式 + if (isNavigatingHistory) { + setIsNavigatingHistory(false); + setHistoryIndex(-1); + } + + // 检测 mention_tag 的删除 + if (editorRef.current) { + const currentMentionTags = Array.from(editorRef.current.querySelectorAll(`.${styles.mention_tag}`)).map( + (tag) => ({ + id: tag.getAttribute('data-id') || '', + type: tag.getAttribute('data-type') || '', + contextId: tag.getAttribute('data-context-id') || '', + }), + ); + + // 找出被删除的 mention_tag + const deletedTags = prevMentionTagsRef.current.filter( + (prevTag) => + !currentMentionTags.some( + (currentTag) => + currentTag.id === prevTag.id && + currentTag.type === prevTag.type && + currentTag.contextId === prevTag.contextId, + ), + ); + + // 清理被删除的 mention_tag 对应的 context + deletedTags.forEach((deletedTag) => { + if (deletedTag.contextId) { + const uri = new URI(deletedTag.contextId); + if (deletedTag.type === MentionType.FILE) { + removeContext(MentionType.FILE, uri); + } else if (deletedTag.type === MentionType.FOLDER) { + removeContext(MentionType.FOLDER, uri); + } else if (deletedTag.type === MentionType.RULE) { + removeContext(MentionType.RULE, uri); + } + } + }); + + // 更新 mention_tag 状态 + prevMentionTagsRef.current = currentMentionTags; + } + + const selection = window.getSelection(); + if (!selection || !selection.rangeCount || !editorRef.current) { + return; + } + + const text = editorRef.current.textContent || ''; + const cursorPos = getCursorPosition(editorRef.current); + + // 判断是否刚输入了 @ + if (text[cursorPos - 1] === mentionKeyword && !mentionState.active && !mentionState.inlineSearchActive) { + setMentionState({ + active: true, + startPos: cursorPos, + filter: mentionKeyword, + position: { top: 0, left: 0 }, // 固定位置,不再需要动态计算 + activeIndex: 0, + level: 0, + parentType: null, + secondLevelFilter: '', + inlineSearchActive: false, + inlineSearchStartPos: null, + loading: false, + trigger: '@', + }); + } + + // 判断是否刚输入了 / + if ( + text[cursorPos - 1] === '/' && + !mentionState.active && + !mentionState.inlineSearchActive && + slashCommands.length > 0 + ) { + setMentionState({ + active: true, + startPos: cursorPos, + filter: '/', + position: { top: 0, left: 0 }, + activeIndex: 0, + level: 0, + parentType: null, + secondLevelFilter: '', + inlineSearchActive: false, + inlineSearchStartPos: null, + loading: false, + trigger: '/', + }); + } + + // 如果已激活提及面板且在一级菜单,更新过滤内容 + if (mentionState.active && mentionState.level === 0 && mentionState.startPos !== null) { + if (cursorPos < mentionState.startPos) { + // 如果光标移到了 @ 之前,关闭面板 + setMentionState((prev) => ({ ...prev, active: false })); + } else { + const newFilter = text.substring(mentionState.startPos - 1, cursorPos); + setMentionState((prev) => ({ + ...prev, + filter: newFilter, + activeIndex: 0, + })); + } + } + + // 如果在输入框中进行二级搜索 + if (mentionState.inlineSearchActive && mentionState.inlineSearchStartPos !== null && mentionState.parentType) { + // 获取父级类型 + const parentItem = mentionItems.find((i) => i.id === mentionState.parentType); + if (!parentItem) { + return; + } + + // 检查光标是否在 @type: 之后 + const typePrefix = `@${parentItem.type}:`; + const prefixPos = mentionState.inlineSearchStartPos - typePrefix.length; + + if (prefixPos >= 0 && cursorPos > prefixPos + typePrefix.length) { + // 提取搜索文本 + const searchText = text.substring(prefixPos + typePrefix.length, cursorPos); + + // 只有当搜索文本变化时才更新状态 + if (searchText !== mentionState.secondLevelFilter) { + setMentionState((prev) => ({ + ...prev, + secondLevelFilter: searchText, + active: true, + activeIndex: 0, + })); + } + } else if (cursorPos <= prefixPos) { + // 如果光标移到了 @type: 之前,关闭内联搜索 + setMentionState((prev) => ({ + ...prev, + inlineSearchActive: false, + active: false, + })); + } + } + + // 检查输入框高度,如果超过最大高度则添加滚动条 + if (editorRef.current) { + const editorHeight = editorRef.current.scrollHeight; + if (editorHeight >= 120) { + editorRef.current.style.overflowY = 'auto'; + } else { + editorRef.current.style.overflowY = 'hidden'; + } + } + + // 检查编辑器内容,处理只有
标签的情况 + if (editorRef.current) { + const content = editorRef.current.innerHTML; + // 如果内容为空或只有
标签 + if (content === '' || content === '
' || content === '
') { + // 清空编辑器内容 + editorRef.current.innerHTML = ''; + } + } + }; + + // 处理键盘事件 + const handleKeyDown = (e: React.KeyboardEvent) => { + // 如果按下ESC键且提及面板处于活动状态或内联搜索处于活动状态 + if (e.key === 'Escape' && (mentionState.active || mentionState.inlineSearchActive)) { + // 如果是 slash command 面板,直接关闭 + if (mentionState.trigger === '/') { + setMentionState((prev) => ({ + ...prev, + active: false, + })); + e.preventDefault(); + return; + } + // 如果在二级菜单,返回一级菜单 + if (mentionState.level > 0) { + setMentionState((prev) => ({ + ...prev, + level: 0, + activeIndex: 0, + secondLevelFilter: '', + inlineSearchActive: false, + })); + } else { + // 如果在一级菜单,完全关闭面板 + setMentionState((prev) => ({ + ...prev, + active: false, + inlineSearchActive: false, + })); + } + e.preventDefault(); + return; + } + + // 当输入框为空时,处理删除键 (Backspace) 或 Delete 键来删除上下文内容 + if ( + (e.key === 'Backspace' || e.key === 'Delete') && + editorRef.current && + (!editorRef.current.textContent || editorRef.current.textContent.trim() === '') + ) { + contextService?.cleanFileContext(); + } + + // 添加对 @ 键的监听,支持在任意位置触发菜单 + if (e.key === MENTION_KEYWORD && !mentionState.active && !mentionState.inlineSearchActive && editorRef.current) { + const cursorPos = getCursorPosition(editorRef.current); + + // 立即设置菜单状态,不等待 handleInput + setMentionState({ + active: true, + startPos: cursorPos + 1, // +1 因为 @ 还没有被插入 + filter: mentionKeyword, + position: { top: 0, left: 0 }, // 固定位置 + activeIndex: 0, + level: 0, + parentType: null, + secondLevelFilter: '', + inlineSearchActive: false, + inlineSearchStartPos: null, + loading: false, + trigger: '@', + }); + } + + // 添加对 / 键的监听,支持在任意位置触发 slash command 菜单 + if ( + e.key === '/' && + !mentionState.active && + !mentionState.inlineSearchActive && + editorRef.current && + slashCommands.length > 0 + ) { + const cursorPos = getCursorPosition(editorRef.current); + + setMentionState({ + active: true, + startPos: cursorPos + 1, + filter: '/', + position: { top: 0, left: 0 }, + activeIndex: 0, + level: 0, + parentType: null, + secondLevelFilter: '', + inlineSearchActive: false, + inlineSearchStartPos: null, + loading: false, + trigger: '/', + }); + } + + // 处理上下方向键导航历史记录 + if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && !e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey) { + // 只有在非提及面板激活状态下才处理历史导航 + if (!mentionState.active && !mentionState.inlineSearchActive && editorRef.current && history.length > 0) { + const currentContent = editorRef.current.innerHTML; + + // 检查是否应该触发历史导航 + const shouldTriggerHistory = + // 当前内容为空 + !currentContent || + currentContent === '
' || + // 或者当前内容与历史记录中的某一项匹配(正在浏览历史) + (isNavigatingHistory && historyIndex >= 0 && history[history.length - 1 - historyIndex] === currentContent); + + if (shouldTriggerHistory) { + e.preventDefault(); + + // 如果是第一次按上下键,保存当前输入 + if (!isNavigatingHistory) { + setCurrentInput(currentContent); + setIsNavigatingHistory(true); + } + + // 计算新的历史索引 + let newIndex = historyIndex; + if (e.key === 'ArrowUp') { + // 向上导航到较早的历史记录 + newIndex = Math.min(history.length - 1, historyIndex + 1); + } else { + // 向下导航到较新的历史记录 + newIndex = Math.max(-1, historyIndex - 1); + } + + setHistoryIndex(newIndex); + + // 更新编辑器内容 + if (newIndex === -1) { + // 恢复到当前输入 + editorRef.current.innerHTML = currentInput; + } else { + // 显示历史记录 + editorRef.current.innerHTML = history[history.length - 1 - newIndex]; + } + + // 将光标移到末尾 + const range = document.createRange(); + range.selectNodeContents(editorRef.current); + range.collapse(false); + const selection = window.getSelection(); + if (selection) { + selection.removeAllRanges(); + selection.addRange(range); + } + + return; + } + } + } else if (isNavigatingHistory && e.key !== 'ArrowUp' && e.key !== 'ArrowDown') { + // 如果用户在浏览历史记录后开始输入其他内容,退出历史导航模式 + setIsNavigatingHistory(false); + setHistoryIndex(-1); + } + + // 添加对 Enter 键的处理,只有在按下 Shift+Enter 时才允许换行 + if (e.key === 'Enter') { + // 检查是否是输入法的回车键 + if (e.nativeEvent.isComposing) { + return; // 如果是输入法组合输入过程中的回车,不做任何处理 + } + + if (!e.shiftKey) { + e.preventDefault(); + if (!mentionState.active) { + handleSend(); + return; + } + } + } + + // 如果提及面板未激活,不处理其他键盘事件 + if (!mentionState.active) { + return; + } + + // 获取当前过滤后的项目 + let filteredItems = mentionState.trigger === '/' ? getSlashItems() : getCurrentItems(); + + // 一级菜单过滤(仅对 mention 面板生效) + if ( + mentionState.level === 0 && + mentionState.filter && + mentionState.filter.length > 1 && + mentionState.trigger !== '/' + ) { + const searchText = mentionState.filter.substring(1).toLowerCase(); + filteredItems = filteredItems.filter((item) => item.text.toLowerCase().includes(searchText)); + } + + if (filteredItems.length === 0) { + return; + } + + if (e.key === 'ArrowDown') { + // 向下导航 + setMentionState((prev) => ({ + ...prev, + activeIndex: (prev.activeIndex + 1) % filteredItems.length, + })); + e.preventDefault(); + } else if (e.key === 'ArrowUp') { + // 向上导航 + setMentionState((prev) => ({ + ...prev, + activeIndex: (prev.activeIndex - 1 + filteredItems.length) % filteredItems.length, + })); + e.preventDefault(); + } else if (e.key === 'Enter' || e.key === 'Tab') { + // 确认选择 + if (filteredItems.length > 0) { + handleSelectItem(filteredItems[mentionState.activeIndex]); + e.preventDefault(); + } + } + }; + + // 添加对输入法事件的处理 + const handleCompositionEnd = () => { + // 输入法输入完成后的处理 + // 这里可以添加额外的逻辑,如果需要的话 + }; + + const handlePaste = async (e: React.ClipboardEvent) => { + const items = e.clipboardData.items; + + // 先收集所有图片文件 + const imageFiles: File[] = []; + // eslint-disable-next-line @typescript-eslint/prefer-for-of + for (let i = 0; i < items.length; i++) { + if (items[i].kind === MentionType.FILE && items[i].type.startsWith('image/')) { + const file = items[i].getAsFile(); + if (file) { + imageFiles.push(file); + } + } + } + + e.preventDefault(); + + // 处理所有收集到的图片 + if (imageFiles.length > 0 && onImageUpload) { + await onImageUpload(imageFiles); + return; + } + + const text = e.clipboardData.getData('text/plain'); + + // 处理文本,保留换行和缩进 + const processedText = text + .replace(/\t/g, ' ') + .replace(/\n\s*\n/g, '\n\n') + .replace(/[ \t]+$/gm, ''); + + const selection = window.getSelection(); + if (!selection || !selection.rangeCount) { + return; + } + + const range = selection.getRangeAt(0); + range.deleteContents(); + + // 将处理后的文本按行分割 + const lines = processedText.split('\n'); + const fragment = document.createDocumentFragment(); + + lines.forEach((line, index) => { + // 处理行首空格,将每个空格转换为   + const processedLine = line.replace(/^[ ]+/g, (match) => { + const span = document.createElement('span'); + span.innerHTML = ' '.repeat(match.length); + return span.innerHTML; + }); + + // 创建一个临时容器来保持 HTML 内容 + const container = document.createElement('span'); + container.innerHTML = processedLine; + + // 将容器的内容添加到文档片段 + while (container.firstChild) { + fragment.appendChild(container.firstChild); + } + + // 如果不是最后一行,添加换行符 + if (index < lines.length - 1) { + fragment.appendChild(document.createElement('br')); + } + }); + + // 插入处理后的内容 + const lastNode = fragment.lastChild; + range.insertNode(fragment); + + // 将光标移动到插入内容的末尾 + if (lastNode && lastNode.parentNode) { + const newRange = document.createRange(); + newRange.setStartAfter(lastNode); + selection.removeAllRanges(); + selection.addRange(newRange); + } + + // 触发 input 事件以更新状态 + handleInput(); + }; + + // 初始化编辑器 + React.useEffect(() => { + if (editorRef.current) { + // 设置初始占位符 + if (placeholder && !editorRef.current.textContent) { + editorRef.current.setAttribute('data-placeholder', placeholder); + } + + // 初始化 mention_tag 状态 + const initialMentionTags = Array.from(editorRef.current.querySelectorAll(`.${styles.mention_tag}`)).map( + (tag) => ({ + id: tag.getAttribute('data-id') || '', + type: tag.getAttribute('data-type') || '', + contextId: tag.getAttribute('data-context-id') || '', + }), + ); + prevMentionTagsRef.current = initialMentionTags; + } + }, [placeholder]); + + // 处理点击事件 + const handleDocumentClick = (e: MouseEvent) => { + if (mentionState.active && !mentionPanelContainerRef.current?.contains(e.target as Node)) { + setMentionState((prev) => ({ + ...prev, + active: false, + inlineSearchActive: false, + })); + } + }; + + // 添加和移除全局点击事件监听器 + React.useEffect(() => { + document.addEventListener('click', handleDocumentClick, true); + return () => { + document.removeEventListener('click', handleDocumentClick, true); + }; + }, [mentionState.active]); + + // 选择提及项目 + const handleSelectItem = (item: MentionItem, isTriggerByClick = true) => { + if (!editorRef.current) { + return; + } + + // 处理 slash command 选择 + if (mentionState.trigger === '/') { + // 仅删除 / 和过滤文本,实际命令文本由事件监听器插入 + let textNode; + let startOffset; + let endOffset; + + const walker = document.createTreeWalker(editorRef.current, NodeFilter.SHOW_TEXT); + let charCount = 0; + let node; + + while ((node = walker.nextNode())) { + const nodeLength = node.textContent?.length || 0; + + if ( + mentionState.startPos !== null && + mentionState.startPos - 1 >= charCount && + mentionState.startPos - 1 < charCount + nodeLength + ) { + textNode = node; + startOffset = mentionState.startPos - 1 - charCount; + const cursorPos = isTriggerByClick + ? mentionState.startPos + mentionState.filter.length - 1 + : getCursorPosition(editorRef.current); + endOffset = Math.min(cursorPos - charCount, nodeLength); + break; + } + + charCount += nodeLength; + } + + if (textNode) { + const tempRange = document.createRange(); + tempRange.setStart(textNode, startOffset); + tempRange.setEnd(textNode, endOffset); + tempRange.deleteContents(); + } + + setMentionState((prev) => ({ ...prev, active: false })); + editorRef.current.focus(); + + onSlashSelect?.(item.text); + // 通过事件通知父组件设置 slash command(事件监听器会负责插入命令文本) + window.dispatchEvent( + new CustomEvent('opensumi-chat-input-insert-slash', { + detail: { nameWithSlash: item.text }, + }), + ); + return; + } + + // 如果项目有子菜单,进入二级菜单 + if (item.getItems) { + const selection = window.getSelection(); + if (!selection || !selection.rangeCount) { + return; + } + + // 如果是从一级菜单选择了带子菜单的项目 + if (mentionState.level === 0 && mentionState.startPos !== null) { + // 更安全地管理文本替换 + let textNode; + let startOffset; + let endOffset; + + // 找到包含 @ 符号的文本节点 + const walker = document.createTreeWalker(editorRef.current, NodeFilter.SHOW_TEXT); + let charCount = 0; + let node; + + while ((node = walker.nextNode())) { + const nodeLength = node.textContent?.length || 0; + + // 检查 @ 符号是否在这个节点中 + if (mentionState.startPos - 1 >= charCount && mentionState.startPos - 1 < charCount + nodeLength) { + textNode = node; + startOffset = mentionState.startPos - 1 - charCount; + + // 确保不会超出节点范围 + const cursorPos = isTriggerByClick + ? mentionState.startPos + mentionState.filter.length - 1 + : getCursorPosition(editorRef.current); + endOffset = Math.min(cursorPos - charCount, nodeLength); + break; + } + + charCount += nodeLength; + } + + if (textNode) { + // 创建一个新的范围来替换文本 + const tempRange = document.createRange(); + tempRange.setStart(textNode, startOffset); + tempRange.setEnd(textNode, endOffset); + + // 替换为 @type: + tempRange.deleteContents(); + const typePrefix = document.createTextNode(`${mentionKeyword}${item.type}:`); + tempRange.insertNode(typePrefix); + + // 将光标移到 @type: 后面 + const newRange = document.createRange(); + newRange.setStartAfter(typePrefix); + newRange.setEndAfter(typePrefix); + selection.removeAllRanges(); + selection.addRange(newRange); + // 激活内联搜索模式 + setMentionState((prev) => ({ + ...prev, + active: true, + level: 1, + parentType: item.id, + inlineSearchActive: true, + inlineSearchStartPos: getCursorPosition(editorRef.current as HTMLElement), + secondLevelFilter: '', + activeIndex: 0, + })); + editorRef.current.focus(); + return; + } + } + + return; + } + + const selection = window.getSelection(); + if (!selection || !selection.rangeCount) { + return; + } + + // 如果是在内联搜索模式下选择项目 + if (mentionState.inlineSearchActive && mentionState.parentType && mentionState.inlineSearchStartPos !== null) { + // 找到 @type: 的位置 + const parentItem = mentionItems.find((i) => i.id === mentionState.parentType); + if (!parentItem) { + return; + } + + const typePrefix = `${mentionKeyword}${parentItem.type}:`; + const prefixPos = mentionState.inlineSearchStartPos - typePrefix.length; + + if (prefixPos >= 0) { + // 创建一个带样式的提及标签 + const mentionTag = document.createElement('span'); + mentionTag.className = styles.mention_tag; + mentionTag.dataset.id = item.id; + mentionTag.dataset.type = item.type; + mentionTag.dataset.contextId = item.contextId || ''; + mentionTag.contentEditable = 'false'; + + if (item.type === MentionType.FILE || item.type === MentionType.FOLDER) { + // 创建图标容器 + const iconSpan = document.createElement('span'); + iconSpan.className = cls( + styles.mention_icon, + item.type === MentionType.FILE ? labelService?.getIcon(new URI(item.text)) : getIcon('folder'), + ); + mentionTag.appendChild(iconSpan); + if (item.type === MentionType.FOLDER) { + contextService?.addFolderToContext(new URI(item.contextId), true); + } else { + contextService?.addFileToContext(new URI(item.contextId), undefined, true); + } + } else if (item.type === MentionType.CODE) { + const iconSpan = document.createElement('span'); + iconSpan.className = cls(styles.mention_icon, item.kind && getSymbolIcon(item.kind) + ' outline-icon'); + mentionTag.appendChild(iconSpan); + if (item.symbol) { + contextService?.addFileToContext( + new URI(item.contextId), + [item.symbol.range.startLineNumber, item.symbol.range.endLineNumber], + true, + ); + } + } else if (item.type === MentionType.RULE) { + const iconSpan = document.createElement('span'); + iconSpan.className = cls(styles.mention_icon, getIcon('rules')); + mentionTag.appendChild(iconSpan); + contextService?.addRuleToContext(new URI(item.contextId), true); + } + const workspace = workspaceService?.workspace; + let relativePath = item.text; + if (workspace && item.contextId) { + relativePath = item.contextId.replace(new URI(workspace.uri).codeUri.fsPath, '').slice(1); + } + // 创建文本内容容器 + const textSpan = document.createTextNode(relativePath); + mentionTag.appendChild(textSpan); + + // 创建一个范围从 @type: 开始到当前光标 + const tempRange = document.createRange(); + + // 定位到 @type: 的位置 + let charIndex = 0; + let foundStart = false; + const textNodes: Array<{ node: Node; start: number; end: number }> = []; + + function findPosition(node: Node) { + if (node.nodeType === 3) { + // 文本节点 + textNodes.push({ + node, + start: charIndex, + end: charIndex + node.textContent!.length, + }); + charIndex += node.textContent!.length; + } else if (node.nodeType === 1) { + // 元素节点 + const children = node.childNodes || []; + for (const child of Array.from(children)) { + findPosition(child); + } + } + } + + findPosition(editorRef.current); + + for (const textNode of textNodes) { + if (prefixPos >= textNode.start && prefixPos <= textNode.end) { + const startOffset = prefixPos - textNode.start; + tempRange.setStart(textNode.node, startOffset); + foundStart = true; + } + + if (foundStart) { + // 如果是点击触发,使用过滤文本的长度来确定结束位置 + const cursorPos = isTriggerByClick + ? prefixPos + typePrefix.length + mentionState.secondLevelFilter.length + : getCursorPosition(editorRef.current); + + if (cursorPos >= textNode.start && cursorPos <= textNode.end) { + const endOffset = cursorPos - textNode.start; + tempRange.setEnd(textNode.node, endOffset); + break; + } + } + } + + if (foundStart) { + tempRange.deleteContents(); + tempRange.insertNode(mentionTag); + + // 将光标移到提及标签后面 + const newRange = document.createRange(); + newRange.setStartAfter(mentionTag); + newRange.setEndAfter(mentionTag); + selection.removeAllRanges(); + selection.addRange(newRange); + + // 添加一个空格,增加间隔 + const spaceNode = document.createTextNode(' '); // 使用不间断空格 + newRange.insertNode(spaceNode); + newRange.setStartAfter(spaceNode); + newRange.setEndAfter(spaceNode); + selection.removeAllRanges(); + selection.addRange(newRange); + } + + setMentionState((prev) => ({ + ...prev, + active: false, + inlineSearchActive: false, + })); + editorRef.current.focus(); + return; + } + } + + // 原有的处理逻辑(用于非内联搜索情况) + // 创建一个带样式的提及标签 + const mentionTag = document.createElement('span'); + mentionTag.className = styles.mention_tag; + mentionTag.dataset.id = item.id; + mentionTag.dataset.type = item.type; + mentionTag.dataset.contextId = item.contextId || ''; + mentionTag.contentEditable = 'false'; + + // 为 file 和 folder 类型添加图标 + if (item.type === MentionType.FILE || item.type === 'folder') { + // 创建图标容器 + const iconSpan = document.createElement('span'); + iconSpan.className = cls( + styles.mention_icon, + item.type === MentionType.FILE ? labelService?.getIcon(new URI(item.text)) : getIcon('folder'), + ); + mentionTag.appendChild(iconSpan); + } + const workspace = workspaceService?.workspace; + let relativePath = item.text; + if (workspace && item.contextId) { + relativePath = item.contextId.replace(new URI(workspace.uri).codeUri.fsPath, '').slice(1); + } + // 创建文本内容容器 + const textSpan = document.createTextNode(relativePath); + mentionTag.appendChild(textSpan); + + // 定位到 @ 符号的位置 + let charIndex = 0; + let foundStart = false; + const textNodes: Array<{ node: Node; start: number; end: number }> = []; + + function findPosition(node: Node) { + if (node.nodeType === 3) { + // 文本节点 + textNodes.push({ + node, + start: charIndex, + end: charIndex + node.textContent!.length, + }); + charIndex += node.textContent!.length; + } else if (node.nodeType === 1) { + // 元素节点 + const children = node.childNodes; + for (const child of Array.from(children)) { + findPosition(child); + } + } + } + + findPosition(editorRef.current); + + const tempRange = document.createRange(); + + if (mentionState.startPos !== null) { + for (const textNode of textNodes) { + if (mentionState.startPos - 1 >= textNode.start && mentionState.startPos - 1 <= textNode.end) { + const startOffset = mentionState.startPos - 1 - textNode.start; + tempRange.setStart(textNode.node, startOffset); + foundStart = true; + } + + if (foundStart) { + // 如果是点击触发,使用过滤文本的长度来确定结束位置 + const cursorPos = isTriggerByClick + ? mentionState.startPos + mentionState.filter.length - 1 + : getCursorPosition(editorRef.current); + + if (cursorPos >= textNode.start && cursorPos <= textNode.end) { + const endOffset = cursorPos - textNode.start; + tempRange.setEnd(textNode.node, endOffset); + break; + } + } + } + } + + if (foundStart) { + tempRange.deleteContents(); + tempRange.insertNode(mentionTag); + + // 将光标移到提及标签后面 + const newRange = document.createRange(); + newRange.setStartAfter(mentionTag); + newRange.setEndAfter(mentionTag); + selection.removeAllRanges(); + selection.addRange(newRange); + + // 添加一个空格,增加间隔 + const spaceNode = document.createTextNode(' '); // 使用不间断空格 + newRange.insertNode(spaceNode); + newRange.setStartAfter(spaceNode); + newRange.setEndAfter(spaceNode); + selection.removeAllRanges(); + selection.addRange(newRange); + } + setMentionState((prev) => ({ ...prev, active: false })); + editorRef.current.focus(); + }; + + // 处理模型选择变更 + const handleModelChange = React.useCallback( + (value: string) => { + setSelectedModel(value); + onSelectionChange?.(value); + }, + [selectedModel, onSelectionChange], + ); + + // 处理 Mode 选择变更 + const handleModeChange = React.useCallback( + (value: string) => { + setSelectedMode(value); + onModeChange?.(value); + }, + [onModeChange], + ); + + // 修改 handleSend 函数 + const handleSend = () => { + if (!editorRef.current) { + return; + } + + // 获取原始HTML内容 + const rawContent = editorRef.current.innerHTML; + if (!rawContent) { + return; + } + + // 创建一个临时元素来处理内容 + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = rawContent; + + // 查找所有提及标签并替换为对应的contextId + const mentionTags = tempDiv.querySelectorAll(`.${styles.mention_tag}`); + mentionTags.forEach((tag) => { + const contextId = tag.getAttribute('data-context-id'); + if (contextId) { + // 替换为contextId + const replacement = document.createTextNode( + `{{${mentionKeyword}${tag.getAttribute('data-type')}:${contextId}}}`, + ); + // 替换内容 + tag.parentNode?.replaceChild(replacement, tag); + } + }); + + // 查找所有 slash 命令标签并替换为纯文本 + const slashTags = tempDiv.querySelectorAll('span[data-command]'); + slashTags.forEach((tag) => { + const replacement = document.createTextNode(tag.getAttribute('data-command') || tag.textContent || ''); + tag.parentNode?.replaceChild(replacement, tag); + }); + + // 获取处理后的内容 + let processedContent = tempDiv.innerHTML; + processedContent = processedContent.trim().replaceAll(WHITE_SPACE_TEXT, ' '); + // 添加到历史记录 + if (rawContent) { + setHistory((prev) => [...prev, rawContent]); + // 重置历史导航状态 + setHistoryIndex(-1); + setIsNavigatingHistory(false); + } + if (onSend) { + // 传递当前选择的模型和其他配置信息 + onSend(processedContent, { + model: selectedModel, + ...footerConfig, + }); + } + + editorRef.current.innerHTML = ''; + + // 重置编辑器高度和滚动条 + if (editorRef.current) { + editorRef.current.style.overflowY = 'hidden'; + editorRef.current.style.height = 'auto'; + } + }; + + const handleClearContext = React.useCallback(() => { + contextService?.cleanFileContext(); + }, [contextService]); + + const handleTitleClick = React.useCallback(() => { + if (!editorRef.current) { + return; + } + + // 聚焦输入框 + editorRef.current.focus(); + + // 获取当前光标位置 + const selection = window.getSelection(); + if (!selection) { + return; + } + + // 在当前位置插入 @ + const range = document.createRange(); + + // 如果编辑器为空,直接插入 + if (!editorRef.current.textContent || editorRef.current.textContent.trim() === '') { + editorRef.current.innerHTML = '@'; + range.setStart(editorRef.current.firstChild || editorRef.current, 1); + range.setEnd(editorRef.current.firstChild || editorRef.current, 1); + } else { + // 当输入框有内容时,总是在末尾插入 @ 符号 + const textNode = document.createTextNode(' @'); + + // 移动到编辑器末尾 + range.selectNodeContents(editorRef.current); + range.collapse(false); // 移动到末尾 + + // 在末尾插入空格和 @ 符号 + range.insertNode(textNode); + range.setStartAfter(textNode); + range.setEndAfter(textNode); + } + + // 设置新的光标位置 + selection.removeAllRanges(); + selection.addRange(range); + + // 获取插入后的光标位置 + const newCursorPos = getCursorPosition(editorRef.current); + + // 激活菜单状态 + setMentionState({ + active: true, + startPos: newCursorPos, + filter: '@', + position: { top: 0, left: 0 }, + activeIndex: 0, + level: 0, + parentType: null, + secondLevelFilter: '', + inlineSearchActive: false, + inlineSearchStartPos: null, + loading: false, + trigger: '@', + }); + }, []); + + const handleStop = React.useCallback(() => { + if (onStop) { + onStop(); + } + }, [onStop]); + + // 渲染自定义按钮 + const renderButtons = React.useCallback( + (position: FooterButtonPosition) => + (footerConfig.buttons || []) + .filter((button) => button.position === position) + .map((button) => { + // Built-in @ mention trigger button + if (button.id === 'mention-trigger') { + return ( + + + + ); + } + return ( + + + + ); + }), + [footerConfig.buttons, handleTitleClick], + ); + + const hasContext = React.useMemo( + () => attachedFiles.files.length > 0 || attachedFiles.folders.length > 0 || attachedFiles.rules.length > 0, + [attachedFiles], + ); + + const renderModelSelectorTip = React.useCallback( + (children: React.ReactNode) => { + if (footerConfig.disableModelSelector) { + return ( + + {children} + + ); + } + return children; + }, + [footerConfig.disableModelSelector], + ); + + // 转换模型选项为扩展格式 + const getExtendedModelOptions = React.useMemo((): ExtendedModelOption[] => { + // 如果有扩展模型选项,直接使用 + if (footerConfig.extendedModelOptions) { + return footerConfig.extendedModelOptions.map((option) => ({ + ...option, + selected: option.value === selectedModel, + })); + } + + // 否则从基础模型选项转换 + return (footerConfig.modelOptions || []).map((option): ExtendedModelOption => { + const extendedOption: ExtendedModelOption = { + ...option, + }; + + // 设置选中状态:如果当前模型匹配选中的模型,则标记为选中 + extendedOption.selected = option.value === selectedModel; + + return extendedOption; + }); + }, [footerConfig.modelOptions, footerConfig.extendedModelOptions, selectedModel]); + + const removeContext = React.useCallback( + (type: MentionType, uri: URI) => { + if (type === MentionType.FILE) { + contextService?.removeFileFromContext(uri, true); + } else if (type === MentionType.FOLDER) { + contextService?.removeFolderFromContext(uri); + } else if (type === MentionType.RULE) { + contextService?.removeRuleFromContext(uri); + } + }, + [contextService], + ); + + const getFileNameFromPath = (path: string) => decodeURIComponent(path.split('/').pop() || 'Unknown Rule'); + + const renderContextPreview = React.useCallback( + () => ( +
+ + {!hasContext ? localize('aiNative.chat.context.title') : ''} + + {attachedFiles.files.map((file, index) => ( +
+ + removeContext(MentionType.FILE, file.uri)} + /> + {new URI(file.uri.toString()).displayName} +
+ ))} + + {attachedFiles.folders.map((folder, index) => ( +
+ + removeContext(MentionType.FOLDER, folder.uri)} + /> + {new URI(folder.uri.toString()).displayName} +
+ ))} + + {attachedFiles.rules.map((rule, index) => ( +
+ + removeContext(MentionType.RULE, new URI(rule.path))} + /> + + {getFileNameFromPath(rule.path).replace('.mdc', '')} + +
+ ))} +
+ ), + [handleClearContext, hasContext, attachedFiles, labelService, contextService, handleTitleClick, removeContext], + ); + + return ( +
+ + {mentionState.active && ( +
+ handleSelectItem(item, true)} + position={{ top: 0, left: 0 }} + filter={mentionState.level === 0 ? mentionState.filter : mentionState.secondLevelFilter} + visible={true} + level={mentionState.level} + loading={mentionState.loading} + /> +
+ )} +
+
+
+
+
+ {footerItems + .filter((item) => item.position !== FooterButtonPosition.RIGHT) + .map((item) => { + const Component = item.component; + return ; + })} + {footerConfig.showModelSelector && + renderModelSelectorTip( + , + )} + + {modeOptions && + modeOptions.length > 0 && + renderModelSelectorTip( + ({ + label: opt.name, + value: opt.id, + description: opt.description, + }))} + value={selectedMode} + onChange={handleModeChange} + className={styles.mode_selector} + size='small' + />, + )} + + {renderButtons(FooterButtonPosition.LEFT)} +
+ {renderContextPreview()} +
+ {footerItems + .filter((item) => item.position === FooterButtonPosition.RIGHT) + .map((item) => { + const Component = item.component; + return ; + })} + {renderButtons(FooterButtonPosition.RIGHT)} + + {!loading ? ( + + ) : ( + + )} + +
+
+
+ ); +}; diff --git a/packages/ai-native/src/browser/components/acp/chat-history.module.less b/packages/ai-native/src/browser/components/acp/chat-history.module.less new file mode 100644 index 0000000000..d8ef17184f --- /dev/null +++ b/packages/ai-native/src/browser/components/acp/chat-history.module.less @@ -0,0 +1,18 @@ +@import '../chat-history.module.less'; + +.chat_history_list_disabled { + pointer-events: none; + opacity: 0.5; +} + +.chat_history_header_actions_new_disabled { + pointer-events: none; + opacity: 0.5; +} + +.chat_history_loading { + display: flex; + align-items: center; + justify-content: center; + padding: 16px; +} diff --git a/packages/ai-native/src/browser/components/acp/mention-input.module.less b/packages/ai-native/src/browser/components/acp/mention-input.module.less new file mode 100644 index 0000000000..82a262ec30 --- /dev/null +++ b/packages/ai-native/src/browser/components/acp/mention-input.module.less @@ -0,0 +1,48 @@ +@import '../mention-input/mention-input.module.less'; + +.popover_icon { +} + +.mention_trigger_logo { + margin-right: 5px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 4px; + border-radius: 4px; + + &:hover { + background-color: var(--badge-background); + } +} + +.mode_selector { + margin-right: 5px; +} + +.left_control { + flex: 0 0 auto !important; +} + +.context_preview_container { + margin: 0 4px; + margin-bottom: 0; + width: auto; + flex: 1 1 auto; + background: none; + border: none; + padding: 0; + max-width: 400px; + min-width: 0; +} + +.context_preview_title { + &::before { + content: '@'; + } +} + +.slash_command_tag { + composes: mention_tag from '../mention-input/mention-input.module.less'; +} diff --git a/packages/ai-native/src/browser/components/acp/types.ts b/packages/ai-native/src/browser/components/acp/types.ts new file mode 100644 index 0000000000..2142502c17 --- /dev/null +++ b/packages/ai-native/src/browser/components/acp/types.ts @@ -0,0 +1,16 @@ +import type { FooterConfig } from '../mention-input/types'; + +export interface ModeOption { + id: string; + name: string; + description?: string; +} + +export interface ACPFooterConfig extends FooterConfig { + modeOptions?: ModeOption[]; + defaultMode?: string; + /** Controlled current mode ID, synced to selector when changed */ + currentMode?: string; + showModeSelector?: boolean; + disableModeSelector?: boolean; +} diff --git a/packages/ai-native/src/browser/components/components.module.less b/packages/ai-native/src/browser/components/components.module.less index cbc92effed..be74789605 100644 --- a/packages/ai-native/src/browser/components/components.module.less +++ b/packages/ai-native/src/browser/components/components.module.less @@ -704,3 +704,69 @@ .shiny_text.disabled { animation: none; } + +.slash_command_container { + position: relative; + display: flex; + align-items: center; +} + +.slash_command_trigger { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + font-size: 14px; + font-weight: 500; + color: var(--design-text-secondary); + cursor: pointer; + border-radius: 4px; + user-select: none; + + &:hover { + background: var(--design-block-hoverBackground); + color: var(--design-text-foreground); + } +} + +.slash_command_dropdown { + position: absolute; + bottom: 100%; + left: 0; + margin-bottom: 4px; + min-width: 200px; + max-height: 300px; + overflow-y: auto; + background: var(--design-container-background); + border-radius: 8px; + box-shadow: 0px 9px 28px 8px var(--design-boxShadow-primary), 0px 3px 6px -4px var(--design-boxShadow-secondary), + 0px 6px 16px 0px var(--design-boxShadow-tertiary); + z-index: 1000; + + ul { + margin: 0; + padding: 4px 0; + + li { + display: flex; + align-items: center; + padding: 5px 8px; + font-size: 12px; + cursor: pointer; + margin: 0; + + &:hover { + background: var(--design-block-hoverBackground); + } + + .name { + margin: 0 8px; + } + + .text { + color: var(--design-text-placeholderForeground); + } + } + } +} diff --git a/packages/ai-native/src/browser/components/mention-input/mention-input.module.less b/packages/ai-native/src/browser/components/mention-input/mention-input.module.less index 9ae79c853d..9ea11c52b7 100644 --- a/packages/ai-native/src/browser/components/mention-input/mention-input.module.less +++ b/packages/ai-native/src/browser/components/mention-input/mention-input.module.less @@ -384,7 +384,6 @@ } .popover_icon { - // 移除 margin-left: auto } .loading_container { diff --git a/packages/ai-native/src/browser/components/mention-input/mention-input.tsx b/packages/ai-native/src/browser/components/mention-input/mention-input.tsx index 3aa8a3e9fc..713b9b611a 100644 --- a/packages/ai-native/src/browser/components/mention-input/mention-input.tsx +++ b/packages/ai-native/src/browser/components/mention-input/mention-input.tsx @@ -53,6 +53,7 @@ export const MentionInput: React.FC = ({ inlineSearchActive: false, // 是否在输入框中进行二级搜索 inlineSearchStartPos: null, // 内联搜索的起始位置 loading: false, // 添加加载状态 + trigger: '@', }); // 添加模型选择状态 @@ -281,6 +282,7 @@ export const MentionInput: React.FC = ({ inlineSearchActive: false, inlineSearchStartPos: null, loading: false, + trigger: '@', }); } @@ -406,6 +408,7 @@ export const MentionInput: React.FC = ({ inlineSearchActive: false, inlineSearchStartPos: null, loading: false, + trigger: '@', }); } @@ -1106,6 +1109,7 @@ export const MentionInput: React.FC = ({ inlineSearchActive: false, inlineSearchStartPos: null, loading: false, + trigger: '@', }); }, []); diff --git a/packages/ai-native/src/browser/components/mention-input/mention-item.tsx b/packages/ai-native/src/browser/components/mention-input/mention-item.tsx index 6f51b5f88d..7e73bf3126 100644 --- a/packages/ai-native/src/browser/components/mention-input/mention-item.tsx +++ b/packages/ai-native/src/browser/components/mention-input/mention-item.tsx @@ -15,7 +15,7 @@ interface MentionItemProps { export const MentionItem: React.FC = ({ item, isActive, onClick }) => (
onClick(item)}>
- + {item.icon && } {item.text} {item.description}
diff --git a/packages/ai-native/src/browser/components/mention-input/types.ts b/packages/ai-native/src/browser/components/mention-input/types.ts index c24f0de6a7..3fcdd3fb89 100644 --- a/packages/ai-native/src/browser/components/mention-input/types.ts +++ b/packages/ai-native/src/browser/components/mention-input/types.ts @@ -41,6 +41,7 @@ export interface MentionState { inlineSearchActive: boolean; // 是否在输入框中进行二级搜索 inlineSearchStartPos: number | null; // 内联搜索的起始位置 loading: boolean; // 加载状态 + trigger: '@' | '/'; // 触发面板的字符 } interface ModelOption { @@ -113,6 +114,7 @@ export interface MentionInputProps { loading?: boolean; onSelectionChange?: (value: string) => void; onImageUpload?: (files: File[]) => Promise; + onSlashSelect?: (nameWithSlash: string) => void; // 通知父组件 slash command 被选中 footerConfig?: FooterConfig; // 新增配置项 mentionKeyword?: string; labelService?: LabelService; diff --git a/packages/ai-native/src/browser/components/permission-dialog-widget.module.less b/packages/ai-native/src/browser/components/permission-dialog-widget.module.less new file mode 100644 index 0000000000..5dda0775a4 --- /dev/null +++ b/packages/ai-native/src/browser/components/permission-dialog-widget.module.less @@ -0,0 +1,131 @@ +.permission_dialog_container { + position: absolute; + left: 0px; + right: 0px; + z-index: 1000; + outline: none; + background-color: var(--editor-background); +} + +.permission_dialog { + display: flex; + flex-direction: column; + border-radius: 6px; + border: 1px solid var(--kt-editorWidget-border); + box-shadow: var(--kt-widget-shadow, 0 4px 12px rgba(0, 0, 0, 0.15)); + padding: 8px; + background-color: var(--kt-editorWidget-background); +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0; + + &.has_content { + margin-bottom: 6px; + } +} + +.title { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.9em; + font-weight: 600; + color: var(--foreground); +} + +.warning_icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border-radius: 50%; + background-color: var(--kt-warningBackground, #f0ad4e); + color: var(--kt-warningForeground, #fff); + font-size: 10px; +} + +.close_button { + background: none; + border: none; + cursor: pointer; + padding: 4px; + color: var(--descriptionForeground); + + &:hover { + color: var(--foreground); + } +} + +.content { + font-size: 0.8em; + color: var(--descriptionForeground); + margin-bottom: 8px; + font-family: var(--monaco-monospace-font, monospace); + word-break: break-word; + white-space: pre-wrap; + max-height: 80px; + overflow-y: auto; + padding: 6px 8px; + background-color: var(--input-background); + border-radius: 4px; +} + +.options { + display: flex; + flex-direction: column; + gap: 2px; +} + +.option_button { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border: 0; + border-radius: 4px; + font-size: 0.85em; + background-color: transparent; + color: var(--foreground); + cursor: pointer; + text-align: left; + outline: none; + transition: all 0.2s ease; + + &:global(.focused) { + color: var(--kt-tree-inactiveSelectionForeground); + background: var(--kt-tree-inactiveSelectionBackground); + } + + &:hover { + color: var(--kt-tree-inactiveSelectionForeground); + background: var(--kt-tree-inactiveSelectionBackground); + } +} + +.option_key { + min-width: 18px; + height: 18px; + border-radius: 4px; + font-size: 0.8em; + font-weight: 600; + background-color: var(--kt-input-border); + color: var(--descriptionForeground); + display: flex; + align-items: center; + justify-content: center; +} + +.option_button:hover .option_key, +.option_button:global(.focused) .option_key { + background-color: var(--kt-primaryButton-background); + color: var(--kt-primaryButton-foreground); +} + +.option_text { + flex: 1; +} diff --git a/packages/ai-native/src/browser/components/permission-dialog-widget.tsx b/packages/ai-native/src/browser/components/permission-dialog-widget.tsx new file mode 100644 index 0000000000..c0efbf7e2d --- /dev/null +++ b/packages/ai-native/src/browser/components/permission-dialog-widget.tsx @@ -0,0 +1,143 @@ +import cls from 'classnames'; +import * as React from 'react'; + +import { useInjectable } from '@opensumi/ide-core-browser'; +import { getIcon } from '@opensumi/ide-core-browser/lib/components'; + +import { ShowPermissionDialogParams } from '../acp'; +import { AcpPermissionBridgeService } from '../acp/permission-bridge.service'; +import { PermissionDialogManager } from '../acp/permission-dialog-container'; + +import styles from './permission-dialog-widget.module.less'; + +export interface PermissionDialogWidgetProps { + dialogManager: PermissionDialogManager; + bottom: number; +} + +export const PermissionDialogWidget: React.FC = ({ dialogManager, bottom }) => { + const [dialogs, setDialogs] = React.useState>([]); + const [focusedIndex, setFocusedIndex] = React.useState(0); + const containerRef = React.useRef(null); + + const permissionBridgeService = useInjectable(AcpPermissionBridgeService); + + React.useEffect(() => { + const unsubscribe = dialogManager.subscribe((newDialogs) => { + setDialogs(newDialogs); + setFocusedIndex(0); + }); + const initialDialogs = dialogManager.getDialogs(); + setDialogs(initialDialogs); + return unsubscribe; + }, [dialogManager]); + + React.useEffect(() => { + if (dialogs.length > 0) { + window.addEventListener('keydown', handleKeyboard); + containerRef.current?.focus(); + } + return () => window.removeEventListener('keydown', handleKeyboard); + }, [dialogs.length, dialogs]); + + const handleKeyboard = (e: KeyboardEvent) => { + if (dialogs.length === 0) { + return; + } + const options = dialogs[0].params.options || []; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + + setFocusedIndex((prev) => Math.min(prev + 1, options.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + + setFocusedIndex((prev) => Math.max(prev - 1, 0)); + } else if (e.key === 'Enter') { + e.preventDefault(); + const option = options[focusedIndex]; + if (option) { + // 通知 Bridge Service 用户决策 + permissionBridgeService.handleUserDecision(dialogs[0].requestId, option.optionId, option.kind); + dialogManager.removeDialog(dialogs[0].requestId); + } + } else if (e.key === 'Escape') { + e.preventDefault(); + dialogManager.removeDialog(dialogs[0].requestId); + // Escape 视为超时/取消 + permissionBridgeService.handleDialogClose(dialogs[0].requestId); + } + }; + + if (dialogs.length === 0) { + return null; + } + + const current = dialogs[0]; + const params = current.params; + + // 智能标题 + let smartTitle = params.title || 'Permission Required'; + if (params.kind === 'edit' || params.kind === 'write') { + smartTitle = `Make this edit to ${params.locations?.[0]?.path?.split('/').pop() || 'file'}?`; + } else if (params.kind === 'execute' || params.kind === 'bash') { + smartTitle = 'Allow this bash command?'; + } else if (params.kind === 'read') { + smartTitle = `Allow read from ${params.locations?.[0]?.path?.split('/').pop() || 'file'}?`; + } + + const shouldShowContent = params.content; + + return ( +
+
+ {/* 标题栏 */} +
+
+ ! + {smartTitle} +
+ +
+ + {/* 内容 */} + {shouldShowContent && params.content &&
{params.content}
} + + {/* 选项 */} +
+ {(params.options || []).map((option, index) => { + const isFocused = focusedIndex === index; + return ( + + ); + })} +
+
+
+ ); +}; diff --git a/packages/ai-native/src/browser/contrib/terminal/ai-terminal.service.ts b/packages/ai-native/src/browser/contrib/terminal/ai-terminal.service.ts index 02374a4984..79f1f2fbb4 100644 --- a/packages/ai-native/src/browser/contrib/terminal/ai-terminal.service.ts +++ b/packages/ai-native/src/browser/contrib/terminal/ai-terminal.service.ts @@ -176,10 +176,17 @@ export class AITerminalService extends Disposable { if (terminal && output && marker) { const lines = output?.split('\n').length; + // 收集所有检测类 action(triggerRules 为数组的),而非只显示匹配到的那一个 + const allActions = this.inlineChatFeatureRegistry.getTerminalActions(); + const detectionActions = allActions.filter((a) => { + const handler = this.inlineChatFeatureRegistry.getTerminalHandler(a.id); + return handler && Array.isArray(handler.triggerRules); + }); + this.terminalDecorations.addZoneDecoration(terminal, marker, lines, { - operationList: [action.action], - onClickItem: () => { - const handler = this.inlineChatFeatureRegistry.getTerminalHandler(action.action.id); + operationList: detectionActions, + onClickItem: (id: string) => { + const handler = this.inlineChatFeatureRegistry.getTerminalHandler(id); if (handler) { handler.execute(output, input || '', action.matcher); } diff --git a/packages/ai-native/src/browser/contrib/terminal/decoration/terminal-decoration.tsx b/packages/ai-native/src/browser/contrib/terminal/decoration/terminal-decoration.tsx index 00061f6da9..41987c50fe 100644 --- a/packages/ai-native/src/browser/contrib/terminal/decoration/terminal-decoration.tsx +++ b/packages/ai-native/src/browser/contrib/terminal/decoration/terminal-decoration.tsx @@ -35,7 +35,7 @@ export class AITerminalDecorationService extends Disposable { terminal: Terminal, marker: IMarker, height: number, - inlineWidget: { operationList: AIActionItem[]; onClickItem: () => void }, + inlineWidget: { operationList: AIActionItem[]; onClickItem: (id: string) => void }, ) { const decoration = terminal.registerDecoration({ marker, @@ -59,8 +59,8 @@ export class AITerminalDecorationService extends Disposable { root.render( { - inlineWidget.onClickItem(); + onClickItem={(id) => { + inlineWidget.onClickItem(id); }} />, ); diff --git a/packages/ai-native/src/browser/index.ts b/packages/ai-native/src/browser/index.ts index c1189c6d45..3603a2cdb1 100644 --- a/packages/ai-native/src/browser/index.ts +++ b/packages/ai-native/src/browser/index.ts @@ -1,4 +1,4 @@ -import { Autowired, Injectable, Provider } from '@opensumi/di'; +import { Autowired, Injectable, Injector, Provider } from '@opensumi/di'; import { AIBackSerivcePath, AIBackSerivceToken, @@ -6,14 +6,21 @@ import { BrowserModule, ChatAgentViewServiceToken, ChatFeatureRegistryToken, + ChatHistoryRegistryToken, + ChatInputFooterRegistryToken, + ChatInputRegistryToken, ChatRenderRegistryToken, ChatServiceToken, + ChatViewRegistryToken, IAIInlineChatService, InlineChatFeatureRegistryToken, RenameCandidatesProviderRegistryToken, ResolveConflictRegistryToken, } from '@opensumi/ide-core-browser'; import { + AcpPermissionServicePath, + AcpPermissionServiceToken, + IACPConfigProvider, IntelligentCompletionsRegistryToken, MCPConfigServiceToken, ProblemFixRegistryToken, @@ -24,6 +31,7 @@ import { FolderFilePreferenceProvider } from '@opensumi/ide-preferences/lib/brow import { ChatProxyServiceToken, + DefaultChatAgentToken, IAIInlineCompletionsProvider, IChatAgentService, IChatInternalService, @@ -35,17 +43,34 @@ import { import { LLMContextServiceToken } from '../common/llm-context'; import { MCPServerManager, MCPServerManagerPath } from '../common/mcp-server-manager'; import { ChatAgentPromptProvider, DefaultChatAgentPromptProvider } from '../common/prompts/context-prompt-provider'; +import { ACPChatAgentPromptProvider } from '../common/prompts/empty-prompt-provider'; +import { AcpPermissionBridgeService, AcpPermissionRpcService } from './acp'; +import { AcpFooterContribution } from './acp/components/AcpFooterContribution'; +import { AcpPermissionDialogContribution, PermissionDialogManager } from './acp/permission-dialog-container'; import { AINativeBrowserContribution } from './ai-core.contribution'; +import { AcpChatAgent } from './chat/acp-chat-agent'; +import { ACPSessionProvider } from './chat/acp-session-provider'; import { ApplyService } from './chat/apply.service'; import { ChatAgentService } from './chat/chat-agent.service'; import { ChatAgentViewService } from './chat/chat-agent.view.service'; +import { ChatInputFooterRegistry } from './chat/chat-input-footer.registry'; import { ChatManagerService } from './chat/chat-manager.service'; +import { AcpChatManagerService } from './chat/chat-manager.service.acp'; import { ChatProxyService } from './chat/chat-proxy.service'; +import { AcpChatProxyService } from './chat/chat-proxy.service.acp'; import { ChatService } from './chat/chat.api.service'; import { ChatFeatureRegistry } from './chat/chat.feature.registry'; +import { ChatHistoryRegistry } from './chat/chat.history.registry'; +import { ChatInputRegistry } from './chat/chat.input.registry'; import { ChatInternalService } from './chat/chat.internal.service'; +import { AcpChatInternalService } from './chat/chat.internal.service.acp'; import { ChatRenderRegistry } from './chat/chat.render.registry'; +import { ChatViewRegistry } from './chat/chat.view.registry'; +import { DefaultACPConfigProvider } from './chat/default-acp-config-provider'; +import { DefaultChatAgent } from './chat/default-chat-agent'; +import { LocalStorageProvider } from './chat/local-storage-provider'; +import { ISessionProviderRegistry, SessionProviderRegistry } from './chat/session-provider-registry'; import { LlmContextContribution } from './context/llm-context.contribution'; import { LLMContextServiceImpl } from './context/llm-context.service'; import { AICodeActionContribution } from './contrib/code-action/code-action.contribution'; @@ -106,6 +131,25 @@ export class AINativeModule extends BrowserModule { MCPConfigContribution, MCPConfigCommandContribution, MCPPreferencesContribution, + AcpPermissionDialogContribution, + PermissionDialogManager, + AcpPermissionBridgeService, + { + token: ISessionProviderRegistry, + useClass: SessionProviderRegistry, + }, + // ACP Config Provider + { + token: IACPConfigProvider, + useClass: DefaultACPConfigProvider, + }, + // Session Providers + LocalStorageProvider, + ACPSessionProvider, + // ACP service subclasses (used conditionally via factory) + AcpChatManagerService, + AcpChatInternalService, + AcpChatProxyService, // MCP Server Contributions START ListDirTool, @@ -121,6 +165,7 @@ export class AINativeModule extends BrowserModule { // Context Service LlmContextContribution, RulesContribution, + AcpFooterContribution, { token: LLMContextServiceToken, useClass: LLMContextServiceImpl, @@ -146,6 +191,22 @@ export class AINativeModule extends BrowserModule { token: ChatRenderRegistryToken, useClass: ChatRenderRegistry, }, + { + token: ChatInputRegistryToken, + useClass: ChatInputRegistry, + }, + { + token: ChatViewRegistryToken, + useClass: ChatViewRegistry, + }, + { + token: ChatHistoryRegistryToken, + useClass: ChatHistoryRegistry, + }, + { + token: ChatInputFooterRegistryToken, + useClass: ChatInputFooterRegistry, + }, { token: ResolveConflictRegistryToken, useClass: ResolveConflictRegistry, @@ -160,7 +221,13 @@ export class AINativeModule extends BrowserModule { }, { token: IChatManagerService, - useClass: ChatManagerService, + useFactory: (injector: Injector) => { + const config = injector.get(AINativeConfigService); + if (config.capabilities.supportsAgentMode) { + return injector.get(AcpChatManagerService); + } + return injector.get(ChatManagerService); + }, }, { token: IChatAgentService, @@ -172,12 +239,30 @@ export class AINativeModule extends BrowserModule { }, { token: IChatInternalService, - useClass: ChatInternalService, + useFactory: (injector: Injector) => { + const config = injector.get(AINativeConfigService); + if (config.capabilities.supportsAgentMode) { + return injector.get(AcpChatInternalService); + } + return injector.get(ChatInternalService); + }, }, { token: ChatProxyServiceToken, - useClass: ChatProxyService, + useFactory: (injector: Injector) => { + const config = injector.get(AINativeConfigService); + if (config.capabilities.supportsAgentMode) { + return injector.get(AcpChatProxyService); + } + return injector.get(ChatProxyService); + }, + }, + { + token: DefaultChatAgentToken, + useClass: DefaultChatAgent, }, + // ACP Agent - 用于 ACP 模式 + AcpChatAgent, { token: ChatServiceToken, useClass: ChatService, @@ -204,7 +289,13 @@ export class AINativeModule extends BrowserModule { }, { token: ChatAgentPromptProvider, - useClass: DefaultChatAgentPromptProvider, + useFactory(injector) { + const config = injector.get(AINativeConfigService); + if (config.capabilities.supportsAgentMode) { + return injector.get(ACPChatAgentPromptProvider); + } + return injector.get(DefaultChatAgentPromptProvider); + }, }, { token: InlineDiffServiceToken, @@ -228,6 +319,10 @@ export class AINativeModule extends BrowserModule { dropdownForTag: true, tag: 'mcp', }, + { + token: AcpPermissionServiceToken, + useClass: AcpPermissionRpcService, + }, ]; backServices = [ @@ -244,5 +339,9 @@ export class AINativeModule extends BrowserModule { clientToken: TokenMCPServerProxyService, servicePath: SumiMCPServerProxyServicePath, }, + { + servicePath: AcpPermissionServicePath, + clientToken: AcpPermissionServiceToken, + }, ]; } diff --git a/packages/ai-native/src/browser/layout/ai-layout.tsx b/packages/ai-native/src/browser/layout/ai-layout.tsx index 36b0d78e65..1929c6353c 100644 --- a/packages/ai-native/src/browser/layout/ai-layout.tsx +++ b/packages/ai-native/src/browser/layout/ai-layout.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { SlotLocation, SlotRenderer, useInjectable } from '@opensumi/ide-core-browser'; import { BoxPanel, SplitPanel, getStorageValue } from '@opensumi/ide-core-browser/lib/components'; @@ -6,10 +6,36 @@ import { DesignLayoutConfig } from '@opensumi/ide-core-browser/lib/layout/consta import { AI_CHAT_VIEW_ID } from '../../common'; +// 使用 UA 判断是否为移动设备 +const isMobileDevice = () => { + if (typeof navigator === 'undefined') { + return false; + } + return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); +}; + export const AILayout = () => { const { layout } = getStorageValue(); const designLayoutConfig = useInjectable(DesignLayoutConfig); + // 判断是否应该显示完整布局 + const shouldShowFullLayout = !isMobileDevice(); + + // 移动端模式:只渲染 AI_CHAT_VIEW_ID,添加 mobile class + if (!shouldShowFullLayout) { + return ( + + ); + } + + // 正常模式:渲染完整布局 const defaultRightSize = useMemo( () => (designLayoutConfig.useMergeRightWithLeftPanel ? 0 : 49), [designLayoutConfig.useMergeRightWithLeftPanel], @@ -64,7 +90,7 @@ export const AILayout = () => { slot={AI_CHAT_VIEW_ID} isTabbar={true} defaultSize={layout['AI-Chat']?.currentId ? layout['AI-Chat']?.size || 360 : 0} - maxResize={420} + maxResize={1080} minResize={280} minSize={0} /> diff --git a/packages/ai-native/src/browser/mcp/base-apply.service.ts b/packages/ai-native/src/browser/mcp/base-apply.service.ts index e8370cc198..58aadf1a05 100644 --- a/packages/ai-native/src/browser/mcp/base-apply.service.ts +++ b/packages/ai-native/src/browser/mcp/base-apply.service.ts @@ -189,7 +189,7 @@ export abstract class BaseApplyService extends WithEventBus { if (!sessionModel) { return []; } - const sessionAdditionals = sessionModel.history.sessionAdditionals; + const sessionAdditionals = sessionModel?.history?.sessionAdditionals; return Array.from(sessionAdditionals.values()) .map((additional) => (additional.codeBlockMap || {}) as { [toolCallId: string]: CodeBlockData }) .reduce((acc, cur) => { diff --git a/packages/ai-native/src/browser/mcp/tools/fileSearch.ts b/packages/ai-native/src/browser/mcp/tools/fileSearch.ts index 68763ec2cc..f5bf0a445d 100644 --- a/packages/ai-native/src/browser/mcp/tools/fileSearch.ts +++ b/packages/ai-native/src/browser/mcp/tools/fileSearch.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import { Autowired } from '@opensumi/di'; import { getValidateInput } from '@opensumi/ide-addons/lib/browser/file-search.contribution'; +import { PreferenceService } from '@opensumi/ide-core-browser'; import { Domain, URI } from '@opensumi/ide-core-common'; import { defaultFilesWatcherExcludes } from '@opensumi/ide-core-common/lib/preferences/file-watch'; import { FileSearchServicePath, IFileSearchService } from '@opensumi/ide-file-search/lib/common'; @@ -33,6 +34,9 @@ export class FileSearchTool implements MCPServerContribution { @Autowired(IChatInternalService) private readonly chatInternalService: ChatInternalService; + @Autowired(PreferenceService) + private readonly preferenceService: PreferenceService; + registerMCPServer(registry: IMCPServerRegistry): void { registry.registerMCPTool(this.getToolDefinition()); registry.registerToolComponent('file_search', FileSearchToolComponent); @@ -69,6 +73,7 @@ export class FileSearchTool implements MCPServerContribution { useGitIgnore: true, noIgnoreParent: true, fuzzyMatch: true, + followSymlinks: this.preferenceService.get('search.followSymlinks') ?? true, }); const files = searchResults.slice(0, MAX_RESULTS).map((file) => { diff --git a/packages/ai-native/src/browser/mcp/tools/grepSearch.ts b/packages/ai-native/src/browser/mcp/tools/grepSearch.ts index ecd85840eb..eddb33202f 100644 --- a/packages/ai-native/src/browser/mcp/tools/grepSearch.ts +++ b/packages/ai-native/src/browser/mcp/tools/grepSearch.ts @@ -90,6 +90,7 @@ export class GrepSearchTool implements MCPServerContribution { isWholeWord: false, isOnlyOpenEditors: false, isIncludeIgnored: false, + isFollowSymlinks: this.searchService.UIState.isFollowSymlinks, }, CancellationToken.None, ); diff --git a/packages/ai-native/src/browser/preferences/schema.ts b/packages/ai-native/src/browser/preferences/schema.ts index fb43fb6bf4..69f794fea6 100644 --- a/packages/ai-native/src/browser/preferences/schema.ts +++ b/packages/ai-native/src/browser/preferences/schema.ts @@ -163,6 +163,43 @@ export const aiNativePreferenceSchema: PreferenceSchema = { }, }, }, + [AINativeSettingSectionsId.AgentConfigs]: { + type: 'object', + description: '%preference.ai.native.agent.configs.description%', + markdownDescription: '%preference.ai.native.agent.configs.markdownDescription%', + additionalProperties: { + type: 'object', + properties: { + command: { + type: 'string', + description: '%preference.ai.native.agent.configs.command.description%', + }, + args: { + type: 'array', + items: { + type: 'string', + }, + default: [], + description: '%preference.ai.native.agent.configs.args.description%', + }, + streaming: { + type: 'boolean', + default: true, + description: '%preference.ai.native.agent.configs.streaming.description%', + }, + description: { + type: 'string', + description: '%preference.ai.native.agent.configs.description.description%', + }, + }, + }, + }, + [AINativeSettingSectionsId.DefaultAgentType]: { + type: 'string', + enum: ['qwen', 'claude-agent-acp'], + default: 'claude-agent-acp', + description: '%preference.ai.native.agent.defaultType.description%', + }, [AINativeSettingSectionsId.TerminalAutoRun]: { type: 'string', enum: [ETerminalAutoExecutionPolicy.off, ETerminalAutoExecutionPolicy.auto, ETerminalAutoExecutionPolicy.always], diff --git a/packages/ai-native/src/browser/types.ts b/packages/ai-native/src/browser/types.ts index f3126cf3e6..80eeb0bd28 100644 --- a/packages/ai-native/src/browser/types.ts +++ b/packages/ai-native/src/browser/types.ts @@ -10,6 +10,7 @@ import { Deferred, IAICompletionOption, IAICompletionResultModel, + IChatProgress, IDisposable, IPosition, IResolveConflictHandler, @@ -31,6 +32,8 @@ import { IMarker } from '@opensumi/monaco-editor-core/esm/vs/platform/markers/co import { IChatWelcomeMessageContent, ISampleQuestions, ITerminalCommandSuggestionDesc } from '../common'; import { LLMContextService } from '../common/llm-context'; +import { ChatModel } from './chat/chat-model'; +import { MessageData } from './components/utils'; import { ICodeEditsContextBean, ICodeEditsResult, @@ -127,8 +130,11 @@ export type TSlashCommandCustomRender = (props: { userMessage: string }) => Reac export interface IChatSlashCommandHandler { execute: (value: string, send: TChatSlashCommandSend, editor?: ICodeEditor) => MaybePromise; providerInputPlaceholder?: (value: string, editor?: ICodeEditor) => string; + providerDefaultInput?: (value: string, editor?: ICodeEditor) => MaybePromise; providerPrompt?: (value: string, editor?: ICodeEditor) => MaybePromise; providerRender?: TSlashCommandCustomRender; + /** 自定义 invoke:有此方法时跳过 ACP/默认 agent,由 handler 自行处理请求和响应 */ + invoke?: (message: string, progress: (part: IChatProgress) => void, token: CancellationToken) => Promise; } export interface IChatFeatureRegistry { @@ -139,6 +145,14 @@ export interface IChatFeatureRegistry { registerMessageSummaryProvider(provider: IMessageSummaryProvider): void; } +export type ChatWelcomePageRender = (props: { + onSend: (message: string, images?: string[], agentId?: string, command?: string) => void; + agentId?: string; + setAgentId: (id: string) => void; + command?: string; + setCommand: (cmd: string) => void; +}) => React.ReactElement | React.JSX.Element; + export type ChatWelcomeRender = (props: { message: IChatWelcomeMessageContent; sampleQuestions: ISampleQuestions[]; @@ -166,6 +180,7 @@ export type ChatInputRender = (props: { theme?: string | null; setTheme: (theme: string | null) => void; agentId: string; + agentCwd?: string; setAgentId: (theme: string) => void; defaultAgentId?: string; command: string; @@ -175,8 +190,40 @@ export type ChatInputRender = (props: { export type ChatViewHeaderRender = (props: { handleClear: () => any; handleCloseChatView: () => any; + sessionModel?: ChatModel; }) => React.ReactElement | React.JSX.Element; +export interface IChatHistoryItem { + id: string; + title: string; + updatedAt: number; + loading: boolean; +} + +export type ChatHistoryRender = (props: { + title: string; + historyList: IChatHistoryItem[]; + currentId?: string; + className?: string; + onNewChat: () => void; + onHistoryItemSelect: (item: IChatHistoryItem) => void; + onHistoryItemDelete: (item: IChatHistoryItem) => void; + onHistoryItemChange: (item: IChatHistoryItem, title: string) => void; + onHistoryPopoverVisibleChange?: (visible: boolean) => void; +}) => React.ReactNode; + +export interface IChatMessageProcessor { + /** + * 处理器优先级,值越小越先执行,默认 100 + */ + priority?: number; + /** + * 处理消息列表:可过滤(返回子集)或变换(修改内容) + * 管道模式:前一个处理器的输出作为下一个处理器的输入 + */ + processMessages(messages: MessageData[]): MessageData[]; +} + export interface IChatRenderRegistry { registerWelcomeRender(render: ChatWelcomeRender): void; /** @@ -194,10 +241,36 @@ export interface IChatRenderRegistry { */ registerInputRender(render: ChatInputRender): void; + /** + * 配置启用的 mention 类型(如 'file', 'folder', 'code', 'rule') + * 不调用时默认全部启用 + */ + registerEnabledMentionTypes(types: string[]): void; + + /** + * 获取启用的 mention 类型,undefined 表示全部启用 + */ + enabledMentionTypes?: string[]; + /** * 顶部栏渲染 */ registerChatViewHeaderRender(render: ChatViewHeaderRender): void; + + /** + * 历史记录渲染 + */ + registerChatHistoryRender(render: ChatHistoryRender): void; + + /** + * 注册消息处理器,用于在渲染前对消息列表进行过滤或变换 + */ + registerMessageProcessor(processor: IChatMessageProcessor): IDisposable; + + /** + * 欢迎页面渲染器(独立于 WelcomeMessage,占据 chat_container 位置) + */ + registerChatWelcomePageRender(render: ChatWelcomePageRender): void; } export interface IResolveConflictRegistry { @@ -447,3 +520,11 @@ export interface IAIMiddleware { provideInlineCompletions?: IProvideInlineCompletionsSignature; }; } + +// Re-export ChatInput types for convenience +export { IChatInputProps, ChatInputContribution, IChatInputRegistry } from './chat/chat.input.registry'; + +// Re-export ChatView and ChatHistory registry types for convenience +export { ChatViewContribution, IChatViewRegistry } from './chat/chat.view.registry'; +export { ChatHistoryContribution, IChatHistoryRegistry } from './chat/chat.history.registry'; +export { ChatViewRegistryToken, ChatHistoryRegistryToken } from '@opensumi/ide-core-common'; diff --git a/packages/ai-native/src/common/index.ts b/packages/ai-native/src/common/index.ts index 02bc735015..70ce008d12 100644 --- a/packages/ai-native/src/common/index.ts +++ b/packages/ai-native/src/common/index.ts @@ -129,6 +129,7 @@ export interface ChatCompletionRequestMessage { export const IChatInternalService = Symbol('IChatInternalService'); export const IChatManagerService = Symbol('IChatManagerService'); export const IChatAgentService = Symbol('IChatAgentService'); +export const DefaultChatAgentToken = Symbol('DefaultChatAgentToken'); export const ChatProxyServiceToken = Symbol('ChatProxyServiceToken'); diff --git a/packages/ai-native/src/common/prompts/empty-prompt-provider.ts b/packages/ai-native/src/common/prompts/empty-prompt-provider.ts new file mode 100644 index 0000000000..9bfa111129 --- /dev/null +++ b/packages/ai-native/src/common/prompts/empty-prompt-provider.ts @@ -0,0 +1,112 @@ +import { Injectable } from '@opensumi/di'; + +import { AttachFileContext, SerializedContext } from '../llm-context'; + +import { DefaultChatAgentPromptProvider } from './context-prompt-provider'; + +/** + * 用于 acp agent,无 XML 标签的 prompt 格式 + * 当没有任何上下文时直接返回 userMessage + */ +@Injectable() +export class ACPChatAgentPromptProvider extends DefaultChatAgentPromptProvider { + async provideContextPrompt(context: SerializedContext, userMessage: string): Promise { + const hasContextFields = + context.globalRules.length > 0 || + context.attachedFolders.length > 0 || + context.attachedFiles.length > 0 || + context.attachedRules.length > 0; + + if (!hasContextFields) { + const currentFileInfo = await this.getACPCurrentFileInfo(); + const hasCurrentFile = + currentFileInfo && !context.attachedFiles.some((file) => file.path === currentFileInfo.path); + if (!hasCurrentFile) { + return userMessage; + } + } + + return this.buildACPPrompt(context, userMessage); + } + + private async getACPCurrentFileInfo() { + const editor = this.workbenchEditorService.currentEditor; + const currentModel = editor?.currentDocumentModel; + + if (!currentModel?.uri) { + return null; + } + + const currentPath = + (await this.workspaceService.asRelativePath(currentModel.uri))?.path || currentModel.uri.codeUri.fsPath; + + const selection = editor?.monacoEditor?.getSelection(); + const currentLine = selection ? selection.startLineNumber : undefined; + let lineContent = ''; + + if (currentLine && editor?.monacoEditor) { + const model = editor.monacoEditor.getModel(); + if (model) { + lineContent = model.getLineContent(currentLine)?.trim() || ''; + } + } + + return { path: currentPath, currentLine, lineContent }; + } + + private async buildACPPrompt(context: SerializedContext, userMessage: string): Promise { + const sections: string[] = []; + + if (context.globalRules.length > 0) { + sections.push(this.stripXmlTags(context.globalRules.join('\n'))); + } + + if (context.attachedFolders.length > 0) { + sections.push(context.attachedFolders.join('\n')); + } + + let currentFileInfo = await this.getACPCurrentFileInfo(); + if (currentFileInfo && context.attachedFiles.some((file) => file.path === currentFileInfo!.path)) { + currentFileInfo = null; + } + if (currentFileInfo) { + let currentFileSection = `Current file: ${currentFileInfo.path}`; + if (currentFileInfo.currentLine && currentFileInfo.lineContent) { + currentFileSection += ` (line ${currentFileInfo.currentLine}: \`${currentFileInfo.lineContent}\`)`; + } + sections.push(currentFileSection); + } + + if (context.attachedFiles.length > 0) { + const filesSections = context.attachedFiles.map((file) => this.buildACPFileSection(file)); + sections.push(filesSections.join('\n\n')); + } + + if (context.attachedRules.length > 0) { + sections.push(this.stripXmlTags(context.attachedRules.join('\n'))); + } + + sections.push('---'); + sections.push(userMessage); + + return sections.join('\n\n'); + } + + private buildACPFileSection(file: AttachFileContext): string { + const header = file.selection + ? `\`\`\`${file.path}, lines: ${file.selection[0]}-${file.selection[1]}` + : `\`\`\`${file.path}`; + const parts = [header, file.content, '```']; + if (file.lineErrors.length > 0) { + parts.push(`Errors: ${file.lineErrors.join(', ')}`); + } + return parts.join('\n'); + } + + private stripXmlTags(text: string): string { + return text + .replace(/<[^>]+>/g, '') + .replace(/\n{3,}/g, '\n\n') + .trim(); + } +} diff --git a/packages/ai-native/src/node/acp/acp-agent.service.ts b/packages/ai-native/src/node/acp/acp-agent.service.ts new file mode 100644 index 0000000000..5efe6c5f17 --- /dev/null +++ b/packages/ai-native/src/node/acp/acp-agent.service.ts @@ -0,0 +1,619 @@ +import { Autowired, Injectable } from '@opensumi/di'; +import { + AcpCliClientServiceToken, + type AvailableCommand, + type CancelNotification, + type ContentBlock, + IAcpCliClientService, + type ListSessionsRequest, + type ListSessionsResponse, + type LoadSessionRequest, + type NewSessionRequest, + type SessionMode, + type SessionModeState, + type SessionNotification, + type SetSessionModeRequest, +} from '@opensumi/ide-core-common/lib/types/ai-native/acp-types'; +import { AgentProcessConfig } from '@opensumi/ide-core-common/lib/types/ai-native/agent-types'; +import { AppConfig, INodeLogger } from '@opensumi/ide-core-node'; +import { SumiReadableStream } from '@opensumi/ide-utils/lib/stream'; + +import { CliAgentProcessManagerToken, ICliAgentProcessManager } from './cli-agent-process-manager'; +import { AcpTerminalHandler, AcpTerminalHandlerToken } from './handlers/terminal.handler'; + +export interface SessionLoadResult { + sessionId: string; + processId: string; + modes: SessionMode[]; + status: AgentSessionStatus; + /** + * 从 Agent 接收到的所有 session/update 消息 + */ + historyUpdates: SessionNotification[]; +} + +// ============================================================================ +// DI Token +// ============================================================================ + +export const AcpAgentServiceToken = Symbol('AcpAgentServiceToken'); + +// ============================================================================ +// Agent Session Types +// ============================================================================ + +export type AgentSessionStatus = 'initializing' | 'ready' | 'running' | 'stopping' | 'stopped' | 'error'; + +export interface SimpleMessage { + role: 'user' | 'assistant' | 'system' | 'tool'; + content: string; +} + +export interface AgentSessionInfo { + sessionId: string; + processId: string; + modes: SessionMode[]; + status: AgentSessionStatus; +} + +export type AgentUpdateType = 'thought' | 'message' | 'tool_call' | 'tool_result' | 'done'; + +export interface AgentUpdate { + type: AgentUpdateType; + content: string; + toolCall?: SimpleToolCall; +} + +export interface SimpleToolCall { + name: string; + input: Record; +} + +/** + * Agent 请求参数 + */ +export interface AgentRequest { + prompt: string; + /** ACP session/prompt 使用的 sessionId(来自 ACP Agent 的 session ID) */ + sessionId: string; + images?: string[]; + history?: SimpleMessage[]; +} + +/** + * 无状态的 ACP Agent 服务接口 + */ +export interface IAcpAgentService { + /** + * 初始化 Agent 进程 + * @param config - Agent 配置 + */ + initializeAgent(config: AgentProcessConfig): Promise; + + /** + * 加载已有 Agent Session + */ + loadSession(sessionId: string, config: AgentProcessConfig): Promise; + + /** + * 发送消息到 Agent(无状态) + */ + sendMessage(request: AgentRequest, config: AgentProcessConfig): SumiReadableStream; + + /** + * 取消请求 + */ + cancelRequest(sessionId: string): Promise; + + /** + * 停止 Agent 进程 + */ + stopAgent(): Promise; + + /** + * 清理所有资源 + */ + dispose(): Promise; + + /** + * 获取当前 Agent Session 信息 + */ + getSessionInfo(): AgentSessionInfo | null; + + createSession(config: AgentProcessConfig): Promise<{ sessionId: string; availableCommands: AvailableCommand[] }>; + + /** + * 列出所有 ACP Agent 会话 + */ + listSessions(params?: ListSessionsRequest): Promise; + + /** + * 切换 Session 模式 + */ + setSessionMode(params: SetSessionModeRequest): Promise; + + /** + * 释放指定 Session 的资源(包括终端等) + */ + disposeSession(sessionId: string): Promise; + + /** + * 获取 initialize 协商时存储的 Session 模式 + */ + getAvailableModes(): Promise; +} + +/** + * 无状态的 ACP Agent 服务 + * + * 设计原则: + * 1. 只维护单一 Agent 进程实例 + * 2. 负责启动/停止 Agent 进程、转发请求、流式返回响应 + */ +@Injectable() +export class AcpAgentService implements IAcpAgentService { + @Autowired(AcpCliClientServiceToken) + private clientService: IAcpCliClientService; + + @Autowired(CliAgentProcessManagerToken) + private processManager: ICliAgentProcessManager; + + @Autowired(AcpTerminalHandlerToken) + private terminalHandler: AcpTerminalHandler; + + @Autowired(AppConfig) + private appConfig: AppConfig; + + @Autowired(INodeLogger) + private readonly logger: INodeLogger; + + // 当前 Agent Session 信息 + private sessionInfo: AgentSessionInfo | null = null; + + // 全局 Agent 进程 ID(单一实例) + private currentProcessId: string | null = null; + + // 当前活跃的通知处理器和 stream + private currentNotificationHandler: { + unsubscribe: () => void; + stream: SumiReadableStream; + sessionId: string; + } | null = null; + + // 确保初始化只执行一次 + private initializingPromise: Promise | null = null; + + // 断开事件订阅的取消函数 + private disconnectUnsubscribe: (() => void) | null = null; + + async createSession( + config: AgentProcessConfig, + ): Promise<{ sessionId: string; availableCommands: AvailableCommand[] }> { + await this.ensureConnected(config); + + // 设置临时通知处理器来收集 availableCommands + const availableCommands: AvailableCommand[] = []; + const tempHandler = (notification: SessionNotification) => { + const update = notification.update as any; + if (update?.sessionUpdate === 'available_commands_update' && Array.isArray(update.availableCommands)) { + availableCommands.push(...update.availableCommands); + } + }; + + // 订阅临时通知处理器 + const unsubscribe = this.clientService.onNotification(tempHandler); + + try { + const res = await Promise.race([ + this.clientService.newSession({ cwd: config.workspaceDir, mcpServers: [] }), + new Promise((_, reject) => setTimeout(() => reject(new Error('Create session timeout')), 60000)), + ]); + + // 等待延迟的 session/update 通知,增加等待时间以确保 availableCommands 通知到达 + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // 根据 name 去重 + const seen = new Set(); + const deduplicated = availableCommands.filter((cmd) => { + if (seen.has(cmd.name)) { + return false; + } + seen.add(cmd.name); + return true; + }); + + return { ...res, availableCommands: deduplicated }; + } finally { + unsubscribe(); + } + } + /** + * 确保 Agent 进程已连接并初始化,复用现有连接或启动新进程 + */ + private async ensureConnected(config: AgentProcessConfig): Promise { + if (this.currentProcessId) { + return this.currentProcessId; + } + + const { processId, stdout, stdin } = await this.processManager.startAgent( + config.command, + config.args, + config.env ?? {}, + config.workspaceDir, + ); + + this.clientService.setTransport(stdout, stdin); + await this.clientService.initialize(); + this.currentProcessId = processId; + + // 订阅断开事件,自动清理上层状态 + if (this.disconnectUnsubscribe) { + this.disconnectUnsubscribe(); + } + this.disconnectUnsubscribe = this.clientService.onDisconnect(() => { + this.logger?.warn('[AcpAgentService] Connection lost, clearing state'); + this.currentProcessId = null; + this.sessionInfo = null; + this.initializingPromise = null; + }); + + return processId; + } + + /** + * 获取当前 Agent Session 信息 + */ + getSessionInfo(): AgentSessionInfo | null { + return this.sessionInfo; + } + + async initializeAgent(config: AgentProcessConfig): Promise { + if (this.sessionInfo && this.currentProcessId) { + return this.sessionInfo; + } + + if (this.initializingPromise) { + return this.initializingPromise; + } + + this.initializingPromise = (async () => { + const processId = await this.ensureConnected(config); + + const newSessionRequest: NewSessionRequest = { + cwd: config.workspaceDir, + mcpServers: [], + }; + + const newSessionResponse = await this.clientService.newSession(newSessionRequest); + + this.sessionInfo = { + sessionId: newSessionResponse.sessionId, + processId, + modes: (newSessionResponse.modes?.availableModes ?? []) as SessionMode[], + status: 'ready', + }; + + this.currentProcessId = processId; + + return this.sessionInfo; + })(); + + try { + const result = await this.initializingPromise; + return result; + } finally { + this.initializingPromise = null; + } + } + + /** + * 加载已有 Agent Session + */ + async loadSession(sessionId: string, config: AgentProcessConfig): Promise { + const processId = await this.ensureConnected(config); + + const historyUpdates: SessionNotification[] = []; + + // 设置临时通知处理器来收集 session/update + const tempHandler = (notification: SessionNotification) => { + if (notification.sessionId === sessionId && notification.update) { + historyUpdates.push(notification); + } + }; + + // 订阅临时通知处理器 + const unsubscribe = this.clientService.onNotification(tempHandler); + + const loadRequest: LoadSessionRequest = { + sessionId, + cwd: config.workspaceDir, + mcpServers: [], + }; + + try { + await Promise.race([ + this.clientService.loadSession(loadRequest), + new Promise((_, reject) => + setTimeout(() => reject(new Error(`Session load timeout for ${sessionId}`)), 60000), + ), + ]); + + // 等待延迟的 session/update 通知 + await new Promise((resolve) => setTimeout(resolve, 500)); + } finally { + unsubscribe(); + } + + const modes: SessionMode[] = []; + for (const notification of historyUpdates) { + const update = notification.update as any; + if (update?.currentModeId) { + const existingMode = modes.find((m) => m.id === update.currentModeId); + if (!existingMode) { + modes.push({ id: update.currentModeId, name: update.currentModeId }); + } + } + } + + this.sessionInfo = { + sessionId, + processId, + modes, + status: 'ready', + }; + + this.currentProcessId = processId; + + const result: SessionLoadResult = { + sessionId, + processId, + modes, + status: 'ready', + historyUpdates, + }; + + return result; + } + + /** + * 发送消息到 Agent(无状态) + */ + sendMessage(request: AgentRequest): SumiReadableStream { + const stream = new SumiReadableStream(); + + if (!this.currentProcessId) { + stream.emitError(new Error('Agent process not initialized')); + return stream; + } + + const promptBlocks = this.buildPromptBlocks(request.prompt, request.images); + + const promptRequest = { + sessionId: request.sessionId, + prompt: promptBlocks, + }; + + const unsubscribe = this.clientService.onNotification((notification: SessionNotification) => { + if (notification.sessionId !== request.sessionId) { + return; + } + + this.handleNotification(notification, stream); + }); + + // 流结束时清理 + stream.onEnd(() => { + unsubscribe(); + this.currentNotificationHandler = null; + }); + stream.onError((error) => { + unsubscribe(); + this.currentNotificationHandler = null; + }); + + // 保存当前处理器信息 + this.currentNotificationHandler = { + unsubscribe, + stream, + sessionId: request.sessionId, + }; + + this.sendPrompt(promptRequest, stream); + + return stream; + } + + /** + * 异步发送 prompt(内部使用) + */ + private async sendPrompt( + promptRequest: { sessionId: string; prompt: ContentBlock[] }, + stream: SumiReadableStream, + ): Promise { + try { + await this.clientService.prompt(promptRequest); + stream.emitData({ type: 'done', content: '' }); + stream.end(); + } catch (error) { + stream.emitError(error instanceof Error ? error : new Error(String(error))); + } + } + + /** + * 处理通知 + * + * tool_call 通知仅用于 UI 展示,不触发权限弹窗。 + * 权限确认完全依赖 agent 发送的 session/request_permission JSON-RPC 请求(阻塞式), + * 由 AcpCliClientService.handleIncomingRequest → agentRequestHandler.handlePermissionRequest 处理。 + */ + private handleNotification(notification: SessionNotification, stream: SumiReadableStream): void { + const update = notification.update; + + switch (update.sessionUpdate) { + case 'agent_thought_chunk': { + const content = update.content; + if (content.type === 'text') { + stream.emitData({ + type: 'thought', + content: content.text, + }); + } + break; + } + + case 'agent_message_chunk': { + const content = update.content; + if (content.type === 'text') { + stream.emitData({ + type: 'message', + content: content.text, + }); + } + break; + } + + case 'tool_call': { + // tool_call 通知仅用于 UI 展示,不触发权限弹窗 + // 权限由 agent 通过 session/request_permission 请求阻塞式处理 + stream.emitData({ + type: 'tool_call', + content: update.title || '', + toolCall: { + name: update.title || '', + input: (update.rawInput as Record) || {}, + }, + }); + break; + } + + case 'tool_call_update': { + if (update.content) { + for (const content of update.content) { + if (content.type === 'diff') { + stream.emitData({ + type: 'tool_result', + content: `Modified ${content.path}`, + }); + } + } + } + break; + } + + default: + this.logger?.log(`Unhandled session update type: ${update.sessionUpdate}`); + break; + } + } + + /** + * 取消请求 + */ + async cancelRequest(sessionId: string): Promise { + if (!this.currentProcessId) { + this.logger?.warn('cancelRequest: Agent process not initialized'); + return; + } + + const cancelNotification: CancelNotification = { + sessionId, + }; + + try { + await this.clientService.cancel(cancelNotification); + } catch (error) {} + } + + async listSessions(params?: ListSessionsRequest): Promise { + return this.clientService.listSessions(params); + } + + async setSessionMode(params: SetSessionModeRequest): Promise { + await this.clientService.setSessionMode(params); + } + + async disposeSession(sessionId: string): Promise { + await this.terminalHandler.releaseSessionTerminals(sessionId); + } + + async getAvailableModes() { + return this.clientService.getSessionModes(); + } + + /** + * 停止 Agent 进程 + */ + async stopAgent(): Promise { + if (!this.currentProcessId) { + return; + } + + await this.processManager.stopAgent(); + + await this.clientService.close(); + + this.sessionInfo = null; + this.currentProcessId = null; + this.initializingPromise = null; + } + + /** + * 清理所有资源 + */ + async dispose(): Promise { + this.logger?.warn('[AcpAgentService] dispose called'); + + // 先取消断开事件订阅,防止后续清理操作触发 handler + if (this.disconnectUnsubscribe) { + this.disconnectUnsubscribe(); + this.disconnectUnsubscribe = null; + } + + if (this.currentNotificationHandler) { + this.currentNotificationHandler.stream.end(); + this.currentNotificationHandler.unsubscribe(); + this.currentNotificationHandler = null; + } + + await this.stopAgent(); + + await this.processManager.killAllAgents(); + + this.initializingPromise = null; + this.sessionInfo = null; + this.currentProcessId = null; + } + + private buildPromptBlocks(input: string, images?: string[]): ContentBlock[] { + const blocks: ContentBlock[] = []; + + blocks.push({ + type: 'text', + text: input, + }); + + if (images && images.length > 0) { + for (const imageData of images) { + const { mimeType, base64Data } = this.parseDataUrl(imageData); + blocks.push({ + type: 'image', + data: base64Data, + mimeType, + }); + } + } + + return blocks; + } + + private parseDataUrl(dataUrl: string): { mimeType: string; base64Data: string } { + if (dataUrl.startsWith('data:')) { + const matches = dataUrl.match(/^data:([^;]+);base64,(.+)$/); + if (matches) { + return { mimeType: matches[1], base64Data: matches[2] }; + } + } + // 默认返回 + return { mimeType: 'image/jpeg', base64Data: dataUrl }; + } +} diff --git a/packages/ai-native/src/node/acp/acp-cli-back.service.ts b/packages/ai-native/src/node/acp/acp-cli-back.service.ts new file mode 100644 index 0000000000..49bf5c0448 --- /dev/null +++ b/packages/ai-native/src/node/acp/acp-cli-back.service.ts @@ -0,0 +1,379 @@ +import { Autowired, Injectable } from '@opensumi/di'; +import { + AvailableCommand, + CancellationToken, + IAIBackService, + IAIBackServiceOption, + IAIBackServiceResponse, + IChatContent, + IChatProgress, + IChatReasoning, + ListSessionsRequest, + ListSessionsResponse, + SessionNotification, + SetSessionModeRequest, +} from '@opensumi/ide-core-common'; +import { AgentProcessConfig } from '@opensumi/ide-core-common/lib/types/ai-native/agent-types'; +import { ChatReadableStream, INodeLogger } from '@opensumi/ide-core-node'; +import { SumiReadableStream } from '@opensumi/ide-utils/lib/stream'; + +import { BaseLanguageModel } from '../base-language-model'; +import { OpenAICompatibleModel } from '../openai-compatible/openai-compatible-language-model'; + +import { + AcpAgentServiceToken, + AgentRequest, + AgentSessionInfo, + AgentUpdate, + IAcpAgentService, + SimpleMessage, +} from './acp-agent.service'; + +import type { CoreMessage } from 'ai'; + +export const AcpCliBackServiceToken = Symbol('AcpCliBackServiceToken'); + +/** + * Type guard to check if a value is a valid CoreMessage + */ +function isCoreMessage(msg: unknown): msg is CoreMessage { + if (!msg || typeof msg !== 'object') { + return false; + } + return 'role' in msg && 'content' in msg; +} + +/** + * Type guard to check if a content part is a text part + */ +function isTextContentPart(part: unknown): part is { type: 'text'; text: string } { + return ( + typeof part === 'object' && + part !== null && + 'type' in part && + (part as { type: string }).type === 'text' && + 'text' in part + ); +} + +function convertToSimpleMessage(msg?: CoreMessage): SimpleMessage { + if (!msg || !isCoreMessage(msg)) { + return { + role: 'user', + content: '', + }; + } + + let content: string; + if (typeof msg.content === 'string') { + content = msg.content; + } else if (Array.isArray(msg.content)) { + content = msg.content + .filter(isTextContentPart) + .map((part) => part.text) + .join('\n'); + } else { + content = String(msg.content ?? ''); + } + + return { + role: msg.role ?? 'user', + content, + }; +} + +function convertMessageHistory(history?: CoreMessage[]): SimpleMessage[] | undefined { + if (!history || history[0] === null) { + return undefined; + } + return history.map(convertToSimpleMessage); +} + +@Injectable() +export class AcpCliBackService implements IAIBackService { + @Autowired(AcpAgentServiceToken) + private agentService: IAcpAgentService; + + @Autowired(INodeLogger) + private readonly logger: INodeLogger; + + @Autowired(OpenAICompatibleModel) + private openAICompatibleModel: OpenAICompatibleModel; + + private isDisposing = false; + + // private registerProcessExitHandlers(): void { + // process.once('SIGTERM', () => { + // this.dispose().then(() => { + // process.exit(0); + // }); + // }); + + // process.once('SIGINT', () => { + // this.dispose().then(() => { + // process.exit(0); + // }); + // }); + // } + + async createSession( + config: AgentProcessConfig, + ): Promise<{ sessionId: string; availableCommands: AvailableCommand[] }> { + await this.ensureAgentInitialized(config); + return this.agentService.createSession(config); + } + + private async ensureAgentInitialized(config: AgentProcessConfig): Promise { + const existingSession = this.agentService.getSessionInfo(); + if (existingSession) { + return existingSession; + } + return this.agentService.initializeAgent(config); + } + + async request( + input: string, + options: IAIBackServiceOption, + cancelToken?: CancellationToken, + ): Promise { + return { + errorCode: -1, + errorMsg: 'request() is not supported. ', + } as IAIBackServiceResponse; + } + + async requestStream( + input: string, + options: IAIBackServiceOption, + cancelToken?: CancellationToken, + ): Promise> { + // Fallback to OpenAI-compatible API when ACP agent is not configured + if (!options.agentSessionConfig) { + return this.openAIRequestStream(input, options, cancelToken); + } + return this.agentRequestStream(input, options, cancelToken); + } + + private async openAIRequestStream( + input: string, + options: IAIBackServiceOption, + cancelToken?: CancellationToken, + ): Promise { + const stream = new ChatReadableStream(); + try { + await this.openAICompatibleModel.request(input, stream, options, cancelToken); + } catch (error) { + stream.emitError(error instanceof Error ? error : new Error(String(error))); + } + return stream; + } + + private agentRequestStream( + input: string, + options: IAIBackServiceOption, + cancelToken?: CancellationToken, + ): SumiReadableStream { + const stream = new SumiReadableStream(); + this.setupAgentStream(options.agentSessionConfig!, input, options, stream, cancelToken); + return stream; + } + + private async setupAgentStream( + config: AgentProcessConfig, + input: string, + options: IAIBackServiceOption, + stream: SumiReadableStream, + cancelToken?: CancellationToken, + ): Promise { + try { + if (!options.agentSessionConfig) { + throw Error('agentSessionConfig is required'); + } + + const sessionInfo = await this.ensureAgentInitialized(options.agentSessionConfig); + const sessionId = options.sessionId || sessionInfo.sessionId; + + const request: AgentRequest = { + sessionId, + prompt: input, + images: options.images, + history: convertMessageHistory(options.history), + }; + + const agentStream = this.agentService.sendMessage(request, config); + + cancelToken?.onCancellationRequested(async () => { + await this.agentService.cancelRequest(sessionId); + stream.end(); + }); + + agentStream.onData((update: AgentUpdate) => { + const progress = this.convertAgentUpdateToChatProgress(update); + if (progress) { + stream.emitData(progress); + } + if (update.type === 'done') { + stream.end(); + } + }); + + agentStream.onError((error) => { + stream.emitError(error instanceof Error ? error : new Error(String(error))); + }); + } catch (error) { + stream.emitError(error instanceof Error ? error : new Error(String(error))); + } + } + + private convertAgentUpdateToChatProgress(update: AgentUpdate): IChatProgress | null { + switch (update.type) { + case 'thought': + return { + kind: 'reasoning', + content: update.content, + } as IChatReasoning; + case 'message': + return { + kind: 'content', + content: update.content, + } as IChatContent; + case 'tool_call': + return null; + case 'tool_result': + return { + kind: 'content', + content: update.content, + } as IChatContent; + case 'done': + return null; + default: + return null; + } + } + + async loadAgentSession( + config: AgentProcessConfig, + sessionId: string, + ): Promise<{ + sessionId: string; + messages: Array<{ + role: 'user' | 'assistant'; + content: string; + timestamp?: number; + }>; + }> { + try { + const result = await this.agentService.loadSession(sessionId, config); + const messages = this.convertSessionUpdatesToMessages(result.historyUpdates); + return { + sessionId, + messages, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.logger.error(`Failed to load session ${sessionId}:`, errorMessage); + + // 抛出错误,让调用方感知实际错误 + throw new Error(`Failed to load session ${sessionId}: ${errorMessage}`); + } + } + + private convertSessionUpdatesToMessages( + updates: SessionNotification[], + ): Array<{ role: 'user' | 'assistant'; content: string; timestamp?: number }> { + const messages: Array<{ role: 'user' | 'assistant'; content: string; timestamp?: number }> = []; + + for (const notification of updates) { + const update = notification.update as any; + if (!update) { + continue; + } + + switch (update.sessionUpdate) { + case 'user_message_chunk': { + const content = update.content; + if (content?.type === 'text') { + messages.push({ + role: 'user', + content: content.text, + }); + } + break; + } + case 'agent_message_chunk': { + const content = update.content; + if (content?.type === 'text') { + messages.push({ + role: 'assistant', + content: content.text, + }); + } + break; + } + default: + break; + } + } + + return messages; + } + + async disposeSession(sessionId: string): Promise { + await this.cancelSession(sessionId); + try { + await this.agentService.disposeSession(sessionId); + } catch (error) { + this.logger.error(`Failed to release terminals for session ${sessionId}:`, error); + } + } + + async cancelSession(sessionId: string): Promise { + await this.agentService.cancelRequest(sessionId); + } + + async setSessionMode(sessionId: string, modeId: string): Promise { + const modeRequest: SetSessionModeRequest = { + sessionId, + modeId, + }; + try { + await this.agentService.setSessionMode(modeRequest); + } catch (error) { + this.logger.error(`Failed to switch mode to ${modeId}:`, error); + throw error; + } + } + + async listSessions(config: AgentProcessConfig): Promise { + const listParams: ListSessionsRequest = { + cwd: config.workspaceDir, + }; + await this.ensureAgentInitialized(config); + + try { + const response = await this.agentService.listSessions(listParams); + return { + sessions: response.sessions, + nextCursor: response.nextCursor, + }; + } catch (error) { + this.logger.error('Failed to list sessions:', error); + throw error; + } + } + + async dispose(): Promise { + if (this.isDisposing) { + return; + } + this.isDisposing = true; + await this.agentService.dispose(); + } + + /** + * 检查默认 rpc 是否就绪,直接返回true + */ + async ready(): Promise { + return true; + } +} diff --git a/packages/ai-native/src/node/acp/acp-cli-client.service.ts b/packages/ai-native/src/node/acp/acp-cli-client.service.ts new file mode 100644 index 0000000000..a4d76392cf --- /dev/null +++ b/packages/ai-native/src/node/acp/acp-cli-client.service.ts @@ -0,0 +1,593 @@ +/** + * ACP CLI 客户端服务 - 基于 NDJSON 格式的 JSON-RPC 2.0 传输层实现 + */ +import { Autowired, Injectable } from '@opensumi/di'; +import { + AgentCapabilities, + AuthMethod, + AuthenticateRequest, + AuthenticateResponse, + CancelNotification, + ExtendedInitializeResponse, + IAcpCliClientService, + InitializeRequest, + ListSessionsRequest, + ListSessionsResponse, + LoadSessionRequest, + LoadSessionResponse, + NewSessionRequest, + NewSessionResponse, + PromptRequest, + PromptResponse, + SessionModeState, + SessionNotification, + SetSessionModeRequest, + SetSessionModeResponse, +} from '@opensumi/ide-core-common'; +import { INodeLogger, Implementation } from '@opensumi/ide-core-node'; + +import { AcpAgentRequestHandler, AcpAgentRequestHandlerToken } from './handlers/agent-request.handler'; + +export const ACP_PROTOCOL_VERSION = 1; + +const ACP_NOT_CONNECTED_ERROR = 'Not connected to agent process'; + +type TransportState = 'disconnected' | 'connecting' | 'connected'; + +@Injectable() +export class AcpCliClientService implements IAcpCliClientService { + private stdout: NodeJS.ReadableStream | null = null; + private stdin: NodeJS.WritableStream | null = null; + private transportState: TransportState = 'disconnected'; + private requestId = 0; + private buffer = ''; + + private notificationHandlers: ((notification: SessionNotification) => void)[] = []; + + private negotiatedProtocolVersion: number | null = null; + private agentCapabilities: AgentCapabilities | null = null; + private agentInfo: Implementation | null = null; + private authMethods: AuthMethod[] = []; + private sessionModes: SessionModeState | null = null; + + private disconnectHandlers: (() => void)[] = []; + + @Autowired(INodeLogger) + private readonly logger: INodeLogger; + + @Autowired(AcpAgentRequestHandlerToken) + private agentRequestHandler: AcpAgentRequestHandler; + + /** + * 统一的可写性检查,替代分散在各处的连接状态判断 + */ + private ensureWritable(): void { + if (this.transportState !== 'connected' || !this.stdin) { + throw new Error(ACP_NOT_CONNECTED_ERROR); + } + } + + /** + * 订阅断开事件,供上层(如 AcpAgentService)监听并清理状态 + */ + onDisconnect(handler: () => void): () => void { + this.disconnectHandlers.push(handler); + return () => { + const index = this.disconnectHandlers.indexOf(handler); + if (index > -1) { + this.disconnectHandlers.splice(index, 1); + } + }; + } + + setTransport(stdout: NodeJS.ReadableStream, stdin: NodeJS.WritableStream): void { + // 先移除旧监听器,防止旧 stdout 的 end/error 事件触发 handleDisconnect + if (this.stdout) { + this.stdout.removeAllListeners(); + } + + if (this.stdin) { + try { + this.stdin.end(); + } catch (_) {} + } + + this.transportState = 'connecting'; + + // 拒绝 pending 请求 + for (const [, pending] of this.pendingRequests) { + pending.reject(new Error(ACP_NOT_CONNECTED_ERROR)); + } + this.pendingRequests.clear(); + + // 清空请求队列并拒绝所有待处理请求 + for (const request of this.requestQueue) { + request.reject(new Error(ACP_NOT_CONNECTED_ERROR)); + } + + this.requestQueue = []; + + this.negotiatedProtocolVersion = null; + this.agentCapabilities = null; + this.agentInfo = null; + this.authMethods = []; + this.sessionModes = null; + + this.stdout = stdout; + this.stdin = stdin; + + this.stdout.on('data', (data: Buffer) => { + this.handleData(data.toString('utf8')); + }); + + this.stdout.on('end', () => { + this.logger?.error('[ACP] stdout ended - connection lost'); + this.handleDisconnect(); + }); + + this.stdout.on('error', (err) => { + this.logger?.error('[ACP] stdout error - connection lost:', err); + this.handleDisconnect(); + }); + + this.buffer = ''; + + this.transportState = 'connected'; + } + + async initialize(params?: InitializeRequest): Promise { + this.ensureWritable(); + + const initParams: InitializeRequest = params || { + protocolVersion: ACP_PROTOCOL_VERSION, + clientCapabilities: { + fs: { + readTextFile: true, + writeTextFile: true, + }, + terminal: true, + }, + clientInfo: { + name: 'opensumi', + title: 'OpenSumi IDE', + version: '3.0.0', + }, + }; + + initParams.protocolVersion = initParams.protocolVersion || ACP_PROTOCOL_VERSION; + + const response = await this.sendRequest('initialize', initParams); + + if (response.protocolVersion !== initParams.protocolVersion) { + this.logger?.warn( + `Agent responded with different protocol version: ${response.protocolVersion}. ` + + `Client requested: ${initParams.protocolVersion}`, + ); + + if (response.protocolVersion > ACP_PROTOCOL_VERSION) { + await this.close(); + throw new Error( + 'Unsupported protocol version: ' + + response.protocolVersion + + '. ' + + 'This client supports up to version ' + + ACP_PROTOCOL_VERSION + + '. ' + + 'Please update the client to use the latest version.', + ); + } + } + + this.negotiatedProtocolVersion = response.protocolVersion; + + if (response.agentCapabilities) { + this.agentCapabilities = response.agentCapabilities; + } + + if (response.agentInfo) { + this.agentInfo = response.agentInfo; + } + + if (response.authMethods && response.authMethods.length > 0) { + this.authMethods = response.authMethods; + } + + if (response.modes) { + this.sessionModes = response.modes; + } + + return response; + } + + async authenticate(params: AuthenticateRequest): Promise { + return this.sendRequest('authenticate', params); + } + + async newSession(params: NewSessionRequest): Promise { + return this.sendRequest('session/new', params); + } + + async loadSession(params: LoadSessionRequest): Promise { + return this.sendRequest('session/load', params); + } + + async listSessions(params?: ListSessionsRequest): Promise { + return this.sendRequest('session/list', params); + } + + async prompt(params: PromptRequest): Promise { + return this.sendRequest('session/prompt', params); + } + + async cancel(params: CancelNotification): Promise { + this.sendNotification('session/cancel', params); + } + + async setSessionMode(params: SetSessionModeRequest): Promise { + return this.sendRequest('session/set_mode', params); + } + + onNotification(handler: (notification: SessionNotification) => void): () => void { + this.notificationHandlers.push(handler); + return () => { + const index = this.notificationHandlers.indexOf(handler); + if (index > -1) { + this.notificationHandlers.splice(index, 1); + } + }; + } + + async close(): Promise { + this.handleDisconnect(); + + this.notificationHandlers = []; + this.disconnectHandlers = []; + + if (this.stdout) { + this.stdout.removeAllListeners(); + } + + if (this.stdin) { + try { + this.stdin.end(); + } catch (_) {} + } + + this.stdout = null; + this.stdin = null; + this.buffer = ''; + } + + isConnected(): boolean { + return this.transportState === 'connected'; + } + + private pendingRequests = new Map< + string | number, + { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + } + >(); + + // 请求队列,确保按顺序发送请求 + private requestQueue: Array<{ + method: string; + params: unknown; + resolve: (value: unknown) => void; + reject: (error: Error) => void; + }> = []; + private isProcessingRequest = false; + + private async sendRequest(method: string, params: unknown): Promise { + this.ensureWritable(); + + return new Promise((resolve, reject) => { + // 将请求加入队列 + this.requestQueue.push({ + method, + params, + resolve, + reject, + }); + + // 处理队列 + this.processRequestQueue(); + }); + } + + private processRequestQueue(): void { + // 如果正在处理请求或队列为空,则直接返回 + if (this.isProcessingRequest || this.requestQueue.length === 0) { + return; + } + + // 检查连接状态 + if (this.transportState !== 'connected' || !this.stdin) { + while (this.requestQueue.length > 0) { + const request = this.requestQueue.shift(); + if (request) { + request.reject(new Error(ACP_NOT_CONNECTED_ERROR)); + } + } + return; + } + + this.isProcessingRequest = true; + + // 取出队列中的第一个请求 + const request = this.requestQueue.shift(); + + if (!request) { + this.isProcessingRequest = false; + return; + } + + const id = ++this.requestId; + + this.logger?.log(`[ACP] Sending request: ${request.method} (id=${id}) ${JSON.stringify(request.params)}`); + + this.pendingRequests.set(id, { + resolve: (value: unknown) => { + this.isProcessingRequest = false; + request.resolve(value); + // 处理下一个请求 + this.processRequestQueue(); + }, + reject: (error: Error) => { + this.isProcessingRequest = false; + request.reject(error); + // 处理下一个请求 + this.processRequestQueue(); + }, + }); + + try { + const message = { jsonrpc: '2.0', id, method: request.method, params: request.params }; + const json = JSON.stringify(message); + + // 在写入前再次检查流的状态 + if (this.transportState !== 'connected' || !this.stdin || !(this.stdin as NodeJS.WritableStream).writable) { + this.pendingRequests.delete(id); + this.isProcessingRequest = false; + request.reject(new Error(ACP_NOT_CONNECTED_ERROR)); + this.processRequestQueue(); + return; + } + + this.stdin.write(json + '\n'); + this.logger?.debug(`[ACP] Sent JSON: ${json}`); + } catch (error) { + // 写入失败时,handleDisconnect 会 reject 所有 pending 请求并清空队列 + this.handleDisconnect(); + } + } + + private sendNotification(method: string, params?: unknown): void { + if (this.transportState !== 'connected' || !this.stdin) { + return; + } + + const message = { jsonrpc: '2.0', method, params }; + const json = JSON.stringify(message); + + try { + this.stdin.write(json + '\n'); + } catch (error) { + this.logger?.warn(`[ACP] Failed to send notification: ${method}`, error); + } + } + + private handleData(dataStr: string): void { + this.buffer += dataStr; + + const lines = this.buffer.split('\n'); + this.buffer = lines.pop() || ''; + + for (const line of lines) { + const trimmedLine = line.trim(); + if (!trimmedLine) { + continue; + } + + try { + const message = JSON.parse(trimmedLine); + // this.logger?.debug('[ACP] Parsed message:', JSON.stringify(message, null, 2).substring(0, 400)); + this.handleMessage(message); + } catch (error) { + this.logger?.error('Failed to parse ACP JSON-RPC message:', { + line: trimmedLine, + error, + }); + } + } + } + + private handleMessage(message: any): void { + if ('id' in message && ('result' in message || 'error' in message)) { + this.handleResponse(message); + } else if ('id' in message && 'method' in message) { + this.handleIncomingRequest(message); + } else if ('method' in message && !('id' in message)) { + this.handleIncomingNotification(message); + } else { + this.logger?.warn(`Invalid ACP JSON-RPC message: ${JSON.stringify(message)}`); + } + } + + private handleResponse(response: { + jsonrpc: '2.0'; + id: string | number; + result?: unknown; + error?: { code: number; message: string; data?: unknown }; + }): void { + const pending = this.pendingRequests.get(response.id); + if (pending) { + this.logger?.log(`[ACP] Matching response to request id=${response.id}`); + this.pendingRequests.delete(response.id); + + if (response.error) { + this.logger?.error(`[ACP] Request id=${response.id} failed:`, response.error); + pending.reject(this.createError(response.error)); + } else { + this.logger?.log(`[ACP] Request id=${response.id} succeeded`); + pending.resolve(response.result); + } + } else { + this.logger?.warn( + `Response received for unknown request id: ${response.id}. ` + 'This may be a late arrival after timeout.', + ); + } + } + + private async handleIncomingRequest(message: { + jsonrpc: '2.0'; + id: string | number; + method: string; + params?: unknown; + }): Promise { + try { + let result: unknown; + switch (message.method) { + case 'fs/read_text_file': + result = await this.agentRequestHandler.handleReadTextFile(message.params as any); + break; + case 'fs/write_text_file': + result = await this.agentRequestHandler.handleWriteTextFile(message.params as any); + break; + case 'session/request_permission': + result = await this.agentRequestHandler.handlePermissionRequest(message.params as any); + break; + case 'terminal/create': + result = await this.agentRequestHandler.handleCreateTerminal(message.params as any); + break; + case 'terminal/output': + result = await this.agentRequestHandler.handleTerminalOutput(message.params as any); + break; + case 'terminal/wait_for_exit': + result = await this.agentRequestHandler.handleWaitForTerminalExit(message.params as any); + break; + case 'terminal/kill': + result = await this.agentRequestHandler.handleKillTerminal(message.params as any); + break; + case 'terminal/release': + result = await this.agentRequestHandler.handleReleaseTerminal(message.params as any); + break; + default: + this.logger?.warn(`Unknown incoming request method: ${message.method}`); + this.sendMessage({ + jsonrpc: '2.0', + id: message.id, + error: { code: -32601, message: `Method not found: ${message.method}` }, + }); + return; + } + this.sendMessage({ jsonrpc: '2.0', id: message.id, result }); + } catch (err: any) { + try { + this.sendMessage({ + jsonrpc: '2.0', + id: message.id, + error: { code: err.code || -32603, message: err.message || `Internal error: ${JSON.stringify(message)}` }, + }); + } catch (_) { + this.logger?.warn(`[ACP] Failed to send error response for ${message.method}: disconnected`); + } + } + } + + private handleIncomingNotification(message: { jsonrpc: '2.0'; method: string; params?: unknown }): void { + if (message.method === 'session/update') { + const notification = message.params as SessionNotification; + + if (notification.update?.sessionUpdate === 'current_mode_update' && notification.update?.currentModeId) { + if (this.sessionModes) { + this.sessionModes.currentModeId = notification.update.currentModeId; + } else { + this.logger?.warn('[ACP] Received current_mode_update but sessionModes is not initialized'); + } + } + + for (const handler of [...this.notificationHandlers]) { + handler(notification); + } + } + } + + private sendMessage(message: { + jsonrpc: '2.0'; + id?: string | number; + method?: string; + params?: unknown; + result?: unknown; + error?: { code: number; message: string; data?: unknown }; + }): void { + this.ensureWritable(); + this.stdin!.write(JSON.stringify(message) + '\n'); + } + + public handleDisconnect(): void { + if (this.transportState === 'disconnected') { + return; + } + + this.transportState = 'disconnected'; + + this.negotiatedProtocolVersion = null; + this.agentCapabilities = null; + this.agentInfo = null; + this.authMethods = []; + this.sessionModes = null; + + for (const [, pending] of this.pendingRequests) { + pending.reject(new Error(ACP_NOT_CONNECTED_ERROR)); + } + this.pendingRequests.clear(); + + for (const request of this.requestQueue) { + request.reject(new Error(ACP_NOT_CONNECTED_ERROR)); + } + this.requestQueue = []; + this.isProcessingRequest = false; + + // 通知上层(如 AcpAgentService)连接已断开 + for (const handler of [...this.disconnectHandlers]) { + try { + handler(); + } catch (e) { + this.logger?.error('[ACP] Disconnect handler error:', e); + } + } + + this.logger?.warn('[ACP] Connection lost'); + } + + private createError(error: { code: number; message: string; data?: unknown }): Error { + const err = new Error(error.message); + (err as any).code = error.code; + if (error.data !== undefined) { + (err as any).data = error.data; + } + return err; + } + + getNegotiatedProtocolVersion(): number | null { + return this.negotiatedProtocolVersion; + } + + getAgentCapabilities(): AgentCapabilities | null { + return this.agentCapabilities; + } + + getAgentInfo(): Implementation | null { + return this.agentInfo; + } + + getAuthMethods(): AuthMethod[] { + return this.authMethods; + } + + getSessionModes(): SessionModeState | null { + return this.sessionModes; + } +} diff --git a/packages/ai-native/src/node/acp/acp-permission-caller.service.ts b/packages/ai-native/src/node/acp/acp-permission-caller.service.ts new file mode 100644 index 0000000000..caabc412e7 --- /dev/null +++ b/packages/ai-native/src/node/acp/acp-permission-caller.service.ts @@ -0,0 +1,222 @@ +import { Autowired, Injectable } from '@opensumi/di'; +import { RPCService } from '@opensumi/ide-connection'; +import { INodeLogger } from '@opensumi/ide-core-node'; + +import type { + AcpPermissionDecision, + AcpPermissionDialogParams, + IAcpPermissionCaller, + IAcpPermissionService, + PermissionOption, + PermissionOptionKind, + RequestPermissionRequest, + RequestPermissionResponse, +} from '@opensumi/ide-core-common/lib/types/ai-native/acp-types'; + +export const AcpPermissionCallerManagerToken = Symbol('AcpPermissionCallerManagerToken'); + +/** + * ACP Permission Caller Manager + * + */ +@Injectable() +export class AcpPermissionCallerManager extends RPCService implements IAcpPermissionCaller { + @Autowired(INodeLogger) + private readonly logger: INodeLogger; + + /** + * 当前活跃的 RPC 客户端(所有连接共享) + * + */ + private static currentRpcClient: IAcpPermissionService | null = null; + + private clientId: string | undefined; + + /** + * 设置连接 clientId + * + * 注意:框架调用 setConnectionClientId 后才设置 rpcClient, + * 因此需要使用微任务延迟赋值,确保 rpcClient 已经准备好 + */ + setConnectionClientId(clientId: string): void { + this.clientId = clientId; + + Promise.resolve().then(() => { + AcpPermissionCallerManager.currentRpcClient = this.client || null; + }); + } + + removeConnectionClientId(clientId: string): void { + if (this.clientId === clientId) { + if (AcpPermissionCallerManager.currentRpcClient === this.client) { + AcpPermissionCallerManager.currentRpcClient = null; + } + this.clientId = undefined; + } + } + + /** + * Request permission from the user via browser dialog + */ + async requestPermission(request: RequestPermissionRequest): Promise { + // Check environment variable to skip permission confirmation + // Set SKIP_PERMISSION_CHECK=true to always allow without dialog + const skipPermissionCheck = process.env.SKIP_PERMISSION_CHECK === 'true'; + + if (skipPermissionCheck) { + const allowOptionId = this.findAllowOptionId(request.options); + return { + outcome: { + outcome: 'selected' as const, + optionId: allowOptionId, + }, + }; + } + + // 原有逻辑:等待前端弹窗返回 + const rpcClient = AcpPermissionCallerManager.currentRpcClient || this.client; + + if (!rpcClient) { + throw new Error('[ACP Permission Caller] No active RPC client available'); + } + + const dialogParams: AcpPermissionDialogParams = { + requestId: `${request.sessionId}:${request.toolCall.toolCallId}`, + sessionId: request.sessionId, + title: request.toolCall.title ?? 'Permission Request', + kind: request.toolCall.kind ?? undefined, + content: this.buildPermissionContent(request), + locations: request.toolCall.locations?.map((loc) => ({ + path: loc.path, + line: loc.line ?? undefined, + })), + options: this.sortOptionsByKind(request.options), + timeout: 60000, + }; + + const decision = await rpcClient.$showPermissionDialog(dialogParams); + + return this.buildPermissionResponse(decision, request.options); + } + + /** + * Find the first "allow" option from the options list + */ + private findAllowOptionId(options: PermissionOption[]): string { + // 优先返回 allow_once + const allowOnce = options.find((o) => o.kind === 'allow_once'); + if (allowOnce) { + return allowOnce.optionId; + } + // 其次返回 allow_always + const allowAlways = options.find((o) => o.kind === 'allow_always'); + if (allowAlways) { + return allowAlways.optionId; + } + // 兜底返回第一个选项 + return options[0]?.optionId || ''; + } + + /** + * Cancel a pending permission request + */ + async cancelRequest(requestId: string): Promise { + try { + const rpcClient = AcpPermissionCallerManager.currentRpcClient || this.client; + if (rpcClient) { + await rpcClient.$cancelRequest(requestId); + } + } catch (error) { + this.logger.error('[ACP Permission Caller] Failed to cancel request:', error); + } + } + + private buildPermissionContent(request: RequestPermissionRequest): string { + const parts: string[] = []; + + if (request.toolCall.title) { + parts.push(`${request.toolCall.title}`); + } + + if (request.toolCall.locations?.length) { + const files = request.toolCall.locations.map((loc) => loc.path).join(', '); + parts.push(`Affected files: ${files}`); + } + + const command = (request.toolCall.rawInput as Record)?.command; + if (command) { + parts.push(`Command: \`${command}\``); + } + + return parts.join('\n\n'); + } + + private buildPermissionResponse( + decision: AcpPermissionDecision, + options: PermissionOption[], + ): RequestPermissionResponse { + switch (decision.type) { + case 'allow': + case 'reject': { + const optionId = decision.optionId || this.findOptionId(decision.type, options); + return { + outcome: { + outcome: 'selected' as const, + optionId, + }, + }; + } + case 'timeout': + case 'cancelled': + return { + outcome: { + outcome: 'cancelled' as const, + }, + }; + default: + return { + outcome: { + outcome: 'cancelled' as const, + }, + }; + } + } + + private findOptionId(decisionType: 'allow' | 'reject', options: PermissionOption[]): string { + const kinds = decisionType === 'allow' ? ['allow_once', 'allow_always'] : ['reject_once', 'reject_always']; + + for (const kind of kinds) { + const option = options.find((o) => o.kind === kind); + if (option) { + return option.optionId; + } + } + + const prefix = decisionType === 'allow' ? 'allow' : 'reject'; + const anyMatching = options.find((o) => o.kind.startsWith(prefix)); + if (anyMatching) { + return anyMatching.optionId; + } + + return options[0]?.optionId || ''; + } + + /** + * Sort permission options by kind to ensure consistent display order + * Order: allow_always > allow_once > reject_always > reject_once + */ + private sortOptionsByKind(options: PermissionOption[]): PermissionOption[] { + const kindOrder: Record = { + allow_always: 0, + allow_once: 1, + reject_always: 2, + reject_once: 3, + }; + + return [...options].sort((a, b) => { + const orderA = kindOrder[a.kind] ?? Number.MAX_SAFE_INTEGER; + const orderB = kindOrder[b.kind] ?? Number.MAX_SAFE_INTEGER; + return orderA - orderB; + }); + } +} diff --git a/packages/ai-native/src/node/acp/cli-agent-process-manager.ts b/packages/ai-native/src/node/acp/cli-agent-process-manager.ts new file mode 100644 index 0000000000..34cb853648 --- /dev/null +++ b/packages/ai-native/src/node/acp/cli-agent-process-manager.ts @@ -0,0 +1,446 @@ +/** + * CLI Agent 进程管理器 + * + * 以单一实例模式管理 ACP CLI Agent 子进程的完整生命周期: + * - 整个应用只维护一个 Agent 进程实例(singleton) + * - startAgent:若进程已存在且仍在运行则直接复用,否则停止旧进程后重新创建 + * - 提供优雅关闭(SIGTERM)和强制杀进程(SIGKILL)两种停止策略 + * - 暴露 isRunning / getExitCode / listRunningAgents 等状态查询接口 + */ +import { ChildProcess, spawn } from 'child_process'; + +import { Autowired, Injectable } from '@opensumi/di'; +import { INodeLogger } from '@opensumi/ide-core-node'; + +export const CliAgentProcessManagerToken = Symbol('CliAgentProcessManagerToken'); + +/** + * 进程配置常量 + */ +const PROCESS_CONFIG = { + /** 优雅关闭超时时间(毫秒) */ + GRACEFUL_SHUTDOWN_TIMEOUT_MS: 5000, + /** 强制杀死超时时间(毫秒) */ + FORCE_KILL_TIMEOUT_MS: 3000, + /** 启动超时时间(毫秒) */ + STARTUP_TIMEOUT_MS: 100, +} as const; + +/** + * 单一实例模式的 CLI Agent 进程管理器 + * 整个应用生命周期内只维护一个 Agent 进程实例 + */ +export interface ICliAgentProcessManager { + /** + * 启动或返回已有的 Agent 进程 + * 如果进程已存在且仍在运行,直接返回已有进程 + * 如果进程已退出,清理后重新创建 + * 如果调用参数与现有进程不同,会先停止现有进程再创建新的 + */ + startAgent( + command: string, + args: string[], + env: Record, + cwd: string, + ): Promise<{ processId: string; stdout: NodeJS.ReadableStream; stdin: NodeJS.WritableStream }>; + /** + * 停止当前运行的 Agent 进程 + * 单一实例模式下,processId 参数被忽略 + */ + stopAgent(): Promise; + /** + * 强制杀死当前运行的 Agent 进程 + * 单一实例模式下,processId 参数被忽略 + */ + killAgent(): Promise; + /** + * 检查当前进程是否仍在运行 + * 单一实例模式下,processId 参数被忽略 + */ + isRunning(): boolean; + /** + * 获取当前进程的退出码 + * 单一实例模式下,processId 参数被忽略 + */ + getExitCode(): number | null; + /** + * 列出所有运行的 Agent 进程 + * 单一实例模式下,最多返回一个进程 ID + */ + listRunningAgents(): string[]; + /** + * 杀死所有 Agent 进程 + * 单一实例模式下,等同于 killAgent + */ + killAllAgents(): Promise; +} + +/** + * 单一实例模式的 CLI Agent 进程管理器 + * + * 设计原则: + * 1. 整个应用生命周期内只维护一个 Agent 进程实例 + * 2. startAgent 返回已有的进程(如果已存在且仍在运行) + * 3. 如果进程已退出,清理后重新创建 + * 4. 如果调用参数与现有进程不同,先停止现有进程再创建新的 + */ +@Injectable() +export class CliAgentProcessManager implements ICliAgentProcessManager { + // 直接持有 ChildProcess 对象,不需要包装 + private currentProcess: ChildProcess | null = null; + // 单独跟踪 command 和 cwd,因为 ChildProcess 没有这些属性 + private currentCommand: string | null = null; + private currentCwd: string | null = null; + + // 固定进程 ID(单一实例模式使用常量) + private readonly SINGLETON_PROCESS_ID = 'singleton-agent-process'; + + @Autowired(INodeLogger) + private readonly logger: INodeLogger; + + /** + * 判断进程是否在运行(三合一检查) + * 1. process.killed - 是否被标记为杀死 + * 2. process.exitCode !== null - 是否已有退出码 + * 3. process.kill(pid, 0) - 确认进程是否实际存在 + */ + private isProcessRunning(): boolean { + if (!this.currentProcess) { + return false; + } + + // 被标记为 killed 或已有退出码,说明进程已退出 + if (this.currentProcess.killed || this.currentProcess.exitCode !== null) { + return false; + } + + // pid 不存在,说明进程未启动完成 + if (!this.currentProcess.pid) { + return false; + } + + // 使用 process.kill(0) 确认进程是否存在(不发送信号,仅检查)__抛出异常__:进程不存在或没有权限,进入 `catch` 块返回 `false` + try { + process.kill(this.currentProcess.pid, 0); + return true; + } catch { + // 进程不存在 + return false; + } + } + + /** + * 比较配置是否相同(检查 command 和 cwd) + */ + private isConfigSame(command: string, args: string[], env: Record, cwd: string): boolean { + return command === this.currentCommand && cwd === this.currentCwd; + } + + /** + * 启动或返回已有的 Agent 进程 + * + * 行为: + * 1. 如果已有进程且仍在运行,直接返回 + * 2. 如果已有进程但已退出,清理后重新创建 + * 3. 如果调用参数与现有进程不同,先停止现有进程再创建新的 + */ + async startAgent( + command: string, + args: string[], + env: Record, + cwd: string, + ): Promise<{ processId: string; stdout: NodeJS.ReadableStream; stdin: NodeJS.WritableStream }> { + this.logger?.log(`[CliAgentProcessManager] startAgent called: command=${command}, cwd=${cwd}`); + // todo 避免多次创建,需要加一个创建中拦截 + // 检查是否已有进程且仍在运行 + if (this.currentProcess && this.isProcessRunning()) { + // 检查配置是否相同 + const isConfigSame = this.isConfigSame(command, args, env, cwd); + if (isConfigSame) { + this.logger?.log('[CliAgentProcessManager] Reusing existing running process'); + return { + processId: this.currentProcess.pid!.toString(), + stdout: this.currentProcess.stdio[1] as NodeJS.ReadableStream, + stdin: this.currentProcess.stdio[0] as NodeJS.WritableStream, + }; + } else { + // 配置不同,先停止现有进程 + this.logger?.log('[CliAgentProcessManager] Config changed, stopping existing process'); + await this.stopAgentInternal(); + } + } else if (this.currentProcess) { + // 进程已退出,自动清理(exit 事件应该已经处理了) + this.logger?.log('[CliAgentProcessManager] Previous process exited, cleaning up'); + this.currentProcess = null; + this.currentCommand = null; + this.currentCwd = null; + } + + // 创建新进程 + this.logger?.log('[CliAgentProcessManager] Creating new agent process'); + const childProcess = await this.createAgentProcess(command, args, env, cwd); + this.currentProcess = childProcess; + this.currentCommand = command; + this.currentCwd = cwd; + + this.logger?.log(`[CliAgentProcessManager] Agent process started with PID: ${childProcess.pid}`); + + return { + processId: this.currentProcess.pid!.toString(), + stdout: childProcess.stdio[1] as NodeJS.ReadableStream, + stdin: childProcess.stdio[0] as NodeJS.WritableStream, + }; + } + + /** + * 创建新的 Agent 进程 + */ + private async createAgentProcess( + command: string, + args: string[], + env: Record, + cwd: string, + ): Promise { + // 从环境变量读取 Agent 命令路径,默认使用 command 参数 + // 通过设置 SUMI_ACP_AGENT_PATH 环境变量,可以指定 ACP Agent 的完整路径 + // 例如:export SUMI_ACP_AGENT_PATH=/usr/local/bin/claude-agent-acp + // 注意:如果设置了此环境变量,将覆盖 command 参数 + const agentPath = process.env.SUMI_ACP_AGENT_PATH || command; + const nodePath = process.env.SUMI_ACP_NODE_PATH || command; + const nodeBinDir = nodePath.substring(0, nodePath.lastIndexOf('/')); + this.logger?.log(`[CliAgentProcessManager] Using Agent path: ${agentPath}`); + this.logger?.log(`[CliAgentProcessManager] Spawning ACP Agent: ${agentPath} ${args.join(' ')}`); + this.logger?.log(`[CliAgentProcessManager] Spawning node path: ${nodePath} ${args.join(' ')}`); + + const newEnv = { + ...process.env, + ...env, + NODE: `${nodeBinDir}/node`, + PATH: `${nodeBinDir}:${process.env.PATH || ''}`, + }; + + const childProcess = spawn(agentPath, args, { + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + detached: false, + shell: false, + env: newEnv, + }); + + return new Promise((resolve, reject) => { + let startupError: Error | null = null; + + // Handle startup errors + childProcess.on('error', (err: Error) => { + this.logger?.error(`Failed to start agent process: ${err.message}`); + startupError = err; + reject(this.wrapError(err, command)); + }); + + childProcess.stderr?.on('data', (data: Buffer) => { + const stderr = data.toString('utf8'); + this.logger?.warn('[CliAgentProcessManager] Agent stderr:', stderr); + }); + + childProcess.on('exit', (code: number | null, signal: string | null) => { + this.logger?.log(`[CliAgentProcessManager] Child process exit event: code=${code}, signal=${signal}`); + this.handleProcessExit(code, signal); + }); + + setTimeout(() => { + if (startupError) { + return; + } + + if (childProcess.pid) { + resolve(childProcess); + } else { + reject(new Error(`Failed to get PID for agent process: ${command}`)); + } + }, PROCESS_CONFIG.STARTUP_TIMEOUT_MS); + }); + } + + /** + * 处理进程退出 - 自动清理状态 + */ + private handleProcessExit(code: number | null, signal: string | null): void { + this.logger?.log(`[CliAgentProcessManager] Process exited: code=${code}, signal=${signal}`); + + // 进程退出后自动清空引用 + this.currentProcess = null; + this.currentCommand = null; + this.currentCwd = null; + } + + /** + * 杀死进程组 + * 尝试用 -pid kill 进程组,失败后 fallback 到单个进程 kill + * @param pid - 进程 ID + * @param signal - 信号类型 + * @returns 是否成功 + */ + private killProcessGroup(pid: number, signal: NodeJS.Signals): boolean { + try { + // 尝试发送信号到进程组 + process.kill(-pid, signal); + this.logger?.log(`[CliAgentProcessManager] Sent ${signal} to process group -${pid}`); + return true; + } catch (err) { + // 如果进程组 kill 失败,尝试直接 kill 单个进程 + this.logger?.log(`[CliAgentProcessManager] Process group kill failed, trying single process kill for ${pid}`); + try { + process.kill(pid, signal); + this.logger?.log(`[CliAgentProcessManager] Sent ${signal} to process ${pid}`); + return true; + } catch (err2) { + this.logger?.warn(`[CliAgentProcessManager] Error sending ${signal}:`, err2); + return false; + } + } + } + + /** + * 停止当前运行的 Agent 进程(内部方法) + */ + private async stopAgentInternal(): Promise { + if (!this.currentProcess) { + return; + } + + this.logger?.log('[CliAgentProcessManager] Stopping agent process gracefully'); + return new Promise((resolve) => { + if (!this.currentProcess) { + resolve(); + return; + } + + // 1. 先发送 SIGTERM,让进程优雅关闭 + const pid = this.currentProcess.pid; + if (pid) { + this.killProcessGroup(pid, 'SIGTERM'); + } + + // 2. 设置超时,超时后强制杀死 + const forceKillTimeout = setTimeout(() => { + if (this.currentProcess && !this.currentProcess.killed) { + this.logger?.warn('[CliAgentProcessManager] Agent did not exit gracefully, forcing kill'); + if (this.currentProcess.pid) { + this.killProcessGroup(this.currentProcess.pid, 'SIGKILL'); + } + } + resolve(); + }, PROCESS_CONFIG.GRACEFUL_SHUTDOWN_TIMEOUT_MS); + + // 3. 监听进程退出,提前 resolve + this.currentProcess.once('exit', () => { + clearTimeout(forceKillTimeout); + resolve(); + }); + }); + } + + /** + * 停止当前运行的 Agent 进程 + */ + async stopAgent(): Promise { + if (!this.currentProcess) { + this.logger?.warn('[CliAgentProcessManager] Cannot stop agent: process not found'); + return; + } + + await this.stopAgentInternal(); + } + + /** + * 强制杀死当前运行的 Agent 进程 + */ + async killAgent(): Promise { + this.logger?.log('[CliAgentProcessManager] Force killing agent process'); + await this.forceKillInternal(); + } + + /** + * 强制杀死进程(内部方法) + * 使用 -pid 杀死整个进程组,确保子进程也被杀死 + */ + private async forceKillInternal(): Promise { + if (!this.currentProcess || !this.currentProcess.pid) { + this.currentProcess = null; + return; + } + + const pid = this.currentProcess.pid; + + // 记录调用堆栈,便于追踪是谁触发了强制杀死 + const stackTrace = new Error('forceKillInternal called').stack; + this.logger?.debug(`[CliAgentProcessManager] forceKillInternal called for PID ${pid}`, stackTrace); + + // 使用负数 PID 杀死整个进程组(包括子进程) + // 注意:需要使用 process.kill(-pid, signal) 而不是 this.currentProcess.kill(signal) + this.killProcessGroup(pid, 'SIGKILL'); + + // 等待进程退出或超时 + return new Promise((resolve) => { + const timeout = setTimeout(() => { + this.logger?.warn(`[CliAgentProcessManager] Force kill timeout for PID ${pid}, clearing reference`); + this.currentProcess = null; + this.currentCommand = null; + this.currentCwd = null; + resolve(); + }, PROCESS_CONFIG.FORCE_KILL_TIMEOUT_MS); + + // 统一使用 exit 事件监听,超时机制确保引用最终被清理 + this.currentProcess!.once('exit', () => { + clearTimeout(timeout); + this.logger?.log(`[CliAgentProcessManager] Process ${pid} exited, clearing reference`); + this.currentProcess = null; + this.currentCommand = null; + this.currentCwd = null; + resolve(); + }); + }); + } + + /** + * 检查当前进程是否仍在运行 + */ + isRunning(): boolean { + return this.isProcessRunning(); + } + + /** + * 获取当前进程的退出码 + */ + getExitCode(): number | null { + return this.currentProcess?.exitCode ?? null; + } + + /** + * 列出所有运行的 Agent 进程 + */ + listRunningAgents(): string[] { + if (this.currentProcess && this.isProcessRunning()) { + return [this.SINGLETON_PROCESS_ID]; + } + return []; + } + + /** + * 杀死所有 Agent 进程 + */ + async killAllAgents(): Promise { + this.logger?.log('[CliAgentProcessManager] Killing all agent processes'); + await this.forceKillInternal(); + } + + private wrapError(err: Error, command: string): Error { + if ((err as any).code === 'ENOENT') { + return new Error(`Command not found: ${command}. Please ensure the CLI agent is installed.`); + } + if ((err as any).code === 'EACCES' || (err as any).code === 'EPERM') { + return new Error(`Permission denied when executing: ${command}`); + } + return err; + } +} diff --git a/packages/ai-native/src/node/acp/handlers/agent-request.handler.ts b/packages/ai-native/src/node/acp/handlers/agent-request.handler.ts new file mode 100644 index 0000000000..5c39f0c981 --- /dev/null +++ b/packages/ai-native/src/node/acp/handlers/agent-request.handler.ts @@ -0,0 +1,364 @@ +/** + * ACP Agent 请求处理器 + * + * 路由并处理 CLI Agent 通过 JSON-RPC 主动发起的请求(Agent → Client): + * - 文件操作:handleReadTextFile / handleWriteTextFile(写入前需用户授权) + * - 终端操作:handleCreateTerminal / handleTerminalOutput / handleWaitForTerminalExit / handleKillTerminal / handleReleaseTerminal(创建前需用户授权) + * - 权限确认:handlePermissionRequest,通过 AcpPermissionCallerManager 在浏览器端弹出对话框 + * + * 设计说明: + * - 在主 Injector 中作为单例创建,与特定 RPC 连接无关 + * - 权限对话框通过 AcpPermissionCallerManager 静态变量路由到当前活跃 Browser Tab + */ +import { Autowired, Injectable } from '@opensumi/di'; +import { + CreateTerminalRequest, + CreateTerminalResponse, + KillTerminalCommandRequest, + KillTerminalCommandResponse, + ReadTextFileRequest, + ReadTextFileResponse, + ReleaseTerminalRequest, + ReleaseTerminalResponse, + RequestPermissionRequest, + RequestPermissionResponse, + TerminalOutputRequest, + TerminalOutputResponse, + WaitForTerminalExitRequest, + WaitForTerminalExitResponse, + WriteTextFileRequest, + WriteTextFileResponse, +} from '@opensumi/ide-core-common/lib/types/ai-native'; +import { INodeLogger } from '@opensumi/ide-core-node'; + +import { AcpPermissionCallerManagerToken } from '../../acp'; +import { AcpPermissionCallerManager } from '../acp-permission-caller.service'; + +import { AcpFileSystemHandler, AcpFileSystemHandlerToken } from './file-system.handler'; +import { AcpTerminalHandler, AcpTerminalHandlerToken } from './terminal.handler'; + +export const AcpAgentRequestHandlerToken = Symbol('AcpAgentRequestHandlerToken'); + +/** + * ACP Agent Request Handler - 处理来自 CLI Agent 的请求 + * + * ## 设计说明 + * + * ### 为什么在主 Injector 中创建 + * + * `AcpAgentRequestHandler` 处理的是 CLI Agent 发出的请求,这些请求与特定的 RPC 连接无关: + * - CLI Agent 通过 stdio 与 Node 进程通信,不依赖 Browser Tab + * - 请求中不包含 `clientId` 信息,无法路由到特定的 childInjector + * - 因此必须在主 Injector 中作为单例存在,处理所有来自 CLI Agent 的请求 + * + * ### Injector 层级问题 + * + * 由于 `AcpAgentRequestHandler` 在主 Injector 中创建,它通过 `@Autowired` 注入的 + * `AcpPermissionCallerManager` 不是 childInjector 中与 RPC 连接关联的实例。 + * + * 解决方案:`AcpPermissionCallerManager` 使用静态变量 `currentRpcClient` 共享 RPC client, + * 确保权限对话框在用户当前活跃的 Browser Tab 中显示。 + * + * @see {@link /docs/ai-native/architecture/injector-hierarchy.md} 详细设计文档 + */ +@Injectable() +export class AcpAgentRequestHandler { + @Autowired(AcpFileSystemHandlerToken) + private fileSystemHandler: AcpFileSystemHandler; + + @Autowired(AcpTerminalHandlerToken) + private terminalHandler: AcpTerminalHandler; + + @Autowired(AcpPermissionCallerManagerToken) + private permissionCaller: AcpPermissionCallerManager; + + @Autowired(INodeLogger) + private readonly logger: INodeLogger; + + private initialized = false; + + /** + * Initialize the handler and register for agent requests + */ + initialize(): void { + if (this.initialized) { + return; + } + + this.initialized = true; + + // The agent will send requests to us via JSON-RPC + // We handle them by processing through the appropriate handlers + } + + /** + * Handle permission request from agent + * Shows UI dialog in browser via RPC and returns user's decision + * + * 注意:权限对话框会在用户当前活跃的 Browser Tab 中显示 + * (通过 AcpPermissionCallerManager 的静态变量 currentRpcClient 实现) + */ + async handlePermissionRequest(request: RequestPermissionRequest): Promise { + try { + // Call browser-side permission dialog via RPC + const response = await this.permissionCaller.requestPermission(request); + + return response; + } catch (error) { + this.logger.error('[ACP Node][handlePermissionRequest] Error:', error); + // Return cancelled on error + return { + outcome: { outcome: 'cancelled' as const }, + }; + } + } + + /** + * Handle read text file request (requires read permission) + */ + async handleReadTextFile(request: ReadTextFileRequest): Promise { + try { + // File reading doesn't require permission (it's a read operation) + // But we log it for audit purposes + const result = await this.fileSystemHandler.readTextFile({ + sessionId: request.sessionId, + path: request.path, + line: request.line ?? undefined, + limit: request.limit ?? undefined, + }); + + if (result.error) { + this.logger.error(`[ACP] File read error: ${result.error.message}`); + const err = new Error(result.error.message); + (err as any).code = result.error.code; + throw err; + } + + return { + content: result.content || '', + }; + } catch (error) { + this.logger.error(`[ACP] Failed to read file: ${request.path}`, error); + throw error; + } + } + + /** + * Handle write text file request (requires write permission) + */ + async handleWriteTextFile(request: WriteTextFileRequest): Promise { + try { + // For write operations, request permission from user first + const permissionResponse = await this.permissionCaller.requestPermission({ + sessionId: request.sessionId, + toolCall: { + toolCallId: `write-${Date.now()}`, + title: `Write file: ${request.path}`, + kind: 'write' as any, + status: 'pending', + locations: [{ path: request.path }], + rawInput: { path: request.path, contentLength: request.content?.length }, + }, + // 默认 options - 实际项目中应根据后端 ACP Agent 传入的 options 为准 + options: [ + { optionId: 'allow_once', name: 'Allow Once', kind: 'allow_once' }, + { optionId: 'allow_always', name: 'Allow Always', kind: 'allow_always' }, + { optionId: 'reject_once', name: 'Reject Once', kind: 'reject_once' }, + ], + }); + + if ( + permissionResponse.outcome.outcome !== 'selected' || + !permissionResponse.outcome.optionId?.startsWith('allow_') + ) { + this.logger.warn(`[ACP] Write permission denied for: ${request.path}`); + const err = new Error('Write permission denied'); + (err as any).code = -32003; // FORBIDDEN + throw err; + } + + const result = await this.fileSystemHandler.writeTextFile({ + sessionId: request.sessionId, + path: request.path, + content: request.content, + }); + + if (result.error) { + this.logger.error(`[ACP] File write error: ${result.error.message}`); + const err = new Error(result.error.message); + (err as any).code = result.error.code; + throw err; + } + + return {}; + } catch (error) { + this.logger.error(`[ACP] Failed to write file: ${request.path}`, error); + throw error; + } + } + + /** + * Handle create terminal request (requires command execution permission) + */ + async handleCreateTerminal(request: CreateTerminalRequest): Promise { + try { + // For command execution, request permission from user first + const commandStr = [request.command, ...(request.args || [])].join(' '); + const permissionResponse = await this.permissionCaller.requestPermission({ + sessionId: request.sessionId, + toolCall: { + toolCallId: `terminal-${Date.now()}`, + title: `Run command: ${commandStr}`, + kind: 'execute', + status: 'pending', + rawInput: { command: request.command, args: request.args, cwd: request.cwd }, + }, + // 默认 options - 实际项目中应根据后端 ACP Agent 传入的 options 为准 + options: [ + { optionId: 'allow_once', name: 'Allow Once', kind: 'allow_once' }, + { optionId: 'allow_always', name: 'Allow Always', kind: 'allow_always' }, + { optionId: 'reject_once', name: 'Reject Once', kind: 'reject_once' }, + ], + }); + + if ( + permissionResponse.outcome.outcome !== 'selected' || + !permissionResponse.outcome.optionId?.startsWith('allow_') + ) { + this.logger.warn(`[ACP] Command execution permission denied: ${commandStr}`); + const err = new Error('Command execution permission denied'); + (err as any).code = -32003; // FORBIDDEN + throw err; + } + + const result = await this.terminalHandler.createTerminal({ + sessionId: request.sessionId, + command: request.command, + args: request.args, + env: request.env + ? request.env.reduce>((acc, v) => { + acc[v.name] = v.value; + return acc; + }, {}) + : undefined, + cwd: request.cwd ?? undefined, + outputByteLimit: request.outputByteLimit ?? undefined, + }); + + if (result.error) { + this.logger.error(`[ACP] Terminal creation error: ${result.error.message}`); + throw new Error(result.error.message); + } + + return { + terminalId: result.terminalId || '', + }; + } catch (error) { + this.logger.error(`[ACP] Failed to create terminal: ${request.command}`, error); + throw error; + } + } + + /** + * Handle terminal output request + */ + async handleTerminalOutput(request: TerminalOutputRequest): Promise { + try { + const result = await this.terminalHandler.getTerminalOutput({ + sessionId: request.sessionId, + terminalId: request.terminalId, + }); + + if (result.error) { + this.logger.error(`[ACP] Terminal output error: ${result.error.message}`); + throw new Error(result.error.message); + } + + return { + output: result.output || '', + truncated: result.truncated || false, + exitStatus: result.exitStatus != null ? { exitCode: result.exitStatus } : undefined, + }; + } catch (error) { + this.logger.error('[ACP] Failed to get terminal output', error); + throw error; + } + } + + /** + * Handle wait for terminal exit request + */ + async handleWaitForTerminalExit(request: WaitForTerminalExitRequest): Promise { + try { + const result = await this.terminalHandler.waitForTerminalExit({ + sessionId: request.sessionId, + terminalId: request.terminalId, + }); + + if (result.error) { + this.logger.error(`[ACP] Wait for exit error: ${result.error.message}`); + throw new Error(result.error.message); + } + + return { + exitCode: result.exitCode, + signal: result.signal, + }; + } catch (error) { + this.logger.error('[ACP] Failed to wait for terminal exit', error); + throw error; + } + } + + /** + * Handle kill terminal request + */ + async handleKillTerminal(request: KillTerminalCommandRequest): Promise { + try { + const result = await this.terminalHandler.killTerminal({ + sessionId: request.sessionId, + terminalId: request.terminalId, + }); + + if (result.error) { + this.logger.error(`[ACP] Kill terminal error: ${result.error.message}`); + throw new Error(result.error.message); + } + + return {}; + } catch (error) { + this.logger.error('[ACP] Failed to kill terminal', error); + throw error; + } + } + + /** + * Handle release terminal request + */ + async handleReleaseTerminal(request: ReleaseTerminalRequest): Promise { + try { + const result = await this.terminalHandler.releaseTerminal({ + sessionId: request.sessionId, + terminalId: request.terminalId, + }); + + if (result.error) { + this.logger.error(`[ACP] Release terminal error: ${result.error.message}`); + throw new Error(result.error.message); + } + + return {}; + } catch (error) { + this.logger.error('[ACP] Failed to release terminal', error); + throw error; + } + } + + /** + * Clean up all session resources + */ + async disposeSession(sessionId: string): Promise { + // Release all terminals for this session + await this.terminalHandler.releaseSessionTerminals(sessionId); + } +} diff --git a/packages/ai-native/src/node/acp/handlers/constants.ts b/packages/ai-native/src/node/acp/handlers/constants.ts new file mode 100644 index 0000000000..6ecadc2499 --- /dev/null +++ b/packages/ai-native/src/node/acp/handlers/constants.ts @@ -0,0 +1,24 @@ +/** + * ACP Error Codes + * Based on JSON-RPC 2.0 standard errors + ACP-specific errors + */ + +export const ACPErrorCode = { + // JSON-RPC standard errors + PARSE_ERROR: -32700, + INVALID_REQUEST: -32600, + METHOD_NOT_FOUND: -32601, + INVALID_PARAMS: -32602, + INTERNAL_ERROR: -32603, + + // ACP-specific errors + SERVER_ERROR: -32000, + RESOURCE_NOT_FOUND: -32002, + + // ACP application errors + AUTHENTICATION_REQUIRED: 1000, + SESSION_NOT_FOUND: 1001, + FORBIDDEN: 1003, +} as const; + +export type ACPErrorCode = (typeof ACPErrorCode)[keyof typeof ACPErrorCode]; diff --git a/packages/ai-native/src/node/acp/handlers/file-system.handler.ts b/packages/ai-native/src/node/acp/handlers/file-system.handler.ts new file mode 100644 index 0000000000..ec9101dfd8 --- /dev/null +++ b/packages/ai-native/src/node/acp/handlers/file-system.handler.ts @@ -0,0 +1,479 @@ +/** + * ACP 文件系统操作处理器 + * + * 为 CLI Agent 提供受工作区沙箱限制的文件操作能力: + * - readTextFile:读取文本文件内容,支持按行范围截取 + * - writeTextFile:写入文本文件,写入前可通过 permissionCallback 触发用户授权 + * - getFileMeta:获取文件元信息(大小、修改时间、MIME 类型等) + * - listDirectory:列举目录条目,支持一层递归 + * - createDirectory:创建目录(含父目录) + * + * 安全机制:所有路径均经过 resolvePath 校验,拒绝工作区外的绝对路径和路径穿越攻击。 + */ +import * as fs from 'fs'; +import * as path from 'path'; + +import { Autowired, Injectable } from '@opensumi/di'; +import { ILogger, URI } from '@opensumi/ide-core-common'; +import { IFileService } from '@opensumi/ide-file-service'; + +import { ACPErrorCode } from './constants'; + +export interface FileSystemRequest { + sessionId: string; + path: string; + line?: number; + limit?: number; + content?: string; + recursive?: boolean; +} + +export const AcpFileSystemHandlerToken = Symbol('AcpFileSystemHandlerToken'); + +export interface FileSystemResponse { + error?: { + code: number; + message: string; + data?: unknown; + }; + content?: string; + size?: number; + mtime?: number; + isFile?: boolean; + mimeType?: string; + entries?: Array<{ + name: string; + isFile: boolean; + size: number; + }>; +} + +export type PermissionCallback = ( + sessionId: string, + operation: 'write' | 'command', + details: { + path?: string; + command?: string; + title: string; + kind: string; + locations?: Array<{ path: string; line?: number }>; + content?: string; + }, +) => Promise; + +@Injectable() +export class AcpFileSystemHandler { + @Autowired(IFileService) + private fileService: IFileService; + + private logger: ILogger | null = null; + private workspaceDir: string = ''; + private maxFileSize = 1024 * 1024; // 1MB default + private permissionCallback: PermissionCallback | null = null; + + setLogger(logger: ILogger): void { + this.logger = logger; + } + + /** + * Set the permission callback for write operations + */ + setPermissionCallback(callback: PermissionCallback): void { + this.permissionCallback = callback; + } + + configure(options: { workspaceDir: string; maxFileSize?: number }): void { + this.workspaceDir = options.workspaceDir; + if (options.maxFileSize !== undefined) { + this.maxFileSize = options.maxFileSize; + } + } + + async readTextFile(request: FileSystemRequest): Promise { + const filePath = this.resolvePath(request.path); + if (!filePath) { + return { + error: { + code: ACPErrorCode.SERVER_ERROR, + message: 'Invalid path', + data: { path: request.path }, + }, + }; + } + + try { + const uri = URI.file(filePath); + + // Check if file exists + const stat = await this.fileService.getFileStat(uri.toString()); + if (!stat) { + return { + error: { + code: ACPErrorCode.RESOURCE_NOT_FOUND, + message: 'File not found', + data: { uri: uri.toString() }, + }, + }; + } + + // Check file size + if (stat.size && stat?.size > this.maxFileSize) { + return { + error: { + code: ACPErrorCode.SERVER_ERROR, + message: `File too large: ${stat.size} bytes (max: ${this.maxFileSize})`, + data: { path: request.path, size: stat.size }, + }, + }; + } + + // Read file content + const content = (await this.fileService.resolveContent(uri.toString())).content; + let text = content.toString(); + + // Apply line range if specified + if (request.line !== undefined || request.limit !== undefined) { + const lines = text.split('\n'); + const startLine = (request.line ?? 1) - 1; + const limit = request.limit ?? lines.length; + text = lines.slice(startLine, startLine + limit).join('\n'); + } + + return { + content: text, + }; + } catch (error) { + this.logger?.error(`Error reading file ${filePath}:`, error); + return { + error: { + code: ACPErrorCode.SERVER_ERROR, + message: error instanceof Error ? error.message : 'Failed to read file', + data: { path: request.path }, + }, + }; + } + } + + async writeTextFile(request: FileSystemRequest): Promise { + const filePath = this.resolvePath(request.path); + if (!filePath) { + return { + error: { + code: ACPErrorCode.SERVER_ERROR, + message: 'Invalid path', + data: { path: request.path }, + }, + }; + } + + if (request.content === undefined) { + return { + error: { + code: ACPErrorCode.INVALID_PARAMS, + message: 'Content is required', + }, + }; + } + + // Check permission for write operation if callback is set + if (this.permissionCallback) { + const permitted = await this.permissionCallback(request.sessionId, 'write', { + path: filePath, + title: `Write file: ${path.basename(filePath)}`, + kind: 'write', + locations: [{ path: filePath }], + content: request.content.substring(0, 200), // Include preview + }); + + if (!permitted) { + this.logger?.warn(`Write permission denied for: ${filePath}`); + return { + error: { + code: ACPErrorCode.FORBIDDEN, + message: 'Write permission denied', + data: { path: filePath }, + }, + }; + } + } + + try { + const uri = URI.file(filePath); + + // Create parent directories if needed + const parentUri = uri.parent; + const parentStat = await this.fileService.getFileStat(parentUri.toString()); + if (!parentStat) { + await this.fileService.createFolder(parentUri.toString()); + } + + // Write file content + const buffer = Buffer.from(request.content, 'utf8'); + const filestat = await this.fileService.getFileStat(uri.toString()); + if (filestat) { + await this.fileService.setContent(filestat, buffer.toString()); + } else { + await this.fileService.createFile(uri.toString(), { content: buffer.toString() }); + } + + this.logger?.log(`File written: ${filePath}`); + + return {}; + } catch (error) { + this.logger?.error(`Error writing file ${filePath}:`, error); + return { + error: { + code: ACPErrorCode.SERVER_ERROR, + message: error instanceof Error ? error.message : 'Failed to write file', + data: { path: request.path }, + }, + }; + } + } + + async getFileMeta(request: FileSystemRequest): Promise { + const filePath = this.resolvePath(request.path); + if (!filePath) { + return { + error: { + code: ACPErrorCode.SERVER_ERROR, + message: 'Invalid path', + data: { path: request.path }, + }, + }; + } + + try { + const uri = URI.file(filePath); + const stat = await this.fileService.getFileStat(uri.toString()); + + if (!stat) { + // File doesn't exist, return false for existence check + return { + isFile: false, + size: 0, + mtime: 0, + }; + } + + return { + size: stat.size, + mtime: stat.lastModification, + isFile: !stat.isDirectory, + mimeType: this.detectMimeType(filePath), + }; + } catch (error) { + this.logger?.error(`Error getting file meta ${filePath}:`, error); + return { + error: { + code: ACPErrorCode.SERVER_ERROR, + message: error instanceof Error ? error.message : 'Failed to get file metadata', + data: { path: request.path }, + }, + }; + } + } + + async listDirectory(request: FileSystemRequest): Promise { + const dirPath = this.resolvePath(request.path); + if (!dirPath) { + return { + error: { + code: ACPErrorCode.SERVER_ERROR, + message: 'Invalid path', + data: { path: request.path }, + }, + }; + } + + try { + const uri = URI.file(dirPath); + const stat = await this.fileService.getFileStat(uri.toString()); + + if (!stat) { + return { + error: { + code: ACPErrorCode.RESOURCE_NOT_FOUND, + message: 'Directory not found', + data: { path: request.path }, + }, + }; + } + + if (!stat.isDirectory) { + return { + error: { + code: ACPErrorCode.INVALID_PARAMS, + message: 'Path is a file, not a directory', + data: { path: request.path }, + }, + }; + } + + const entries: Array<{ name: string; isFile: boolean; size: number }> = []; + + if (stat.children) { + for (const child of stat.children) { + entries.push({ + name: path.basename(child.uri.toString()), + isFile: !child.isDirectory, + size: child.size || 0, + }); + const childName = path.basename(child.uri.toString()); + // Handle recursive listing + if (request.recursive && child.isDirectory && child.children) { + for (const grandChild of child.children) { + entries.push({ + name: `${childName}/${path.basename(grandChild.uri.toString())}`, + isFile: !grandChild.isDirectory, + size: grandChild.size || 0, + }); + } + } + } + } + + return { + entries, + }; + } catch (error) { + this.logger?.error(`Error listing directory ${dirPath}:`, error); + return { + error: { + code: ACPErrorCode.SERVER_ERROR, + message: error instanceof Error ? error.message : 'Failed to list directory', + data: { path: request.path }, + }, + }; + } + } + + async createDirectory(request: FileSystemRequest): Promise { + const dirPath = this.resolvePath(request.path); + if (!dirPath) { + return { + error: { + code: ACPErrorCode.SERVER_ERROR, + message: 'Invalid path', + data: { path: request.path }, + }, + }; + } + + // Check permission for write operation if callback is set + if (this.permissionCallback) { + const permitted = await this.permissionCallback(request.sessionId, 'write', { + path: dirPath, + title: `Create directory: ${path.basename(dirPath)}`, + kind: 'createDirectory', + locations: [{ path: dirPath }], + }); + + if (!permitted) { + this.logger?.warn(`Create directory permission denied for: ${dirPath}`); + return { + error: { + code: ACPErrorCode.FORBIDDEN, + message: 'Create directory permission denied', + data: { path: dirPath }, + }, + }; + } + } + + try { + const uri = URI.file(dirPath); + await this.fileService.createFolder(uri.toString()); + + this.logger?.log(`Directory created: ${dirPath}`); + + return {}; + } catch (error) { + this.logger?.error(`Error creating directory ${dirPath}:`, error); + return { + error: { + code: ACPErrorCode.SERVER_ERROR, + message: error instanceof Error ? error.message : 'Failed to create directory', + data: { path: request.path }, + }, + }; + } + } + + /** + * Resolve a path relative to workspace, validating it stays within workspace bounds + */ + private resolvePath(inputPath: string): string | null { + // Reject immediately if workspaceDir is not set + if (!this.workspaceDir) { + this.logger?.warn('Workspace directory not configured'); + return null; + } + + // Resolve the input path (handles both absolute and relative paths) + let resolvedPath: string; + if (path.isAbsolute(inputPath)) { + resolvedPath = path.resolve(inputPath); + } else { + resolvedPath = path.resolve(this.workspaceDir, inputPath); + } + + // Resolve symlinks for both the resolved path and workspace directory + let realResolvedPath: string; + let realWorkspaceDir: string; + try { + realResolvedPath = fs.realpathSync(resolvedPath); + } catch (error) { + // If the path doesn't exist yet (e.g., new file for write), use the resolved path as-is + realResolvedPath = resolvedPath; + } + try { + realWorkspaceDir = fs.realpathSync(this.workspaceDir); + } catch (error) { + this.logger?.warn(`Cannot resolve workspace directory: ${this.workspaceDir}`); + return null; + } + + // Compute the relative path and ensure it does not escape workspace + const relativePath = path.relative(realWorkspaceDir, realResolvedPath); + + // Reject if relative path equals '..' or starts with '..' + separator + if (relativePath === '..' || relativePath.startsWith(`..${path.sep}`)) { + this.logger?.warn(`Path outside workspace rejected: ${inputPath}`); + return null; + } + + return realResolvedPath; + } + + /** + * Detect MIME type based on file extension + */ + private detectMimeType(filePath: string): string { + const ext = path.extname(filePath).toLowerCase(); + const mimeTypes: Record = { + '.txt': 'text/plain', + '.md': 'text/markdown', + '.js': 'application/javascript', + '.ts': 'application/typescript', + '.jsx': 'text/jsx', + '.tsx': 'text/tsx', + '.json': 'application/json', + '.css': 'text/css', + '.html': 'text/html', + '.xml': 'application/xml', + '.yaml': 'application/yaml', + '.yml': 'application/yaml', + '.py': 'text/x-python', + '.java': 'text/x-java', + '.go': 'text/x-go', + '.rs': 'text/x-rust', + '.c': 'text/x-c', + '.cpp': 'text/x-c++', + '.h': 'text/x-c', + '.hpp': 'text/x-c++', + }; + + return mimeTypes[ext] || 'application/octet-stream'; + } +} diff --git a/packages/ai-native/src/node/acp/handlers/terminal.handler.ts b/packages/ai-native/src/node/acp/handlers/terminal.handler.ts new file mode 100644 index 0000000000..283b18392e --- /dev/null +++ b/packages/ai-native/src/node/acp/handlers/terminal.handler.ts @@ -0,0 +1,471 @@ +/** + * ACP 终端操作处理器 + * + * 为 CLI Agent 提供进程级终端(命令执行)能力: + * - createTerminal:创建新终端并执行命令,创建前可通过 permissionCallback 触发用户授权; + * 自动收集输出并按 outputByteLimit 滑动截断 + * - getTerminalOutput:读取终端当前输出缓冲及退出状态 + * - waitForTerminalExit:等待终端进程退出(带超时) + * - killTerminal:强制终止终端进程 + * - releaseTerminal / releaseSessionTerminals:释放终端资源,支持按 Session 批量释放 + */ +import * as pty from 'node-pty'; + +import { Autowired, Injectable } from '@opensumi/di'; +import { uuid } from '@opensumi/ide-core-common'; +import { INodeLogger } from '@opensumi/ide-core-node'; + +import { ACPErrorCode } from './constants'; + +// Re-export the permission callback type for convenience +export const AcpTerminalHandlerToken = Symbol('AcpTerminalHandlerToken'); + +export type TerminalPermissionCallback = ( + sessionId: string, + operation: 'command', + details: { + command: string; + args?: string[]; + cwd?: string; + title: string; + kind: string; + }, +) => Promise; + +export interface TerminalRequest { + sessionId: string; + command?: string; + args?: string[]; + env?: Record; + cwd?: string; + outputByteLimit?: number; + terminalId?: string; + timeout?: number; +} + +export interface TerminalResponse { + error?: { + code: number; + message: string; + }; + terminalId?: string; + output?: string; + truncated?: boolean; + exitStatus?: number | null; + exitCode?: number; + signal?: string; +} + +interface TerminalSession { + terminalId: string; + sessionId: string; + ptyProcess: pty.IPty; + outputBuffer: string; + outputByteLimit: number; + exited: boolean; + exitCode?: number; + killed: boolean; + startTime: number; +} + +@Injectable() +export class AcpTerminalHandler { + @Autowired(INodeLogger) + private readonly logger: INodeLogger; + + private terminals = new Map(); + private defaultOutputLimit = 1024 * 1024; // 1MB default + private permissionCallback: TerminalPermissionCallback | null = null; + + /** + * Set the permission callback for terminal command execution + */ + setPermissionCallback(callback: TerminalPermissionCallback): void { + this.permissionCallback = callback; + } + + configure(options: { outputLimit?: number }): void { + if (options.outputLimit !== undefined) { + this.defaultOutputLimit = options.outputLimit; + } + } + + async createTerminal(request: TerminalRequest): Promise { + const startTime = Date.now(); + this.logger?.log( + `[AcpTerminalHandler] createTerminal called, sessionId=${request.sessionId}, command=${ + request.command + }, args=${JSON.stringify(request.args)}`, + ); + + try { + const terminalId = uuid(); + this.logger?.log(`[AcpTerminalHandler] Generated terminalId: ${terminalId}`); + + // Check permission for command execution if callback is set + if (this.permissionCallback) { + const commandStr = [request.command, ...(request.args || [])].join(' '); + this.logger?.log(`[AcpTerminalHandler] Checking permission for command: ${commandStr}`); + + const permitted = await this.permissionCallback(request.sessionId, 'command', { + command: commandStr, + args: request.args, + cwd: request.cwd, + title: `Run command: ${commandStr}`, + kind: 'command', + }); + + if (!permitted) { + this.logger?.warn(`[AcpTerminalHandler] Command execution permission denied: ${commandStr}`); + return { + error: { + code: ACPErrorCode.FORBIDDEN, + message: 'Command execution permission denied', + }, + }; + } + this.logger?.log(`[AcpTerminalHandler] Permission granted for command: ${commandStr}`); + } + + // Merge environment variables + const env = { + ...process.env, + ...request.env, + }; + this.logger?.log( + `[AcpTerminalHandler] Spawning PTY process: command=${request.command || '/bin/sh'}, cwd=${ + request.cwd || process.cwd() + }`, + ); + + // Create PTY process using node-pty + const ptyProcess = pty.spawn(request.command || '/bin/sh', request.args || [], { + name: 'xterm-256color', + cwd: request.cwd || process.cwd(), + env, + cols: 80, + rows: 24, + }); + + this.logger?.log(`[AcpTerminalHandler] PTY process spawned successfully, pid=${ptyProcess.pid}`); + + const terminalSession: TerminalSession = { + terminalId, + sessionId: request.sessionId, + ptyProcess, + outputBuffer: '', + outputByteLimit: request.outputByteLimit ?? this.defaultOutputLimit, + exited: false, + killed: false, + startTime: Date.now(), + }; + + // Listen to terminal output + ptyProcess.onData((data) => { + if (!terminalSession.killed) { + terminalSession.outputBuffer += data; + + // Trim buffer if it exceeds limit + const bufferSize = Buffer.byteLength(terminalSession.outputBuffer, 'utf8'); + if (bufferSize > terminalSession.outputByteLimit) { + // Keep recent output, drop old data + const keepSize = Math.floor(terminalSession.outputByteLimit * 0.8); + terminalSession.outputBuffer = terminalSession.outputBuffer.slice(-keepSize); + this.logger?.debug(`[AcpTerminalHandler] Terminal output buffer trimmed, kept ${keepSize} bytes`); + } + } + }); + + // Listen to exit + ptyProcess.onExit((e) => { + terminalSession.exited = true; + terminalSession.exitCode = e.exitCode; + const duration = Date.now() - startTime; + this.logger?.log( + `[AcpTerminalHandler] Terminal ${terminalId} exited with code ${e.exitCode}, duration=${duration}ms`, + ); + }); + + this.terminals.set(terminalId, terminalSession); + this.logger?.log( + `[AcpTerminalHandler] Terminal created successfully: ${terminalId}, total terminals: ${this.terminals.size}`, + ); + + return { + terminalId, + }; + } catch (error) { + this.logger?.error('[AcpTerminalHandler] Error creating terminal:', error); + return { + error: { + code: ACPErrorCode.SERVER_ERROR, + message: error instanceof Error ? error.message : 'Failed to create terminal', + }, + }; + } + } + + async getTerminalOutput(request: TerminalRequest): Promise { + this.logger?.debug(`[AcpTerminalHandler] getTerminalOutput called, terminalId=${request.terminalId}`); + + const terminalSession = this.terminals.get(request.terminalId || ''); + if (!terminalSession) { + this.logger?.warn(`[AcpTerminalHandler] Terminal not found: ${request.terminalId}`); + return { + error: { + code: ACPErrorCode.RESOURCE_NOT_FOUND, + message: 'Terminal not found', + }, + }; + } + + if (terminalSession.sessionId !== request.sessionId) { + this.logger?.warn( + `[AcpTerminalHandler] Session mismatch: expected ${terminalSession.sessionId}, got ${request.sessionId}`, + ); + return { + error: { + code: ACPErrorCode.SERVER_ERROR, + message: 'Session mismatch', + }, + }; + } + + const output = terminalSession.outputBuffer; + const bufferSize = Buffer.byteLength(output, 'utf8'); + const truncated = bufferSize > terminalSession.outputByteLimit; + + this.logger?.debug( + `[AcpTerminalHandler] getTerminalOutput: bufferSize=${bufferSize}, truncated=${truncated}, exited=${terminalSession.exited}`, + ); + + return { + output, + truncated, + exitStatus: terminalSession.exited ? terminalSession.exitCode ?? 0 : null, + }; + } + + async waitForTerminalExit(request: TerminalRequest): Promise { + this.logger?.debug( + `[AcpTerminalHandler] waitForTerminalExit called, terminalId=${request.terminalId}, timeout=${ + request.timeout ?? 30000 + }ms`, + ); + + const terminalSession = this.terminals.get(request.terminalId || ''); + if (!terminalSession) { + this.logger?.warn(`[AcpTerminalHandler] Terminal not found: ${request.terminalId}`); + return { + error: { + code: ACPErrorCode.RESOURCE_NOT_FOUND, + message: 'Terminal not found', + }, + }; + } + + if (terminalSession.sessionId !== request.sessionId) { + this.logger?.warn( + `[AcpTerminalHandler] Session mismatch: expected ${terminalSession.sessionId}, got ${request.sessionId}`, + ); + return { + error: { + code: ACPErrorCode.SERVER_ERROR, + message: 'Session mismatch', + }, + }; + } + + // If already exited, return immediately + if (terminalSession.exited) { + this.logger?.log( + `[AcpTerminalHandler] Terminal ${request.terminalId} already exited, code=${terminalSession.exitCode}`, + ); + return { + exitCode: terminalSession.exitCode, + }; + } + + this.logger?.log(`[AcpTerminalHandler] Waiting for terminal ${request.terminalId} to exit...`); + + // Wait for exit with timeout + const timeout = request.timeout ?? 30000; // 30s default + const waitStartTime = Date.now(); + + return new Promise((resolve) => { + const checkInterval = setInterval(() => { + if (terminalSession.exited) { + clearInterval(checkInterval); + clearTimeout(timeoutId); + const waitDuration = Date.now() - waitStartTime; + this.logger?.log( + `[AcpTerminalHandler] Terminal ${request.terminalId} exited after ${waitDuration}ms, code=${terminalSession.exitCode}`, + ); + resolve({ + exitCode: terminalSession.exitCode, + }); + } + }, 100); + + const timeoutId = setTimeout(() => { + clearInterval(checkInterval); + const waitDuration = Date.now() - waitStartTime; + this.logger?.warn( + `[AcpTerminalHandler] waitForTerminalExit timeout after ${waitDuration}ms for terminal ${request.terminalId}`, + ); + // Return null exitStatus to indicate still running + resolve({ + exitStatus: null, + }); + }, timeout); + }); + } + + async killTerminal(request: TerminalRequest): Promise { + const terminalSession = this.terminals.get(request.terminalId || ''); + if (!terminalSession) { + return { + error: { + code: ACPErrorCode.RESOURCE_NOT_FOUND, + message: 'Terminal not found', + }, + }; + } + + if (terminalSession.sessionId !== request.sessionId) { + return { + error: { + code: ACPErrorCode.SERVER_ERROR, + message: 'Session mismatch', + }, + }; + } + + // If already exited, just return success + if (terminalSession.exited) { + return { + exitStatus: terminalSession.exitCode ?? 0, + }; + } + + try { + this.logger?.log(`Killing terminal ${request.terminalId}`); + + terminalSession.killed = true; + + // Kill the PTY process + terminalSession.ptyProcess.kill(); + + // Wait for graceful exit + await new Promise((resolve) => { + const checkInterval = setInterval(() => { + if (terminalSession.exited) { + clearInterval(checkInterval); + resolve(); + } + }, 100); + + // Force kill after 2 seconds + setTimeout(() => { + clearInterval(checkInterval); + resolve(); + }, 2000); + }); + + // If not exited, mark as exited + if (!terminalSession.exited) { + terminalSession.exited = true; + } + + return { + exitCode: terminalSession.exitCode ?? -1, + }; + } catch (error) { + this.logger?.error('Error killing terminal:', error); + return { + error: { + code: ACPErrorCode.SERVER_ERROR, + message: error instanceof Error ? error.message : 'Failed to kill terminal', + }, + }; + } + } + + async releaseTerminal(request: TerminalRequest): Promise { + const terminalSession = this.terminals.get(request.terminalId || ''); + if (!terminalSession) { + // Already released or doesn't exist + return {}; + } + + if (terminalSession.sessionId !== request.sessionId) { + return { + error: { + code: ACPErrorCode.SERVER_ERROR, + message: 'Session mismatch', + }, + }; + } + + try { + this.logger?.log(`Releasing terminal ${request.terminalId}`); + + // Kill the PTY process if not already exited + if (!terminalSession.exited) { + try { + terminalSession.ptyProcess.kill(); + } catch (e) { + this.logger?.warn(`Failed to kill pty process ${request.terminalId}:`, e); + } + } + + // Remove from tracking + this.terminals.delete(request.terminalId || ''); + + return {}; + } catch (error) { + this.logger?.error('Error releasing terminal:', error); + return { + error: { + code: ACPErrorCode.SERVER_ERROR, + message: error instanceof Error ? error.message : 'Failed to release terminal', + }, + }; + } + } + + /** + * Release all terminals for a session + */ + async releaseSessionTerminals(sessionId: string): Promise { + const terminalsToRelease: string[] = []; + + for (const [terminalId, session] of this.terminals) { + if (session.sessionId === sessionId) { + terminalsToRelease.push(terminalId); + } + } + + for (const terminalId of terminalsToRelease) { + await this.releaseTerminal({ + sessionId, + terminalId, + }); + } + + this.logger?.log(`Released ${terminalsToRelease.length} terminals for session ${sessionId}`); + } + + /** + * Get all terminal IDs for a session + */ + getSessionTerminals(sessionId: string): string[] { + const terminalIds: string[] = []; + for (const [terminalId, session] of this.terminals) { + if (session.sessionId === sessionId) { + terminalIds.push(terminalId); + } + } + return terminalIds; + } +} diff --git a/packages/ai-native/src/node/acp/index.ts b/packages/ai-native/src/node/acp/index.ts new file mode 100644 index 0000000000..b74860ef98 --- /dev/null +++ b/packages/ai-native/src/node/acp/index.ts @@ -0,0 +1,12 @@ +export { AcpCliClientService } from './acp-cli-client.service'; +export { + CliAgentProcessManager, + CliAgentProcessManagerToken, + ICliAgentProcessManager, +} from './cli-agent-process-manager'; +export { AcpCliBackService, AcpCliBackServiceToken } from './acp-cli-back.service'; +export { AcpFileSystemHandler, AcpFileSystemHandlerToken } from './handlers/file-system.handler'; +export { AcpTerminalHandler, AcpTerminalHandlerToken } from './handlers/terminal.handler'; +export { AcpAgentRequestHandler, AcpAgentRequestHandlerToken } from './handlers/agent-request.handler'; +export { AcpAgentService, AcpAgentServiceToken, IAcpAgentService } from './acp-agent.service'; +export { AcpPermissionCallerManager, AcpPermissionCallerManagerToken } from './acp-permission-caller.service'; diff --git a/packages/ai-native/src/node/index.ts b/packages/ai-native/src/node/index.ts index f455b6a92d..1456684025 100644 --- a/packages/ai-native/src/node/index.ts +++ b/packages/ai-native/src/node/index.ts @@ -1,19 +1,56 @@ import { Injectable, Provider } from '@opensumi/di'; -import { AIBackSerivcePath, AIBackSerivceToken } from '@opensumi/ide-core-common'; +import { + AIBackSerivcePath, + AIBackSerivceToken, + AcpCliClientServiceToken, + AcpPermissionServicePath, +} from '@opensumi/ide-core-common'; import { NodeModule } from '@opensumi/ide-core-node'; -import { BaseAIBackService } from '@opensumi/ide-core-node/lib/ai-native/base-back.service'; import { SumiMCPServerProxyServicePath, TokenMCPServerProxyService } from '../common'; import { ToolInvocationRegistryManager, ToolInvocationRegistryManagerImpl } from '../common/tool-invocation-registry'; +import { + AcpAgentRequestHandler, + AcpAgentRequestHandlerToken, + AcpAgentService, + AcpAgentServiceToken, + AcpFileSystemHandler, + AcpFileSystemHandlerToken, + AcpPermissionCallerManager, + AcpPermissionCallerManagerToken, + AcpTerminalHandler, + AcpTerminalHandlerToken, + CliAgentProcessManager, + CliAgentProcessManagerToken, +} from './acp'; +import { AcpCliBackService } from './acp/acp-cli-back.service'; +import { AcpCliClientService } from './acp/acp-cli-client.service'; import { SumiMCPServerBackend } from './mcp/sumi-mcp-server'; +import { OpenAICompatibleModel } from './openai-compatible/openai-compatible-language-model'; @Injectable() export class AINativeModule extends NodeModule { providers: Provider[] = [ { token: AIBackSerivceToken, - useClass: BaseAIBackService, + useClass: AcpCliBackService, + }, + { + token: AcpCliClientServiceToken, + useClass: AcpCliClientService, + }, + { + token: CliAgentProcessManagerToken, + useClass: CliAgentProcessManager, + }, + { + token: AcpAgentServiceToken, + useClass: AcpAgentService, + }, + { + token: AcpPermissionCallerManagerToken, + useClass: AcpPermissionCallerManager, }, { token: ToolInvocationRegistryManager, @@ -23,6 +60,20 @@ export class AINativeModule extends NodeModule { token: TokenMCPServerProxyService, useClass: SumiMCPServerBackend, }, + { + token: AcpFileSystemHandlerToken, + useClass: AcpFileSystemHandler, + }, + { + token: AcpTerminalHandlerToken, + useClass: AcpTerminalHandler, + }, + { + token: AcpAgentRequestHandlerToken, + useClass: AcpAgentRequestHandler, + }, + // Language models for non-ACP fallback + OpenAICompatibleModel, ]; backServices = [ @@ -38,5 +89,9 @@ export class AINativeModule extends NodeModule { servicePath: SumiMCPServerProxyServicePath, token: TokenMCPServerProxyService, }, + { + servicePath: AcpPermissionServicePath, + token: AcpPermissionCallerManagerToken, + }, ]; } diff --git a/packages/core-browser/src/ai-native/ai-config.service.ts b/packages/core-browser/src/ai-native/ai-config.service.ts index a2b422efe9..0cbf4f4c67 100644 --- a/packages/core-browser/src/ai-native/ai-config.service.ts +++ b/packages/core-browser/src/ai-native/ai-config.service.ts @@ -23,6 +23,7 @@ const DEFAULT_CAPABILITIES: Required = { supportsTerminalCommandSuggest: true, supportsCustomLLMSettings: true, supportsMCP: true, + supportsAgentMode: true, // agent 模式 }; const DISABLED_ALL_CAPABILITIES = {} as Required; diff --git a/packages/core-common/package.json b/packages/core-common/package.json index 7749ea7a4e..53aeef76b7 100644 --- a/packages/core-common/package.json +++ b/packages/core-common/package.json @@ -18,10 +18,12 @@ "build": "tsc --build ../../configs/ts/references/tsconfig.core-common.json" }, "dependencies": { + "@agentclientprotocol/sdk": "^0.16.1", "@opensumi/di": "^1.8.0", "@opensumi/events": "^1.0.0", "@opensumi/ide-utils": "workspace:*", - "ai": "^4.3.16" + "ai": "^4.3.16", + "electron": "^22.3.21" }, "devDependencies": { "@opensumi/ide-dev-tool": "workspace:*" diff --git a/packages/core-common/src/log.ts b/packages/core-common/src/log.ts index d4c9698a80..832a953235 100644 --- a/packages/core-common/src/log.ts +++ b/packages/core-common/src/log.ts @@ -296,7 +296,7 @@ export class DebugLog implements IDebugLog { return console.info(this.getPre('log', 'green'), ...args); }; - destroy() { } + destroy() {} } /** @@ -338,6 +338,6 @@ export function getDebugLogger(namespace?: string): IDebugLog { showWarn(); return debugLog.warn; }, - destroy() { }, + destroy() {}, }; } diff --git a/packages/core-common/src/settings/ai-native.ts b/packages/core-common/src/settings/ai-native.ts index 19dad92c56..ca4a08bd5b 100644 --- a/packages/core-common/src/settings/ai-native.ts +++ b/packages/core-common/src/settings/ai-native.ts @@ -42,6 +42,16 @@ export enum AINativeSettingSectionsId { */ MCPServers = 'ai.native.mcp.servers', + /** + * Agent configurations + */ + AgentConfigs = 'ai.native.agent.configs', + + /** + * Default Agent Type + */ + DefaultAgentType = 'ai.native.agent.defaultType', + TerminalAutoRun = 'ai.native.terminal.autorun', /** diff --git a/packages/core-common/src/settings/search.ts b/packages/core-common/src/settings/search.ts index 6ac572bc9b..73c7fec27c 100644 --- a/packages/core-common/src/settings/search.ts +++ b/packages/core-common/src/settings/search.ts @@ -4,4 +4,5 @@ export const enum SearchSettingId { UseReplacePreview = 'search.useReplacePreview', SearchOnType = 'search.searchOnType', SearchOnTypeDebouncePeriod = 'search.searchOnTypeDebouncePeriod', + FollowSymlinks = 'search.followSymlinks', } diff --git a/packages/core-common/src/storage.ts b/packages/core-common/src/storage.ts index 545147a890..633986afd3 100644 --- a/packages/core-common/src/storage.ts +++ b/packages/core-common/src/storage.ts @@ -55,6 +55,7 @@ export const STORAGE_NAMESPACE = { OUTLINE: new URI('outline').withScheme(STORAGE_SCHEMA.SCOPE), CHAT: new URI('chat').withScheme(STORAGE_SCHEMA.SCOPE), MCP: new URI('mcp').withScheme(STORAGE_SCHEMA.SCOPE), + AI_NATIVE: new URI('ai-native').withScheme(STORAGE_SCHEMA.SCOPE), // global database GLOBAL_LAYOUT: new URI('layout-global').withScheme(STORAGE_SCHEMA.GLOBAL), GLOBAL_EXTENSIONS: new URI('extensions').withScheme(STORAGE_SCHEMA.GLOBAL), diff --git a/packages/core-common/src/types/ai-native/acp-types.ts b/packages/core-common/src/types/ai-native/acp-types.ts new file mode 100644 index 0000000000..48fb57f12b --- /dev/null +++ b/packages/core-common/src/types/ai-native/acp-types.ts @@ -0,0 +1,251 @@ +// @ts-nocheck +import type { + AgentCapabilities, + AuthMethod, + AuthenticateRequest, + AuthenticateResponse, + CancelNotification, + Implementation, + InitializeRequest, + InitializeResponse, + ListSessionsRequest, + ListSessionsResponse, + LoadSessionRequest, + LoadSessionResponse, + NewSessionRequest, + NewSessionResponse, + PermissionOption, + PromptRequest, + PromptResponse, + RequestPermissionRequest, + RequestPermissionResponse, + SessionModeState, + SessionNotification, + SetSessionModeRequest, + SetSessionModeResponse, +} from '@agentclientprotocol/sdk'; +/** + * CJS-compatible re-export bridge for @agentclientprotocol/sdk types. + * + * The @agentclientprotocol/sdk package declares "type": "module" in its package.json, + * which causes TS1479 errors in CJS modules when using `nodenext` module resolution. + * Since all imports here are type-only (zero runtime impact), we use @ts-nocheck + * to suppress the diagnostic. All other files import from this bridge instead + * of directly from the SDK. + */ +export type { + AgentCapabilities, + AuthenticateRequest, + AuthenticateResponse, + AuthMethod, + AvailableCommand, + AvailableCommandsUpdate, + CancelNotification, + ClientCapabilities, + ContentBlock, + CreateTerminalRequest, + CreateTerminalResponse, + Implementation, + InitializeRequest, + InitializeResponse, + ListSessionsRequest, + ListSessionsResponse, + LoadSessionRequest, + LoadSessionResponse, + McpCapabilities, + NewSessionRequest, + NewSessionResponse, + PermissionOption, + PermissionOptionKind, + PromptCapabilities, + PromptRequest, + PromptResponse, + ReadTextFileRequest, + ReadTextFileResponse, + ReleaseTerminalRequest, + ReleaseTerminalResponse, + RequestPermissionRequest, + RequestPermissionResponse, + SessionCapabilities, + SessionInfo, + SessionMode, + SessionModeState, + SessionNotification, + SetSessionModeRequest, + SetSessionModeResponse, + TerminalOutputRequest, + TerminalOutputResponse, + ToolCallLocation, + ToolCallUpdate, + WaitForTerminalExitRequest, + WaitForTerminalExitResponse, + WriteTextFileRequest, + WriteTextFileResponse, + KillTerminalCommandResponse, + KillTerminalCommandRequest, + ToolKind, +} from '@agentclientprotocol/sdk'; + +// Extend InitializeResponse to include modes field (not in official SDK yet) +export type ExtendedInitializeResponse = InitializeResponse & { + modes?: SessionModeState; +}; + +// Permission RPC Service Types +export interface AcpPermissionDialogParams { + requestId: string; + sessionId: string; + title: string; + kind?: string; + content: string; + locations?: Array<{ path: string; line?: number }>; + command?: string; + options: PermissionOption[]; + timeout: number; +} + +export type AcpPermissionDecision = + | { type: 'allow'; optionId?: string; always?: boolean } + | { type: 'reject'; optionId?: string; always?: boolean } + | { type: 'timeout' } + | { type: 'cancelled' }; + +export const AcpPermissionServicePath = 'AcpPermissionServicePath'; + +/** + * Browser-side RPC service interface + * Called from Node layer to show permission dialogs + */ +export interface IAcpPermissionService { + $showPermissionDialog(params: AcpPermissionDialogParams): Promise; + $cancelRequest(requestId: string): Promise; +} + +export const AcpPermissionServiceToken = Symbol('AcpPermissionServiceToken'); + +/** + * Node-side caller interface (for internal use) + * This is what Node layer uses to call browser + * Implemented by AcpPermissionCallerManager (multi-instance, per clientId) + */ +export interface IAcpPermissionCaller { + requestPermission(request: RequestPermissionRequest): Promise; + cancelRequest(requestId: string): Promise; +} + +// ACP CLI Client Service Types + +/** + * Connection state for ACP CLI client + * Represents the lifecycle states of the JSON-RPC connection + */ +export type ConnectionState = 'disconnected' | 'connecting' | 'connected'; + +/** + * ACP CLI 客户端服务接口 - 基于 JSON-RPC 2.0 协议的传输层 + */ +export interface IAcpCliClientService { + /** + * Set up transport streams for JSON-RPC communication + * @param stdout - Readable stream from agent process + * @param stdin - Writable stream to agent process + */ + setTransport(stdout: NodeJS.ReadableStream, stdin: NodeJS.WritableStream): void; + + /** + * Initialize the ACP connection + */ + initialize(params?: InitializeRequest): Promise; + + /** + * Authenticate with the agent + */ + authenticate(params: AuthenticateRequest): Promise; + + /** + * Create a new session + */ + newSession(params: NewSessionRequest): Promise; + + /** + * Load an existing session + */ + loadSession(params: LoadSessionRequest): Promise; + + /** + * List all sessions + */ + listSessions(params?: ListSessionsRequest): Promise; + + /** + * Send a prompt to the session + */ + prompt(params: PromptRequest): Promise; + + /** + * Cancel an ongoing operation + */ + cancel(params: CancelNotification): Promise; + + /** + * Change the session mode + */ + setSessionMode(params: SetSessionModeRequest): Promise; + + /** + * Register a notification handler + * @returns Unsubscribe function + */ + onNotification(handler: (notification: SessionNotification) => void): () => void; + + /** + * Close the connection and cleanup resources + */ + close(): Promise; + + /** + * Check if currently connected + */ + isConnected(): boolean; + + /** + * Handle unexpected disconnect + */ + handleDisconnect(): void; + + /** + * Register a disconnect handler, called when the connection is lost + * @returns Unsubscribe function + */ + onDisconnect(handler: () => void): () => void; + + /** + * Get the negotiated protocol version + */ + getNegotiatedProtocolVersion(): number | null; + + /** + * Get agent capabilities from initialize response + */ + getAgentCapabilities(): AgentCapabilities | null; + + /** + * Get agent info from initialize response + */ + getAgentInfo(): Implementation | null; + + /** + * Get available authentication methods + */ + getAuthMethods(): AuthMethod[]; + + /** + * Get available session modes + */ + getSessionModes(): SessionModeState | null; +} + +/** + * Symbol token for dependency injection + */ +export const AcpCliClientServiceToken = Symbol('AcpCliClientServiceToken'); diff --git a/packages/core-common/src/types/ai-native/agent-types.ts b/packages/core-common/src/types/ai-native/agent-types.ts new file mode 100644 index 0000000000..a2960bf1c2 --- /dev/null +++ b/packages/core-common/src/types/ai-native/agent-types.ts @@ -0,0 +1,88 @@ +/** + * ACP Agent Type Definitions + * Centralized configuration for supported CLI agents + */ + +// ACP Agent 类型 +export type ACPAgentType = 'qwen' | 'claude-agent-acp'; + +// Default agent type (fallback when no preference is set) +export const DEFAULT_AGENT_TYPE: ACPAgentType = 'claude-agent-acp'; + +// Supported agent types +export enum ACPAgentTypeEnum { + Qwen = 'qwen', + ClaudeCodeACP = 'claude-agent-acp', +} + +// Agent configuration preset +export interface AgentConfig { + /** + * CLI command to start the agent + */ + command: string; + + /** + * Arguments passed to the agent + */ + args: string[]; + + /** + * Whether this agent supports streaming + */ + streaming?: boolean; + + /** + * Agent description for UI display + */ + description?: string; +} + +/** + * Check if an agent type is supported + */ +export function isSupportedAgentType(type: string): type is ACPAgentType { + return type === 'qwen' || type === 'claude-agent-acp'; +} + +/** + * Get list of all supported agent types + */ +export function getSupportedAgentTypes(): ACPAgentType[] { + return ['qwen', 'claude-agent-acp']; +} + +/** + * Configuration for spawning and running the ACP CLI agent process. + * Used to initialize the agent connection and process, not to configure individual sessions. + */ +export interface AgentProcessConfig { + /** + * CLI command to start the agent + */ + command: string; + /** + * Arguments passed to the agent + */ + args: string[]; + workspaceDir: string; + env?: Record; + enablePermissionConfirmation?: boolean; +} + +/** + * DI Token for ACP config provider. + * Allows downstream projects to customize AgentProcessConfig construction + * (e.g., inject custom env vars, override command paths, add validation). + */ +export const IACPConfigProvider = Symbol('IACPConfigProvider'); + +export interface IACPConfigProvider { + /** + * Build the AgentProcessConfig for ACP operations. + * Called by ACPSessionProvider and AcpChatAgent before any agent operation. + * Implementations can customize command, args, workspaceDir, env, etc. + * Should throw if prerequisites are not met (e.g., missing API key). + */ + resolveConfig(): Promise; +} diff --git a/packages/core-common/src/types/ai-native/index.ts b/packages/core-common/src/types/ai-native/index.ts index e1519b943b..479236ea15 100644 --- a/packages/core-common/src/types/ai-native/index.ts +++ b/packages/core-common/src/types/ai-native/index.ts @@ -1,13 +1,17 @@ -import { CancellationToken, MaybePromise, Uri } from '@opensumi/ide-utils'; +import { CancellationToken, IDisposable, MaybePromise, Uri } from '@opensumi/ide-utils'; +import { Event } from '@opensumi/ide-utils/lib/event'; import { SumiReadableStream } from '@opensumi/ide-utils/lib/stream'; import { FileType } from '../file'; import { IMarkdownString } from '../markdown'; +import { AvailableCommand, ListSessionsResponse } from './acp-types'; +import { AgentProcessConfig } from './agent-types'; import { IAIReportCompletionOption } from './reporter'; import type { CoreMessage } from 'ai'; export * from './reporter'; +export type { AvailableCommand }; export interface IAINativeCapabilities { /** @@ -58,6 +62,10 @@ export interface IAINativeCapabilities { * supports modelcontextprotocol */ supportsMCP?: boolean; + /** + * supports agent mode for chat input + */ + supportsAgentMode?: boolean; } export interface IDesignLayoutConfig { @@ -188,6 +196,7 @@ export interface IAIBackServiceOption { /** 响应首尾是否有需要trim的内容 */ trimTexts?: [string, string]; disabledTools?: string[]; + agentSessionConfig?: AgentProcessConfig; } /** @@ -247,6 +256,29 @@ export interface IAIBackService< * @deprecated */ reportCompletion?(input: I): Promise; + + loadAgentSession?( + config: AgentProcessConfig, + agentSessionId: string, + ): Promise<{ + sessionId: string; + messages: Array<{ + role: 'user' | 'assistant'; + content: string; + timestamp?: number; + }>; + }>; + + listSessions?(config: AgentProcessConfig): Promise; + + createSession?(config: AgentProcessConfig): Promise<{ + sessionId: string; + availableCommands: AvailableCommand[]; + }>; + + setSessionMode?(sessionId: string, modeId: string): Promise; + + ready?(): Promise; } export class ReplyResponse { @@ -306,6 +338,31 @@ export const MCPConfigServiceToken = Symbol('MCPConfigServiceToken'); export const RulesServiceToken = Symbol('RulesServiceToken'); export const ChatServiceToken = Symbol('ChatServiceToken'); export const ChatAgentViewServiceToken = Symbol('ChatAgentViewServiceToken'); +export const ChatInputRegistryToken = Symbol('ChatInputRegistryToken'); +export const ChatViewRegistryToken = Symbol('ChatViewRegistryToken'); +export const ChatHistoryRegistryToken = Symbol('ChatHistoryRegistryToken'); +export const ChatInputFooterRegistryToken = Symbol('ChatInputFooterRegistryToken'); + +/** + * Chat Input Footer Contribution Point + */ +export enum FooterButtonPosition { + LEFT = 'left', + RIGHT = 'right', +} + +export interface ChatInputFooterItem { + component: React.ComponentType; + order?: number; + position?: FooterButtonPosition; + visible?: () => boolean; +} + +export interface IChatInputFooterRegistry { + registerFooterItem(id: string, item: ChatInputFooterItem): IDisposable; + getItems(): ChatInputFooterItem[]; + onDidChange: Event; +} /** * Contribute Registry @@ -467,3 +524,6 @@ export enum ECodeEditsSourceTyping { Trigger = 'trigger', } // ## Code Edits ends ## + +export * from './acp-types'; +export * from './agent-types'; diff --git a/packages/extension/src/browser/vscode/api/main.thread.workspace.ts b/packages/extension/src/browser/vscode/api/main.thread.workspace.ts index b05e2b0c37..22e3f98d39 100644 --- a/packages/extension/src/browser/vscode/api/main.thread.workspace.ts +++ b/packages/extension/src/browser/vscode/api/main.thread.workspace.ts @@ -1,6 +1,14 @@ import { Autowired, Injectable, Optional } from '@opensumi/di'; import { IRPCProtocol } from '@opensumi/ide-connection'; -import { CancellationToken, IDisposable, ILogger, OnEvent, URI, WithEventBus } from '@opensumi/ide-core-browser'; +import { + CancellationToken, + IDisposable, + ILogger, + OnEvent, + PreferenceService, + URI, + WithEventBus, +} from '@opensumi/ide-core-browser'; import { WorkbenchEditorService } from '@opensumi/ide-editor'; import { IExtensionStorageService } from '@opensumi/ide-extension-storage'; import { FileSearchServicePath, IFileSearchService } from '@opensumi/ide-file-search/lib/common'; @@ -38,6 +46,9 @@ export class MainThreadWorkspace extends WithEventBus implements IMainThreadWork @Autowired(ILogger) logger: ILogger; + @Autowired(PreferenceService) + private readonly preferenceService: PreferenceService; + private workspaceChangeEvent: IDisposable; constructor(@Optional(Symbol()) private rpcProtocol: IRPCProtocol) { @@ -67,6 +78,7 @@ export class MainThreadWorkspace extends WithEventBus implements IMainThreadWork excludePatterns: excludePatternOrDisregardExcludes ? [excludePatternOrDisregardExcludes] : undefined, limit: maxResult, includePatterns: [includePattern], + followSymlinks: this.preferenceService.get('search.followSymlinks') ?? true, }; const result = await this.fileSearchService.find('', fileSearchOptions, token); return result; diff --git a/packages/file-search/src/common/file-search.ts b/packages/file-search/src/common/file-search.ts index 96fddaedf9..24d57418f9 100644 --- a/packages/file-search/src/common/file-search.ts +++ b/packages/file-search/src/common/file-search.ts @@ -25,6 +25,7 @@ export namespace IFileSearchService { noIgnoreParent?: boolean; // 是否忽略祖先目录的 gitIgnore includePatterns?: string[]; excludePatterns?: string[]; + followSymlinks?: boolean; } export interface RootOptions { [rootUri: string]: BaseOptions; diff --git a/packages/file-search/src/node/file-search.service.ts b/packages/file-search/src/node/file-search.service.ts index 7ee46e2f14..35e09a5f50 100644 --- a/packages/file-search/src/node/file-search.service.ts +++ b/packages/file-search/src/node/file-search.service.ts @@ -78,6 +78,9 @@ export class FileSearchService implements IFileSearchService { if (rootOptions.noIgnoreParent === undefined) { rootOptions.noIgnoreParent = opts.noIgnoreParent; } + if (rootOptions.followSymlinks === undefined) { + rootOptions.followSymlinks = opts.followSymlinks; + } } const exactMatches = new Set(); @@ -185,6 +188,9 @@ export class FileSearchService implements IFileSearchService { if (options.noIgnoreParent) { args.push('--no-ignore-parent'); } + if (options.followSymlinks) { + args.push('--follow'); + } return args; } } diff --git a/packages/file-service/package.json b/packages/file-service/package.json index 97b5b62b3f..427ace80a8 100644 --- a/packages/file-service/package.json +++ b/packages/file-service/package.json @@ -18,6 +18,7 @@ "url": "git@github.com:opensumi/core.git" }, "dependencies": { + "@furyjs/fury": "0.5.9-beta", "@opensumi/ide-connection": "workspace:*", "@opensumi/ide-core-common": "workspace:*", "@opensumi/ide-core-node": "workspace:*", diff --git a/packages/i18n/src/common/en-US.lang.ts b/packages/i18n/src/common/en-US.lang.ts index fe3838b588..d0cdc5cbee 100644 --- a/packages/i18n/src/common/en-US.lang.ts +++ b/packages/i18n/src/common/en-US.lang.ts @@ -430,6 +430,7 @@ export const localizationBundle = { 'preference.search.searchOnType': 'Controls whether to search as you type', 'preference.search.searchOnTypeDebouncePeriod': 'Controls the debounce period of search as you type in milliseconds.', + 'preference.search.followSymlinks': 'Controls whether to follow symlinks while searching.', 'preference.files.exclude.title': 'Exclude file display `files.exclude`', 'preference.array.additem': 'Add', 'preference.files.associations.title': 'File Association', @@ -1078,6 +1079,10 @@ export const localizationBundle = { 'terminal.process.unHealthy': '*This terminal session has been timed out and killed by the system. Please open a new terminal session to proceed with operations.', 'terminal.selectCWDForNewTerminal': 'Select current working directory for new terminal', + 'chat.selectCWDForACP': 'Select working directory for AI chat', + 'chat.defaultCWDSelected': 'No directory selected, using default: {0}', + 'chat.switchWorkspaceDir': 'Switch working directory', + 'chat.switchWorkspaceDirHint': 'Current path: {0}. Click to switch working directory (creates a new session)', 'terminal.focusNext.inTerminalGroup': 'Terminal: Focus Next Terminal in Terminal Group', 'terminal.focusPrevious.inTerminalGroup': 'Terminal: Focus Previous Terminal in Terminal Group', @@ -1451,6 +1456,7 @@ export const localizationBundle = { // #region AI Native 'aiNative.chat.ai.assistant.name': 'AI Assistant', 'aiNative.chat.input.placeholder.default': 'Ask anything, @ to mention', + 'aiNative.chat.input.placeholder.acp': 'message claude-agent-acp @to include context, / for command', 'aiNative.chat.stop.immediately': 'I don’t think about it anymore. If you need anything, you can ask me anytime.', 'aiNative.chat.error.response': 'There are too many people interacting with me at the moment. Please try again later. Thank you for your understanding and support.', @@ -1504,6 +1510,7 @@ export const localizationBundle = { 'aiNative.operate.chatHistory.delete': 'Delete', 'aiNative.chat.welcome.loading.text': 'Initializing...', + 'aiNative.chat.acp.initializing.text': 'Initializing ACP service...', 'aiNative.chat.ai.assistant.limit.message': '{0} earliest messages are dropped due to the input token limit', 'aiNative.inlineDiff.acceptAll': 'Accept All', 'aiNative.inlineDiff.rejectAll': 'Reject All', @@ -1547,6 +1554,18 @@ export const localizationBundle = { 'preference.ai.native.chat.system.prompt': 'Default Chat System Prompt', 'preference.ai.native.globalRules.description': 'These rules will be sent to all chats and Agents.', + + 'preference.ai.native.agent.configs.title': 'Agent Configurations', + 'preference.ai.native.agent.configs': 'Agent Configs', + 'preference.ai.native.agent.configs.description': + 'Agent configurations for setting up different Agent commands and arguments', + 'preference.ai.native.agent.configs.markdownDescription': + 'Configure AI Agents with their command and arguments. Example:\n```json\n{\n "qwen": {\n "command": "qwen",\n "args": ["--acp", "--channel=ACP"],\n "streaming": true,\n "description": "Qwen CLI Agent"\n },\n "claude-agent-acp": {\n "command": "claude-agent-acp",\n "args": [],\n "streaming": true,\n "description": "Claude Code ACP Agent"\n }\n}\n```', + 'preference.ai.native.agent.configs.command.description': 'Command to start the Agent', + 'preference.ai.native.agent.configs.args.description': 'Arguments passed to the Agent', + 'preference.ai.native.agent.configs.streaming.description': 'Whether streaming output is supported', + 'preference.ai.native.agent.configs.description.description': 'Agent description information', + 'preference.ai.native.agent.defaultType.description': 'Default Agent Type to use for AI chat and commands', // #endregion AI Native // #endregion merge editor diff --git a/packages/i18n/src/common/zh-CN.lang.ts b/packages/i18n/src/common/zh-CN.lang.ts index 61c66da50a..c9200c91bc 100644 --- a/packages/i18n/src/common/zh-CN.lang.ts +++ b/packages/i18n/src/common/zh-CN.lang.ts @@ -402,6 +402,7 @@ export const localizationBundle = { 'preference.search.useReplacePreview': '控制搜索替换打开编辑器时,是否打开“替换预览”。', 'preference.search.searchOnType': '控制是否在搜索框中输入时自动搜索。', 'preference.search.searchOnTypeDebouncePeriod': '控制输入时自动搜索的延迟时间(毫秒)。', + 'preference.search.followSymlinks': '控制搜索时是否跟随符号链接。', 'preference.files.exclude.title': '排除文件显示', 'preference.debug.internalConsoleOptions': '控制何时打开内部调试控制台。', 'preference.debug.openDebug': '控制何时打开调试视图。', @@ -724,6 +725,10 @@ export const localizationBundle = { 'terminal.killProcess': '结束进程', 'terminal.process.unHealthy': '*此终端会话已被系统超时回收,请打开新的终端会话来进行操作', 'terminal.selectCWDForNewTerminal': '为新 terminal 选择当前工作路径', + 'chat.selectCWDForACP': '为 AI 对话选择工作路径', + 'chat.defaultCWDSelected': '未选择路径,默认使用:{0}', + 'chat.switchWorkspaceDir': '切换工作路径', + 'chat.switchWorkspaceDirHint': '当前路径:{0},点击切换工作路径(切换会新建会话)', 'view.command.show': '打开 {0}', @@ -1220,6 +1225,7 @@ export const localizationBundle = { // #region AI Native 'aiNative.chat.ai.assistant.name': 'AI 研发助手', 'aiNative.chat.input.placeholder.default': '可以问我任何问题,输入 @ 可引用内容', + 'aiNative.chat.input.placeholder.acp': '向 claude-agent-acp 发送消息,输入 @ 引用上下文,/ 使用命令', 'aiNative.chat.stop.immediately': '我先不想了,有需要可以随时问我', 'aiNative.chat.error.response': '当前与我互动的人太多,请稍后再试,感谢您的理解与支持', 'aiNative.chat.code.insert': '插入代码', @@ -1272,6 +1278,7 @@ export const localizationBundle = { 'aiNative.operate.chatHistory.delete': '删除', 'aiNative.chat.welcome.loading.text': '初始化中...', + 'aiNative.chat.acp.initializing.text': '正在初始化 ACP 服务...', 'aiNative.chat.ai.assistant.limit.message': '{0} 条最早的消息因输入 Tokens 限制而被丢弃', 'aiNative.inlineDiff.acceptAll': '接受全部', 'aiNative.inlineDiff.rejectAll': '拒绝全部', @@ -1313,6 +1320,17 @@ export const localizationBundle = { 'preference.ai.native.globalRules.description': '这些规则将发送到所有聊天、Agent 中。', 'preference.ai.native.chat.system.prompt': '默认聊天系统提示词', + + 'preference.ai.native.agent.configs.title': 'Agent 配置', + 'preference.ai.native.agent.configs': 'Agent 配置', + 'preference.ai.native.agent.configs.description': 'Agent 配置,用于配置不同 Agent 的启动命令和参数', + 'preference.ai.native.agent.configs.markdownDescription': + '配置 AI Agent 的命令和参数。示例:\n```json\n{\n "qwen": {\n "command": "qwen",\n "args": ["--acp", "--channel=ACP"],\n "streaming": true,\n "description": "Qwen CLI Agent"\n },\n "claude-agent-acp": {\n "command": "claude-agent-acp",\n "args": [],\n "streaming": true,\n "description": "Claude Code ACP Agent"\n }\n}\n```', + 'preference.ai.native.agent.configs.command.description': '启动 Agent 的命令', + 'preference.ai.native.agent.configs.args.description': '传递给 Agent 的参数', + 'preference.ai.native.agent.configs.streaming.description': '是否支持流式输出', + 'preference.ai.native.agent.configs.description.description': 'Agent 描述信息', + 'preference.ai.native.agent.defaultType.description': '用于 AI 聊天和命令的默认 Agent 类型', // #endregion AI Native 'webview.webviewTagUnavailable': '非 Electron 环境不支持 webview 标签,请使用 iframe 标签', diff --git a/packages/preferences/src/browser/preference-settings.service.ts b/packages/preferences/src/browser/preference-settings.service.ts index 955147b68b..b097d169b8 100644 --- a/packages/preferences/src/browser/preference-settings.service.ts +++ b/packages/preferences/src/browser/preference-settings.service.ts @@ -815,6 +815,7 @@ export const defaultSettingSections: { // { id: 'search.maxResults' }, { id: SearchSettingId.SearchOnType }, { id: SearchSettingId.SearchOnTypeDebouncePeriod }, + { id: SearchSettingId.FollowSymlinks, localized: 'preference.search.followSymlinks' }, // { id: 'search.showLineNumbers' }, // { id: 'search.smartCase' }, // { id: 'search.useGlobalIgnoreFiles' }, diff --git a/packages/search/__tests__/browser/search-tree.service.test.ts b/packages/search/__tests__/browser/search-tree.service.test.ts index 3eab56700f..49a320218d 100644 --- a/packages/search/__tests__/browser/search-tree.service.test.ts +++ b/packages/search/__tests__/browser/search-tree.service.test.ts @@ -163,6 +163,8 @@ describe('search-tree.service.ts', () => { [SearchSettingId.Include]: '', [SearchSettingId.SearchOnType]: true, [SearchSettingId.SearchOnTypeDebouncePeriod]: 300, + [SearchSettingId.FollowSymlinks]: true, + onPreferenceChanged: () => Disposable.NULL, }, }, ); diff --git a/packages/search/__tests__/browser/search.service.test.ts b/packages/search/__tests__/browser/search.service.test.ts index 7e416b1867..a6fd9a7af0 100644 --- a/packages/search/__tests__/browser/search.service.test.ts +++ b/packages/search/__tests__/browser/search.service.test.ts @@ -1,6 +1,6 @@ import { Injectable, Injector } from '@opensumi/di'; import { CorePreferences } from '@opensumi/ide-core-browser'; -import { URI } from '@opensumi/ide-core-common'; +import { Disposable, URI } from '@opensumi/ide-core-common'; import { createBrowserInjector } from '@opensumi/ide-dev-tool/src/injector-helper'; import { IEditorDocumentModelService, WorkbenchEditorService } from '@opensumi/ide-editor/lib/browser'; import { EditorDocumentModelServiceImpl } from '@opensumi/ide-editor/lib/browser/doc-model/main'; @@ -90,6 +90,9 @@ describe('search.service.ts', () => { '*.java': true, '*.ts': true, }, + 'search.followSymlinks': true, + 'search.searchOnType': true, + onPreferenceChanged: () => Disposable.NULL, }, }, { diff --git a/packages/search/src/browser/search-preferences.ts b/packages/search/src/browser/search-preferences.ts index 900aa20ea2..3117a660d3 100644 --- a/packages/search/src/browser/search-preferences.ts +++ b/packages/search/src/browser/search-preferences.ts @@ -50,6 +50,11 @@ export const searchPreferenceSchema: PreferenceSchema = { description: '%preference.search.searchOnTypeDebouncePeriod%', default: 300, }, + [SearchSettingId.FollowSymlinks]: { + type: 'boolean', + default: true, + description: '%preference.search.followSymlinks%', + }, }, }; @@ -60,6 +65,7 @@ export interface SearchConfiguration { [SearchSettingId.UseReplacePreview]: boolean; [SearchSettingId.SearchOnType]: boolean; [SearchSettingId.SearchOnTypeDebouncePeriod]: number; + [SearchSettingId.FollowSymlinks]: boolean; } export const SearchPreferences = Symbol('SearchPreferences'); diff --git a/packages/search/src/browser/search.service.ts b/packages/search/src/browser/search.service.ts index 6b3587f204..a7f10cff9d 100644 --- a/packages/search/src/browser/search.service.ts +++ b/packages/search/src/browser/search.service.ts @@ -160,6 +160,7 @@ export class ContentSearchClientService extends Disposable implements IContentSe isUseRegexp: false, isIncludeIgnored: false, isOnlyOpenEditors: false, + isFollowSymlinks: true, }; public searchResults: Map = new Map(); @@ -204,6 +205,20 @@ export class ContentSearchClientService extends Disposable implements IContentSe maxWait: timeout * 5, }, ); + + this.addDispose( + this.searchPreferences.onPreferenceChanged((e) => { + if (e.affects(SearchSettingId.FollowSymlinks)) { + const newValue = this.searchPreferences[SearchSettingId.FollowSymlinks] ?? true; + if (this.UIState.isFollowSymlinks !== newValue) { + this.updateUIState({ isFollowSymlinks: newValue }); + } + } + if (e.affects(SearchSettingId.SearchOnType)) { + this.searchOnType = this.searchPreferences[SearchSettingId.SearchOnType] ?? true; + } + }), + ); } private searchId: number = new Date().getTime(); @@ -243,6 +258,7 @@ export class ContentSearchClientService extends Disposable implements IContentSe matchWholeWord: state.isWholeWord, useRegExp: state.isUseRegexp, includeIgnored: state.isIncludeIgnored, + followSymlinks: this.searchPreferences[SearchSettingId.FollowSymlinks] ?? true, include: state.include || splitOnComma(this.includeValue || ''), exclude: state.exclude || splitOnComma(this.excludeValue || ''), @@ -602,7 +618,7 @@ export class ContentSearchClientService extends Disposable implements IContentSe }; private shouldSearch = (uiState: Partial) => - ['isWholeWord', 'isMatchCase', 'isUseRegexp', 'isIncludeIgnored', 'isOnlyOpenEditors'].some( + ['isWholeWord', 'isMatchCase', 'isUseRegexp', 'isIncludeIgnored', 'isOnlyOpenEditors', 'isFollowSymlinks'].some( (v) => uiState[v] !== undefined && uiState[v] !== this.UIState[v], ); diff --git a/packages/search/src/common/content-search.ts b/packages/search/src/common/content-search.ts index 1f067d96a6..3ce6f6da9c 100644 --- a/packages/search/src/common/content-search.ts +++ b/packages/search/src/common/content-search.ts @@ -42,6 +42,10 @@ export interface ContentSearchOptions { * See the setting `"files.encoding"` */ encoding?: string; + /** + * Follow symbolic links while searching. + */ + followSymlinks?: boolean; } export interface IContentSearchServer { @@ -119,6 +123,7 @@ export interface IUIState { isOnlyOpenEditors: boolean; isIncludeIgnored: boolean; + isFollowSymlinks: boolean; } export interface ContentSearchResult { diff --git a/packages/search/src/node/content-search.service.ts b/packages/search/src/node/content-search.service.ts index 3f2ff2dadc..200f0bbb2d 100644 --- a/packages/search/src/node/content-search.service.ts +++ b/packages/search/src/node/content-search.service.ts @@ -280,6 +280,10 @@ export class ContentSearchService extends RPCService i args.push('--encoding', options.encoding); } + if (options?.followSymlinks) { + args.push('--follow'); + } + if ((options && options.useRegExp) || (options && options.matchWholeWord)) { args.push('--regexp'); } else { diff --git a/packages/startup/entry/sample-modules/ai-native/WelcomePage.module.less b/packages/startup/entry/sample-modules/ai-native/WelcomePage.module.less new file mode 100644 index 0000000000..e85f0f6368 --- /dev/null +++ b/packages/startup/entry/sample-modules/ai-native/WelcomePage.module.less @@ -0,0 +1,56 @@ +.welcome_container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + padding: 32px 24px; + gap: 24px; +} + +.welcome_header { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + + .welcome_title { + margin: 0; + font-size: 20px; + font-weight: 600; + color: var(--design-text-foreground); + } + + .welcome_desc { + margin: 0; + font-size: 13px; + color: var(--design-text-secondary); + text-align: center; + } +} + +.sample_questions { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 8px; + width: 100%; + max-width: 380px; +} + +.sample_card { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + border-radius: 8px; + background-color: var(--design-block-hoverBackground); + cursor: pointer; + font-size: 12px; + color: var(--design-text-foreground); + transition: background-color 0.2s; + + &:hover { + background-color: var(--design-block-hoverActiveBackground); + color: var(--design-text-hoverForeground); + } +} diff --git a/packages/startup/entry/sample-modules/ai-native/WelcomePage.tsx b/packages/startup/entry/sample-modules/ai-native/WelcomePage.tsx new file mode 100644 index 0000000000..81c735e5b4 --- /dev/null +++ b/packages/startup/entry/sample-modules/ai-native/WelcomePage.tsx @@ -0,0 +1,55 @@ +import React from 'react'; + +import { ChatWelcomePageRender } from '@opensumi/ide-ai-native/lib/browser/types'; +import { getIcon } from '@opensumi/ide-core-browser'; +import { Icon } from '@opensumi/ide-core-browser/lib/components'; +import { localize } from '@opensumi/ide-core-common'; + +import styles from './WelcomePage.module.less'; + +interface IWelcomePageProps { + onSend: (message: string, images?: string[], agentId?: string, command?: string) => void; + agentId?: string; + setAgentId: (id: string) => void; + command?: string; + setCommand: (cmd: string) => void; +} + +export const ExampleWelcomePage: React.FC = ({ onSend }) => { + const handleSampleClick = (message: string) => { + onSend(message); + }; + + return ( +
+
+ +

{localize('aiNative.chat.ai.assistant.name')}

+

+ {localize('aiNative.chat.welcome.loading.text') || 'Your AI-powered coding assistant'} +

+
+ +
+
handleSampleClick('Explain my code')}> + + Explain my code +
+
handleSampleClick('Optimize my code')}> + + Optimize my code +
+
handleSampleClick('Generate unit tests')}> + + Generate unit tests +
+
handleSampleClick('Find and fix bugs')}> + + Find and fix bugs +
+
+
+ ); +}; + +export const exampleWelcomePageRender: ChatWelcomePageRender = (props) => ; diff --git a/packages/startup/entry/sample-modules/ai-native/ai-native.contribution.ts b/packages/startup/entry/sample-modules/ai-native/ai-native.contribution.ts index 6700757ba0..5c7a843ad0 100644 --- a/packages/startup/entry/sample-modules/ai-native/ai-native.contribution.ts +++ b/packages/startup/entry/sample-modules/ai-native/ai-native.contribution.ts @@ -1,4 +1,6 @@ import { Autowired, INJECTOR_TOKEN, Injector } from '@opensumi/di'; +import { AcpChatMentionInput } from '@opensumi/ide-ai-native/lib/browser/acp/components/AcpChatMentionInput'; +import { AcpChatViewHeader } from '@opensumi/ide-ai-native/lib/browser/acp/components/AcpChatViewHeader'; import { ChatService } from '@opensumi/ide-ai-native/lib/browser/chat/chat.api.service'; import { BaseTerminalDetectionLineMatcher, @@ -13,6 +15,7 @@ import { AINativeCoreContribution, ERunStrategy, IChatFeatureRegistry, + IChatRenderRegistry, IInlineChatFeatureRegistry, IIntelligentCompletionsRegistry, IProblemFixContext, @@ -20,6 +23,7 @@ import { IRenameCandidatesProviderRegistry, IResolveConflictRegistry, ITerminalProviderRegistry, + TChatSlashCommandSend, TerminalSuggestionReadableStream, } from '@opensumi/ide-ai-native/lib/browser/types'; import { InlineChatController } from '@opensumi/ide-ai-native/lib/browser/widget/inline-chat/inline-chat-controller'; @@ -30,7 +34,7 @@ import { import { MergeConflictPromptManager } from '@opensumi/ide-ai-native/lib/common/prompts/merge-conflict-prompt'; import { RenamePromptManager } from '@opensumi/ide-ai-native/lib/common/prompts/rename-prompt'; import { TerminalDetectionPromptManager } from '@opensumi/ide-ai-native/lib/common/prompts/terminal-detection-prompt'; -import { Domain, getIcon } from '@opensumi/ide-core-browser'; +import { AINativeConfigService, Domain, getIcon } from '@opensumi/ide-core-browser'; import { AIBackSerivcePath, CancelResponse, @@ -47,6 +51,9 @@ import { import { ICodeEditor, ISelection, NewSymbolName, NewSymbolNameTag, Range, Selection } from '@opensumi/ide-monaco'; import { MarkdownString } from '@opensumi/monaco-editor-core/esm/vs/base/common/htmlContent'; +import { SlashCommand } from './SlashCommand'; +import { exampleWelcomePageRender } from './WelcomePage'; + export enum EInlineOperation { Comments = 'Comments', Optimize = 'Optimize', @@ -77,6 +84,9 @@ export class AINativeContribution implements AINativeCoreContribution { @Autowired(ChatServiceToken) private readonly aiChatService: ChatService; + @Autowired(AINativeConfigService) + private readonly aiNativeConfigService: AINativeConfigService; + logger = getDebugLogger(); registerInlineChatFeature(registry: IInlineChatFeatureRegistry) { @@ -193,7 +203,11 @@ export class AINativeContribution implements AINativeCoreContribution { }, { triggerRules: 'selection', - execute: async (stdout: string) => {}, + execute: async (stdout: string) => { + this.aiChatService.sendMessage({ + message: `Explain terminal output:\n\`\`\`\n${stdout}\n\`\`\``, + }); + }, }, ); @@ -222,6 +236,27 @@ export class AINativeContribution implements AINativeCoreContribution { }, }, ); + + registry.registerTerminalInlineChat( + { + id: 'terminal-qa', + name: '智能答疑', + }, + { + triggerRules: 'selection', + // triggerRules: [NodeMatcher, TSCMatcher, NPMMatcher, ShellMatcher, JavaMatcher], + execute: async (stdout: string, stdin: string) => { + // 1. 打开 Chat 面板,将错误信息填入输入框 + this.aiChatService.showChatView(); + + // 2. 直接调用独立 API(TODO: 替换为真实 API) + const reply = `【智能答疑】\n\n**命令:** \`${stdin}\`\n\n**错误分析:**\n${stdout}\n\n这是模拟回复,后续替换为真实 API。`; + + // 3. 以 AI 角色推送回复到 Chat 面板 + this.aiChatService.sendReplyMessage(reply); + }, + }, + ); } registerChatFeature(registry: IChatFeatureRegistry): void { @@ -291,26 +326,46 @@ Good: "Instance network interfaces exceeded system limit"`; }, }); - // registry.registerSlashCommand( - // { - // name: 'Explain', - // description: 'Explain', - // isShortcut: true, - // tooltip: 'Explain', - // }, - // { - // providerRender: SlashCommand, - // providerInputPlaceholder(value, editor) { - // return 'Please enter or paste the code.'; - // }, - // providerPrompt(value, editor) { - // return `Explain code: \`\`\`\n${value}\n\`\`\``; - // }, - // execute: (value: string, send: TChatSlashCommandSend, editor: ICodeEditor) => { - // send(value); - // }, - // }, - // ); + registry.registerSlashCommand( + { + name: 'Explain', + description: 'Explain', + isShortcut: true, + tooltip: 'Explain', + }, + { + // providerRender: SlashCommand, + providerInputPlaceholder(value, editor) { + return 'Please enter or paste the code.'; + }, + // providerDefaultInput: 当用户点击 slash command 快捷入口时,自动填充输入框的默认内容 + // 如果编辑器中有选中的代码,则自动填充选中的代码;否则返回空字符串 + providerDefaultInput(value, editor) { + if (editor) { + const selection = editor.getSelection(); + if (selection && !selection.isEmpty()) { + return editor.getModel()?.getValueInRange(Selection.liftSelection(selection)) || ''; + } + } + return ''; + }, + providerPrompt(value, editor) { + return `Explain code: \`\`\`\n${value}\n\`\`\``; + }, + execute: (value: string, send: TChatSlashCommandSend, editor: ICodeEditor) => { + send(value); + }, + // 自定义 invoke:跳过 ACP,走独立 API 服务 + // TODO: 替换为真实 API 调用 + invoke: async (message, progress, _token) => { + await new Promise((resolve) => setTimeout(resolve, 500)); + progress({ + content: `【Explain Mock】\n\n**输入:**\n\`\`\`\n${message}\n\`\`\`\n\n**回答:**\n这是模拟回复,后续替换为真实 API。`, + kind: 'content', + }); + }, + }, + ); // registry.registerSlashCommand( // { @@ -557,12 +612,16 @@ Good: "Instance network interfaces exceeded system limit"`; }); } - registerChatAgentPromptProvider(): void { - this.injector.overrideProviders({ - token: ChatAgentPromptProvider, - useClass: DefaultChatAgentPromptProvider, - }); + registerChatRender(registry: IChatRenderRegistry): void { + if (this.aiNativeConfigService.capabilities.supportsAgentMode) { + registry.registerInputRender(AcpChatMentionInput); + registry.registerChatViewHeaderRender(AcpChatViewHeader); + registry.registerEnabledMentionTypes(['file', 'folder', 'rule']); + registry.registerChatWelcomePageRender(exampleWelcomePageRender); + } } + + registerChatAgentPromptProvider(): void {} } const MAX_IMAGE_SIZE = 3 * 1024 * 1024; diff --git a/packages/startup/entry/web/server.ts b/packages/startup/entry/web/server.ts index bb94ec8193..88d1508fbb 100644 --- a/packages/startup/entry/web/server.ts +++ b/packages/startup/entry/web/server.ts @@ -15,10 +15,10 @@ import { CommonNodeModules } from '../../src/node/common-modules'; import { AIBackService } from '../sample-modules/ai-native/ai.back.service'; const injectorProviders: Provider[] = [ - { - token: AIBackSerivceToken, - useClass: AIBackService, - }, + // { + // token: AIBackSerivceToken, + // useClass: AIBackService, + // }, ]; // Only override terminal pty manager to use remote proxy when env is provided. diff --git a/tools/playwright/src/tests/search-view.test.ts b/tools/playwright/src/tests/search-view.test.ts index 1e9961af27..f1feec1f63 100644 --- a/tools/playwright/src/tests/search-view.test.ts +++ b/tools/playwright/src/tests/search-view.test.ts @@ -85,6 +85,7 @@ test.describe('OpenSumi Search Panel', () => { }); const input = await search.focusOnReplace(); await page.keyboard.type(replaceText); + await app.page.waitForTimeout(1000); const contentNode = await search.getTreeNodeByIndex(1); expect(contentNode).toBeDefined(); diff --git a/yarn.lock b/yarn.lock index 36e04defaa..0f0be2d976 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12,6 +12,15 @@ __metadata: languageName: node linkType: hard +"@agentclientprotocol/sdk@npm:^0.16.1": + version: 0.16.1 + resolution: "@agentclientprotocol/sdk@npm:0.16.1" + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + checksum: 10/565495a1024712423ddac966a944ae03ee1e54592504be74cc538250979e907f91550f7ba9fef080aac4cc15de430ee44bdd3bda32009a2735513d4024cec1f5 + languageName: node + linkType: hard + "@ai-sdk/anthropic@npm:^1.1.9": version: 1.1.9 resolution: "@ai-sdk/anthropic@npm:1.1.9" @@ -3414,6 +3423,7 @@ __metadata: version: 0.0.0-use.local resolution: "@opensumi/ide-ai-native@workspace:packages/ai-native" dependencies: + "@agentclientprotocol/sdk": "npm:^0.16.1" "@ai-sdk/anthropic": "npm:^1.1.9" "@ai-sdk/deepseek": "npm:^0.1.11" "@ai-sdk/openai": "npm:^1.1.9" @@ -3449,6 +3459,7 @@ __metadata: diff: "npm:^7.0.0" dom-align: "npm:^1.7.0" eventsource: "npm:^3.0.5" + node-pty: "npm:1.0.0" rc-collapse: "npm:^4.0.0" react-chat-elements: "npm:^12.0.10" react-highlight: "npm:^0.15.0" @@ -3581,11 +3592,13 @@ __metadata: version: 0.0.0-use.local resolution: "@opensumi/ide-core-common@workspace:packages/core-common" dependencies: + "@agentclientprotocol/sdk": "npm:^0.16.1" "@opensumi/di": "npm:^1.8.0" "@opensumi/events": "npm:^1.0.0" "@opensumi/ide-dev-tool": "workspace:*" "@opensumi/ide-utils": "workspace:*" ai: "npm:^4.3.16" + electron: "npm:^22.3.21" languageName: unknown linkType: soft @@ -3890,6 +3903,7 @@ __metadata: version: 0.0.0-use.local resolution: "@opensumi/ide-file-service@workspace:packages/file-service" dependencies: + "@furyjs/fury": "npm:0.5.9-beta" "@opensumi/ide-connection": "workspace:*" "@opensumi/ide-core-browser": "workspace:*" "@opensumi/ide-core-common": "workspace:*"