From a4cc78865a2bafb9b5e3d0ffc68ec115950d2f3f Mon Sep 17 00:00:00 2001 From: F0RLE Date: Tue, 28 Apr 2026 21:27:41 +0300 Subject: [PATCH 001/126] fix: stabilize module selection UI --- src/shared/shell/AppUI.test.ts | 86 +++---------------- src/shared/shell/AppUI.ts | 18 ---- .../shell/ui/AppUiCardActionFlow.test.ts | 27 +++++- src/shared/shell/ui/AppUiCardActionFlow.ts | 9 +- src/shared/shell/ui/AppUiModuleFlow.test.ts | 23 +++++ .../shell/ui/AppUiSelectionFlow.test.ts | 11 +-- src/shared/shell/ui/AppUiSelectionFlow.ts | 20 +---- .../shell/ui/ModuleCardRenderer.test.ts | 51 ++++++----- src/shared/shell/ui/ModuleCardRenderer.ts | 74 ++-------------- src/shared/shell/ui/ToastManager.test.ts | 18 ++++ src/shared/shell/ui/ToastManager.ts | 37 ++++++++ src/shared/utils/moduleCategoryPolicy.test.ts | 7 -- src/shared/utils/moduleCategoryPolicy.ts | 4 - 13 files changed, 166 insertions(+), 219 deletions(-) diff --git a/src/shared/shell/AppUI.test.ts b/src/shared/shell/AppUI.test.ts index 27240136..7de79d84 100644 --- a/src/shared/shell/AppUI.test.ts +++ b/src/shared/shell/AppUI.test.ts @@ -377,7 +377,7 @@ describe('AppUI lifecycle', () => { expect(platformServiceMock.stop).toHaveBeenCalledWith(serviceApp); }); - it('should stop the previous services module when switching cards without action button running state', () => { + it('should not stop the previous services module when only switching selected cards', () => { appUI = createAppUI(); document.body.innerHTML = `
@@ -399,10 +399,10 @@ describe('AppUI lifecycle', () => { appUI.updateModuleCard('services', newApp); - expect(platformServiceMock.stop).toHaveBeenCalledWith(oldApp); + expect(platformServiceMock.stop).not.toHaveBeenCalled(); }); - it('should swallow stop errors when switching away from a previous module', async () => { + it('should not touch runtime stop path when switching selected cards', async () => { appUI = createAppUI(); platformServiceMock.stop.mockRejectedValueOnce(new Error('stop failed')); document.body.innerHTML = ` @@ -427,6 +427,7 @@ describe('AppUI lifecycle', () => { }).not.toThrow(); await Promise.resolve(); + expect(platformServiceMock.stop).not.toHaveBeenCalled(); }); it('should reset services card instead of showing an AI module when clearing services', () => { @@ -539,10 +540,7 @@ describe('AppUI lifecycle', () => { appUI.updateModuleCard('ai_image', sharedApp); expect(card.dataset['currentCapability']).toBe('ai_image'); - const resolvedCategory = ( - appUI as unknown as { _resolveCategoryFromCard: (card: HTMLElement) => string } - )._resolveCategoryFromCard(card); - expect(resolvedCategory).toBe('ai_image'); + expect(appUI.getPreferredAiCategory()).toBe('ai_image'); }); it('should retarget AI card settings and close actions after wheel switching slots', () => { @@ -633,13 +631,13 @@ describe('AppUI lifecycle', () => { privateAppUI._performSelectionAction('services', serviceApp); expect(updateSelectionSpy).toHaveBeenCalledWith('svc'); expect(uiStateMocks.setSelectedModule).toHaveBeenCalled(); - expect(launchAppMock).toHaveBeenCalledWith('services', serviceApp); + expect(launchAppMock).not.toHaveBeenCalled(); privateAppUI._performSelectionAction('services', serviceApp); expect(updateSelectionSpy).toHaveBeenLastCalledWith(null); }); - it('should mark selected service cards as running after backend status confirms launch', async () => { + it('should keep selected service cards as indicators without probing runtime status', async () => { appUI = createAppUI(); document.body.innerHTML = `
@@ -664,42 +662,9 @@ describe('AppUI lifecycle', () => { await new Promise((resolve) => globalThis.setTimeout(resolve, 0)); const card = document.getElementById('services-module-card') as HTMLElement; - expect(platformServiceMock.getStatus).toHaveBeenCalledWith(serviceApp); - expect(card.classList.contains('module-running')).toBe(true); - expect(card.dataset['runtimeStatus']).toBe('running'); - }); - - it('should handle modal download success and error', () => { - appUI = createAppUI(); - const privateAppUI = appUI as unknown as { - _onModalDownloadSuccess: (btn: HTMLElement | null, app: IApp, category: string) => void; - _onModalDownloadError: (btn: HTMLElement | null, err: unknown) => void; - _modalManager: { - refreshCurrentSelection: ReturnType; - isViewingCategory: ReturnType; - }; - }; - vi.spyOn(privateAppUI._modalManager, 'isViewingCategory').mockReturnValue(true); - const refreshSpy = vi.spyOn(privateAppUI._modalManager, 'refreshCurrentSelection'); - - const card = document.createElement('div'); - card.className = 'app-card'; - card.innerHTML = ` -
-
-
- `; - const btn = document.createElement('button'); - btn.className = 'download-btn downloading indeterminate'; - card.appendChild(btn); - - const app = { id: 'local-app', name: 'Local App', installed: false } as IApp; - privateAppUI._onModalDownloadSuccess(btn, app, 'services'); - expect(app.installed).toBe(true); - expect(btn.classList.contains('downloading')).toBe(false); - expect(refreshSpy).toHaveBeenCalled(); - - privateAppUI._onModalDownloadError(btn, new Error('broken')); + expect(platformServiceMock.getStatus).not.toHaveBeenCalled(); + expect(card.classList.contains('module-running')).toBe(false); + expect(card.dataset['runtimeStatus']).toBe('stopped'); }); it('should show a placeholder toast instead of selecting or downloading coming-soon modules', async () => { @@ -729,7 +694,7 @@ describe('AppUI lifecycle', () => { expect(launchAppMock).not.toHaveBeenCalled(); }); - it('should stop stale launched module after quick reselection', async () => { + it('should not launch or stop services during quick reselection', async () => { appUI = createAppUI(); let releaseFirstLaunch!: () => void; @@ -777,9 +742,8 @@ describe('AppUI lifecycle', () => { await Promise.resolve(); await Promise.resolve(); - expect(launchApp).toHaveBeenNthCalledWith(1, 'services', firstApp); - expect(launchApp).toHaveBeenNthCalledWith(2, 'services', secondApp); - expect(platformServiceMock.stop).toHaveBeenCalledWith(firstApp); + expect(launchApp).not.toHaveBeenCalled(); + expect(platformServiceMock.stop).not.toHaveBeenCalled(); }); it('should not reopen modal after delete if app selection was already closed', async () => { @@ -827,30 +791,6 @@ describe('AppUI lifecycle', () => { expect(reopenSpy).toHaveBeenCalledWith('services', refreshedApps); }); - it('should not refresh modal after download success when viewing another category', () => { - appUI = createAppUI(); - - const privateAppUI = appUI as unknown as { - _onModalDownloadSuccess: (btn: HTMLElement | null, app: IApp, category: string) => void; - _modalManager: { - isViewingCategory: (category: string) => boolean; - refreshCurrentSelection: () => void; - }; - }; - - vi.spyOn(privateAppUI._modalManager, 'isViewingCategory').mockReturnValue(false); - const refreshSpy = vi.spyOn(privateAppUI._modalManager, 'refreshCurrentSelection'); - - const btn = document.createElement('button'); - btn.className = 'download-btn downloading indeterminate'; - const app = { id: 'local-app', name: 'Local App', installed: false } as IApp; - - privateAppUI._onModalDownloadSuccess(btn, app, 'services'); - - expect(app.installed).toBe(true); - expect(refreshSpy).not.toHaveBeenCalled(); - }); - it('should resolve app by id from injected catalog resolver', () => { appUI = createAppUI(); getCatalogCategoryMock.mockImplementation((category: string) => diff --git a/src/shared/shell/AppUI.ts b/src/shared/shell/AppUI.ts index 1106cd93..833b1997 100644 --- a/src/shared/shell/AppUI.ts +++ b/src/shared/shell/AppUI.ts @@ -308,7 +308,6 @@ export class AppUI { } this._dashboardSupport.cancelPendingSwitch(); - this._moduleLifecycle.stopPreviousModule(card, app, category); this._dashboardSupport.applySelectedCardState(card, app, category); this._selectionState.set(category, app); this._updateMultiSlotBadge(); @@ -404,14 +403,6 @@ export class AppUI { await this._moduleFlow.handleDeleteModule(app, category); } - public _onModalDownloadSuccess(btn: HTMLElement | null, app: IApp, category: string): void { - this._moduleFlow.onModalDownloadSuccess(btn, app, category); - } - - public _onModalDownloadError(btn: HTMLElement | null, err: unknown): void { - this._moduleFlow.onModalDownloadError(btn, err); - } - private _resolveAppById(appId: string): IApp | undefined { for (const selectedApp of this._selectionState.values()) { if (selectedApp.id === appId) { @@ -431,15 +422,6 @@ export class AppUI { return this._getCatalogApps(this._dashboardSupport.resolveCatalogCategory(category)); } - public _resolveCategoryFromCard(card: HTMLElement): string { - const currentCapability = card.dataset['currentCapability']; - if (typeof currentCapability === 'string' && currentCapability !== '') { - return currentCapability; - } - - return card.id === 'ai-module-card' ? CategoryKey.AI_TEXT : CategoryKey.SERVICES; - } - public getPreferredAiCategory(): 'ai_text' | 'ai_image' { const card = this._dashboardSupport.getDashboardCard(CategoryKey.AI_TEXT); if (card instanceof HTMLElement) { diff --git a/src/shared/shell/ui/AppUiCardActionFlow.test.ts b/src/shared/shell/ui/AppUiCardActionFlow.test.ts index a86625dc..51b5e064 100644 --- a/src/shared/shell/ui/AppUiCardActionFlow.test.ts +++ b/src/shared/shell/ui/AppUiCardActionFlow.test.ts @@ -78,7 +78,7 @@ describe('AppUiCardActionFlow', () => { const event = { stopPropagation: vi.fn(), currentTarget: card, - target: card, + target: btn, clientX: 20, } as unknown as MouseEvent; const app = { id: 'svc', installed: false, repoUrl: 'https://repo' } as IApp; @@ -114,7 +114,7 @@ describe('AppUiCardActionFlow', () => { const event = { stopPropagation: vi.fn(), currentTarget: card, - target: card, + target: btn, clientX: 20, } as unknown as MouseEvent; const app = { id: 'svc', installed: false, repoUrl: 'https://repo' } as IApp; @@ -145,7 +145,7 @@ describe('AppUiCardActionFlow', () => { const event = { stopPropagation: vi.fn(), currentTarget: card, - target: card, + target: btn, clientX: 75, } as unknown as MouseEvent; const app = { id: 'svc', installed: false, repoUrl: 'https://repo' } as IApp; @@ -158,4 +158,25 @@ describe('AppUiCardActionFlow', () => { expect(deps.resetDownloadButton).toHaveBeenCalledWith(btn); expect(deps.restoreDownloadButtonLabel).toHaveBeenCalledWith(btn); }); + + it('does not start a download from a plain card click', async () => { + const card = document.createElement('div'); + card.className = 'app-card'; + const btn = document.createElement('button'); + btn.className = 'download-btn'; + card.appendChild(btn); + + const event = { + stopPropagation: vi.fn(), + currentTarget: card, + target: card, + clientX: 20, + } as unknown as MouseEvent; + const app = { id: 'llamacpp', installed: false, repoUrl: 'https://repo' } as IApp; + + await flow.handleAppCardClick(event, app, 'ai_text'); + + expect(deps.handleDownloadModule).not.toHaveBeenCalled(); + expect(deps.performSelectionAction).toHaveBeenCalledWith('ai_text', app); + }); }); diff --git a/src/shared/shell/ui/AppUiCardActionFlow.ts b/src/shared/shell/ui/AppUiCardActionFlow.ts index 5c07e45a..5a19f259 100644 --- a/src/shared/shell/ui/AppUiCardActionFlow.ts +++ b/src/shared/shell/ui/AppUiCardActionFlow.ts @@ -58,6 +58,14 @@ export class AppUiCardActionFlow { return true; } + const btn = this._resolveDownloadButton(event); + const clickedDownloadButton = (event.target as HTMLElement | null)?.closest( + '.download-btn', + ); + if (clickedDownloadButton === null) { + return false; + } + if (this._deps.platformService.isApiModule(app) || app.installed === true) { return false; } @@ -75,7 +83,6 @@ export class AppUiCardActionFlow { } event.stopPropagation(); - const btn = this._resolveDownloadButton(event); if (btn?.classList.contains('downloading') === true) { this._handleActiveDownloadAction(event, app, btn); return true; diff --git a/src/shared/shell/ui/AppUiModuleFlow.test.ts b/src/shared/shell/ui/AppUiModuleFlow.test.ts index 6e068cd1..39f0d242 100644 --- a/src/shared/shell/ui/AppUiModuleFlow.test.ts +++ b/src/shared/shell/ui/AppUiModuleFlow.test.ts @@ -85,6 +85,29 @@ describe('AppUiModuleFlow', () => { expect(modalManager.refreshCurrentSelection).toHaveBeenCalledWith([app], 'svc'); }); + it('does not refresh modal selection after download success in another category', () => { + const app = { id: 'svc', name: 'Service', installed: false } as IApp; + const btn = document.createElement('button'); + btn.className = 'download-btn downloading indeterminate'; + modalManager.isViewingCategory.mockReturnValue(false); + + flow.onModalDownloadSuccess(btn, app, 'services'); + + expect(app.installed).toBe(true); + expect(btn.classList.contains('downloading')).toBe(false); + expect(modalManager.refreshCurrentSelection).not.toHaveBeenCalled(); + }); + + it('resets download button and shows a toast after download errors', () => { + const btn = document.createElement('button'); + btn.className = 'download-btn downloading indeterminate'; + + flow.onModalDownloadError(btn, new Error('broken')); + + expect(btn.classList.contains('downloading')).toBe(false); + expect(showToast).toHaveBeenCalledWith('Download failed', 'error'); + }); + it('keeps paused download button state for resume', async () => { const app = { id: 'svc', name: 'Service', installed: false } as IApp; const btn = document.createElement('button'); diff --git a/src/shared/shell/ui/AppUiSelectionFlow.test.ts b/src/shared/shell/ui/AppUiSelectionFlow.test.ts index 542c34b3..b25eb770 100644 --- a/src/shared/shell/ui/AppUiSelectionFlow.test.ts +++ b/src/shared/shell/ui/AppUiSelectionFlow.test.ts @@ -32,7 +32,7 @@ describe('AppUiSelectionFlow', () => { }); }); - it('selects integration module, persists it and launches it', () => { + it('selects integration module and persists it without launching', () => { const app = { id: 'svc', name: 'Service', type: 'local', icon: 'S', desc: 'Desc' } as IApp; getSelectedApp.mockReturnValue(undefined); @@ -41,7 +41,8 @@ describe('AppUiSelectionFlow', () => { expect(updateModuleCard).toHaveBeenCalledWith('services', app); expect(updateModalSelection).toHaveBeenCalledWith('svc'); expect(setSelectedModule).toHaveBeenCalled(); - expect(launchSelectedApp).toHaveBeenCalled(); + expect(launchSelectedApp).not.toHaveBeenCalled(); + expect(launchApp).not.toHaveBeenCalled(); }); it('selects AI module without launching it immediately', () => { @@ -57,10 +58,10 @@ describe('AppUiSelectionFlow', () => { expect(launchApp).not.toHaveBeenCalled(); }); - it('does not launch AI when switching the visible shared AI slot', () => { - const app = { id: 'image-model', name: 'Image Model', type: 'local' } as IApp; + it('does not launch an existing selection when switching visible state', () => { + const app = { id: 'svc', name: 'Service', type: 'local' } as IApp; - flow.activateExistingSelection('ai_image', app); + flow.activateExistingSelection('services', app); expect(launchApp).not.toHaveBeenCalled(); }); diff --git a/src/shared/shell/ui/AppUiSelectionFlow.ts b/src/shared/shell/ui/AppUiSelectionFlow.ts index 56265e45..ede95b1e 100644 --- a/src/shared/shell/ui/AppUiSelectionFlow.ts +++ b/src/shared/shell/ui/AppUiSelectionFlow.ts @@ -1,6 +1,4 @@ import type { IApp } from '../../types/coreTypes'; -import { isAiCategory, shouldLaunchOnSelection } from '../../utils/moduleCategoryPolicy'; - type LaunchAppFn = (category: string, app: IApp) => Promise; type AppUiSelectionFlowDeps = { @@ -35,19 +33,10 @@ export class AppUiSelectionFlow { return; } - const launchSelectionVersion = this._deps.bumpLaunchSelectionVersion(category); + this._deps.bumpLaunchSelectionVersion(category); this._deps.updateModuleCard(category, app); this._deps.updateModalSelection(app.id); this._persistSelectedModule(category, app); - - if (shouldLaunchOnSelection(category) && typeof this._deps.launchApp === 'function') { - void this._deps.launchSelectedApp( - category, - app, - launchSelectionVersion, - this._deps.launchApp, - ); - } } private _persistSelectedModule(category: string, app: IApp): void { @@ -63,10 +52,7 @@ export class AppUiSelectionFlow { } public activateExistingSelection(category: string, app: IApp): void { - if (isAiCategory(category) || typeof this._deps.launchApp !== 'function') { - return; - } - - void this._deps.launchApp(category, app); + void category; + void app; } } diff --git a/src/shared/shell/ui/ModuleCardRenderer.test.ts b/src/shared/shell/ui/ModuleCardRenderer.test.ts index 0843c97c..c17322a7 100644 --- a/src/shared/shell/ui/ModuleCardRenderer.test.ts +++ b/src/shared/shell/ui/ModuleCardRenderer.test.ts @@ -192,6 +192,29 @@ describe('ModuleCardRenderer', () => { expect(card.querySelector('.download-btn')).toBeNull(); }); + it('renders AI engine cards as selectable even before install checks', () => { + const onClick = vi.fn(); + const onDownload = vi.fn(); + + const card = renderer.createSelectionCard( + { + id: 'llamacpp', + name: 'llama.cpp', + desc: 'Local engine', + installed: false, + type: 'local', + capability: 'text', + } as never, + 'ai_text', + false, + onClick, + onDownload, + ); + + expect(card.querySelector('.download-btn')).toBeNull(); + expect(card.querySelector('.modal-btn-primary')?.textContent).toContain('Select'); + }); + it('renders delete badge emoji for installed local modules', () => { const onClick = vi.fn(); const card = renderer.createSelectionCard( @@ -205,7 +228,7 @@ describe('ModuleCardRenderer', () => { expect(deleteIcon?.textContent).toContain('🗑'); }); - it('opens module settings on right click for installed cards and ignores uninstalled ones', async () => { + it('opens module settings on right click for installed cards and ignores uninstalled ones', () => { const onClick = vi.fn(); const installedCard = renderer.createSelectionCard( @@ -219,21 +242,6 @@ describe('ModuleCardRenderer', () => { ); expect(openModuleSettingsSpy).toHaveBeenCalled(); - (checkInstalled as ReturnType).mockResolvedValue(true); - const asyncCard = renderer.createSelectionCard( - { id: 'late-install', name: 'Later', desc: 'Desc', installed: false } as never, - 'services', - false, - onClick, - ); - document.body.appendChild(asyncCard); - await Promise.resolve(); - await Promise.resolve(); - - expect(asyncCard.classList.contains('is-installed')).toBe(true); - asyncCard.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })); - expect(openModuleSettingsSpy).toHaveBeenCalledTimes(2); - const uninstalledCard = renderer.createSelectionCard( { id: 'not-installed', name: 'Missing', desc: 'Desc', installed: false } as never, 'services', @@ -243,7 +251,7 @@ describe('ModuleCardRenderer', () => { uninstalledCard.dispatchEvent( new MouseEvent('contextmenu', { bubbles: true, cancelable: true }), ); - expect(openModuleSettingsSpy).toHaveBeenCalledTimes(2); + expect(openModuleSettingsSpy).toHaveBeenCalledTimes(1); }); it('should not open settings for modules with settings disabled', () => { @@ -262,22 +270,21 @@ describe('ModuleCardRenderer', () => { expect(openModuleSettingsSpy).not.toHaveBeenCalled(); }); - it('should ignore late async install resolution for detached cards', async () => { + it('does not run per-card install checks while rendering the modal', async () => { const onClick = vi.fn(); (checkInstalled as ReturnType).mockResolvedValue(true); - const card = renderer.createSelectionCard( - { id: 'late-install-detached', name: 'Later', desc: 'Desc', installed: false } as never, + renderer.createSelectionCard( + { id: 'late-install', name: 'Later', desc: 'Desc', installed: false } as never, 'services', false, onClick, ); - card.remove(); await Promise.resolve(); await Promise.resolve(); - expect(card.classList.contains('is-installed')).toBe(false); + expect(checkInstalled).not.toHaveBeenCalled(); }); it('updates dashboard card content and marks cards as installed', () => { diff --git a/src/shared/shell/ui/ModuleCardRenderer.ts b/src/shared/shell/ui/ModuleCardRenderer.ts index 4d56b183..b9999da8 100644 --- a/src/shared/shell/ui/ModuleCardRenderer.ts +++ b/src/shared/shell/ui/ModuleCardRenderer.ts @@ -13,6 +13,7 @@ import { setModuleCardDownloadProgress, } from './ModuleCardDownloadProgress'; import { ModuleCardPresentationHelper } from './ModuleCardPresentationHelper'; +import { isAiCategory } from '../../utils/moduleCategoryPolicy'; type ModuleCardRendererDeps = { checkInstalled?: (moduleId: string) => Promise; @@ -101,7 +102,7 @@ export class ModuleCardRenderer { } card.dataset['appId'] = app.id; - const state = this._resolveCardState(app); + const state = this._resolveCardState(app, _category); this._applyCardState(card, state); const template = document.getElementById('tpl-module-card') as HTMLTemplateElement | null; @@ -120,15 +121,15 @@ export class ModuleCardRenderer { this._applyExistingDownloadState(card, app); this._attachEventHandlers(card, app, state.isApi, onClick); - this._startAsyncInstallCheck(card, app, state, onClick); return card; } - private _resolveCardState(app: IApp): CardState { + private _resolveCardState(app: IApp, category: string): CardState { const isApi = this._isApiModule(app); const isComingSoon = app.comingSoon === true; - const isInstalled = isApi || (!isComingSoon && app.installed === true); + const isInstalled = + isApi || isAiCategory(category) || (!isComingSoon && app.installed === true); return { isApi, @@ -304,61 +305,6 @@ export class ModuleCardRenderer { ); } - private _startAsyncInstallCheck( - card: HTMLElement, - app: IApp, - state: CardState, - onClick: (e: MouseEvent, app: IApp) => void, - ): void { - if (state.isInstalled || state.isApi || state.isComingSoon) { - return; - } - - const checkInstalled = this._deps.checkInstalled; - if (checkInstalled === undefined) { - return; - } - - void this._runAsyncInstallCheck(card, app, state.isApi, onClick, checkInstalled); - } - - private async _runAsyncInstallCheck( - card: HTMLElement, - app: IApp, - isApi: boolean, - onClick: (e: MouseEvent, app: IApp) => void, - checkInstalled: (moduleId: string) => Promise, - ): Promise { - try { - if (await checkInstalled(app.id)) { - this._handleAsyncInstallSuccess(card, app, isApi, onClick); - } - } catch (err) { - this._tracer?.debug( - `[ModuleCardRenderer] Failed to check installation status for ${app.id}: ${String(err)}`, - ); - } - } - - private _handleAsyncInstallSuccess( - card: HTMLElement, - app: IApp, - isApi: boolean, - onClick: (e: MouseEvent, app: IApp) => void, - ): void { - if (!card.isConnected) return; - if (card.dataset['appId'] !== app.id) return; - - app.installed = true; - - this._applyInstalledCardAppearance(card); - this._replaceCardActions( - card, - buildModuleCardActionButton(app, false, this._translate, onClick), - ); - this._ensureDeleteBadge(card, isApi); - } - public updateSlotCardAttributes(card: HTMLElement, app: IApp, capability?: string): void { card.dataset['currentModule'] = app.id; card.dataset['currentModuleName'] = app.name ?? app.id; @@ -448,16 +394,6 @@ export class ModuleCardRenderer { } } - private _replaceCardActions(card: HTMLElement, actionButton: HTMLElement): void { - const actionsContainer = card.querySelector('.module-selection-card-actions'); - if (actionsContainer === null) { - return; - } - - actionsContainer.innerHTML = ''; - actionsContainer.appendChild(actionButton); - } - private _ensureDeleteBadge(card: HTMLElement, isApi: boolean): void { if (card.querySelector('.app-delete-badge') !== null) { return; diff --git a/src/shared/shell/ui/ToastManager.test.ts b/src/shared/shell/ui/ToastManager.test.ts index e6af1438..e779afda 100644 --- a/src/shared/shell/ui/ToastManager.test.ts +++ b/src/shared/shell/ui/ToastManager.test.ts @@ -131,4 +131,22 @@ describe('ToastManager', () => { expect(document.getElementById('toast-container')).toBeNull(); expect(document.querySelector('.toast')).toBeNull(); }); + + it('should run an action when an actionable toast is clicked', () => { + const onClick = vi.fn(); + + manager.show('Open settings', 'warning', 1000, null, 'settings', onClick); + + const toast = document.getElementById('toast-settings'); + if (!(toast instanceof HTMLElement)) { + throw new Error('Toast was not created'); + } + + expect(toast.classList.contains('toast--actionable')).toBe(true); + expect(toast.getAttribute('role')).toBe('button'); + + toast.click(); + + expect(onClick).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/shared/shell/ui/ToastManager.ts b/src/shared/shell/ui/ToastManager.ts index d2595465..8fc86f87 100644 --- a/src/shared/shell/ui/ToastManager.ts +++ b/src/shared/shell/ui/ToastManager.ts @@ -9,6 +9,7 @@ type ToastType = 'success' | 'error' | 'warning' | 'info' | (string & {}); export interface ToastElement extends HTMLElement { _timeout?: ReturnType; _removeTimeout?: ReturnType; + _actionHandler?: () => void; } /** @@ -172,6 +173,7 @@ export class ToastManager { toast.className = `toast ${type}`; this._bindToastClick(toast, onClick); toast.classList.remove('leaving'); + this._setToastAction(toast, onClick); this._clearToastTimers(toast); this._scheduleToastRemoval(toast, duration); } @@ -200,6 +202,7 @@ export class ToastManager {
`); + this._setToastAction(toast, onClick); container.appendChild(toast); this._scheduleToastRemoval(toast, duration); } @@ -273,6 +276,40 @@ export class ToastManager { } } + private _setToastAction(toast: ToastElement, onClick: (() => void) | null): void { + if (toast._actionHandler !== undefined) { + toast.removeEventListener('click', toast._actionHandler); + toast.removeEventListener('keydown', this._handleActionKeydown); + delete toast._actionHandler; + } + + if (onClick === null) { + toast.classList.remove('toast--actionable'); + toast.removeAttribute('role'); + toast.removeAttribute('tabindex'); + return; + } + + toast._actionHandler = onClick; + toast.classList.add('toast--actionable'); + toast.setAttribute('role', 'button'); + toast.setAttribute('tabindex', '0'); + toast.addEventListener('click', onClick); + toast.addEventListener('keydown', this._handleActionKeydown); + } + + private readonly _handleActionKeydown = (event: KeyboardEvent): void => { + if (event.key !== 'Enter' && event.key !== ' ') { + return; + } + + event.preventDefault(); + const toast = event.currentTarget; + if (toast instanceof HTMLElement) { + toast.click(); + } + }; + private _cleanupContainer(): void { const container = document.getElementById(ToastManager._containerId); if (container === null) { diff --git a/src/shared/utils/moduleCategoryPolicy.test.ts b/src/shared/utils/moduleCategoryPolicy.test.ts index 26d5de54..eb2b0978 100644 --- a/src/shared/utils/moduleCategoryPolicy.test.ts +++ b/src/shared/utils/moduleCategoryPolicy.test.ts @@ -7,16 +7,9 @@ import { isAiCategory, resolveCatalogCategory, resolveModalCategory, - shouldLaunchOnSelection, } from './moduleCategoryPolicy'; describe('moduleCategoryPolicy', () => { - it('keeps AI slots selectable without immediate launch', () => { - expect(shouldLaunchOnSelection(CategoryKey.AI_TEXT)).toBe(false); - expect(shouldLaunchOnSelection(CategoryKey.AI_IMAGE)).toBe(false); - expect(shouldLaunchOnSelection(CategoryKey.SERVICES)).toBe(true); - }); - it('normalizes AI categories for catalog and modal routing', () => { expect(isAiCategory(CategoryKey.AI_IMAGE)).toBe(true); expect(resolveCatalogCategory(CategoryKey.AI_IMAGE)).toBe(CategoryKey.AI); diff --git a/src/shared/utils/moduleCategoryPolicy.ts b/src/shared/utils/moduleCategoryPolicy.ts index 7e38e5fa..a2d857f7 100644 --- a/src/shared/utils/moduleCategoryPolicy.ts +++ b/src/shared/utils/moduleCategoryPolicy.ts @@ -24,10 +24,6 @@ export function getOtherAiSlot(category: string): AiSlotCategory { return category === CategoryKey.AI_IMAGE ? CategoryKey.AI_TEXT : CategoryKey.AI_IMAGE; } -export function shouldLaunchOnSelection(category: string): boolean { - return !isAiCategory(category); -} - export function resolveCatalogCategory(category: string): string { return isAiCategory(category) ? CategoryKey.AI : category; } From fc26c9d6dda4b7217653e62b9a82ba1209229d2f Mon Sep 17 00:00:00 2001 From: F0RLE Date: Tue, 28 Apr 2026 21:38:49 +0300 Subject: [PATCH 002/126] fix: initialize token timeout handle --- src/app/CoreUiBridgeHelpers.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/app/CoreUiBridgeHelpers.ts b/src/app/CoreUiBridgeHelpers.ts index 0c64131f..2ce003d0 100644 --- a/src/app/CoreUiBridgeHelpers.ts +++ b/src/app/CoreUiBridgeHelpers.ts @@ -87,7 +87,7 @@ export function createTokenEstimator( } async function withTimeout(promise: Promise, timeoutMs: number): Promise { - let timeoutId!: ReturnType; + let timeoutId: ReturnType | undefined; const timeout = new Promise((_, reject) => { timeoutId = globalThis.setTimeout(() => { reject(new Error(`Timed out after ${String(timeoutMs)}ms`)); @@ -97,6 +97,8 @@ async function withTimeout(promise: Promise, timeoutMs: number): Promise Date: Tue, 28 Apr 2026 21:55:39 +0300 Subject: [PATCH 003/126] fix: address review feedback --- src/app/CoreUiBridgeHelpers.ts | 9 +++++++ src/features/ai/services/AIChatTransport.ts | 26 ++++++++++++++----- src/features/chat/chat.ts | 11 ++++++++ .../chat/controllers/ChatHistoryController.ts | 7 +++++ .../chat/services/VoiceInputService.ts | 25 +++++++++++++++--- src/features/chat/ui/ChatInputContextMenu.ts | 4 +++ src/infrastructure/tauri/TauriProvider.ts | 12 +++++++++ src/scripts/check-size.js | 10 ++++--- src/vite.config.ts | 18 ++++++++++--- 9 files changed, 103 insertions(+), 19 deletions(-) diff --git a/src/app/CoreUiBridgeHelpers.ts b/src/app/CoreUiBridgeHelpers.ts index 2ce003d0..8f742490 100644 --- a/src/app/CoreUiBridgeHelpers.ts +++ b/src/app/CoreUiBridgeHelpers.ts @@ -67,8 +67,15 @@ export function createExternalUrlOpener( export function createTokenEstimator( deps: TokenEstimatorDeps, ): (text: string, model?: string) => Promise { + let backendCountInFlight = false; + return async (text, model = 'gpt-4') => { if (deps.tauriProvider.isTauri()) { + if (backendCountInFlight) { + return estimateTokenCount(text); + } + + backendCountInFlight = true; try { return await withTimeout( deps.tauriProvider.invoke('count_tokens', { @@ -79,6 +86,8 @@ export function createTokenEstimator( ); } catch (error) { deps.tracer.warn(`[TokenCount] Backend failed, using heuristic: ${String(error)}`); + } finally { + backendCountInFlight = false; } } diff --git a/src/features/ai/services/AIChatTransport.ts b/src/features/ai/services/AIChatTransport.ts index f032825f..4e80a67f 100644 --- a/src/features/ai/services/AIChatTransport.ts +++ b/src/features/ai/services/AIChatTransport.ts @@ -191,9 +191,17 @@ export class AIChatTransport implements IChatTransport { } try { - return await this._context.tauriProvider.invoke('cancel_chat_generation', { - requestId, - }); + const cancelled = await this._runWithTimeout( + this._context.tauriProvider.invoke('cancel_chat_generation', { + requestId, + }), + STALE_REQUEST_CANCEL_TIMEOUT_MS, + 'AI request cancel timed out', + ); + if (cancelled && this._activeChatRequestId === requestId) { + this._activeChatRequestId = null; + } + return cancelled; } catch (error: unknown) { this._tracer.error('[AIChatTransport] IPC cancel error:', error); return false; @@ -282,7 +290,7 @@ export class AIChatTransport implements IChatTransport { timeoutMs: number, timeoutMessage: string, ): Promise { - let timeoutId!: ReturnType; + let timeoutId: ReturnType | undefined; const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => { reject(new Error(timeoutMessage)); @@ -292,12 +300,14 @@ export class AIChatTransport implements IChatTransport { try { return await Promise.race([operation, timeoutPromise]); } finally { - clearTimeout(timeoutId); + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } } } private async _waitForStreamFinalization(streamDone: Promise): Promise { - let timeoutId!: ReturnType; + let timeoutId: ReturnType | undefined; const timeout = new Promise<'timeout'>((resolve) => { timeoutId = setTimeout(() => { resolve('timeout'); @@ -310,7 +320,9 @@ export class AIChatTransport implements IChatTransport { this._tracer.warn('[AIChatTransport] Stream finalization marker was not received'); } } finally { - clearTimeout(timeoutId); + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } } } diff --git a/src/features/chat/chat.ts b/src/features/chat/chat.ts index 36a47515..b3c79cfd 100644 --- a/src/features/chat/chat.ts +++ b/src/features/chat/chat.ts @@ -497,6 +497,17 @@ export class ChatController { return; } + if (!this._historyController.canRegenerateLastTurnFromText()) { + this._ui.showToast( + this._i18n.t( + 'ui.chat.regenerate_structured_unsupported', + 'Regeneration is available only for text-only messages', + ), + 'error', + ); + return; + } + const text = await this._historyController.regenerateLastTurn(false); if (text === null || text.trim() === '') { this._ui.showToast( diff --git a/src/features/chat/controllers/ChatHistoryController.ts b/src/features/chat/controllers/ChatHistoryController.ts index e2c79fee..3b058b79 100644 --- a/src/features/chat/controllers/ChatHistoryController.ts +++ b/src/features/chat/controllers/ChatHistoryController.ts @@ -95,6 +95,13 @@ export class ChatHistoryController { } } + public canRegenerateLastTurnFromText(): boolean { + const lastUserMessage = [...this._options.getHistory()] + .reverse() + .find((message) => message.role === 'user'); + return typeof lastUserMessage?.content === 'string'; + } + public rewindLocalHistory(): void { const history = [...this._options.getHistory()]; diff --git a/src/features/chat/services/VoiceInputService.ts b/src/features/chat/services/VoiceInputService.ts index 4ac1b0ae..c6f63b67 100644 --- a/src/features/chat/services/VoiceInputService.ts +++ b/src/features/chat/services/VoiceInputService.ts @@ -34,6 +34,7 @@ export class VoiceInputService { private _sessionId = 0; private _onStateChange: VoiceStateCallback | null = null; private _onError: VoiceErrorCallback | null = null; + private _nativeRecognitionActive = false; public constructor( private readonly _tracer: VoiceInputLogger, @@ -46,7 +47,18 @@ export class VoiceInputService { * Native voice input is currently available only in the Windows Tauri host. */ public isSupported(): boolean { - return this._hostBridge.isTauri() && document.body.dataset['platform'] === 'windows'; + const capabilityBridge = this._hostBridge as IBridge & { + hasCapability?: (capability: string) => boolean; + }; + if (!this._hostBridge.isTauri()) { + return false; + } + + if (document.body.dataset['platform'] !== 'windows') { + return false; + } + + return capabilityBridge.hasCapability?.('speechRecognition') ?? true; } /** @@ -60,7 +72,7 @@ export class VoiceInputService { * Starts one native voice recognition request. */ public start(onResult: VoiceResultCallback, callbacks: VoiceSessionCallbacks = {}): boolean { - if (this.isActive()) { + if (this.isActive() || this._nativeRecognitionActive) { this.stop(); return false; } @@ -83,7 +95,7 @@ export class VoiceInputService { * Stops the current frontend session and ignores the pending native result. */ public stop(): void { - if (!this.isActive()) { + if (!this.isActive() && !this._nativeRecognitionActive) { return; } @@ -93,11 +105,14 @@ export class VoiceInputService { ); }); this._sessionId += 1; - this._setState('stopping'); + if (this.isActive()) { + this._setState('stopping'); + } this._finishSession('user'); } private async _recognize(sessionId: number, onResult: VoiceResultCallback): Promise { + this._nativeRecognitionActive = true; try { const language = this._getCurrentLang(); this._tracer.info(`[VoiceInputService] Native recognition language: ${language}`); @@ -126,6 +141,8 @@ export class VoiceInputService { this._tracer.error(`[VoiceInputService] Native recognition error: ${payload.message}`); this._onError?.(payload); this._finishSession(payload.code === 'startup_failed' ? 'startup_failed' : 'error'); + } finally { + this._nativeRecognitionActive = false; } } diff --git a/src/features/chat/ui/ChatInputContextMenu.ts b/src/features/chat/ui/ChatInputContextMenu.ts index 2d448c84..1bd30432 100644 --- a/src/features/chat/ui/ChatInputContextMenu.ts +++ b/src/features/chat/ui/ChatInputContextMenu.ts @@ -173,6 +173,10 @@ export class ChatInputContextMenu { menu.appendChild(this._createButton(input, item, state)); }); + if (this._openToken !== openToken || this._input !== input) { + return; + } + document.body.appendChild(menu); this._menu = menu; this._positionMenu(menu, clientX, clientY); diff --git a/src/infrastructure/tauri/TauriProvider.ts b/src/infrastructure/tauri/TauriProvider.ts index c3b16703..4042d35e 100644 --- a/src/infrastructure/tauri/TauriProvider.ts +++ b/src/infrastructure/tauri/TauriProvider.ts @@ -65,6 +65,18 @@ export class TauriProvider implements IBridge { return this._runtime.hasTauriGlobals(); } + public hasCapability(capability: string): boolean { + if (!this.isTauri()) { + return false; + } + + if (capability === 'speechRecognition') { + return /\bWindows\b/i.test(globalThis.navigator.userAgent); + } + + return false; + } + public async invoke = Record>( cmd: string, args: A = {} as A, diff --git a/src/scripts/check-size.js b/src/scripts/check-size.js index c6c187e1..38a43dae 100644 --- a/src/scripts/check-size.js +++ b/src/scripts/check-size.js @@ -45,13 +45,15 @@ const files = walkFiles(DIST_DIR).map((file) => ({ size: statSync(file).size, })); -const totalBytes = files.reduce((sum, file) => sum + file.size, 0); +const isFont = (file) => /\.(woff2|woff|ttf|otf)$/iu.test(file.relative); +const isEntryDocument = (file) => /\.html$/iu.test(file.relative); +const totalBytes = files + .filter((file) => !isFont(file) && !isEntryDocument(file)) + .reduce((sum, file) => sum + file.size, 0); const mainJs = files.find((file) => /^main-.*\.js$/u.test(file.relative)); const vendorJs = files.find((file) => /^chunks\/vendor-.*\.js$/u.test(file.relative)); const cssBundle = files.find((file) => /^assets\/main-.*\.css$/u.test(file.relative)); -const largestFont = files - .filter((file) => /\.(woff2|woff|ttf|otf)$/iu.test(file.relative)) - .sort((left, right) => right.size - left.size)[0]; +const largestFont = files.filter(isFont).sort((left, right) => right.size - left.size)[0]; if (totalBytes > LIMITS.totalBytes) { warn(`Total dist size ${formatKb(totalBytes)} exceeds ${formatKb(LIMITS.totalBytes)}`); diff --git a/src/vite.config.ts b/src/vite.config.ts index 03d2d12a..bd6bed12 100644 --- a/src/vite.config.ts +++ b/src/vite.config.ts @@ -120,14 +120,24 @@ export default defineConfig({ }, output: { manualChunks(id) { + const normalizedId = id.replaceAll('\\', '/'); if ( - id.includes('marked') || - id.includes('dompurify') || - id.includes('marked-alert') || - id.includes('marked-footnote') + normalizedId.includes('marked') || + normalizedId.includes('dompurify') || + normalizedId.includes('marked-alert') || + normalizedId.includes('marked-footnote') ) { return 'vendor-markdown'; } + if (normalizedId.includes('/src/features/chat/')) { + return 'feature-chat'; + } + if (normalizedId.includes('/src/features/ai/')) { + return 'feature-ai'; + } + if (normalizedId.includes('/src/shared/shell/')) { + return 'app-shell'; + } return undefined; }, // Cleaner asset naming From 0338f3d54d6d6924c73d2bd1d42ddaefe4bd03b0 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Tue, 28 Apr 2026 22:51:41 +0300 Subject: [PATCH 004/126] refactor: simplify sdcpp settings --- src-tauri/resources/locales/en.json | 4 +- src-tauri/resources/locales/ru.json | 4 +- src-tauri/resources/locales/zh.json | 4 +- src-tauri/src/domain/engine/config.rs | 16 +-- src-tauri/src/domain/engine/engine_args.rs | 117 +++--------------- src-tauri/src/domain/engine/manager.rs | 75 ++--------- src-tauri/src/domain/engine/types.rs | 6 - .../ModuleSettingsEngineFieldCatalog.test.ts | 5 +- .../ui/ModuleSettingsEngineFieldCatalog.ts | 38 +++--- .../ui/ModuleSettingsEngineFieldController.ts | 4 +- .../ModuleSettingsEngineFieldRowRenderer.ts | 4 +- .../ui/ModuleSettingsEngineFieldSupport.ts | 15 +-- .../ui/ModuleSettingsEngineHtmlBuilder.ts | 4 +- .../ui/ModuleSettingsEngineInfoPopover.ts | 7 +- .../ui/ModuleSettingsEngineRenderFlow.ts | 34 ++--- .../ui/ModuleSettingsEngineRenderer.ts | 5 +- src/shared/types/bindings.ts | 4 - src/styles/features/ai-module-settings.css | 100 ++++++++------- 18 files changed, 137 insertions(+), 309 deletions(-) diff --git a/src-tauri/resources/locales/en.json b/src-tauri/resources/locales/en.json index 088eec40..8f4e4b40 100644 --- a/src-tauri/resources/locales/en.json +++ b/src-tauri/resources/locales/en.json @@ -317,8 +317,8 @@ "ui.settings.engine.model_not_selected": "Model not selected", "ui.settings.engine.model_path": "Model Path (*.gguf, *.safetensors)", "ui.settings.engine.image_model_path": "Main Image Model (*.gguf, *.safetensors)", - "ui.settings.engine.image_model_path_hint": "Put the main diffusion model here: a regular SD model or a qwen-image*.gguf file.", - "ui.settings.engine.extra_args_hint": "Advanced startup flags only. Qwen Image companion files are auto-detected next to the selected model or can be passed here.", + "ui.settings.engine.image_model_path_hint": "Main diffusion model file.", + "ui.settings.engine.extra_args_hint": "Advanced startup flags appended to sd.cpp.", "ui.settings.engine.performance_mode": "Performance Mode", "ui.settings.engine.performance_mode_title": "Close launcher during generation", "ui.settings.engine.thinking_level": "Thinking Level", diff --git a/src-tauri/resources/locales/ru.json b/src-tauri/resources/locales/ru.json index 0c40c6c6..ee305eef 100644 --- a/src-tauri/resources/locales/ru.json +++ b/src-tauri/resources/locales/ru.json @@ -318,8 +318,8 @@ "ui.settings.engine.model_not_selected": "Модель не выбрана", "ui.settings.engine.model_path": "Путь к модели (*.gguf, *.safetensors)", "ui.settings.engine.image_model_path": "Основная модель изображения (*.gguf, *.safetensors)", - "ui.settings.engine.image_model_path_hint": "Сюда ставится основная diffusion-модель: обычная SD-модель или qwen-image*.gguf.", - "ui.settings.engine.extra_args_hint": "Только для продвинутых флагов запуска. Файлы-компаньоны Qwen Image определяются рядом с выбранной моделью или передаются здесь.", + "ui.settings.engine.image_model_path_hint": "Основной файл diffusion-модели.", + "ui.settings.engine.extra_args_hint": "Продвинутые флаги запуска для sd.cpp.", "ui.settings.engine.performance_mode": "Режим производительности", "ui.settings.engine.performance_mode_title": "Закрывать лаунчер во время генерации", "ui.settings.engine.thinking_level": "Уровень размышления", diff --git a/src-tauri/resources/locales/zh.json b/src-tauri/resources/locales/zh.json index f7a89435..5fe5d662 100644 --- a/src-tauri/resources/locales/zh.json +++ b/src-tauri/resources/locales/zh.json @@ -314,8 +314,8 @@ "ui.settings.engine.model_not_selected": "未选择模型", "ui.settings.engine.model_path": "模型路径 (*.gguf, *.safetensors)", "ui.settings.engine.image_model_path": "主图像模型路径 (*.gguf, *.safetensors)", - "ui.settings.engine.image_model_path_hint": "这里放主要 diffusion 模型:普通 SD 模型或 qwen-image*.gguf。", - "ui.settings.engine.extra_args_hint": "这里只放高级启动参数。Qwen Image 配套文件会从所选模型旁自动检测,也可以在这里传入。", + "ui.settings.engine.image_model_path_hint": "主 diffusion 模型文件。", + "ui.settings.engine.extra_args_hint": "传给 sd.cpp 的高级启动参数。", "ui.settings.engine.performance_mode": "性能模式", "ui.settings.engine.performance_mode_title": "生成期间关闭启动器", "ui.settings.engine.thinking_level": "思考强度", diff --git a/src-tauri/src/domain/engine/config.rs b/src-tauri/src/domain/engine/config.rs index df5ad77a..1c69d5e9 100644 --- a/src-tauri/src/domain/engine/config.rs +++ b/src-tauri/src/domain/engine/config.rs @@ -15,8 +15,6 @@ pub fn build_default_engine_config(def: &EngineDefinition) -> EngineConfig { compute_mode: EngineComputeMode::Gpu, context_size: def.default_context_size, model_path: None, - vae_path: None, - llm_path: None, extra_args: vec![], }) } @@ -34,8 +32,6 @@ pub fn merge_user_engine_config(def: &EngineDefinition, saved: &EngineConfig) -> compute_mode: saved.compute_mode, context_size: saved.context_size, model_path: saved.model_path.clone(), - vae_path: saved.vae_path.clone(), - llm_path: saved.llm_path.clone(), extra_args: saved.extra_args.clone(), }) } @@ -47,11 +43,6 @@ pub fn normalize_engine_config(mut config: EngineConfig) -> EngineConfig { config.context_size = MIN_LLAMACPP_CONTEXT_SIZE; } - if config.engine_id == "sdcpp" || config.engine_id == "stable-diffusion" { - config.vae_path = None; - config.llm_path = None; - } - config } @@ -78,15 +69,13 @@ mod tests { } #[test] - fn merge_user_engine_config_clears_sdcpp_companion_paths() { + fn merge_user_engine_config_keeps_sdcpp_runtime_settings() { let def = sample_definition(); let saved = EngineConfig { engine_id: "sdcpp".to_string(), compute_mode: EngineComputeMode::Cpu, context_size: 8192, model_path: Some("C:/models/test.gguf".to_string()), - vae_path: Some("C:/models/test.vae.safetensors".to_string()), - llm_path: Some("C:/models/test-mm.gguf".to_string()), extra_args: vec!["--flash-attn".to_string()], }; @@ -95,7 +84,6 @@ mod tests { assert_eq!(merged.compute_mode, EngineComputeMode::Cpu); assert_eq!(merged.context_size, 8192); assert_eq!(merged.model_path.as_deref(), Some("C:/models/test.gguf")); - assert_eq!(merged.vae_path, None); - assert_eq!(merged.llm_path, None); + assert_eq!(merged.extra_args, vec!["--flash-attn"]); } } diff --git a/src-tauri/src/domain/engine/engine_args.rs b/src-tauri/src/domain/engine/engine_args.rs index 7a001d15..7522686c 100644 --- a/src-tauri/src/domain/engine/engine_args.rs +++ b/src-tauri/src/domain/engine/engine_args.rs @@ -1,6 +1,4 @@ -use std::path::{Path, PathBuf}; - -use crate::errors::AppError; +use std::path::PathBuf; use super::types::{EngineComputeMode, EngineConfig}; @@ -8,13 +6,6 @@ fn is_qwen_model(model_path: Option<&str>) -> bool { model_path.is_some_and(|path| path.to_ascii_lowercase().contains("qwen")) } -fn is_qwen_image_model(model_path: Option<&str>) -> bool { - model_path.is_some_and(|path| { - let normalized = path.replace('\\', "/").to_ascii_lowercase(); - normalized.contains("qwen-image") || normalized.contains("qwen_image") - }) -} - fn has_arg(args: &[String], candidates: &[&str]) -> bool { args.iter().any(|arg| { candidates.iter().any(|candidate| { @@ -80,6 +71,16 @@ fn push_llamacpp_compute_args(args: &mut Vec, config: &EngineConfig) { } } +fn push_sdcpp_compute_args(args: &mut Vec, config: &EngineConfig) { + if config.compute_mode != EngineComputeMode::Cpu { + return; + } + + push_arg_if_missing(args, &config.extra_args, &["--offload-to-cpu"], None); + push_arg_if_missing(args, &config.extra_args, &["--clip-on-cpu"], None); + push_arg_if_missing(args, &config.extra_args, &["--vae-on-cpu"], None); +} + /// Resolves the explicit stable-diffusion.cpp preview output path from extra arguments. pub fn resolve_sdcpp_preview_path(extra_args: &[String]) -> Option { extract_arg_value(extra_args, &["--preview-path"]).map(PathBuf::from) @@ -90,103 +91,17 @@ pub(super) fn sdcpp_preview_enabled(extra_args: &[String]) -> bool { .is_none_or(|value| !value.trim().eq_ignore_ascii_case("none")) } -fn find_companion_model_file( - model_path: &Path, - stems: &[&str], - extensions: &[&str], -) -> Option { - let model_dir = model_path.parent()?; - let mut entries = std::fs::read_dir(model_dir) - .ok()? - .flatten() - .collect::>(); - entries.sort_by_key(std::fs::DirEntry::file_name); - - for entry in entries { - let path = entry.path(); - if !path.is_file() { - continue; - } - - let file_name = path.file_name()?.to_string_lossy().to_ascii_lowercase(); - let extension = path.extension()?.to_string_lossy().to_ascii_lowercase(); - - if extensions.iter().all(|candidate| extension != *candidate) { - continue; - } - - if stems.iter().any(|stem| file_name.contains(stem)) { - return Some(path.to_string_lossy().to_string()); - } - } - - None -} - -fn resolve_qwen_image_support_file( - model_path: &Path, - extra_args: &[String], - arg_names: &[&str], - stems: &[&str], - extensions: &[&str], -) -> Option { - extract_arg_value(extra_args, arg_names) - .or_else(|| find_companion_model_file(model_path, stems, extensions)) -} - -fn qwen_image_requirements_error(model_path: &str) -> AppError { - AppError::Validation(format!( - "Qwen Image model '{model_path}' needs companion files for stable-diffusion.cpp. Place 'qwen_image_vae.safetensors' and 'Qwen2.5-VL-7B-Instruct*.gguf' next to the selected model, or pass '--vae' and '--llm' in Extra Arguments." - )) -} - -pub(super) fn build_sdcpp_args(config: &EngineConfig, port: u16) -> Result, AppError> { +pub(super) fn build_sdcpp_args(config: &EngineConfig, port: u16) -> Vec { let mut args = vec!["--listen-port".to_string(), port.to_string()]; if let Some(model_path) = config.model_path.as_deref() { - if is_qwen_image_model(Some(model_path)) { - let model_path_buf = Path::new(model_path); - let vae_path = resolve_qwen_image_support_file( - model_path_buf, - &config.extra_args, - &["--vae"], - &["qwen_image_vae", "qwen-image-vae"], - &["safetensors"], - ); - let llm_path = resolve_qwen_image_support_file( - model_path_buf, - &config.extra_args, - &["--llm"], - &["qwen2.5-vl", "qwen2_5_vl", "qwen25-vl", "qwen25_vl"], - &["gguf"], - ); - - if vae_path.is_none() || llm_path.is_none() { - return Err(qwen_image_requirements_error(model_path)); - } - - args.push("--diffusion-model".to_string()); - args.push(model_path.to_string()); - push_arg_if_missing( - &mut args, - &config.extra_args, - &["--vae"], - vae_path.as_deref(), - ); - push_arg_if_missing( - &mut args, - &config.extra_args, - &["--llm"], - llm_path.as_deref(), - ); - } else { - args.push("--model".to_string()); - args.push(model_path.to_string()); - } + args.push("--model".to_string()); + args.push(model_path.to_string()); } + push_sdcpp_compute_args(&mut args, config); args.extend(config.extra_args.clone()); - Ok(args) + args } pub(super) fn build_llamacpp_args(config: &EngineConfig, port: u16) -> Vec { diff --git a/src-tauri/src/domain/engine/manager.rs b/src-tauri/src/domain/engine/manager.rs index ed644192..c4b7abbc 100644 --- a/src-tauri/src/domain/engine/manager.rs +++ b/src-tauri/src/domain/engine/manager.rs @@ -272,11 +272,7 @@ impl EngineManager { if config.engine_id == "llamacpp" { cmd.args(build_llamacpp_args(&config, selected_port)); } else if config.engine_id == "sdcpp" { - let sdcpp_args = build_sdcpp_args(&config, selected_port).inspect_err(|error| { - self.emitter - .emit_error(&config.engine_id, &error.to_string()); - })?; - cmd.args(sdcpp_args); + cmd.args(build_sdcpp_args(&config, selected_port)); } else { // Default fallback for other engines cmd.arg("--port").arg(selected_port.to_string()); @@ -442,9 +438,7 @@ mod tests { use crate::domain::engine::engine_runtime::classify_engine_start_failure; use crate::domain::engine::types::EngineComputeMode; use crate::domain::system::ports::ENGINE_LOCAL_PORT_RANGE; - use std::fs; use std::net::TcpListener; - use std::time::{SystemTime, UNIX_EPOCH}; fn sample_config(model_path: Option<&str>) -> EngineConfig { EngineConfig { @@ -452,8 +446,6 @@ mod tests { compute_mode: EngineComputeMode::Gpu, context_size: 4096, model_path: model_path.map(str::to_string), - vae_path: None, - llm_path: None, extra_args: vec![], } } @@ -464,8 +456,6 @@ mod tests { compute_mode: EngineComputeMode::Gpu, context_size: 4096, model_path: model_path.map(str::to_string), - vae_path: None, - llm_path: None, extra_args: vec![], } } @@ -566,8 +556,7 @@ mod tests { let args = build_sdcpp_args( &sample_sdcpp_config(Some("C:/models/sd15.safetensors")), 8082, - ) - .unwrap(); + ); assert!(args.windows(2).any(|w| w == ["--listen-port", "8082"])); assert!( @@ -577,59 +566,13 @@ mod tests { } #[test] - fn rejects_qwen_image_without_companion_files() { - let error = build_sdcpp_args( - &sample_sdcpp_config(Some("C:/models/qwen-image-Q2_K.gguf")), - 8082, - ) - .unwrap_err(); - - match error { - AppError::Validation(message) => { - assert!(message.contains("Qwen Image model")); - assert!(message.contains("--vae")); - assert!(message.contains("--llm")); - } - other => panic!("expected validation error, got {other:?}"), - } - } - - #[test] - fn auto_detects_qwen_image_companion_files_in_model_directory() { - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let temp_dir = std::env::temp_dir().join(format!("axelate-sdcpp-qwen-{unique}")); - fs::create_dir_all(&temp_dir).unwrap(); - - let diffusion = temp_dir.join("qwen-image-Q2_K.gguf"); - let vae = temp_dir.join("qwen_image_vae.safetensors"); - let llm = temp_dir.join("Qwen2.5-VL-7B-Instruct.Q4_K_M.gguf"); - - fs::write(&diffusion, []).unwrap(); - fs::write(&vae, []).unwrap(); - fs::write(&llm, []).unwrap(); - - let args = build_sdcpp_args( - &sample_sdcpp_config(Some(diffusion.to_string_lossy().as_ref())), - 8082, - ) - .unwrap(); - - assert!( - args.windows(2) - .any(|w| w == ["--diffusion-model", diffusion.to_string_lossy().as_ref()]) - ); - assert!( - args.windows(2) - .any(|w| w == ["--vae", vae.to_string_lossy().as_ref()]) - ); - assert!( - args.windows(2) - .any(|w| w == ["--llm", llm.to_string_lossy().as_ref()]) - ); + fn builds_sdcpp_cpu_mode_args() { + let mut config = sample_sdcpp_config(Some("C:/models/sd15.safetensors")); + config.compute_mode = EngineComputeMode::Cpu; + let args = build_sdcpp_args(&config, 8082); - let _ = fs::remove_dir_all(&temp_dir); + assert!(args.contains(&"--offload-to-cpu".to_string())); + assert!(args.contains(&"--clip-on-cpu".to_string())); + assert!(args.contains(&"--vae-on-cpu".to_string())); } } diff --git a/src-tauri/src/domain/engine/types.rs b/src-tauri/src/domain/engine/types.rs index f5973c62..f2475513 100644 --- a/src-tauri/src/domain/engine/types.rs +++ b/src-tauri/src/domain/engine/types.rs @@ -107,12 +107,6 @@ pub struct EngineConfig { pub context_size: u32, /// Path to model file pub model_path: Option, - /// Optional companion VAE path for image engines - #[serde(default)] - pub vae_path: Option, - /// Optional companion LLM path for multimodal image engines - #[serde(default)] - pub llm_path: Option, /// Extra CLI arguments #[serde(default)] pub extra_args: Vec, diff --git a/src/features/settings/ui/ModuleSettingsEngineFieldCatalog.test.ts b/src/features/settings/ui/ModuleSettingsEngineFieldCatalog.test.ts index fff9f7a4..ff9fb59c 100644 --- a/src/features/settings/ui/ModuleSettingsEngineFieldCatalog.test.ts +++ b/src/features/settings/ui/ModuleSettingsEngineFieldCatalog.test.ts @@ -32,6 +32,9 @@ describe('ModuleSettingsEngineFieldCatalog', () => { showInfoButton: true, fullWidth: true, }); - expect(catalog.buildImageCompanionFields(t, 'sdcpp')).toEqual([]); + expect(catalog.buildComputeModeField(t)).toMatchObject({ + key: 'compute_mode', + defaultValue: 'gpu', + }); }); }); diff --git a/src/features/settings/ui/ModuleSettingsEngineFieldCatalog.ts b/src/features/settings/ui/ModuleSettingsEngineFieldCatalog.ts index b946bf10..3de9e1f4 100644 --- a/src/features/settings/ui/ModuleSettingsEngineFieldCatalog.ts +++ b/src/features/settings/ui/ModuleSettingsEngineFieldCatalog.ts @@ -14,7 +14,7 @@ export type EngineFieldDefinition = { fullWidth?: boolean; showInfoButton?: boolean; isFile?: boolean; - fileKind?: 'model' | 'vae' | 'llm'; + fileKind?: 'model'; description?: string; }; @@ -49,27 +49,31 @@ export class ModuleSettingsEngineFieldCatalog { ? { description: t( 'ui.settings.engine.image_model_path_hint', - 'Main image model. Use your SD model here, or a qwen-image*.gguf file for Qwen Image.', + 'Main diffusion model file.', ), } : {}), }; } + public buildComputeModeField(t: TranslateFn): EngineFieldDefinition { + return { + label: t('ui.settings.engine.compute_mode', 'Compute Device'), + key: 'compute_mode', + type: 'select', + isEngineConfig: true, + options: ['gpu', 'cpu'], + optionLabels: { + gpu: t('ui.settings.engine.compute_mode_gpu', 'GPU'), + cpu: t('ui.settings.engine.compute_mode_cpu', 'CPU'), + }, + defaultValue: 'gpu', + }; + } + public buildTextEngineFields(t: TranslateFn): EngineFieldDefinition[] { return [ - { - label: t('ui.settings.engine.compute_mode', 'Compute Device'), - key: 'compute_mode', - type: 'select', - isEngineConfig: true, - options: ['gpu', 'cpu'], - optionLabels: { - gpu: t('ui.settings.engine.compute_mode_gpu', 'GPU'), - cpu: t('ui.settings.engine.compute_mode_cpu', 'CPU'), - }, - defaultValue: 'gpu', - }, + this.buildComputeModeField(t), { label: t('ui.settings.engine.context_size', 'Context Window'), key: 'context_size', @@ -247,10 +251,6 @@ export class ModuleSettingsEngineFieldCatalog { }; } - public buildImageCompanionFields(_t: TranslateFn, _appId: string): EngineFieldDefinition[] { - return []; - } - public buildImageExtraArgsField(t: TranslateFn): EngineFieldDefinition { return { label: t('ui.settings.engine.extra_args', 'Extra Arguments'), @@ -263,7 +263,7 @@ export class ModuleSettingsEngineFieldCatalog { showInfoButton: true, description: t( 'ui.settings.engine.extra_args_hint', - 'Advanced startup flags only. Qwen Image companion files are auto-detected next to the selected model or can be passed here.', + 'Advanced startup flags appended to sd.cpp.', ), }; } diff --git a/src/features/settings/ui/ModuleSettingsEngineFieldController.ts b/src/features/settings/ui/ModuleSettingsEngineFieldController.ts index 672dd2ab..1812a6a8 100644 --- a/src/features/settings/ui/ModuleSettingsEngineFieldController.ts +++ b/src/features/settings/ui/ModuleSettingsEngineFieldController.ts @@ -20,7 +20,7 @@ type ModuleSettingsEngineFieldControllerDeps = { translate: (key: string, fallback: string) => string; getModelFileName: (modelPath: string) => string; getModelFileFilters: ( - fileKind: 'model' | 'vae' | 'llm', + fileKind: 'model', isImage: boolean, ) => Array<{ name: string; extensions: string[] }>; tracer: Pick; @@ -100,7 +100,7 @@ export class ModuleSettingsEngineFieldController { container: HTMLElement, input: HTMLInputElement, isImage: boolean, - fileKind: 'model' | 'vae' | 'llm', + fileKind: 'model', ): void { const browseBtn = document.createElement('button'); browseBtn.className = 'btn btn-secondary local-engine-browse-btn'; diff --git a/src/features/settings/ui/ModuleSettingsEngineFieldRowRenderer.ts b/src/features/settings/ui/ModuleSettingsEngineFieldRowRenderer.ts index d1eefac7..1ec848ae 100644 --- a/src/features/settings/ui/ModuleSettingsEngineFieldRowRenderer.ts +++ b/src/features/settings/ui/ModuleSettingsEngineFieldRowRenderer.ts @@ -16,7 +16,7 @@ type EngineFieldRowOptions = { max?: number; isFile?: boolean; isImage?: boolean; - fileKind?: 'model' | 'vae' | 'llm'; + fileKind?: 'model'; description?: string; fullWidth?: boolean; showInfoButton?: boolean; @@ -45,7 +45,7 @@ type EngineFieldRowRendererDeps = { container: HTMLElement, input: HTMLInputElement, isImage: boolean, - fileKind: 'model' | 'vae' | 'llm', + fileKind: 'model', ) => void; getExtraArgsInfoText: () => string; toggleInfoPopover: ( diff --git a/src/features/settings/ui/ModuleSettingsEngineFieldSupport.ts b/src/features/settings/ui/ModuleSettingsEngineFieldSupport.ts index 4c2c9eed..ec5f0fca 100644 --- a/src/features/settings/ui/ModuleSettingsEngineFieldSupport.ts +++ b/src/features/settings/ui/ModuleSettingsEngineFieldSupport.ts @@ -3,7 +3,7 @@ type EngineInputElement = HTMLInputElement | HTMLSelectElement | HTMLTextAreaEle type EngineFieldValue = string | number | string[] | null | undefined; type ExtraArgsTranslate = (key: string, fallback: string) => string; type PerformanceTranslate = (key: string, fallback: string) => string; -export type EngineModelFileKind = 'model' | 'vae' | 'llm'; +export type EngineModelFileKind = 'model'; export type EngineModelFileFilter = { name: string; extensions: string[] }; type EngineFieldInitialOptions = { @@ -239,17 +239,9 @@ export function getEngineModelFileName(modelPath: string, notSelectedLabel: stri } export function getEngineModelFileFilters( - fileKind: EngineModelFileKind, + _fileKind: EngineModelFileKind, isImage: boolean, ): EngineModelFileFilter[] { - if (fileKind === 'vae') { - return [{ name: 'SafeTensors', extensions: ['safetensors'] }]; - } - - if (fileKind === 'llm') { - return [{ name: 'GGUF Models', extensions: ['gguf'] }]; - } - if (isImage) { return [ { name: 'SD Models', extensions: ['gguf', 'safetensors'] }, @@ -373,8 +365,7 @@ export function getEngineExtraArgDocs(appId: string): EngineExtraArgDocs { if (appId === 'sdcpp' || appId === 'stable-diffusion') { return { title: 'Manual sd.cpp flags', - subtitle: - 'These go into Extra Arguments as startup flags. Qwen Image companion files are auto-detected beside the selected model.', + subtitle: 'Startup flags appended to sd-server.', items: [ { flag: '--fa', description: 'Enable flash attention globally.' }, { diff --git a/src/features/settings/ui/ModuleSettingsEngineHtmlBuilder.ts b/src/features/settings/ui/ModuleSettingsEngineHtmlBuilder.ts index 53c16c76..f672ce27 100644 --- a/src/features/settings/ui/ModuleSettingsEngineHtmlBuilder.ts +++ b/src/features/settings/ui/ModuleSettingsEngineHtmlBuilder.ts @@ -31,7 +31,7 @@ export class ModuleSettingsEngineHtmlBuilder {
-

🧩 ${this.escapeHtml( +

${this.escapeHtml( this._translate( 'ui.settings.engine.core_config', 'Core Config', @@ -88,7 +88,7 @@ export class ModuleSettingsEngineHtmlBuilder {
-

🎛️ ${this.escapeHtml( +

${this.escapeHtml( this._translate( 'ui.settings.engine.generation_presets', 'Generation Presets', diff --git a/src/features/settings/ui/ModuleSettingsEngineInfoPopover.ts b/src/features/settings/ui/ModuleSettingsEngineInfoPopover.ts index cb6c15c2..12e3112d 100644 --- a/src/features/settings/ui/ModuleSettingsEngineInfoPopover.ts +++ b/src/features/settings/ui/ModuleSettingsEngineInfoPopover.ts @@ -198,15 +198,13 @@ export function createEngineInfoPopover(deps: EngineInfoPopoverDeps): EngineInfo const availableWidth = modalRect.width; const edgeGap = Math.max(16, Math.min(32, Math.round(availableWidth * 0.015))); const gap = Math.max(14, Math.min(20, Math.round(availableWidth * 0.008))); - const panelWidth = Math.max(300, Math.min(344, Math.round(availableWidth * 0.18))); + const panelWidth = Math.max(300, Math.min(328, Math.round(availableWidth * 0.18))); modal?.style.setProperty('--app-modal-edge-gap', `${edgeGap}px`); modal?.style.setProperty('--app-modal-popover-width', `${panelWidth}px`); modal?.style.setProperty('--app-modal-popover-spacing', `${gap}px`); }; updatePosition(); - modal?.classList.add('popover-open'); - popover.style.opacity = '0'; popover.style.transition = 'opacity 0.22s cubic-bezier(0.22, 1, 0.36, 1)'; runtime.requestAnimationFrame(() => { @@ -231,11 +229,8 @@ export function createEngineInfoPopover(deps: EngineInfoPopoverDeps): EngineInfo globalThis.clearTimeout(settlePositionTimer); popover.classList.add('closing'); - modal?.classList.remove('popover-open'); - modal?.classList.add('popover-closing'); globalThis.setTimeout(() => { - modal?.classList.remove('popover-closing'); modal?.style.removeProperty('--app-modal-edge-gap'); modal?.style.removeProperty('--app-modal-popover-width'); modal?.style.removeProperty('--app-modal-popover-spacing'); diff --git a/src/features/settings/ui/ModuleSettingsEngineRenderFlow.ts b/src/features/settings/ui/ModuleSettingsEngineRenderFlow.ts index df45de57..94e810a2 100644 --- a/src/features/settings/ui/ModuleSettingsEngineRenderFlow.ts +++ b/src/features/settings/ui/ModuleSettingsEngineRenderFlow.ts @@ -47,7 +47,7 @@ type ModuleSettingsEngineRenderOptions = { modelPlaceholder: string, isImage: boolean, ) => EngineFieldDefinition; - getImageCompanionFields: (translate: TranslateFn, appId: string) => EngineFieldDefinition[]; + getComputeModeField: (translate: TranslateFn) => EngineFieldDefinition; getImageExtraArgsField: (translate: TranslateFn) => EngineFieldDefinition; }; @@ -76,7 +76,7 @@ export class ModuleSettingsEngineRenderFlow { modelPlaceholder, translate: options.translate, getCoreModelField: options.getCoreModelField, - getImageCompanionFields: options.getImageCompanionFields, + getComputeModeField: options.getComputeModeField, getImageExtraArgsField: options.getImageExtraArgsField, }); @@ -108,7 +108,7 @@ export class ModuleSettingsEngineRenderFlow { modelPlaceholder: string; translate: TranslateFn; getCoreModelField: ModuleSettingsEngineRenderOptions['getCoreModelField']; - getImageCompanionFields: ModuleSettingsEngineRenderOptions['getImageCompanionFields']; + getComputeModeField: ModuleSettingsEngineRenderOptions['getComputeModeField']; getImageExtraArgsField: ModuleSettingsEngineRenderOptions['getImageExtraArgsField']; }): void { const coreField = options.getCoreModelField( @@ -118,31 +118,23 @@ export class ModuleSettingsEngineRenderFlow { ); if (options.isImage) { - const splitRow = document.createElement('div'); - splitRow.className = 'local-engine-split-row'; - - this._deps.renderFieldRow(splitRow, { + this._deps.renderFieldRow(options.container, { ...coreField, isFile: true, isImage: true, appId: options.appId, config: options.config, }); - this._deps.renderPerformanceModeFieldRow(splitRow, options.appId); - options.container.appendChild(splitRow); - - const companionFields = options.getImageCompanionFields( - options.translate, - options.appId, - ); - companionFields.forEach((field) => { - this._deps.renderFieldRow(options.container, { - ...field, - isImage: field.fileKind === 'vae', - appId: options.appId, - config: options.config, - }); + + const coreControls = document.createElement('div'); + coreControls.className = 'local-engine-core-controls'; + this._deps.renderFieldRow(coreControls, { + ...options.getComputeModeField(options.translate), + appId: options.appId, + config: options.config, }); + this._deps.renderPerformanceModeFieldRow(coreControls, options.appId); + options.container.appendChild(coreControls); this._deps.renderFieldRow(options.container, { ...options.getImageExtraArgsField(options.translate), diff --git a/src/features/settings/ui/ModuleSettingsEngineRenderer.ts b/src/features/settings/ui/ModuleSettingsEngineRenderer.ts index 3d8616ba..31276630 100644 --- a/src/features/settings/ui/ModuleSettingsEngineRenderer.ts +++ b/src/features/settings/ui/ModuleSettingsEngineRenderer.ts @@ -42,7 +42,7 @@ type EngineFieldControlOptions = { key: string; isEngineConfig: boolean; appId: string; - fileKind?: 'model' | 'vae' | 'llm'; + fileKind?: 'model'; placeholder?: string; defaultValue?: number | string; min?: number; @@ -243,8 +243,7 @@ export class ModuleSettingsEngineRenderer { getTextFields: (translate) => this._fieldCatalog.buildTextEngineFields(translate), getCoreModelField: (translate, modelPlaceholder, isImage) => this._fieldCatalog.buildCoreModelField(translate, modelPlaceholder, isImage), - getImageCompanionFields: (translate, appId) => - this._fieldCatalog.buildImageCompanionFields(translate, appId), + getComputeModeField: (translate) => this._fieldCatalog.buildComputeModeField(translate), getImageExtraArgsField: (translate) => this._fieldCatalog.buildImageExtraArgsField(translate), }); diff --git a/src/shared/types/bindings.ts b/src/shared/types/bindings.ts index fd42ce02..ac4bf1c1 100644 --- a/src/shared/types/bindings.ts +++ b/src/shared/types/bindings.ts @@ -619,10 +619,6 @@ export type EngineConfig = { context_size?: number, // Path to model file model_path: string | null, - // Optional companion VAE path for image engines - vae_path?: string | null, - // Optional companion LLM path for multimodal image engines - llm_path?: string | null, // Extra CLI arguments extra_args?: string[], }; diff --git a/src/styles/features/ai-module-settings.css b/src/styles/features/ai-module-settings.css index 9ca67df5..2b98407e 100644 --- a/src/styles/features/ai-module-settings.css +++ b/src/styles/features/ai-module-settings.css @@ -748,9 +748,9 @@ } .local-engine-section-header h3 { - font-size: 1.2rem; + font-size: 1.05rem; line-height: 1.15; - letter-spacing: 0.02em; + letter-spacing: 0; width: 100%; text-align: center; } @@ -785,10 +785,10 @@ .local-engine-panel-card { background: var(--glass-surface); border: 1px solid var(--glass-border); - border-radius: 16px; + border-radius: 12px; padding: 0.9rem 1rem; box-sizing: border-box; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.22); + box-shadow: none; display: flex; flex-direction: column; gap: 0.65rem; @@ -1014,18 +1014,15 @@ z-index: 5200; display: flex; flex-direction: column; - gap: 0.85rem; - padding: 1rem; - border-radius: var(--modal-shell-radius); - border: 1px solid rgba(255, 255, 255, 0.03); - background: rgba(var(--background-raw), 0.065); - box-shadow: - inset -1px 0 0 rgba(255, 255, 255, 0.018), - 0 18px 48px rgba(0, 0, 0, 0.2); - width: calc(var(--app-modal-popover-width, 344px) / var(--module-settings-zoom, 1)); - height: calc((100% - (var(--app-modal-edge-gap, 16px) * 2)) / var(--module-settings-zoom, 1)); + gap: 0.72rem; + padding: 0.85rem; + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.075); + background: rgba(18, 18, 26, 0.96); + box-shadow: 0 14px 36px rgba(0, 0, 0, 0.32); + width: calc(var(--app-modal-popover-width, 328px) / var(--module-settings-zoom, 1)); max-height: calc( - (100% - (var(--app-modal-edge-gap, 16px) * 2)) / var(--module-settings-zoom, 1) + min(460px, 100% - (var(--app-modal-edge-gap, 16px) * 2)) / var(--module-settings-zoom, 1) ); box-sizing: border-box; overflow: hidden; @@ -1034,7 +1031,6 @@ animation: engineArgsPanelIn 0.24s cubic-bezier(0.22, 1, 0.36, 1); transition: width 0.3s cubic-bezier(0.22, 1, 0.36, 1), - height 0.3s cubic-bezier(0.22, 1, 0.36, 1), max-height 0.3s cubic-bezier(0.22, 1, 0.36, 1), right 0.3s cubic-bezier(0.22, 1, 0.36, 1), top 0.3s cubic-bezier(0.22, 1, 0.36, 1), @@ -1061,7 +1057,7 @@ } .local-engine-args-popover-title { - font-size: 1rem; + font-size: 0.95rem; line-height: 1.15; color: var(--text-primary); font-weight: 700; @@ -1071,8 +1067,8 @@ .local-engine-args-popover-subtitle { margin: 0; color: var(--text-secondary); - font-size: 0.82rem; - line-height: 1.42; + font-size: 0.78rem; + line-height: 1.32; } .local-engine-args-popover-actions { @@ -1099,8 +1095,8 @@ .local-engine-args-recommended { padding: 0.48rem 0.76rem; - border-color: rgba(179, 112, 255, 0.28); - background: rgba(139, 71, 255, 0.16); + border-color: rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.06); } .local-engine-args-copy-all { @@ -1127,9 +1123,9 @@ .local-engine-args-item { display: block; align-items: start; - padding: 0.7rem 0.75rem; - border-radius: 12px; - background: rgba(255, 255, 255, 0.025); + padding: 0.62rem 0.68rem; + border-radius: 10px; + background: rgba(255, 255, 255, 0.03); border: 1px solid rgba(255, 255, 255, 0.045); cursor: pointer; transition: @@ -1279,8 +1275,8 @@ align-items: center; justify-content: space-between; gap: 0.8rem; - min-height: 52px; - padding: 0.78rem 0.95rem; + min-height: 50px; + padding: 0.68rem 0.8rem; border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.03); @@ -1300,9 +1296,10 @@ transform: translateY(-1px); } +.local-engine-performance-toggle.is-enabled, .local-engine-performance-toggle.active { - background: linear-gradient(180deg, rgba(154, 83, 243, 0.24), rgba(109, 56, 184, 0.2)); - border-color: rgba(179, 112, 255, 0.34); + background: rgba(255, 255, 255, 0.065); + border-color: rgba(255, 255, 255, 0.14); } .local-engine-performance-toggle-status { @@ -1318,6 +1315,7 @@ font-weight: 700; } +.local-engine-performance-toggle.is-enabled .local-engine-performance-toggle-status, .local-engine-performance-toggle.active .local-engine-performance-toggle-status { background: rgba(0, 0, 0, 0.18); color: #ffffff; @@ -1401,6 +1399,17 @@ align-items: start; } +.local-engine-core-controls { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(220px, 0.8fr); + gap: 0.75rem; + align-items: end; +} + +.local-engine-core-controls .local-engine-field-row { + gap: 0.42rem; +} + .local-engine-input--readonly { overflow: hidden; text-overflow: ellipsis; @@ -1469,6 +1478,14 @@ border-radius: 0; } +.local-engine-input-row:has(.local-engine-select) { + padding: 0; + gap: 0; + background: transparent; + border: none; + border-radius: 0; +} + .local-engine-segmented-option { min-height: 58px; padding: 0.75rem; @@ -1544,13 +1561,9 @@ .local-engine-select-trigger:hover, .local-engine-select.open .local-engine-select-trigger { - border-color: rgba(179, 112, 255, 0.38); - background: - linear-gradient(180deg, rgba(154, 83, 243, 0.18), rgba(109, 56, 184, 0.14)), - rgba(255, 255, 255, 0.045); - box-shadow: - 0 0 0 3px rgba(166, 100, 247, 0.12), - 0 10px 24px rgba(76, 28, 148, 0.2); + border-color: rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.055); + box-shadow: none; } .local-engine-select-text { @@ -1579,14 +1592,9 @@ overflow-y: auto; padding: 0.35rem; border-radius: 14px; - border: 1px solid rgba(176, 110, 255, 0.22); - background: - linear-gradient(180deg, rgba(31, 27, 49, 0.98), rgba(18, 16, 29, 0.98)), - rgba(20, 19, 29, 0.98); - box-shadow: - 0 22px 50px rgba(0, 0, 0, 0.48), - 0 0 0 1px rgba(173, 107, 255, 0.08), - inset 0 1px 0 rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(18, 18, 26, 0.98); + box-shadow: 0 18px 42px rgba(0, 0, 0, 0.38); animation: localSelectFade 0.14s ease; } @@ -1624,7 +1632,7 @@ } .local-engine-select-option.selected { - background: rgba(166, 100, 247, 0.16); + background: rgba(255, 255, 255, 0.08); color: var(--text-primary); } @@ -1660,6 +1668,10 @@ } @media (max-width: 720px) { + .local-engine-core-controls { + grid-template-columns: 1fr; + } + .local-engine-field-grid { grid-template-columns: 1fr; } From d1acee491b64660dea1fc2ffe046463824ff6c27 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 00:29:12 +0000 Subject: [PATCH 005/126] fix: apply CodeRabbit auto-fixes Fixed 9 file(s) based on 7 unresolved review comments. Co-authored-by: CodeRabbit --- src-tauri/resources/locales/en.json | 2 +- src-tauri/resources/locales/zh.json | 2 +- src/features/chat/chat.ts | 7 ++++++- src/features/chat/services/ChatControllerState.ts | 9 +++++++++ src/features/chat/services/VoiceInputService.ts | 6 +++++- src/features/chat/ui/ChatInputContextMenu.ts | 8 ++++---- .../ui/ModuleSettingsEngineFieldController.ts | 10 +++++----- .../settings/ui/ModuleSettingsEngineRenderer.test.ts | 4 ++-- .../settings/ui/ModuleSettingsEngineRenderer.ts | 11 ++++++++--- 9 files changed, 41 insertions(+), 18 deletions(-) diff --git a/src-tauri/resources/locales/en.json b/src-tauri/resources/locales/en.json index 8f4e4b40..88cba9aa 100644 --- a/src-tauri/resources/locales/en.json +++ b/src-tauri/resources/locales/en.json @@ -64,7 +64,7 @@ "ui.chat.context_used": "used", "ui.chat.context_remaining": "remaining", "ui.chat.context_unknown": "unknown", - "ui.chat.error.local_model_memory": "Not enough memory to start the local model. Reduce context size or GPU layers, or use a smaller model.", + "ui.chat.error.local_model_memory": "Not enough memory to start the local model. Reduce context size or change Compute Device to CPU, or use a smaller model.", "ui.chat.error.local_model_system_memory": "Not enough system memory to start the local model. Close other apps or use a smaller model.", "ui.chat.error.image_vram": "Not enough GPU memory to generate the image. Lower image size, steps, or batch size, or use a smaller model.", "ui.chat.error.local_image_engine_connection": "Local image engine stopped or closed the connection while generating. Restart the image engine and lower image size, steps, or batch size if it happens again.", diff --git a/src-tauri/resources/locales/zh.json b/src-tauri/resources/locales/zh.json index 5fe5d662..4e98ddb5 100644 --- a/src-tauri/resources/locales/zh.json +++ b/src-tauri/resources/locales/zh.json @@ -64,7 +64,7 @@ "ui.chat.context_used": "已用", "ui.chat.context_remaining": "剩余", "ui.chat.context_unknown": "未知", - "ui.chat.error.local_model_memory": "启动本地模型的内存不足。请降低上下文大小或 GPU layers,或改用更小的模型。", + "ui.chat.error.local_model_memory": "启动本地模型的内存不足。请降低上下文大小或将计算设备切换为 CPU,或改用更小的模型。", "ui.chat.error.local_model_system_memory": "启动本地模型的系统内存不足。请关闭其他应用,或改用更小的模型。", "ui.chat.error.image_vram": "生成图片所需的显存不足。请降低图片尺寸、steps 或 batch size,或改用更小的模型。", "ui.chat.error.local_image_engine_connection": "本地图像引擎在生成时停止或关闭了连接。请重启图像引擎;如果再次发生,请降低图片尺寸、steps 或 batch size。", diff --git a/src/features/chat/chat.ts b/src/features/chat/chat.ts index b3c79cfd..a6cbe4d7 100644 --- a/src/features/chat/chat.ts +++ b/src/features/chat/chat.ts @@ -364,7 +364,7 @@ export class ChatController { this._generationController.startImagePreviewPolling(handle); }, cancelTextGeneration: async () => { - const providerId = this._aiBridge.getState().activeProviderId; + const providerId = this._state.currentGenerationProviderId ?? this._aiBridge.getState().activeProviderId; if (this._generationController.isImageProvider(providerId)) { this._generationController.stopImagePreviewPolling(); await this._aiBridge.cancelImageGeneration(); @@ -384,6 +384,11 @@ export class ChatController { isSending: () => this._state.isSending, setSending: (value) => { this._state.isSending = value; + if (value) { + this._state.currentGenerationProviderId = this._aiBridge.getState().activeProviderId; + } else { + this._state.currentGenerationProviderId = null; + } }, tracer: this._tracer, }); diff --git a/src/features/chat/services/ChatControllerState.ts b/src/features/chat/services/ChatControllerState.ts index f8dd8143..2f313dda 100644 --- a/src/features/chat/services/ChatControllerState.ts +++ b/src/features/chat/services/ChatControllerState.ts @@ -7,6 +7,7 @@ export class ChatControllerState { private _eventsBound = false; private _isInitialized = false; private _isDestroyed = false; + private _currentGenerationProviderId: string | null = null; public get history(): IChatMessage[] { return this._history; @@ -63,4 +64,12 @@ export class ChatControllerState { public set isDestroyed(value: boolean) { this._isDestroyed = value; } + + public get currentGenerationProviderId(): string | null { + return this._currentGenerationProviderId; + } + + public set currentGenerationProviderId(value: string | null) { + this._currentGenerationProviderId = value; + } } diff --git a/src/features/chat/services/VoiceInputService.ts b/src/features/chat/services/VoiceInputService.ts index c6f63b67..dbf04653 100644 --- a/src/features/chat/services/VoiceInputService.ts +++ b/src/features/chat/services/VoiceInputService.ts @@ -129,7 +129,11 @@ export class VoiceInputService { const text = response.text.trim(); if (text.length > 0) { - onResult(text); + try { + onResult(text); + } catch (err) { + this._tracer.error(`[VoiceInputService] onResult handler threw: ${String(err)}`); + } } this._finishSession('ended'); } catch (error) { diff --git a/src/features/chat/ui/ChatInputContextMenu.ts b/src/features/chat/ui/ChatInputContextMenu.ts index 1bd30432..9512f0cc 100644 --- a/src/features/chat/ui/ChatInputContextMenu.ts +++ b/src/features/chat/ui/ChatInputContextMenu.ts @@ -24,25 +24,25 @@ const CHAT_INPUT_CONTEXT_MENU_ITEMS: ChatInputContextMenuItem[] = [ { action: 'cut', labelKey: 'ui.chat.input_menu.cut', - fallback: 'Вырезать', + fallback: 'Cut', shortcut: 'Ctrl+X', }, { action: 'copy', labelKey: 'ui.chat.input_menu.copy', - fallback: 'Копировать', + fallback: 'Copy', shortcut: 'Ctrl+C', }, { action: 'paste', labelKey: 'ui.chat.input_menu.paste', - fallback: 'Вставить', + fallback: 'Paste', shortcut: 'Ctrl+V', }, { action: 'selectAll', labelKey: 'ui.chat.input_menu.select_all', - fallback: 'Выбрать все', + fallback: 'Select all', shortcut: 'Ctrl+A', dividerBefore: true, }, diff --git a/src/features/settings/ui/ModuleSettingsEngineFieldController.ts b/src/features/settings/ui/ModuleSettingsEngineFieldController.ts index 1812a6a8..073dc1ef 100644 --- a/src/features/settings/ui/ModuleSettingsEngineFieldController.ts +++ b/src/features/settings/ui/ModuleSettingsEngineFieldController.ts @@ -14,7 +14,7 @@ type EngineInputElement = HTMLInputElement | HTMLSelectElement | HTMLTextAreaEle type ModuleSettingsEngineFieldControllerDeps = { getSettings: () => Record; - setConfig: (config: EngineConfig) => void; + setConfig: (config: EngineConfig) => Promise; debouncedSave: (key: string, value: string | number | boolean | null) => void; showSaveIndicator: () => void; translate: (key: string, fallback: string) => string; @@ -78,7 +78,7 @@ export class ModuleSettingsEngineFieldController { input.parentElement?.classList.remove('focused'); }); - const handleSave = () => this.handleSave(input, options); + const handleSave = () => void this.handleSave(input, options); if ( options.type === 'text' || @@ -131,7 +131,7 @@ export class ModuleSettingsEngineFieldController { container.appendChild(browseBtn); } - public handleSave( + public async handleSave( input: EngineInputElement, options: { key: string; @@ -143,7 +143,7 @@ export class ModuleSettingsEngineFieldController { max?: number; defaultValue?: number | string; }, - ): void { + ): Promise { let rawValue = input.value.trim(); if (options.isFile === true && input instanceof HTMLInputElement) { rawValue = input.dataset['fullPath']?.trim() ?? rawValue; @@ -159,7 +159,7 @@ export class ModuleSettingsEngineFieldController { (options.config as unknown as Record)[ options.key ] = formatEngineFieldSaveValue(options.key, value); - this._deps.setConfig(options.config); + await this._deps.setConfig(options.config); this._deps.showSaveIndicator(); } } diff --git a/src/features/settings/ui/ModuleSettingsEngineRenderer.test.ts b/src/features/settings/ui/ModuleSettingsEngineRenderer.test.ts index 73c63a7c..b27ae45a 100644 --- a/src/features/settings/ui/ModuleSettingsEngineRenderer.test.ts +++ b/src/features/settings/ui/ModuleSettingsEngineRenderer.test.ts @@ -417,7 +417,7 @@ describe('ModuleSettingsEngineRenderer', () => { expect(input.value).toBe('512'); }); - it('should save engine field values', () => { + it('should save engine field values', async () => { const setConfig = vi.fn(); const showSaveIndicator = vi.fn(); const fieldController = new ModuleSettingsEngineFieldController({ @@ -436,7 +436,7 @@ describe('ModuleSettingsEngineRenderer', () => { const engineInput = document.createElement('input'); engineInput.value = '--ctx 4096'; const config = { extra_args: [] as string[] }; - fieldController.handleSave(engineInput, { + await fieldController.handleSave(engineInput, { key: 'extra_args', type: 'text', isEngineConfig: true, diff --git a/src/features/settings/ui/ModuleSettingsEngineRenderer.ts b/src/features/settings/ui/ModuleSettingsEngineRenderer.ts index 31276630..a89c8c6e 100644 --- a/src/features/settings/ui/ModuleSettingsEngineRenderer.ts +++ b/src/features/settings/ui/ModuleSettingsEngineRenderer.ts @@ -156,9 +156,14 @@ export class ModuleSettingsEngineRenderer { return { getSettings: () => this._deps.service.getSettings() as Record, - setConfig: (config) => { - void this._deps.engineConfigService.setConfig(config); - this._deps.notifySettingsChanged(); + setConfig: async (config) => { + try { + await this._deps.engineConfigService.setConfig(config); + this._deps.notifySettingsChanged(); + } catch (error) { + this._deps.tracer.error('[ModuleSettingsEngineRenderer] setConfig failed:', error); + throw error; + } }, debouncedSave: (key, value) => { this._deps.debouncedSave(key, value); From 89f0d39be8f82a83e153704d00414aa1872a0f64 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Wed, 29 Apr 2026 15:35:55 +0300 Subject: [PATCH 006/126] fix: prevent duplicate startup log emission --- src-tauri/src/domain/ai/session.rs | 2 +- src-tauri/src/domain/engine/registry.rs | 2 +- src-tauri/src/domain/integration_api.rs | 75 ++++++-- .../src/infrastructure/logging/logger.rs | 68 ++++--- src-tauri/src/lib.rs | 4 +- src/app/CoreAssembly.ts | 2 +- src/app/CoreBootstrapRunner.test.ts | 1 + src/app/CoreEntry.ts | 95 ++++++---- src/app/CoreRuntimeSupport.test.ts | 12 +- src/app/CoreRuntimeSupport.ts | 2 +- src/features/ai/services/AIBridgeRuntime.ts | 55 ++---- .../ai/services/AIChatTransport.test.ts | 32 +--- src/features/ai/services/AIChatTransport.ts | 59 +++--- .../ai/services/EngineStatusService.test.ts | 3 +- .../ai/services/EngineStatusService.ts | 39 ++-- src/features/chat/chat.ts | 172 ++++++++++++++++-- .../controllers/ChatHistoryController.test.ts | 2 +- .../chat/controllers/ChatHistoryController.ts | 46 ++++- .../logging/LoggerService.test.ts | 37 ++++ src/infrastructure/logging/LoggerService.ts | 50 ++++- .../navigation/NavigationService.ts | 2 +- src/infrastructure/navigation/NavigationUI.ts | 5 + src/infrastructure/tauri/TauriProvider.ts | 2 +- src/shared/services/CatalogService.ts | 4 +- src/shared/services/ErrorHandler.test.ts | 4 +- src/shared/services/ErrorHandler.ts | 4 +- src/shared/services/StateManager.ts | 6 +- src/shared/services/WindowService.test.ts | 3 +- src/shared/services/WindowService.ts | 6 +- src/shared/services/WindowServicePolicy.ts | 3 +- src/test/helpers/catalogTestUtils.ts | 3 +- 31 files changed, 553 insertions(+), 247 deletions(-) diff --git a/src-tauri/src/domain/ai/session.rs b/src-tauri/src/domain/ai/session.rs index 8c261f13..796c315e 100644 --- a/src-tauri/src/domain/ai/session.rs +++ b/src-tauri/src/domain/ai/session.rs @@ -92,7 +92,7 @@ impl ChatSessionManager { let save_notify = Arc::clone(&self.save_notify); tauri::async_runtime::spawn(async move { - tracing::info!("Background chat session saver started."); + tracing::debug!("Background chat session saver started."); loop { save_notify.notified().await; tokio::time::sleep(std::time::Duration::from_secs(5)).await; diff --git a/src-tauri/src/domain/engine/registry.rs b/src-tauri/src/domain/engine/registry.rs index 798d42d3..638ad9ec 100644 --- a/src-tauri/src/domain/engine/registry.rs +++ b/src-tauri/src/domain/engine/registry.rs @@ -17,7 +17,7 @@ pub fn load_engine_definitions(modules: &[ModuleItem]) -> Vec .map(convert_module_to_definition) .collect(); - tracing::info!( + tracing::debug!( count = defs.len(), "Loaded engine definitions from local_modules" ); diff --git a/src-tauri/src/domain/integration_api.rs b/src-tauri/src/domain/integration_api.rs index 0d7aebc5..ad6e6541 100644 --- a/src-tauri/src/domain/integration_api.rs +++ b/src-tauri/src/domain/integration_api.rs @@ -29,6 +29,10 @@ use tauri::{AppHandle, Emitter}; const DEFAULT_API_BASE_URL: &str = "http://127.0.0.1:3000"; const MAX_REQUEST_BYTES: usize = 1024 * 1024; +const CUSTOM_TEXT_PROVIDER_ID: &str = "openrouter-custom-text"; +const CUSTOM_IMAGE_PROVIDER_ID: &str = "openrouter-custom-image"; +const CUSTOM_TEXT_BACKEND_PROVIDER_ID: &str = "gpt"; +const CUSTOM_IMAGE_BACKEND_PROVIDER_ID: &str = "gpt-image"; static API_BASE_URL: OnceCell = OnceCell::new(); static API_TOKEN: Lazy = Lazy::new(|| { @@ -90,7 +94,7 @@ pub fn start_launcher_http_api( message: format!("Failed to start launcher HTTP API thread: {error}"), })?; - tracing::info!("Launcher integration API listening at {base_url}"); + tracing::debug!("Launcher integration API listening at {base_url}"); Ok(LauncherHttpApiHandle { base_url }) } @@ -503,17 +507,22 @@ async fn handle_text_request( context: LauncherHttpApiContext, ) -> Result { let payload: IntegrationTextRequest = parse_json_body(request)?; - let provider = match payload.provider.filter(|value| !value.trim().is_empty()) { - Some(provider) => provider, + let requested_provider = payload.provider.filter(|value| !value.trim().is_empty()); + let ui_provider = match requested_provider.as_ref() { + Some(provider) => provider.clone(), None => selected_module_id(&context.ui_state_service, "ai_text") .await .ok_or_else(|| AppError::Validation("No selected text AI provider".to_string()))?, }; - select_provider_for_category(&context, "ai_text", &provider).await?; + if requested_provider.is_some() { + select_provider_for_category(&context, "ai_text", &ui_provider).await?; + } + let provider = backend_provider_id(&ui_provider).to_string(); let model = resolve_model_id( &context.config_service, &context.ui_state_service, &provider, + Some(&ui_provider), payload.model.as_deref(), "text", ) @@ -577,17 +586,22 @@ async fn handle_image_request( context: LauncherHttpApiContext, ) -> Result { let payload: IntegrationImageRequest = parse_json_body(request)?; - let provider = match payload.provider.filter(|value| !value.trim().is_empty()) { - Some(provider) => provider, + let requested_provider = payload.provider.filter(|value| !value.trim().is_empty()); + let ui_provider = match requested_provider.as_ref() { + Some(provider) => provider.clone(), None => selected_module_id(&context.ui_state_service, "ai_image") .await .ok_or_else(|| AppError::Validation("No selected image AI provider".to_string()))?, }; - select_provider_for_category(&context, "ai_image", &provider).await?; + if requested_provider.is_some() { + select_provider_for_category(&context, "ai_image", &ui_provider).await?; + } + let provider = backend_provider_id(&ui_provider).to_string(); let model = resolve_model_id( &context.config_service, &context.ui_state_service, &provider, + Some(&ui_provider), payload.model.as_deref(), "image", ) @@ -732,6 +746,21 @@ fn selected_module_from_api_provider(provider: &ApiProvider) -> SelectedModule { } } +fn backend_provider_id(provider_id: &str) -> &str { + match provider_id { + CUSTOM_TEXT_PROVIDER_ID => CUSTOM_TEXT_BACKEND_PROVIDER_ID, + CUSTOM_IMAGE_PROVIDER_ID => CUSTOM_IMAGE_BACKEND_PROVIDER_ID, + _ => provider_id, + } +} + +fn is_custom_provider_id(provider_id: &str) -> bool { + matches!( + provider_id, + CUSTOM_TEXT_PROVIDER_ID | CUSTOM_IMAGE_PROVIDER_ID + ) +} + async fn selected_module_id(ui_state_service: &UiStateService, category: &str) -> Option { let state = ui_state_service.get_ui_state().await.ok()?; state @@ -790,6 +819,7 @@ async fn resolve_model_id( config_service: &ConfigService, ui_state_service: &UiStateService, provider_id: &str, + ui_provider_id: Option<&str>, requested_model: Option<&str>, capability: &str, ) -> Result { @@ -800,11 +830,13 @@ async fn resolve_model_id( return Ok(model.to_string()); } - let selected_model = ui_state_service - .get_ui_state() - .await - .ok() - .and_then(|state| state.selected_ai_models.get(provider_id).cloned()); + let state = ui_state_service.get_ui_state().await.ok(); + let selected_model = state.as_ref().and_then(|state| { + ui_provider_id + .and_then(|id| state.selected_ai_models.get(id)) + .or_else(|| state.selected_ai_models.get(provider_id)) + .cloned() + }); let config = config_service.load_full_config()?; let provider = config .api_providers @@ -818,6 +850,15 @@ async fn resolve_model_id( return Ok(model); } + if ui_provider_id.is_some_and(is_custom_provider_id) + && let Some(model) = selected_model + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return Ok(model.to_string()); + } + if let Some(model) = provider.and_then(|provider| strongest_provider_model(provider, capability)) { @@ -928,7 +969,8 @@ mod tests { #![allow(clippy::expect_used)] use super::{ - find_header_end, is_authorized, model_api_id, parse_header_line, status_text, tier_rank, + backend_provider_id, find_header_end, is_authorized, model_api_id, parse_header_line, + status_text, tier_rank, }; use crate::models::{AiModel, ApiModelConfig, ModelStats, ModelTier}; use std::collections::HashMap; @@ -995,6 +1037,13 @@ mod tests { assert_eq!(model_api_id(&model, "image").as_deref(), Some("api-image")); } + #[test] + fn maps_custom_ui_provider_ids_to_backend_providers() { + assert_eq!(backend_provider_id("openrouter-custom-text"), "gpt"); + assert_eq!(backend_provider_id("openrouter-custom-image"), "gpt-image"); + assert_eq!(backend_provider_id("llamacpp"), "llamacpp"); + } + #[test] fn ranks_model_tiers_for_default_selection() { assert!(tier_rank(&ModelTier::Strong) > tier_rank(&ModelTier::Medium)); diff --git a/src-tauri/src/infrastructure/logging/logger.rs b/src-tauri/src/infrastructure/logging/logger.rs index b38ad645..5236aa2b 100644 --- a/src-tauri/src/infrastructure/logging/logger.rs +++ b/src-tauri/src/infrastructure/logging/logger.rs @@ -1,3 +1,4 @@ +use chrono::TimeZone; use serde::Serialize; use std::cmp::Ordering; use std::collections::VecDeque; @@ -155,39 +156,9 @@ pub fn get_logs_since(since: f64) -> Vec { } } -fn is_frontend_relevant_log(entry: &LogEntry) -> bool { - let message = entry.message.to_ascii_uppercase(); - let source = entry.source.to_ascii_uppercase(); - - let is_bot_source = source.contains("CHATSERVICE") - || source.contains("AIBRIDGE") - || source.contains("AI_SERVICE") - || source.contains("DOMAIN::AI") - || source.contains("DOMAIN::ENGINE") - || source.contains("INFRASTRUCTURE::ENGINE"); - - let is_ai_noise = message.contains("GEMINI_ERROR") - || message.contains("ERROR 429") - || message.contains("ERROR 400") - || message.contains("ERROR 403") - || message.contains("ERROR 500") - || message.contains("QUOTA") - || message.contains("PERMISSION_DENIED") - || message.contains("INVALID_ARGUMENT") - || message.contains("DEADLINE_EXCEEDED") - || message.contains("FAILED_PRECONDITION") - || message.contains("UNAVAILABLE") - || message.contains("INTERNAL_ERROR"); - - !(is_bot_source || is_ai_noise) -} - -/// Retrieves frontend-facing log entries since a timestamp with noisy AI chatter removed. +/// Retrieves frontend-facing log entries since a timestamp. pub fn get_frontend_logs_since(since: f64) -> Vec { - let mut logs: Vec = get_logs_since(since) - .into_iter() - .filter(is_frontend_relevant_log) - .collect(); + let mut logs: Vec = get_logs_since(since); logs.extend(RuntimeLogCollector::collect_since(since)); logs.sort_by(|left, right| { @@ -199,10 +170,19 @@ pub fn get_frontend_logs_since(since: f64) -> Vec { logs } +/// Retrieves frontend-facing log entries for one explicit console view. +pub fn get_frontend_logs_for_view(view_id: &str, since: f64) -> Vec { + get_frontend_logs_since(since) + .into_iter() + .filter(|entry| is_entry_in_console_view(entry, view_id)) + .collect() +} + fn parse_runtime_log_line( namespace: RuntimeLogNamespace, runtime_id: &str, line: &str, + line_index: usize, since: f64, ) -> Option { let line = line.trim(); @@ -210,7 +190,8 @@ fn parse_runtime_log_line( return None; } - let timestamp = parse_log_timestamp(line)?; + let timestamp_offset = f64::from(u32::try_from(line_index).ok()?) / 1_000_000.0; + let timestamp = parse_log_timestamp(line)? + timestamp_offset; if timestamp <= since { return None; } @@ -580,7 +561,10 @@ fn parse_log_timestamp(line: &str) -> Option { chrono::NaiveDateTime::parse_from_str(timestamp_text, "%Y-%m-%d %H:%M:%S") .ok() .and_then(|timestamp| { - let timestamp = timestamp.and_utc(); + let timestamp = chrono::Local + .from_local_datetime(×tamp) + .single() + .or_else(|| chrono::Local.from_local_datetime(×tamp).earliest())?; let seconds = timestamp.timestamp().to_string().parse::().ok()?; let milliseconds = timestamp .timestamp_subsec_millis() @@ -648,6 +632,7 @@ fn clear_module_runtime_logs() { pub fn init_global_logger() -> Result { let log_dir = &*crate::utils::paths::LOG_DIR; std::fs::create_dir_all(log_dir).map_err(|e| e.to_string())?; + clear_startup_log_files(log_dir); // Keep the launcher log easy to open from the UI and external editors. let file_appender = tracing_appender::rolling::never(log_dir, "axelate.log"); @@ -662,6 +647,9 @@ pub fn init_global_logger() -> Result Result bool { let source = canonical_engine_id(source); @@ -785,7 +778,10 @@ impl RuntimeLogCollector { entries.extend( content .lines() - .filter_map(|line| parse_runtime_log_line(namespace, runtime_id, line, since)), + .enumerate() + .filter_map(|(line_index, line)| { + parse_runtime_log_line(namespace, runtime_id, line, line_index, since) + }), ); } @@ -863,6 +859,7 @@ mod tests { RuntimeLogNamespace::Module, "sample-integration", "2026-04-24 07:00:00 [INFO] Integration started", + 0, 0.0, ) .ok_or_else(|| "module runtime log entry".to_string())?; @@ -879,6 +876,7 @@ mod tests { RuntimeLogNamespace::Engine, "llamacpp", "2026-04-24 07:00:00 [INFO] model loaded", + 0, 0.0, ) .ok_or_else(|| "engine runtime log entry".to_string())?; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 99d88fac..9377ad3b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -99,6 +99,7 @@ pub fn create_specta_builder() -> Builder { settings::save_module_settings, settings::get_system_language, logs::get_logs, + logs::get_console_logs, logs::get_console_overview, logs::clear_logs, logs::clear_console_logs, @@ -161,7 +162,6 @@ pub fn create_specta_builder() -> Builder { ai::rewind_last_turn, ai::count_tokens, ai::generate_image, - ai::generate_image_background, ai::cancel_image_generation, ai::get_image_generation_preview, ai::delete_chat_image, @@ -300,7 +300,7 @@ fn setup_dependencies(app: &tauri::App) -> Result<(), Box ui_state_service_for_api, ), )?; - tracing::info!( + tracing::debug!( "Launcher integration API ready at {}", integration_api.base_url() ); diff --git a/src/app/CoreAssembly.ts b/src/app/CoreAssembly.ts index 135cab67..c10cf1dc 100644 --- a/src/app/CoreAssembly.ts +++ b/src/app/CoreAssembly.ts @@ -65,7 +65,7 @@ export function createCoreAssembly(args: CreateCoreAssemblyArgs): CoreAssembly { const serviceBundle = createCoreServiceBundle(args.tracer); configureTracerTransport(args.tracer, serviceBundle.tauriProvider); - args.tracer.info(`AXELATE v${__APP_VERSION__}`); + args.tracer.debug(`AXELATE v${__APP_VERSION__}`); configureCoreServices({ windowService: serviceBundle.windowService, diff --git a/src/app/CoreBootstrapRunner.test.ts b/src/app/CoreBootstrapRunner.test.ts index c80caf86..94d5f9a2 100644 --- a/src/app/CoreBootstrapRunner.test.ts +++ b/src/app/CoreBootstrapRunner.test.ts @@ -69,6 +69,7 @@ function createRunnerDeps() { navigationUI: { showPage: recordAsync('navigation-ui:show-page'), init: record('navigation-ui:init'), + syncActiveNavigationButton: record('navigation-ui:sync-active-button'), }, chatController: { init: record('chat:init'), diff --git a/src/app/CoreEntry.ts b/src/app/CoreEntry.ts index 1e3a113d..63dfbbef 100644 --- a/src/app/CoreEntry.ts +++ b/src/app/CoreEntry.ts @@ -6,45 +6,68 @@ type CoreRuntime = { }; type CoreFactory = () => CoreRuntime; -type EntryLogger = Pick; +type EntryLogger = Pick; -let activeCoreInstance: CoreRuntime | null = null; -let coreInitializationInFlight = false; -let coreBootBound = false; -let coreBeforeUnloadBound = false; -let bootHandler: (() => void) | null = null; -let beforeUnloadHandler: (() => void) | null = null; +type CoreEntryState = { + activeCoreInstance: CoreRuntime | null; + coreInitializationInFlight: boolean; + coreBootBound: boolean; + coreBeforeUnloadBound: boolean; + bootHandler: (() => void) | null; + beforeUnloadHandler: (() => void) | null; +}; + +const CORE_ENTRY_STATE_KEY = '__AXELATE_CORE_ENTRY_STATE__'; + +function getCoreEntryState(): CoreEntryState { + const runtime = globalThis as typeof globalThis & { + [CORE_ENTRY_STATE_KEY]?: CoreEntryState; + }; + + runtime[CORE_ENTRY_STATE_KEY] ??= { + activeCoreInstance: null, + coreInitializationInFlight: false, + coreBootBound: false, + coreBeforeUnloadBound: false, + bootHandler: null, + beforeUnloadHandler: null, + }; + + return runtime[CORE_ENTRY_STATE_KEY]; +} function clearBootState(): void { - activeCoreInstance = null; - coreInitializationInFlight = false; + const state = getCoreEntryState(); + state.activeCoreInstance = null; + state.coreInitializationInFlight = false; } function destroyActiveCoreInstance(): void { - activeCoreInstance?.destroy(); + const state = getCoreEntryState(); + state.activeCoreInstance?.destroy(); clearBootState(); } function bootCoreOnce(createCore: CoreFactory, tracer: EntryLogger): void { - if (activeCoreInstance !== null) { - tracer.warn('[Core] Double init blocked (global singleton already active).'); + const state = getCoreEntryState(); + + if (state.activeCoreInstance !== null) { return; } - if (coreInitializationInFlight) { - tracer.warn('[Core] Double init blocked (initialization already in flight).'); + if (state.coreInitializationInFlight) { return; } - coreInitializationInFlight = true; + state.coreInitializationInFlight = true; try { const coreInstance = createCore(); - activeCoreInstance = coreInstance; - coreInitializationInFlight = false; + state.activeCoreInstance = coreInstance; + state.coreInitializationInFlight = false; coreInstance.init().catch((error: unknown) => { - if (activeCoreInstance === coreInstance) { + if (state.activeCoreInstance === coreInstance) { clearBootState(); } coreInstance.destroy(); @@ -58,40 +81,42 @@ function bootCoreOnce(createCore: CoreFactory, tracer: EntryLogger): void { } export function bindCoreEntry(createCore: CoreFactory, tracer: EntryLogger): void { + const state = getCoreEntryState(); + if (document.readyState === 'loading') { - if (!coreBootBound) { - coreBootBound = true; - bootHandler = () => { - bootHandler = null; + if (!state.coreBootBound) { + state.coreBootBound = true; + state.bootHandler = () => { + state.bootHandler = null; bootCoreOnce(createCore, tracer); }; - document.addEventListener('DOMContentLoaded', bootHandler, { once: true }); + document.addEventListener('DOMContentLoaded', state.bootHandler, { once: true }); } } else { bootCoreOnce(createCore, tracer); } - if (!coreBeforeUnloadBound) { - coreBeforeUnloadBound = true; - beforeUnloadHandler = () => { + if (!state.coreBeforeUnloadBound) { + state.coreBeforeUnloadBound = true; + state.beforeUnloadHandler = () => { destroyActiveCoreInstance(); }; - globalThis.addEventListener('beforeunload', beforeUnloadHandler); + globalThis.addEventListener('beforeunload', state.beforeUnloadHandler); } if (import.meta.hot) { import.meta.hot.dispose(() => { destroyActiveCoreInstance(); - if (bootHandler !== null) { - document.removeEventListener('DOMContentLoaded', bootHandler); - bootHandler = null; + if (state.bootHandler !== null) { + document.removeEventListener('DOMContentLoaded', state.bootHandler); + state.bootHandler = null; } - if (beforeUnloadHandler !== null) { - globalThis.removeEventListener('beforeunload', beforeUnloadHandler); - beforeUnloadHandler = null; + if (state.beforeUnloadHandler !== null) { + globalThis.removeEventListener('beforeunload', state.beforeUnloadHandler); + state.beforeUnloadHandler = null; } - coreBootBound = false; - coreBeforeUnloadBound = false; + state.coreBootBound = false; + state.coreBeforeUnloadBound = false; }); } } diff --git a/src/app/CoreRuntimeSupport.test.ts b/src/app/CoreRuntimeSupport.test.ts index 59c12992..6b30c427 100644 --- a/src/app/CoreRuntimeSupport.test.ts +++ b/src/app/CoreRuntimeSupport.test.ts @@ -17,8 +17,8 @@ describe('CoreRuntimeSupport', () => { init: vi.fn(() => { callOrder.push('navigation:init'); }), - showPage: vi.fn(() => { - callOrder.push('navigation:showPage'); + syncActiveNavigationButton: vi.fn(() => { + callOrder.push('navigation:syncActiveNavigationButton'); }), }; const moduleService = { @@ -42,11 +42,11 @@ describe('CoreRuntimeSupport', () => { expect(sidebarUI.init).toHaveBeenCalledTimes(1); expect(navigationUI.init).toHaveBeenCalledTimes(1); - expect(navigationUI.showPage).toHaveBeenCalledWith('settings', null, true, true); + expect(navigationUI.syncActiveNavigationButton).toHaveBeenCalledWith('settings'); expect(callOrder).toEqual([ 'sidebar:init', 'navigation:init', - 'navigation:showPage', + 'navigation:syncActiveNavigationButton', 'module:init', 'download:init', ]); @@ -61,7 +61,7 @@ describe('CoreRuntimeSupport', () => { }; const navigationUI = { init: vi.fn(() => {}), - showPage: vi.fn(() => {}), + syncActiveNavigationButton: vi.fn(() => {}), }; const moduleService = { init: vi.fn(() => {}), @@ -78,6 +78,6 @@ describe('CoreRuntimeSupport', () => { sidebarUI: sidebarUI as never, }); - expect(navigationUI.showPage).toHaveBeenCalledWith('home', null, true, true); + expect(navigationUI.syncActiveNavigationButton).toHaveBeenCalledWith('home'); }); }); diff --git a/src/app/CoreRuntimeSupport.ts b/src/app/CoreRuntimeSupport.ts index 6ea7d4e9..d55023f6 100644 --- a/src/app/CoreRuntimeSupport.ts +++ b/src/app/CoreRuntimeSupport.ts @@ -154,7 +154,7 @@ export async function showInitialPage(args: ShowInitialPageArgs): Promise export async function initializeImmediateUi(args: InitializeImmediateUiArgs): Promise { await args.sidebarUI.init(); args.navigationUI.init(); - await args.navigationUI.showPage(args.navigation.getCurrentPage() ?? 'home', null, true, true); + args.navigationUI.syncActiveNavigationButton(args.navigation.getCurrentPage() ?? 'home'); void args.moduleService.init(); void args.downloadUI.init(); } diff --git a/src/features/ai/services/AIBridgeRuntime.ts b/src/features/ai/services/AIBridgeRuntime.ts index cb7a1f5b..15dc1883 100644 --- a/src/features/ai/services/AIBridgeRuntime.ts +++ b/src/features/ai/services/AIBridgeRuntime.ts @@ -1,7 +1,6 @@ import type { IChatMessage, IChunkHandler, IImageGenerationPreview } from '../types/aiTypes'; import type { AIBridgeContext } from './AIBridgeContext'; import type { AIBridgeEvents } from './AIBridgeEvents'; -import type { AIBridgeProviderPolicy } from './AIBridgeProviderPolicy'; import type { IChatTransport } from './AIChatTransport'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; import type { TauriProvider } from '@/infrastructure/tauri/TauriProvider'; @@ -18,12 +17,6 @@ type InitializeStreamingArgs = { broadcastThought: IChunkHandler; }; -type StopCrossSlotEnginesArgs = { - context: AIBridgeContext | null; - providerId: string; - providerPolicy: AIBridgeProviderPolicy; -}; - type ImageGenerationLogProgress = { percent: number | null; step: number | null; @@ -133,55 +126,29 @@ export class AIBridgeRuntime { args.broadcastThought(payload); }); - this._tracer.info('[AIBridge] Streaming active (IPC via Transport)'); + this._tracer.debug('[AIBridge] Streaming active (IPC via Transport)'); return [unlistenLog, unlistenChunk, unlistenThought]; } - public async stopCrossSlotEngines(args: StopCrossSlotEnginesArgs): Promise { - if (args.context?.tauriProvider.isTauri() !== true) { - return; - } - - if (args.providerPolicy.isCloudProvider(args.providerId)) { + public stopProviderEngine(context: AIBridgeContext | null): void { + if (context?.tauriProvider.isTauri() !== true) { return; } - const isImageProvider = args.providerPolicy.isImageProvider(args.providerId); - const isManagedLocalImageEngine = args.providerPolicy.isManagedLocalImageEngine( - args.providerId, - ); - - try { - if (isImageProvider) { - await args.context.tauriProvider.invoke('stop_engine_slot', { - capability: 'text', - }); - if (!isManagedLocalImageEngine) { - await args.context.tauriProvider.invoke('stop_engine_slot', { - capability: 'image', - }); - } - return; - } - - await args.context.tauriProvider.invoke('stop_engine_slot', { - capability: 'image', - }); - } catch (error) { - this._tracer.warn( - `[AIBridge] Failed to stop cross-slot engine for VRAM savings: ${String(error)}`, - ); - } + void context.tauriProvider.invoke('stop_engine').catch((error) => { + this._tracer.warn(`[AIBridge] Failed to invoke stop_engine: ${String(error)}`); + }); } - public stopProviderEngine(context: AIBridgeContext | null): void { + public async stopEngineSlot( + context: AIBridgeContext | null, + capability: 'text' | 'image' | 'vision', + ): Promise { if (context?.tauriProvider.isTauri() !== true) { return; } - void context.tauriProvider.invoke('stop_engine').catch((error) => { - this._tracer.warn(`[AIBridge] Failed to invoke stop_engine: ${String(error)}`); - }); + await context.tauriProvider.invoke('stop_engine_slot', { capability }); } public async getHistory( diff --git a/src/features/ai/services/AIChatTransport.test.ts b/src/features/ai/services/AIChatTransport.test.ts index 9a830dd1..b1739ae8 100644 --- a/src/features/ai/services/AIChatTransport.test.ts +++ b/src/features/ai/services/AIChatTransport.test.ts @@ -38,11 +38,12 @@ function makeRequest(overrides: Partial = {}): IChatRequest { describe('AIChatTransport', () => { let transport: AIChatTransport; let mockCore: ReturnType; - let tracer: Pick; + let tracer: Pick; beforeEach(() => { vi.useFakeTimers(); tracer = { + debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), @@ -273,35 +274,6 @@ describe('AIChatTransport', () => { }); }); - describe('generateImageBackground', () => { - const request = { provider: 'sdcpp', prompt: 'city', model: 'default' } as Parameters< - AIChatTransport['generateImageBackground'] - >[0]; - - it('should reject in web mode', async () => { - mockCore.tauriProvider.isTauri.mockReturnValue(false); - await expect(transport.generateImageBackground(request)).resolves.toEqual({ - ok: false, - error: 'IPC host unavailable', - }); - }); - - it('should invoke background generation and normalize errors', async () => { - mockCore.tauriProvider.invoke.mockResolvedValueOnce(undefined); - await expect(transport.generateImageBackground(request)).resolves.toEqual({ ok: true }); - expect(mockCore.tauriProvider.invoke).toHaveBeenCalledWith( - 'generate_image_background', - { request }, - ); - - mockCore.tauriProvider.invoke.mockRejectedValueOnce('bg failed'); - await expect(transport.generateImageBackground(request)).resolves.toEqual({ - ok: false, - error: 'bg failed', - }); - }); - }); - // ---------------------------------------------------------- Stream Listeners (onStream, onThought) describe.each([ ['onStream', 'chatChannel'], diff --git a/src/features/ai/services/AIChatTransport.ts b/src/features/ai/services/AIChatTransport.ts index 4e80a67f..b6d34977 100644 --- a/src/features/ai/services/AIChatTransport.ts +++ b/src/features/ai/services/AIChatTransport.ts @@ -9,7 +9,7 @@ import type { import type { LoggerService } from '@/infrastructure/logging/LoggerService'; import type { AITransportContext } from './AIBridgeContext'; -type AIChatTransportLogger = Pick; +type AIChatTransportLogger = Pick; const STALE_REQUEST_CANCEL_TIMEOUT_MS = 750; /** @@ -38,9 +38,9 @@ interface IStreamChunkEnvelope { export interface IChatTransport { init(): Promise; send(request: IChatRequest): Promise; + sendSilent(request: IChatRequest): Promise; cancelActiveChatRequest(): Promise; generateImage(request: IImageGenerationRequest): Promise; - generateImageBackground(request: IImageGenerationRequest): Promise; onStream(listener: (chunk: string) => void): () => void; onThought(listener: (chunk: string) => void): () => void; setContext(context: AITransportContext): void; @@ -74,7 +74,7 @@ export class AIChatTransport implements IChatTransport { // Setup global listener for streaming chunks if needed here, // or let the bridge handle the subscription via onStream. // For now, we follow the pattern that Transport manages the low-level listener. - this._tracer.info('[AIChatTransport] Transport initialized'); + this._tracer.debug('[AIChatTransport] Transport initialized'); } await Promise.resolve(); } @@ -161,6 +161,39 @@ export class AIChatTransport implements IChatTransport { } } + public async sendSilent(request: IChatRequest): Promise { + if (this._context?.tauriProvider.isTauri() !== true) { + return { ok: false, error: 'IPC host unavailable' }; + } + + const requestId = this._generateRequestId(); + const { session_id: _sessionId, ...requestWithoutSession } = request; + const requestWithId: IChatRequest = { + ...requestWithoutSession, + request_id: requestId, + }; + const chatChannel = new Channel(); + const thoughtChannel = new Channel(); + + try { + const response = await this._runWithTimeout( + this._context.tauriProvider.invoke('send_chat_message', { + request: requestWithId, + chatChannel, + thoughtChannel, + }), + 90000, + 'AI request timed out', + ); + + return this._normalizeResponse(response); + } catch (error: unknown) { + const errorMsg = extractError(error); + this._tracer.error('[AIChatTransport] Silent IPC error:', error); + return { ok: false, error: errorMsg }; + } + } + private async _cancelStaleActiveRequest(requestId: string): Promise { try { await this._runWithTimeout( @@ -232,26 +265,6 @@ export class AIChatTransport implements IChatTransport { } } - /** - * Starts an image generation job that survives window closure. - */ - public async generateImageBackground( - request: IImageGenerationRequest, - ): Promise { - if (this._context?.tauriProvider.isTauri() !== true) { - return { ok: false, error: 'IPC host unavailable' }; - } - - try { - await this._context.tauriProvider.invoke('generate_image_background', { request }); - return { ok: true }; - } catch (error: unknown) { - const errorMsg = extractError(error); - this._tracer.error('[AIChatTransport] IPC background image error:', error); - return { ok: false, error: errorMsg }; - } - } - /** * Subscribes to the AI stream events. * Returns an unlisten function. diff --git a/src/features/ai/services/EngineStatusService.test.ts b/src/features/ai/services/EngineStatusService.test.ts index 9a57763e..7d822eb8 100644 --- a/src/features/ai/services/EngineStatusService.test.ts +++ b/src/features/ai/services/EngineStatusService.test.ts @@ -7,7 +7,7 @@ describe('EngineStatusService', () => { let service: EngineStatusService; let listeners: Record void>; let core: EngineStatusContext; - let tracer: Pick; + let tracer: Pick; beforeEach(() => { listeners = {}; @@ -33,6 +33,7 @@ describe('EngineStatusService', () => { } as unknown as EngineStatusContext; tracer = { + debug: vi.fn(), info: vi.fn(), error: vi.fn(), }; diff --git a/src/features/ai/services/EngineStatusService.ts b/src/features/ai/services/EngineStatusService.ts index f0174ffc..5a6ec22f 100644 --- a/src/features/ai/services/EngineStatusService.ts +++ b/src/features/ai/services/EngineStatusService.ts @@ -1,7 +1,7 @@ import type { LoggerService } from '@/infrastructure/logging/LoggerService'; import type { EngineStatusContext } from './AIBridgeContext'; -type EngineStatusLogger = Pick; +type EngineStatusLogger = Pick; type EngineState = 'idle' | 'starting' | 'swapping' | 'ready' | 'error'; type BackendEngineState = @@ -100,7 +100,7 @@ export class EngineStatusService { }), ); - this._tracer.info('[EngineStatusService] Listening for engine events'); + this._tracer.debug('[EngineStatusService] Listening for engine events'); this._initialized = true; this._startDomSyncObserver(); void this.refreshFromBackend(); @@ -111,10 +111,7 @@ export class EngineStatusService { this._unlisteners.length = 0; this._domObserver?.disconnect(); this._domObserver = null; - if (this._domSyncFrame !== null) { - cancelAnimationFrame(this._domSyncFrame); - this._domSyncFrame = null; - } + this._cancelDomSyncFrame(); this._activeSlots.clear(); this._initialized = false; } @@ -204,6 +201,8 @@ export class EngineStatusService { private _startDomSyncObserver(): void { this._domObserver?.disconnect(); + this._cancelDomSyncFrame(); + this._domObserver = new MutationObserver((records) => { if (this._retargetDomSyncObserver(records)) { this._scheduleDomSync(); @@ -214,13 +213,14 @@ export class EngineStatusService { this._scheduleDomSync(); } }); - const target = this._getDomSyncTarget(); - this._domObserver.observe(target, { + const observeOptions: MutationObserverInit = { childList: true, subtree: true, attributes: true, attributeFilter: ['data-app-id', 'data-current-module'], - }); + }; + const target = this._getDomSyncTarget(); + this._domObserver.observe(target, observeOptions); } private _getDomSyncTarget(): HTMLElement { @@ -289,22 +289,31 @@ export class EngineStatusService { return element.hasAttribute('data-app-id') || element.hasAttribute('data-current-module'); } + private _applyActiveStatesToDom(): void { + this._activeSlots.forEach((_endpoint, engineId) => { + this._setCardState(engineId, 'ready'); + this._setDashboardCardState(engineId, 'ready'); + }); + } + private _scheduleDomSync(): void { if (this._domSyncFrame !== null) { return; } - this._domSyncFrame = requestAnimationFrame(() => { + this._domSyncFrame = globalThis.requestAnimationFrame(() => { this._domSyncFrame = null; this._applyActiveStatesToDom(); }); } - private _applyActiveStatesToDom(): void { - this._activeSlots.forEach((_endpoint, engineId) => { - this._setCardState(engineId, 'ready'); - this._setDashboardCardState(engineId, 'ready'); - }); + private _cancelDomSyncFrame(): void { + if (this._domSyncFrame === null) { + return; + } + + globalThis.cancelAnimationFrame(this._domSyncFrame); + this._domSyncFrame = null; } private _applyBackendState(state: BackendEngineState): void { diff --git a/src/features/chat/chat.ts b/src/features/chat/chat.ts index a6cbe4d7..c02f3e3b 100644 --- a/src/features/chat/chat.ts +++ b/src/features/chat/chat.ts @@ -79,6 +79,8 @@ export class ChatController { private readonly _generationController: ChatGenerationController; private readonly _sendController: ChatSendController; private readonly _state = new ChatControllerState(); + private _restoredImageGenerationTimer: ReturnType | null = null; + private _forceImageGeneration = false; private _contextTokenTotal = 0; private _contextTokenVersion = 0; private readonly _boundFileInputChange = (e: Event) => this._filePicker.handleFileSelect(e); @@ -316,13 +318,7 @@ export class ChatController { this._ui.removeTyping(typingId); return this._ui.createStreamingMessage('assistant'); }, - createImageHandle: () => - this._ui.createImageGenerationMessage({ - onCancel: async () => { - this._generationController.stopImagePreviewPolling(); - await this._aiBridge.cancelImageGeneration(); - }, - }), + createImageHandle: () => this._ui.createImageGenerationMessage(), translate: (key, fallback) => this._i18n.t(key, fallback), showTyping: (typingId) => { this._ui.showTyping(typingId); @@ -348,6 +344,10 @@ export class ChatController { }, getSelectedModule: (category) => deps.getSelectedModule(category), getPreferredAiCategory: () => deps.getPreferredAiCategory(), + isForceImageGeneration: () => this._forceImageGeneration, + clearForceImageGeneration: () => { + this._forceImageGeneration = false; + }, handleResponse: async (response, streamingHandle, imageHandle) => await this._generationController.handleChatResponse( response, @@ -411,8 +411,8 @@ export class ChatController { this._state.isInitialized = true; this._state.isDestroyed = false; - this._tracer.info('[Chat] Initializing TS Controller...'); - await this._ui.init().catch((err: unknown) => { + this._tracer.debug('[Chat] Initializing TS Controller...'); + void this._ui.init().catch((err: unknown) => { this._tracer.error(`[Chat] UI init failed: ${String(err)}`); }); this._ui.setEditMessageHandler(async (text) => { @@ -422,12 +422,14 @@ export class ChatController { await this.regenerateLastResponse(); }); this._lifecycleHelper.start(); + void this._restoreActiveImageGeneration(); } public destroy(): void { if (this._state.isDestroyed) return; this._state.isDestroyed = true; this._state.isInitialized = false; + this._clearRestoredImageGenerationTimer(); this._lifecycleHelper.stop(); this._viewHelper.unbindEvents(); this._generationController.stopImagePreviewPolling(); @@ -439,12 +441,130 @@ export class ChatController { this._ui.destroy(); } + private async _restoreActiveImageGeneration(): Promise { + if (this._state.isDestroyed || this._state.isSending) { + return; + } + + const previewProvider = this._aiBridge as { + getImageGenerationPreview?: () => Promise< + Awaited> + >; + }; + if (typeof previewProvider.getImageGenerationPreview !== 'function') { + return; + } + + const preview = await previewProvider.getImageGenerationPreview(); + if (preview === null) { + return; + } + + const imageHandle = this._ui.createImageGenerationMessage(); + imageHandle.setStatus('image status=running elapsed=0s'); + if (preview.data_url.trim() !== '') { + imageHandle.setPreview(preview.data_url); + } + + this._state.isSending = true; + this._generationController.startImagePreviewPolling(imageHandle); + this._scheduleRestoredImageGenerationCheck(); + } + + private _scheduleRestoredImageGenerationCheck(): void { + this._clearRestoredImageGenerationTimer(); + this._restoredImageGenerationTimer = globalThis.setTimeout(() => { + this._restoredImageGenerationTimer = null; + void this._checkRestoredImageGeneration(); + }, 1200); + } + + private async _checkRestoredImageGeneration(): Promise { + if (this._state.isDestroyed || !this._state.isSending) { + return; + } + + const preview = await this._aiBridge.getImageGenerationPreview(); + if (preview !== null) { + this._scheduleRestoredImageGenerationCheck(); + return; + } + + this._generationController.stopImagePreviewPolling(); + this._state.isSending = false; + await this._historyController.loadHistory(); + } + + private _clearRestoredImageGenerationTimer(): void { + if (this._restoredImageGenerationTimer === null) { + return; + } + + globalThis.clearTimeout(this._restoredImageGenerationTimer); + this._restoredImageGenerationTimer = null; + } + // --- Public Actions --- public async pickChatFiles(): Promise { await this._filePicker.pick(); } + public toggleAttachMenu(): void { + const existing = document.querySelector('.chat-attach-menu'); + if (existing instanceof HTMLElement) { + existing.remove(); + return; + } + + const button = document.getElementById('chat-attach-btn'); + const compose = document.getElementById('chat-compose'); + if (!(button instanceof HTMLElement) || !(compose instanceof HTMLElement)) { + return; + } + + const menu = document.createElement('div'); + menu.className = 'chat-attach-menu'; + menu.setAttribute('role', 'menu'); + + const fileButton = this._createAttachMenuButton( + 'file', + this._i18n.t('ui.chat.attach_file', 'Add file'), + '#icon-paperclip', + ); + const imageButton = this._createAttachMenuButton( + 'image', + this._i18n.t('ui.chat.generate_image', 'Generate image'), + '#icon-ai', + ); + + menu.append(fileButton, imageButton); + compose.appendChild(menu); + + const close = (event: MouseEvent) => { + if (event.target instanceof Node && menu.contains(event.target)) { + return; + } + if (event.target instanceof Node && button.contains(event.target)) { + return; + } + menu.remove(); + document.removeEventListener('mousedown', close, true); + }; + setTimeout(() => document.addEventListener('mousedown', close, true), 0); + } + + public async pickChatFilesFromMenu(): Promise { + document.querySelector('.chat-attach-menu')?.remove(); + await this.pickChatFiles(); + } + + public async sendImageGenerationFromMenu(): Promise { + document.querySelector('.chat-attach-menu')?.remove(); + this._forceImageGeneration = true; + await this.sendChat(); + } + public toggleVoiceInput(): void { this._voice.toggle((text) => { this._inputCoordinator.appendVoiceText(text); @@ -484,6 +604,7 @@ export class ChatController { const input = this._inputCoordinator.getInput(); const text = input?.value.trim() ?? ''; if (!this._sendController.validateInput(text)) { + this._forceImageGeneration = false; this._ui.showToast( this._i18n.t('ui.chat.input_required', 'Enter a message or attach a file'), 'error', @@ -491,8 +612,12 @@ export class ChatController { return; } - const isActive = await this._activationCoordinator.ensureActive(input); - if (!isActive) return; + const activationPrompt = this._forceImageGeneration ? `generate image ${text}` : undefined; + const isActive = await this._activationCoordinator.ensureActive(input, activationPrompt); + if (!isActive) { + this._forceImageGeneration = false; + return; + } await this._sendController.sendChat(input); } @@ -513,7 +638,7 @@ export class ChatController { return; } - const text = await this._historyController.regenerateLastTurn(false); + const text = await this._historyController.regenerateLastTurn(this._state.isSending); if (text === null || text.trim() === '') { this._ui.showToast( this._i18n.t('ui.chat.regenerate_failed', 'Failed to regenerate response'), @@ -542,6 +667,29 @@ export class ChatController { this._scheduleAutoResizeInput(); } + private _createAttachMenuButton(action: string, label: string, iconHref: string): HTMLElement { + const button = document.createElement('button'); + button.className = 'chat-attach-menu-item'; + button.type = 'button'; + button.dataset['chatAttachAction'] = action; + button.setAttribute('role', 'menuitem'); + + const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + icon.setAttribute('class', 'icon'); + icon.setAttribute('viewBox', '0 0 24 24'); + icon.setAttribute('aria-hidden', 'true'); + icon.setAttribute('focusable', 'false'); + const use = document.createElementNS('http://www.w3.org/2000/svg', 'use'); + use.setAttribute('href', iconHref); + icon.appendChild(use); + + const text = document.createElement('span'); + text.textContent = label; + + button.append(icon, text); + return button; + } + private _pushAssistantMessage( content: IChatMessage['content'], thoughtSignature?: string, diff --git a/src/features/chat/controllers/ChatHistoryController.test.ts b/src/features/chat/controllers/ChatHistoryController.test.ts index 7e0b0714..4145ea52 100644 --- a/src/features/chat/controllers/ChatHistoryController.test.ts +++ b/src/features/chat/controllers/ChatHistoryController.test.ts @@ -34,7 +34,7 @@ function createController(stateOverrides?: Partial) { isDestroyed: vi.fn(() => false), getPendingChatRevealStore: vi.fn(() => null), tracer: { - info: vi.fn(), + debug: vi.fn(), error: vi.fn(), }, }; diff --git a/src/features/chat/controllers/ChatHistoryController.ts b/src/features/chat/controllers/ChatHistoryController.ts index 3b058b79..1870d0d0 100644 --- a/src/features/chat/controllers/ChatHistoryController.ts +++ b/src/features/chat/controllers/ChatHistoryController.ts @@ -2,7 +2,7 @@ import type { AIBridge } from '@/features/ai/services/AIBridge'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; import type { IChatMessage } from '../types/chatTypes'; -type ChatHistoryLogger = Pick; +type ChatHistoryLogger = Pick; type PendingChatRevealStore = { getState: () => { pending_chat_reveal?: boolean }; @@ -142,13 +142,14 @@ export class ChatHistoryController { return historyMessage; }) : []; + const visibleHistory = this._stripPersistedImagePromptPreparation(nextHistory); - this._options.setHistory(nextHistory); - this._options.renderHistory(nextHistory); + this._options.setHistory(visibleHistory); + this._options.renderHistory(visibleHistory); - if (nextHistory.length > 0) { - this._options.tracer.info( - `[ChatController] Restoring ${String(nextHistory.length)} messages from persistence`, + if (visibleHistory.length > 0) { + this._options.tracer.debug( + `[ChatController] Restoring ${String(visibleHistory.length)} messages from persistence`, ); } @@ -163,6 +164,39 @@ export class ChatHistoryController { } } + private _stripPersistedImagePromptPreparation(history: IChatMessage[]): IChatMessage[] { + const visible: IChatMessage[] = []; + for (let index = 0; index < history.length; index += 1) { + const message = history[index]; + if (message === undefined) { + continue; + } + + if (message.role === 'user' && this._isImagePromptPreparationRequest(message.content)) { + const next = history[index + 1]; + if (next?.role === 'assistant') { + index += 1; + } + continue; + } + + visible.push(message); + } + + return visible; + } + + private _isImagePromptPreparationRequest(content: IChatMessage['content']): boolean { + if (typeof content !== 'string') { + return false; + } + + return ( + content.includes('Stable Diffusion') && + content.includes('Return only the final prompt text') + ); + } + public scheduleRevealLatestMessage(): void { this.clearRevealLatestMessageTimeout(); if (this._revealLatestMessageFrame !== null) { diff --git a/src/infrastructure/logging/LoggerService.test.ts b/src/infrastructure/logging/LoggerService.test.ts index 23829eaf..31ecd312 100644 --- a/src/infrastructure/logging/LoggerService.test.ts +++ b/src/infrastructure/logging/LoggerService.test.ts @@ -32,6 +32,7 @@ describe('LoggerService', () => { (tracer as unknown as { _transport: null })._transport = null; (tracer as unknown as { _fallbackTransport: null })._fallbackTransport = null; (tracer as unknown as { _buffer: unknown[] })._buffer = []; + (tracer as unknown as { _flushPromise: null })._flushPromise = null; (tracer as unknown as { _initialized: boolean })._initialized = false; // allow re-init for tests that test init }); @@ -175,6 +176,7 @@ describe('LoggerService', () => { } as PromiseRejectionEvent); } await Promise.resolve(); + await Promise.resolve(); const logs2 = mockTransport.mock.calls[0]?.[0] as Array>; expect(logs2[0]?.['message']).toBe('Unhandled Promise: String rejection'); }); @@ -451,6 +453,41 @@ describe('LoggerService', () => { expect(tracer.getLogs()).toHaveLength(0); }); + it('should not send the same buffered logs twice while a flush is pending', async () => { + let resolveTransport: (() => void) | undefined; + const pendingTransport = vi + .fn<(logs: { level: string; message: string }[]) => Promise>() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveTransport = resolve; + }), + ) + .mockImplementation(() => Promise.resolve()); + tracer.setTransport(pendingTransport); + + for (let i = 0; i < 10; i++) { + tracer.info(`Msg ${i}`); + } + tracer.error('Error during pending flush'); + await Promise.resolve(); + + expect(pendingTransport).toHaveBeenCalledTimes(1); + expect(pendingTransport.mock.calls[0]?.[0] as unknown as unknown[]).toHaveLength(10); + + expect(resolveTransport).toBeDefined(); + resolveTransport?.(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + expect(pendingTransport).toHaveBeenCalledTimes(2); + expect(pendingTransport.mock.calls[1]?.[0]).toEqual([ + { level: 'ERROR', message: 'Error during pending flush' }, + ]); + expect(tracer.getLogs()).toHaveLength(0); + }); + it('should use fallback transport if main transport is not set', async () => { const mockInvoke = vi.fn().mockResolvedValue(undefined); tracer.setFallbackTransport(async (logs): Promise => { diff --git a/src/infrastructure/logging/LoggerService.ts b/src/infrastructure/logging/LoggerService.ts index 75290e83..88de9b7c 100644 --- a/src/infrastructure/logging/LoggerService.ts +++ b/src/infrastructure/logging/LoggerService.ts @@ -17,6 +17,7 @@ import type { ILogEntry } from '@/shared/types/coreTypes'; export class LoggerService { private _buffer: ILogEntry[] = []; private _flushTimeout: ReturnType | null = null; + private _flushPromise: Promise | null = null; private readonly _FLUSH_INTERVAL = 500; private readonly _MAX_BUFFER = 10; private _originalConsoleError: (..._args: unknown[]) => void; @@ -221,12 +222,10 @@ export class LoggerService { }); if (level === 'ERROR' || this._buffer.length >= this._MAX_BUFFER) { + this._clearFlushTimeout(); void this._flush(); } else { - if (this._flushTimeout) clearTimeout(this._flushTimeout); - this._flushTimeout = setTimeout(() => { - void this._flush(); - }, this._FLUSH_INTERVAL); + this._scheduleFlush(); } } finally { this._isInternalLog = false; @@ -266,8 +265,33 @@ export class LoggerService { * Flushes the current log buffer to the backend. */ private async _flush(): Promise { + if (this._flushPromise !== null) { + return this._flushPromise; + } + if (this._buffer.length === 0) return; + this._clearFlushTimeout(); + this._flushPromise = (async () => { + const flushed = await this._flushOnce(); + this._flushPromise = null; + + if (this._buffer.length === 0) { + return; + } + + if (flushed) { + void this._flush(); + return; + } + + this._scheduleFlush(); + })(); + + return this._flushPromise; + } + + private async _flushOnce(): Promise { // Take snapshot const logs = [...this._buffer]; @@ -282,9 +306,26 @@ export class LoggerService { } // Clear only the logs we successfully sent this._buffer = this._buffer.slice(logs.length); + return true; } catch (e) { // Keep buffer intact so next flush might succeed this._originalConsoleError('Log batch sync failed:', e); + return false; + } + } + + private _scheduleFlush(): void { + this._clearFlushTimeout(); + this._flushTimeout = setTimeout(() => { + this._flushTimeout = null; + void this._flush(); + }, this._FLUSH_INTERVAL); + } + + private _clearFlushTimeout(): void { + if (this._flushTimeout !== null) { + clearTimeout(this._flushTimeout); + this._flushTimeout = null; } } @@ -317,6 +358,7 @@ export class LoggerService { */ public clear(): void { this._buffer = []; + this._clearFlushTimeout(); const overlay = document.getElementById('debug-overlay'); if (overlay !== null) { overlay.innerHTML = ''; diff --git a/src/infrastructure/navigation/NavigationService.ts b/src/infrastructure/navigation/NavigationService.ts index 87a7f42f..0c42c4e9 100644 --- a/src/infrastructure/navigation/NavigationService.ts +++ b/src/infrastructure/navigation/NavigationService.ts @@ -38,7 +38,7 @@ export class NavigationService { this._historyStack.push(lastPage); this._currentIndex = this._historyStack.length - 1; this._trimHistoryStack(); - this._tracer.info(`[NavigationService] Restored last page: ${lastPage}`); + this._tracer.debug(`[NavigationService] Restored last page: ${lastPage}`); } } diff --git a/src/infrastructure/navigation/NavigationUI.ts b/src/infrastructure/navigation/NavigationUI.ts index 5391474d..f7674fd8 100644 --- a/src/infrastructure/navigation/NavigationUI.ts +++ b/src/infrastructure/navigation/NavigationUI.ts @@ -223,6 +223,11 @@ export class NavigationUI { return Promise.resolve(); } + public syncActiveNavigationButton(pageId: string): void { + const navBtns = document.querySelectorAll('.nav-btn'); + this._activateNavigationButton(navBtns, pageId, null); + } + private _bindWindowHandlers(): void { this._mouseDownHandler = (e: MouseEvent) => { this._handleMouseNavigation(e); diff --git a/src/infrastructure/tauri/TauriProvider.ts b/src/infrastructure/tauri/TauriProvider.ts index 4042d35e..c7cf4d7d 100644 --- a/src/infrastructure/tauri/TauriProvider.ts +++ b/src/infrastructure/tauri/TauriProvider.ts @@ -49,7 +49,7 @@ export class TauriProvider implements IBridge { // Priority Check: Try to call a safe, neutral command await this._performInvoke('get_health', {}); this._isTauriDetected = true; - this._tracer.info('[TauriProvider] IPC Handshake successful'); + this._tracer.debug('[TauriProvider] IPC Handshake successful'); } catch { this._isTauriDetected = false; this._tracer.warn('[TauriProvider] Handshake failed, operating in Mock mode'); diff --git a/src/shared/services/CatalogService.ts b/src/shared/services/CatalogService.ts index 5d6d6abc..5354e3e4 100644 --- a/src/shared/services/CatalogService.ts +++ b/src/shared/services/CatalogService.ts @@ -10,7 +10,7 @@ import type { LoggerService } from '@/infrastructure/logging/LoggerService'; import { FALLBACK_CONFIG } from '@/shared/config/catalog_fallback'; import type { CatalogLoadSnapshot, EngineDefinition } from './CatalogLoadSnapshot'; -type CatalogLogger = Pick; +type CatalogLogger = Pick; export class CatalogService { private readonly _appData: ICatalogData = { ai: [], services: [] }; @@ -213,7 +213,7 @@ export class CatalogService { if (discovered.length === 0) return; this._appData.services.push(...discovered); - this._tracer.info( + this._tracer.debug( `[CatalogService] Added ${String(discovered.length)} discovered integration(s).`, ); } diff --git a/src/shared/services/ErrorHandler.test.ts b/src/shared/services/ErrorHandler.test.ts index 1a67480f..63b6b056 100644 --- a/src/shared/services/ErrorHandler.test.ts +++ b/src/shared/services/ErrorHandler.test.ts @@ -6,7 +6,7 @@ import type { LoggerService } from '@/infrastructure/logging/LoggerService'; describe('ErrorHandler', () => { let errorHandler: ErrorHandler; let testEventBus: EventBus; - let tracer: Pick; + let tracer: Pick; const resetHandler = () => { errorHandler.destroy(); @@ -18,7 +18,7 @@ describe('ErrorHandler', () => { beforeEach(() => { testEventBus = new EventBus(); tracer = { - info: vi.fn(), + debug: vi.fn(), warn: vi.fn(), error: vi.fn(), }; diff --git a/src/shared/services/ErrorHandler.ts b/src/shared/services/ErrorHandler.ts index 4d7b4510..a4e30fe8 100644 --- a/src/shared/services/ErrorHandler.ts +++ b/src/shared/services/ErrorHandler.ts @@ -23,7 +23,7 @@ type ErrorCallback = (_error: IErrorInfo) => void; type ErrorHandlerDeps = { eventBus: EventBus; - tracer: Pick; + tracer: Pick; }; type UnhandledRejectionHandler = (event: PromiseRejectionEvent) => unknown; @@ -73,7 +73,7 @@ export class ErrorHandler { }; this._initialized = true; - this._deps.tracer.info('[ErrorHandler] Initialized'); + this._deps.tracer.debug('[ErrorHandler] Initialized'); } public destroy(): void { diff --git a/src/shared/services/StateManager.ts b/src/shared/services/StateManager.ts index 4e3efb8f..2d4827f9 100644 --- a/src/shared/services/StateManager.ts +++ b/src/shared/services/StateManager.ts @@ -14,7 +14,7 @@ import type { LoggerService } from '@/infrastructure/logging/LoggerService'; -type StateManagerLogger = Pick; +type StateManagerLogger = Pick; export interface StatePersistenceTarget { /** Human-readable name for logging */ @@ -47,7 +47,7 @@ export class StateManager { register(target: StatePersistenceTarget): void { if (this._isDestroyed) return; this._targets.set(target.name, target); - this._tracer.info(`[StateManager] Registered: ${target.name}`); + this._tracer.debug(`[StateManager] Registered: ${target.name}`); } /** @@ -125,7 +125,7 @@ export class StateManager { init(): void { document.addEventListener('visibilitychange', this._boundVisibilityChange); globalThis.addEventListener('beforeunload', this._boundBeforeUnload); - this._tracer.info('[StateManager] Global listeners registered'); + this._tracer.debug('[StateManager] Global listeners registered'); } /** diff --git a/src/shared/services/WindowService.test.ts b/src/shared/services/WindowService.test.ts index 67c7ac75..7bf07df1 100644 --- a/src/shared/services/WindowService.test.ts +++ b/src/shared/services/WindowService.test.ts @@ -18,7 +18,7 @@ describe('WindowService', () => { getResolutionZoom: ReturnType; setResolutionZoom: ReturnType; }; - let mockTracer: Pick; + let mockTracer: Pick; let service: WindowService; let mockRuntime: { addEventListener: ReturnType; @@ -57,6 +57,7 @@ describe('WindowService', () => { }; mockTracer = { + debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), diff --git a/src/shared/services/WindowService.ts b/src/shared/services/WindowService.ts index 007ab78a..bd3511fa 100644 --- a/src/shared/services/WindowService.ts +++ b/src/shared/services/WindowService.ts @@ -15,7 +15,7 @@ import { type WindowZoomSettingsStore, } from './WindowServiceZoom'; -type WindowServiceLogger = Pick; +type WindowServiceLogger = Pick; export interface IWindowBreakpoints { compact: number; @@ -177,7 +177,9 @@ export class WindowService { (await this._bridge.invoke('get_window_config')); // Update breakpoints from backend (placeholder/not used in UI yet) - this._tracer.info(`[WindowService] Loaded config: ${JSON.stringify(this._config)}`); + this._tracer.debug( + `[WindowService] Loaded config: ${JSON.stringify(this._config)}`, + ); // Use pre-loaded initialZoom or determine it const zoom = diff --git a/src/shared/services/WindowServicePolicy.ts b/src/shared/services/WindowServicePolicy.ts index 832b6e9c..a81ef79a 100644 --- a/src/shared/services/WindowServicePolicy.ts +++ b/src/shared/services/WindowServicePolicy.ts @@ -5,6 +5,7 @@ import type { IWindowPolicy } from './WindowService'; import type { WindowServiceZoom } from './WindowServiceZoom'; type WindowPolicyLogger = { + debug: (message: string) => void; info: (message: string) => void; error: (message: string) => void; }; @@ -59,7 +60,7 @@ export class WindowServicePolicy { const previousResolutionKey = this._lastResolutionKey; this._lastResolutionKey = currentResolutionKey; - this._deps.tracer.info( + this._deps.tracer.debug( `[WindowService] Resolution changed: ${previousResolutionKey} -> ${currentResolutionKey}`, ); void this.handleResolutionChange(); diff --git a/src/test/helpers/catalogTestUtils.ts b/src/test/helpers/catalogTestUtils.ts index 5a870178..07ea6690 100644 --- a/src/test/helpers/catalogTestUtils.ts +++ b/src/test/helpers/catalogTestUtils.ts @@ -43,7 +43,8 @@ export function createCatalogHarness(): { globalThis.dispatchEvent = vi.fn(); const mockBridge = createMockBridge() as unknown as MockCatalogBridge; - const tracer: Pick = { + const tracer: Pick = { + debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), From 53614b471313f366b5a89e908e394b361b70c5a1 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Wed, 29 Apr 2026 15:37:15 +0300 Subject: [PATCH 007/126] refactor: update runtime and chat integration flow --- src-tauri/resources/locales/en.json | 133 +- src-tauri/resources/locales/ru.json | 137 +- src-tauri/resources/locales/zh.json | 133 +- src-tauri/src/api/ai/mod.rs | 105 +- src-tauri/src/api/engine/mod.rs | 5 + src-tauri/src/api/system/logs.rs | 140 +- src-tauri/src/domain/ai/ai_dispatch.rs | 8 +- src-tauri/src/domain/ai/ai_service.rs | 4 +- src-tauri/src/domain/ai/image_cloud.rs | 47 + src-tauri/src/domain/ai/image_comfyui.rs | 598 ++++++ .../src/domain/ai/image_generation_state.rs | 8 + src-tauri/src/domain/ai/image_http.rs | 34 + src-tauri/src/domain/ai/image_local.rs | 354 ++++ src-tauri/src/domain/ai/image_payload.rs | 304 +++ src-tauri/src/domain/ai/image_response.rs | 204 ++ src-tauri/src/domain/ai/image_service.rs | 1633 +---------------- src-tauri/src/domain/ai/image_settings.rs | 169 ++ src-tauri/src/domain/ai/mod.rs | 10 + src-tauri/src/domain/ai/provider_http.rs | 42 + src-tauri/src/domain/ai/provider_payload.rs | 230 +++ src-tauri/src/domain/ai/provider_response.rs | 187 ++ src-tauri/src/domain/ai/streaming.rs | 538 +----- src-tauri/src/domain/engine/engine_args.rs | 71 +- src-tauri/src/domain/engine/engine_runtime.rs | 23 +- src-tauri/src/domain/engine/manager.rs | 227 ++- .../modules/github_release_selection.rs | 92 +- .../src/domain/modules/github_releases.rs | 78 +- src-tauri/src/domain/system/hardware_probe.rs | 26 +- .../infrastructure/config/engine_settings.rs | 6 +- src/app/events.ts | 15 +- src/features/ai/services/AIBridge.test.ts | 6 +- src/features/ai/services/AIBridge.ts | 35 +- .../AIBridgeMessageController.test.ts | 2 - .../ai/services/AIBridgeMessageController.ts | 85 +- .../services/AIBridgeProviderPolicy.test.ts | 20 - .../ai/services/AIBridgeProviderPolicy.ts | 26 - src/features/ai/types/IAIBridge.ts | 6 + src/features/chat/chat.ts | 7 +- .../controllers/ChatGenerationController.ts | 1 - .../controllers/ChatSendController.test.ts | 68 +- .../chat/controllers/ChatSendController.ts | 143 +- .../services/ChatActivationCoordinator.ts | 7 +- .../chat/services/ChatControllerFactory.ts | 10 +- src/features/chat/services/ChatService.ts | 12 +- .../chat/services/VoiceInputService.test.ts | 1 + .../chat/services/VoiceInputService.ts | 11 +- src/features/chat/ui/ChatImageController.ts | 170 +- .../chat/ui/ChatImageGenerationMessage.ts | 93 +- .../chat/ui/ChatTranslationRefresher.ts | 30 +- src/features/chat/ui/ChatUI.test.ts | 177 +- src/features/chat/ui/ChatUI.ts | 24 +- .../services/ConsoleLogService.test.ts | 657 +------ .../console/services/ConsoleLogService.ts | 454 ++--- .../console/ui/ConsoleFilterControlHelper.ts | 35 + .../console/ui/ConsoleRefreshCoordinator.ts | 5 +- src/features/console/ui/ConsoleUI.test.ts | 26 + src/features/console/ui/ConsoleUI.ts | 13 + .../ModuleSettingsEngineFieldCatalog.test.ts | 39 +- .../ui/ModuleSettingsEngineFieldCatalog.ts | 88 +- .../ui/ModuleSettingsEngineFieldController.ts | 13 +- .../ModuleSettingsEngineFieldRowRenderer.ts | 5 +- .../ui/ModuleSettingsEngineFieldSupport.ts | 432 +++-- .../ui/ModuleSettingsEngineHtmlBuilder.ts | 14 +- .../ui/ModuleSettingsEngineInfoPopover.ts | 24 +- .../ui/ModuleSettingsEngineRenderFlow.ts | 21 +- .../ui/ModuleSettingsEngineRenderer.test.ts | 117 +- .../ui/ModuleSettingsEngineRenderer.ts | 371 +++- src/features/settings/ui/SettingsUI.test.ts | 4 +- src/shared/services/ModulePlatformService.ts | 14 +- src/shared/shell/AppUI.test.ts | 26 +- src/shared/shell/AppUI.ts | 5 +- src/shared/shell/WindowUI.test.ts | 8 + .../shell/WindowUiInteractionController.ts | 10 + src/shared/shell/ui/AppUiDashboardSupport.ts | 6 + .../shell/ui/AppUiSelectionFlow.test.ts | 12 +- src/shared/shell/ui/AppUiSelectionFlow.ts | 15 +- .../shell/ui/ModalFocusTrapHelper.test.ts | 26 +- src/shared/shell/ui/ModalFocusTrapHelper.ts | 46 +- src/shared/shell/ui/ModalManager.test.ts | 22 +- src/shared/types/bindings.ts | 4 +- src/styles/base/document-reset.css | 5 +- src/styles/features/ai-module-settings.css | 584 ++++-- src/styles/features/chat-page.css | 376 +++- .../features/module-selection-modal.css | 6 +- 84 files changed, 5764 insertions(+), 4214 deletions(-) create mode 100644 src-tauri/src/domain/ai/image_cloud.rs create mode 100644 src-tauri/src/domain/ai/image_comfyui.rs create mode 100644 src-tauri/src/domain/ai/image_http.rs create mode 100644 src-tauri/src/domain/ai/image_local.rs create mode 100644 src-tauri/src/domain/ai/image_payload.rs create mode 100644 src-tauri/src/domain/ai/image_response.rs create mode 100644 src-tauri/src/domain/ai/image_settings.rs create mode 100644 src-tauri/src/domain/ai/provider_http.rs create mode 100644 src-tauri/src/domain/ai/provider_payload.rs create mode 100644 src-tauri/src/domain/ai/provider_response.rs diff --git a/src-tauri/resources/locales/en.json b/src-tauri/resources/locales/en.json index 88cba9aa..17b51a76 100644 --- a/src-tauri/resources/locales/en.json +++ b/src-tauri/resources/locales/en.json @@ -75,19 +75,19 @@ "ui.chat.image_open_folder_failed": "Failed to open image folder", "ui.chat.open_image_folder": "Open image folder", "ui.chat.close_image_preview": "Close image preview", + "ui.chat.previous_image": "Previous image", + "ui.chat.next_image": "Next image", "ui.chat.image_preview": "Image preview", "ui.chat.save_image": "Save Image", "ui.chat.image_generating": "Rendering image", "ui.chat.streaming_text": "Model is typing...", "ui.chat.thinking": "Thinking...", "ui.chat.image_ready": "Generated image", - "ui.chat.image_cancel": "Cancel", "ui.chat.image_cancelled": "Image generation cancelled", "ui.chat.regenerate_failed": "Failed to regenerate response", "ui.ai.communication_failure": "Communication failure", "ui.ai.no_api_key": "API key missing", "ui.ai.no_provider": "No AI module running. Please launch a module first.", - "ui.ai.performance_mode_active": "Performance mode active", "ui.ai.provider_activation_failed": "Provider activation failed", "ui.claude.model.46sonnet.desc": "Anthropic's most capable Sonnet-class model yet, with frontier performance across coding, agents, and professional work", "ui.claude.model.haiku.desc": "Anthropic's fastest and most efficient model with near-frontier quality for low-latency and high-volume workloads", @@ -232,7 +232,7 @@ "ui.launcher.web.home": "Home", "ui.launcher.web.home_title": "Main Menu", "ui.launcher.web.information": "Information", - "ui.launcher.web.logs_general": "General", + "ui.launcher.web.logs_general": "Platform", "ui.launcher.web.main_menu": "Main Menu", "ui.launcher.web.marketplace": "Market", "ui.connectivity.offline_title": "No internet connection", @@ -294,9 +294,6 @@ "ui.settings.engine.browse": "Browse", "ui.settings.engine.config_unavailable": "Engine config unavailable (Tauri not connected)", "ui.settings.engine.context_size": "Context Window", - "ui.settings.engine.compute_mode": "Compute Device", - "ui.settings.engine.compute_mode_cpu": "CPU", - "ui.settings.engine.compute_mode_gpu": "GPU", "ui.settings.engine.core_config": "Core Config", "ui.settings.engine.extra_args": "Extra Arguments", "ui.settings.engine.extra_args.add_all": "Add all", @@ -311,16 +308,20 @@ "ui.settings.engine.extra_args.recommended": "Recommended", "ui.settings.engine.extra_args.remove": "Remove", "ui.settings.engine.generation_presets": "Generation Presets", + "ui.settings.engine.generation_settings": "Generation Settings", "ui.settings.engine.group_batch": "Batch & Seed", "ui.settings.engine.group_sampling": "Sampling", "ui.settings.engine.group_size": "Image Size", "ui.settings.engine.model_not_selected": "Model not selected", + "ui.settings.engine.model_profiles": "Model Profiles", "ui.settings.engine.model_path": "Model Path (*.gguf, *.safetensors)", "ui.settings.engine.image_model_path": "Main Image Model (*.gguf, *.safetensors)", "ui.settings.engine.image_model_path_hint": "Main diffusion model file.", "ui.settings.engine.extra_args_hint": "Advanced startup flags appended to sd.cpp.", - "ui.settings.engine.performance_mode": "Performance Mode", - "ui.settings.engine.performance_mode_title": "Close launcher during generation", + "ui.settings.engine.profile_save": "Save Current", + "ui.settings.engine.profile_save_button": "Save", + "ui.settings.engine.profile_save_desc": "Store model, generation settings, and startup flags.", + "ui.settings.engine.profile_select_model_first": "Select a model first", "ui.settings.engine.thinking_level": "Thinking Level", "ui.settings.engine.thinking_level_desc": "Controls how much the model reasons before answering.", "ui.settings.engine.max_output_tokens": "Max Output Tokens", @@ -330,8 +331,10 @@ "ui.settings.engine.sd_clip_skip": "Clip Skip", "ui.settings.engine.sd_denoising_strength": "Denoising strength", "ui.settings.engine.sd_height": "Height (px)", - "ui.settings.engine.sd_negative_prompt": "Negative Prompt Prefix", - "ui.settings.engine.sd_positive_prompt": "Positive Prompt Prefix", + "ui.settings.engine.sd_negative_prompt": "Negative Prompt", + "ui.settings.engine.sd_negative_prompt_placeholder": "Things to avoid: blurry, low quality, watermark, distortion.", + "ui.settings.engine.sd_positive_prompt": "Positive Prompt", + "ui.settings.engine.sd_positive_prompt_placeholder": "Describe the image style, subject, lighting, and details.", "ui.settings.engine.sd_sampler": "Sampler", "ui.settings.engine.sd_scheduler": "Scheduler", "ui.settings.engine.sd_seed": "Seed", @@ -369,5 +372,113 @@ "ui.launcher.engine.comfyui.name": "ComfyUI", "ui.launcher.engine.comfyui.desc": "Node-based image workflow engine for maximum quality and control.", "ui.gpt.model.gpt55.desc": "Frontier model for complex professional workloads with stronger reasoning, higher reliability, and improved token efficiency", - "ui.gpt.model.gpt55pro.desc": "High-capability model optimized for deep reasoning and accuracy on complex, high-stakes workloads" + "ui.gpt.model.gpt55pro.desc": "High-capability model optimized for deep reasoning and accuracy on complex, high-stakes workloads", + "ui.settings.engine.sdcpp_flags.title": "Manual sd.cpp flags", + "ui.settings.engine.sdcpp_flags.subtitle": "Startup flags appended to sd-server.", + "ui.settings.engine.sdcpp_flag.threads_8": "Set worker thread count.", + "ui.settings.engine.sdcpp_flag.model_path": "Override the main model path.", + "ui.settings.engine.sdcpp_flag.diffusion_model_path": "Set diffusion model path.", + "ui.settings.engine.sdcpp_flag.vae_path": "Set VAE model path.", + "ui.settings.engine.sdcpp_flag.taesd_path": "Set TAESD model path.", + "ui.settings.engine.sdcpp_flag.control_net_path": "Set ControlNet model path.", + "ui.settings.engine.sdcpp_flag.embd_dir_path": "Load textual inversion embeddings.", + "ui.settings.engine.sdcpp_flag.stacked_id_embd_dir_path": "Load stacked ID embeddings.", + "ui.settings.engine.sdcpp_flag.input_id_images_dir_path": "Load input ID images.", + "ui.settings.engine.sdcpp_flag.lora_model_dir_path": "Directory containing LoRA models.", + "ui.settings.engine.sdcpp_flag.vae_decode_only": "Decode a latent image with VAE only.", + "ui.settings.engine.sdcpp_flag.vae_encode_only": "Encode an image into latent space.", + "ui.settings.engine.sdcpp_flag.control_image_path": "Image used by ControlNet.", + "ui.settings.engine.sdcpp_flag.output_path": "Set output image path.", + "ui.settings.engine.sdcpp_flag.output_video_path": "Set output video path.", + "ui.settings.engine.sdcpp_flag.init_img_path": "Use an initial image for img2img.", + "ui.settings.engine.sdcpp_flag.mask_path": "Use a mask image for inpainting.", + "ui.settings.engine.sdcpp_flag.ref_image_path": "Use a reference image.", + "ui.settings.engine.sdcpp_flag.clip_l_path": "Set CLIP-L model path.", + "ui.settings.engine.sdcpp_flag.clip_g_path": "Set CLIP-G model path.", + "ui.settings.engine.sdcpp_flag.clip_vision_path": "Set CLIP-Vision model path.", + "ui.settings.engine.sdcpp_flag.t5xxl_path": "Set T5-XXL model path.", + "ui.settings.engine.sdcpp_flag.llm_path": "Set LLM model path.", + "ui.settings.engine.sdcpp_flag.diffusion_fa": "Enable flash attention for diffusion.", + "ui.settings.engine.sdcpp_flag.fa": "Enable flash attention globally.", + "ui.settings.engine.sdcpp_flag.no_fallback": "Disable fallback execution paths.", + "ui.settings.engine.sdcpp_flag.mmap": "Memory-map model weights from disk.", + "ui.settings.engine.sdcpp_flag.no_mmap": "Disable memory mapping.", + "ui.settings.engine.sdcpp_flag.offload_to_cpu": "Offload model work to CPU.", + "ui.settings.engine.sdcpp_flag.clip_on_cpu": "Run CLIP on CPU.", + "ui.settings.engine.sdcpp_flag.vae_on_cpu": "Run VAE on CPU.", + "ui.settings.engine.sdcpp_flag.vae_tiling": "Use tiled VAE decoding to reduce VRAM usage.", + "ui.settings.engine.sdcpp_flag.free_params_immediately": "Free model params after load.", + "ui.settings.engine.sdcpp_flag.keep_clip_on_cpu": "Keep CLIP weights on CPU.", + "ui.settings.engine.sdcpp_flag.keep_control_net_cpu": "Keep ControlNet weights on CPU.", + "ui.settings.engine.sdcpp_flag.keep_vae_on_cpu": "Keep VAE weights on CPU.", + "ui.settings.engine.sdcpp_flag.control_net_cpu": "Run ControlNet on CPU when ControlNet is used.", + "ui.settings.engine.sdcpp_flag.canny": "Apply Canny preprocessing for ControlNet.", + "ui.settings.engine.sdcpp_flag.color": "Apply color preprocessing or colored output.", + "ui.settings.engine.sdcpp_flag.cpu_params": "Keep parameters in regular CPU memory.", + "ui.settings.engine.sdcpp_flag.normalize_input": "Normalize input image values.", + "ui.settings.engine.sdcpp_flag.upscale_model_path": "Set ESRGAN upscale model path.", + "ui.settings.engine.sdcpp_flag.upscale_repeats_2": "Repeat upscaling passes.", + "ui.settings.engine.sdcpp_flag.type_q8_0": "Set weight precision/type.", + "ui.settings.engine.sdcpp_flag.rng_cuda": "Prefer CUDA RNG on NVIDIA systems.", + "ui.settings.engine.sdcpp_flag.sampling_method_euler_a": "Set sampling method.", + "ui.settings.engine.sdcpp_flag.schedule_karras": "Set scheduler.", + "ui.settings.engine.sdcpp_flag.prediction_v": "Set prediction mode.", + "ui.settings.engine.sdcpp_flag.clip_skip_2": "Skip final CLIP layers.", + "ui.settings.engine.sdcpp_flag.cfg_scale_7": "Set classifier-free guidance scale.", + "ui.settings.engine.sdcpp_flag.guidance_3_5": "Set guidance scale.", + "ui.settings.engine.sdcpp_flag.eta_0": "Set DDIM eta.", + "ui.settings.engine.sdcpp_flag.steps_30": "Set default sampling steps.", + "ui.settings.engine.sdcpp_flag.strength_0_75": "Set img2img denoise strength.", + "ui.settings.engine.sdcpp_flag.pm_style_strength_20": "Set PhotoMaker style strength.", + "ui.settings.engine.sdcpp_flag.control_strength_0_9": "Set ControlNet strength.", + "ui.settings.engine.sdcpp_flag.width_1024": "Set default output width.", + "ui.settings.engine.sdcpp_flag.height_1024": "Set default output height.", + "ui.settings.engine.sdcpp_flag.batch_count_1": "Set number of batches.", + "ui.settings.engine.sdcpp_flag.video_frames_16": "Set generated video frame count.", + "ui.settings.engine.sdcpp_flag.fps_24": "Set generated video FPS.", + "ui.settings.engine.sdcpp_flag.motion_bucket_id_127": "Set SVD motion bucket.", + "ui.settings.engine.sdcpp_flag.augmentation_level_0": "Set SVD augmentation level.", + "ui.settings.engine.sdcpp_flag.sample_start_0": "Set sample start value.", + "ui.settings.engine.sdcpp_flag.sample_end_1": "Set sample end value.", + "ui.settings.engine.sdcpp_flag.slg_scale_0": "Set skip-layer guidance scale.", + "ui.settings.engine.sdcpp_flag.skip_layers_7_8_9": "Set skip-layer guidance layers.", + "ui.settings.engine.sdcpp_flag.skip_layer_start_0_01": "Set skip-layer start ratio.", + "ui.settings.engine.sdcpp_flag.skip_layer_end_0_2": "Set skip-layer end ratio.", + "ui.settings.engine.sdcpp_flag.seed_42": "Set generation seed.", + "ui.settings.engine.sdcpp_flag.negative_prompt_text": "Set default negative prompt.", + "ui.settings.engine.sdcpp_flag.prompt_text": "Set default positive prompt.", + "ui.settings.engine.sdcpp_flag.cfg_negative_prompt_text": "Set CFG negative prompt.", + "ui.settings.engine.sdcpp_flag.vae_tile_size_32x32": "Set VAE tile size.", + "ui.settings.engine.sdcpp_flag.vae_tile_overlap_0_5": "Set VAE tile overlap.", + "ui.settings.engine.sdcpp_flag.vae_relative_tile_size_0_5x0_5": "Set relative VAE tile size.", + "ui.settings.engine.sdcpp_flag.clip_g_layers_0": "Set CLIP-G layer count.", + "ui.settings.engine.sdcpp_flag.clip_l_layers_0": "Set CLIP-L layer count.", + "ui.settings.engine.sdcpp_flag.t5xxl_layers_0": "Set T5-XXL layer count.", + "ui.settings.engine.sdcpp_flag.diffusion_model_layers_0": "Set diffusion layer count.", + "ui.settings.engine.sdcpp_flag.vae_layers_0": "Set VAE layer count.", + "ui.settings.engine.sdcpp_flag.verbose": "Enable verbose logging.", + "ui.settings.engine.sdcpp_flag.quiet": "Reduce logging output.", + "ui.settings.engine.sdcpp_flag.preview_none": "Disable preview image output.", + "ui.settings.engine.sdcpp_flag.preview_path_path": "Set preview image path.", + "ui.settings.engine.sdcpp_flag.diffusion_on_cpu": "Run diffusion model on CPU.", + "ui.settings.engine.sdcpp_flag.vae_on_gpu": "Run VAE on GPU when possible.", + "ui.settings.engine.sdcpp_flag.clip_on_gpu": "Run CLIP on GPU when possible.", + "ui.settings.engine.sdcpp_flag.control_net_on_gpu": "Run ControlNet on GPU.", + "ui.settings.engine.sdcpp_flag.chroma_disable_ds": "Disable Chroma downsampling.", + "ui.settings.engine.sdcpp_flag.chroma_enable_t5_mask": "Enable Chroma T5 mask.", + "ui.settings.engine.sdcpp_flag.chroma_t5_mask_pad_1": "Set Chroma T5 mask padding.", + "ui.settings.engine.sdcpp_flag.flow_shift_3": "Set flow shift value.", + "ui.settings.engine.sdcpp_flag.timestep_shift_250": "Set shifted timestep value.", + "ui.settings.engine.sdcpp_flag.diffusion_cpu_params": "Keep diffusion params on CPU.", + "ui.settings.engine.sdcpp_flag.vae_cpu_params": "Keep VAE params on CPU.", + "ui.settings.engine.sdcpp_flag.clip_cpu_params": "Keep CLIP params on CPU.", + "ui.settings.engine.sdcpp_flag.control_net_cpu_params": "Keep ControlNet params on CPU.", + "ui.settings.engine.sdcpp_flag.rng_std_default": "Use standard RNG.", + "ui.settings.engine.sdcpp_flag.sampler_rng_cuda": "Use CUDA RNG specifically for the sampler.", + "ui.settings.engine.sdcpp_flag.load_id_weights_path": "Load ID weights file.", + "ui.settings.engine.sdcpp_flag.photo_maker_path": "Set PhotoMaker model path.", + "ui.settings.engine.sdcpp_flag.photo_maker_vae_path": "Set PhotoMaker VAE path.", + "ui.settings.engine.sdcpp_flag.style_strength_20": "Set PhotoMaker style strength.", + "ui.settings.engine.sdcpp_flag.taesd_decode": "Use TAESD decoder.", + "ui.settings.engine.sdcpp_flag.taesd_encode": "Use TAESD encoder." } diff --git a/src-tauri/resources/locales/ru.json b/src-tauri/resources/locales/ru.json index ee305eef..d0c9fafd 100644 --- a/src-tauri/resources/locales/ru.json +++ b/src-tauri/resources/locales/ru.json @@ -64,7 +64,7 @@ "ui.chat.context_used": "использовано", "ui.chat.context_remaining": "осталось", "ui.chat.context_unknown": "неизвестно", - "ui.chat.error.local_model_memory": "Недостаточно памяти для запуска локальной модели. Уменьшите размер контекста или число GPU layers, либо выберите модель поменьше.", + "ui.chat.error.local_model_memory": "Недостаточно памяти для запуска локальной модели. Уменьшите размер контекста, переключите режим вычислений или выберите модель поменьше.", "ui.chat.error.local_model_system_memory": "Недостаточно системной памяти для запуска локальной модели. Закройте лишние приложения или выберите модель поменьше.", "ui.chat.error.image_vram": "Недостаточно видеопамяти для генерации изображения. Уменьшите размер изображения, steps или batch size, либо выберите модель поменьше.", "ui.chat.error.local_image_engine_connection": "Локальный движок изображений остановился или закрыл соединение во время генерации. Перезапустите движок и уменьшите размер изображения, steps или batch size, если ошибка повторится.", @@ -75,19 +75,19 @@ "ui.chat.image_open_folder_failed": "Не удалось открыть папку с изображением", "ui.chat.open_image_folder": "Открыть папку с изображением", "ui.chat.close_image_preview": "Закрыть просмотр изображения", + "ui.chat.previous_image": "Предыдущее изображение", + "ui.chat.next_image": "Следующее изображение", "ui.chat.image_preview": "Просмотр изображения", "ui.chat.save_image": "Сохранить изображение", - "ui.chat.image_generating": "Рендеринг изображения", + "ui.chat.image_generating": "Создание изображения", "ui.chat.streaming_text": "Модель печатает...", "ui.chat.thinking": "Думает...", "ui.chat.image_ready": "Изображение готово", - "ui.chat.image_cancel": "Отменить", "ui.chat.image_cancelled": "Генерация изображения отменена", "ui.chat.regenerate_failed": "Не удалось повторить ответ", "ui.ai.communication_failure": "Ошибка соединения", "ui.ai.no_api_key": "API ключ отсутствует", "ui.ai.no_provider": "Нет активного AI-модуля. Сначала выберите и запустите модуль.", - "ui.ai.performance_mode_active": "Режим производительности активен", "ui.ai.provider_activation_failed": "Не удалось активировать провайдер", "ui.claude.model.46sonnet.desc": "Самая сильная Sonnet-модель Anthropic с фронтирной производительностью для кодинга, агентов и профессиональной работы", "ui.claude.model.haiku.desc": "Самая быстрая и экономичная модель Anthropic с near-frontier качеством для задач с низкой задержкой и большим объемом запросов", @@ -233,7 +233,7 @@ "ui.launcher.web.home": "Главное меню", "ui.launcher.web.home_title": "Главное меню", "ui.launcher.web.information": "Информация", - "ui.launcher.web.logs_general": "Общие", + "ui.launcher.web.logs_general": "Платформа", "ui.launcher.web.main_menu": "Главное меню", "ui.launcher.web.marketplace": "Маркет", "ui.connectivity.offline_title": "Нет подключения к интернету", @@ -295,9 +295,6 @@ "ui.settings.engine.browse": "Обзор", "ui.settings.engine.config_unavailable": "Конфигурация движка недоступна (Tauri не подключен)", "ui.settings.engine.context_size": "Размер контекстного окна", - "ui.settings.engine.compute_mode": "Устройство вычислений", - "ui.settings.engine.compute_mode_cpu": "Процессор", - "ui.settings.engine.compute_mode_gpu": "Видеокарта", "ui.settings.engine.core_config": "Основная конфигурация", "ui.settings.engine.extra_args": "Дополнительные аргументы", "ui.settings.engine.extra_args.add_all": "Добавить все", @@ -312,16 +309,20 @@ "ui.settings.engine.extra_args.recommended": "Рекомендуемые", "ui.settings.engine.extra_args.remove": "Удалить", "ui.settings.engine.generation_presets": "Пресеты генерации", + "ui.settings.engine.generation_settings": "Настройки генерации", "ui.settings.engine.group_batch": "Пакет и Seed", "ui.settings.engine.group_sampling": "Сэмплинг", "ui.settings.engine.group_size": "Размер изображения", "ui.settings.engine.model_not_selected": "Модель не выбрана", + "ui.settings.engine.model_profiles": "Профили моделей", "ui.settings.engine.model_path": "Путь к модели (*.gguf, *.safetensors)", "ui.settings.engine.image_model_path": "Основная модель изображения (*.gguf, *.safetensors)", "ui.settings.engine.image_model_path_hint": "Основной файл diffusion-модели.", "ui.settings.engine.extra_args_hint": "Продвинутые флаги запуска для sd.cpp.", - "ui.settings.engine.performance_mode": "Режим производительности", - "ui.settings.engine.performance_mode_title": "Закрывать лаунчер во время генерации", + "ui.settings.engine.profile_save": "Сохранить текущую", + "ui.settings.engine.profile_save_button": "Сохранить", + "ui.settings.engine.profile_save_desc": "Сохранить модель, настройки генерации и флаги запуска.", + "ui.settings.engine.profile_select_model_first": "Сначала выберите модель", "ui.settings.engine.thinking_level": "Уровень размышления", "ui.settings.engine.thinking_level_desc": "Управляет тем, насколько глубоко модель размышляет перед ответом.", "ui.settings.engine.max_output_tokens": "Максимум токенов ответа", @@ -331,8 +332,10 @@ "ui.settings.engine.sd_clip_skip": "Clip Skip", "ui.settings.engine.sd_denoising_strength": "Сила денойзинга", "ui.settings.engine.sd_height": "Высота (px)", - "ui.settings.engine.sd_negative_prompt": "Префикс негативного промпта", - "ui.settings.engine.sd_positive_prompt": "Префикс позитивного промпта", + "ui.settings.engine.sd_negative_prompt": "Негативный промпт", + "ui.settings.engine.sd_negative_prompt_placeholder": "Что исключить: мыло, низкое качество, водяные знаки, искажения.", + "ui.settings.engine.sd_positive_prompt": "Позитивный промпт", + "ui.settings.engine.sd_positive_prompt_placeholder": "Опишите стиль, объект, свет и детали изображения.", "ui.settings.engine.sd_sampler": "Сэмплер", "ui.settings.engine.sd_scheduler": "Планировщик", "ui.settings.engine.sd_seed": "Seed", @@ -370,5 +373,113 @@ "ui.launcher.engine.comfyui.name": "ComfyUI", "ui.launcher.engine.comfyui.desc": "Нодовый движок workflow для изображений с упором на максимальное качество и контроль.", "ui.gpt.model.gpt55.desc": "Фронтирная модель для сложных профессиональных задач с более сильным reasoning, высокой надёжностью и лучшей токен-эффективностью", - "ui.gpt.model.gpt55pro.desc": "Модель повышенной мощности для глубокого reasoning и точности в сложных high-stakes задачах" + "ui.gpt.model.gpt55pro.desc": "Модель повышенной мощности для глубокого reasoning и точности в сложных high-stakes задачах", + "ui.settings.engine.sdcpp_flags.title": "Ручные флаги sd.cpp", + "ui.settings.engine.sdcpp_flags.subtitle": "Параметры запуска, которые добавляются к sd-server.", + "ui.settings.engine.sdcpp_flag.threads_8": "Задаёт количество рабочих потоков.", + "ui.settings.engine.sdcpp_flag.model_path": "Переопределяет путь основной модели.", + "ui.settings.engine.sdcpp_flag.diffusion_model_path": "Задаёт путь diffusion-модели.", + "ui.settings.engine.sdcpp_flag.vae_path": "Задаёт путь модели VAE.", + "ui.settings.engine.sdcpp_flag.taesd_path": "Задаёт путь модели TAESD.", + "ui.settings.engine.sdcpp_flag.control_net_path": "Задаёт путь модели ControlNet.", + "ui.settings.engine.sdcpp_flag.embd_dir_path": "Загружает textual inversion embeddings.", + "ui.settings.engine.sdcpp_flag.stacked_id_embd_dir_path": "Загружает stacked ID embeddings.", + "ui.settings.engine.sdcpp_flag.input_id_images_dir_path": "Загружает входные ID-изображения.", + "ui.settings.engine.sdcpp_flag.lora_model_dir_path": "Папка с моделями LoRA.", + "ui.settings.engine.sdcpp_flag.vae_decode_only": "Только декодирует latent-изображение через VAE.", + "ui.settings.engine.sdcpp_flag.vae_encode_only": "Кодирует изображение в latent-пространство.", + "ui.settings.engine.sdcpp_flag.control_image_path": "Изображение для ControlNet.", + "ui.settings.engine.sdcpp_flag.output_path": "Задаёт путь выходного изображения.", + "ui.settings.engine.sdcpp_flag.output_video_path": "Задаёт путь выходного видео.", + "ui.settings.engine.sdcpp_flag.init_img_path": "Использует начальное изображение для img2img.", + "ui.settings.engine.sdcpp_flag.mask_path": "Использует маску для inpainting.", + "ui.settings.engine.sdcpp_flag.ref_image_path": "Использует референсное изображение.", + "ui.settings.engine.sdcpp_flag.clip_l_path": "Задаёт путь модели CLIP-L.", + "ui.settings.engine.sdcpp_flag.clip_g_path": "Задаёт путь модели CLIP-G.", + "ui.settings.engine.sdcpp_flag.clip_vision_path": "Задаёт путь модели CLIP-Vision.", + "ui.settings.engine.sdcpp_flag.t5xxl_path": "Задаёт путь модели T5-XXL.", + "ui.settings.engine.sdcpp_flag.llm_path": "Задаёт путь LLM-модели.", + "ui.settings.engine.sdcpp_flag.diffusion_fa": "Включает flash attention для diffusion.", + "ui.settings.engine.sdcpp_flag.fa": "Включает flash attention глобально.", + "ui.settings.engine.sdcpp_flag.no_fallback": "Отключает fallback-пути выполнения.", + "ui.settings.engine.sdcpp_flag.mmap": "Использует mmap для весов модели с диска.", + "ui.settings.engine.sdcpp_flag.no_mmap": "Отключает memory mapping.", + "ui.settings.engine.sdcpp_flag.offload_to_cpu": "Переносит часть работы модели на CPU.", + "ui.settings.engine.sdcpp_flag.clip_on_cpu": "Запускает CLIP на CPU.", + "ui.settings.engine.sdcpp_flag.vae_on_cpu": "Запускает VAE на CPU.", + "ui.settings.engine.sdcpp_flag.vae_tiling": "Включает tiled VAE decoding для экономии VRAM.", + "ui.settings.engine.sdcpp_flag.free_params_immediately": "Освобождает параметры модели после загрузки.", + "ui.settings.engine.sdcpp_flag.keep_clip_on_cpu": "Оставляет веса CLIP на CPU.", + "ui.settings.engine.sdcpp_flag.keep_control_net_cpu": "Оставляет веса ControlNet на CPU.", + "ui.settings.engine.sdcpp_flag.keep_vae_on_cpu": "Оставляет веса VAE на CPU.", + "ui.settings.engine.sdcpp_flag.control_net_cpu": "Запускает ControlNet на CPU при использовании ControlNet.", + "ui.settings.engine.sdcpp_flag.canny": "Применяет Canny preprocessing для ControlNet.", + "ui.settings.engine.sdcpp_flag.color": "Включает color preprocessing или цветной вывод.", + "ui.settings.engine.sdcpp_flag.cpu_params": "Держит параметры в обычной CPU-памяти.", + "ui.settings.engine.sdcpp_flag.normalize_input": "Нормализует значения входного изображения.", + "ui.settings.engine.sdcpp_flag.upscale_model_path": "Задаёт путь ESRGAN upscale-модели.", + "ui.settings.engine.sdcpp_flag.upscale_repeats_2": "Повторяет проходы апскейла.", + "ui.settings.engine.sdcpp_flag.type_q8_0": "Задаёт тип/точность весов.", + "ui.settings.engine.sdcpp_flag.rng_cuda": "Использует CUDA RNG на NVIDIA.", + "ui.settings.engine.sdcpp_flag.sampling_method_euler_a": "Задаёт метод сэмплинга.", + "ui.settings.engine.sdcpp_flag.schedule_karras": "Задаёт scheduler.", + "ui.settings.engine.sdcpp_flag.prediction_v": "Задаёт режим prediction.", + "ui.settings.engine.sdcpp_flag.clip_skip_2": "Пропускает последние слои CLIP.", + "ui.settings.engine.sdcpp_flag.cfg_scale_7": "Задаёт CFG scale.", + "ui.settings.engine.sdcpp_flag.guidance_3_5": "Задаёт guidance scale.", + "ui.settings.engine.sdcpp_flag.eta_0": "Задаёт DDIM eta.", + "ui.settings.engine.sdcpp_flag.steps_30": "Задаёт число шагов сэмплинга по умолчанию.", + "ui.settings.engine.sdcpp_flag.strength_0_75": "Задаёт силу denoise для img2img.", + "ui.settings.engine.sdcpp_flag.pm_style_strength_20": "Задаёт силу стиля PhotoMaker.", + "ui.settings.engine.sdcpp_flag.control_strength_0_9": "Задаёт силу ControlNet.", + "ui.settings.engine.sdcpp_flag.width_1024": "Задаёт ширину вывода по умолчанию.", + "ui.settings.engine.sdcpp_flag.height_1024": "Задаёт высоту вывода по умолчанию.", + "ui.settings.engine.sdcpp_flag.batch_count_1": "Задаёт количество batch-ов.", + "ui.settings.engine.sdcpp_flag.video_frames_16": "Задаёт число кадров видео.", + "ui.settings.engine.sdcpp_flag.fps_24": "Задаёт FPS видео.", + "ui.settings.engine.sdcpp_flag.motion_bucket_id_127": "Задаёт SVD motion bucket.", + "ui.settings.engine.sdcpp_flag.augmentation_level_0": "Задаёт уровень SVD augmentation.", + "ui.settings.engine.sdcpp_flag.sample_start_0": "Задаёт начальное значение sample.", + "ui.settings.engine.sdcpp_flag.sample_end_1": "Задаёт конечное значение sample.", + "ui.settings.engine.sdcpp_flag.slg_scale_0": "Задаёт skip-layer guidance scale.", + "ui.settings.engine.sdcpp_flag.skip_layers_7_8_9": "Задаёт слои skip-layer guidance.", + "ui.settings.engine.sdcpp_flag.skip_layer_start_0_01": "Задаёт старт skip-layer ratio.", + "ui.settings.engine.sdcpp_flag.skip_layer_end_0_2": "Задаёт конец skip-layer ratio.", + "ui.settings.engine.sdcpp_flag.seed_42": "Задаёт seed генерации.", + "ui.settings.engine.sdcpp_flag.negative_prompt_text": "Задаёт негативный промпт по умолчанию.", + "ui.settings.engine.sdcpp_flag.prompt_text": "Задаёт позитивный промпт по умолчанию.", + "ui.settings.engine.sdcpp_flag.cfg_negative_prompt_text": "Задаёт CFG negative prompt.", + "ui.settings.engine.sdcpp_flag.vae_tile_size_32x32": "Задаёт размер VAE tile.", + "ui.settings.engine.sdcpp_flag.vae_tile_overlap_0_5": "Задаёт перекрытие VAE tile.", + "ui.settings.engine.sdcpp_flag.vae_relative_tile_size_0_5x0_5": "Задаёт относительный размер VAE tile.", + "ui.settings.engine.sdcpp_flag.clip_g_layers_0": "Задаёт число слоёв CLIP-G.", + "ui.settings.engine.sdcpp_flag.clip_l_layers_0": "Задаёт число слоёв CLIP-L.", + "ui.settings.engine.sdcpp_flag.t5xxl_layers_0": "Задаёт число слоёв T5-XXL.", + "ui.settings.engine.sdcpp_flag.diffusion_model_layers_0": "Задаёт число diffusion-слоёв.", + "ui.settings.engine.sdcpp_flag.vae_layers_0": "Задаёт число VAE-слоёв.", + "ui.settings.engine.sdcpp_flag.verbose": "Включает подробные логи.", + "ui.settings.engine.sdcpp_flag.quiet": "Уменьшает объём логов.", + "ui.settings.engine.sdcpp_flag.preview_none": "Отключает preview-изображение.", + "ui.settings.engine.sdcpp_flag.preview_path_path": "Задаёт путь preview-изображения.", + "ui.settings.engine.sdcpp_flag.diffusion_on_cpu": "Запускает diffusion-модель на CPU.", + "ui.settings.engine.sdcpp_flag.vae_on_gpu": "Запускает VAE на GPU, если возможно.", + "ui.settings.engine.sdcpp_flag.clip_on_gpu": "Запускает CLIP на GPU, если возможно.", + "ui.settings.engine.sdcpp_flag.control_net_on_gpu": "Запускает ControlNet на GPU.", + "ui.settings.engine.sdcpp_flag.chroma_disable_ds": "Отключает Chroma downsampling.", + "ui.settings.engine.sdcpp_flag.chroma_enable_t5_mask": "Включает Chroma T5 mask.", + "ui.settings.engine.sdcpp_flag.chroma_t5_mask_pad_1": "Задаёт padding для Chroma T5 mask.", + "ui.settings.engine.sdcpp_flag.flow_shift_3": "Задаёт flow shift.", + "ui.settings.engine.sdcpp_flag.timestep_shift_250": "Задаёт смещение timestep.", + "ui.settings.engine.sdcpp_flag.diffusion_cpu_params": "Держит diffusion params на CPU.", + "ui.settings.engine.sdcpp_flag.vae_cpu_params": "Держит VAE params на CPU.", + "ui.settings.engine.sdcpp_flag.clip_cpu_params": "Держит CLIP params на CPU.", + "ui.settings.engine.sdcpp_flag.control_net_cpu_params": "Держит ControlNet params на CPU.", + "ui.settings.engine.sdcpp_flag.rng_std_default": "Использует стандартный RNG.", + "ui.settings.engine.sdcpp_flag.sampler_rng_cuda": "Использует CUDA RNG именно для sampler.", + "ui.settings.engine.sdcpp_flag.load_id_weights_path": "Загружает файл ID weights.", + "ui.settings.engine.sdcpp_flag.photo_maker_path": "Задаёт путь модели PhotoMaker.", + "ui.settings.engine.sdcpp_flag.photo_maker_vae_path": "Задаёт путь VAE для PhotoMaker.", + "ui.settings.engine.sdcpp_flag.style_strength_20": "Задаёт силу стиля PhotoMaker.", + "ui.settings.engine.sdcpp_flag.taesd_decode": "Использует TAESD decoder.", + "ui.settings.engine.sdcpp_flag.taesd_encode": "Использует TAESD encoder." } diff --git a/src-tauri/resources/locales/zh.json b/src-tauri/resources/locales/zh.json index 4e98ddb5..16586627 100644 --- a/src-tauri/resources/locales/zh.json +++ b/src-tauri/resources/locales/zh.json @@ -75,19 +75,19 @@ "ui.chat.image_open_folder_failed": "无法打开图片文件夹", "ui.chat.open_image_folder": "打开图片文件夹", "ui.chat.close_image_preview": "关闭图片预览", + "ui.chat.previous_image": "上一张图片", + "ui.chat.next_image": "下一张图片", "ui.chat.image_preview": "图片预览", "ui.chat.save_image": "保存图片", "ui.chat.image_generating": "正在渲染图片", "ui.chat.streaming_text": "模型正在输入...", "ui.chat.thinking": "正在思考...", "ui.chat.image_ready": "图片已生成", - "ui.chat.image_cancel": "取消", "ui.chat.image_cancelled": "图片生成已取消", "ui.chat.regenerate_failed": "重新生成回复失败", "ui.ai.communication_failure": "通信失败", "ui.ai.no_api_key": "缺少 API 密钥", "ui.ai.no_provider": "当前没有运行中的 AI 模块。请先选择并启动一个模块。", - "ui.ai.performance_mode_active": "性能模式已启用", "ui.ai.provider_activation_failed": "提供商激活失败", "ui.claude.model.46sonnet.desc": "Anthropic 最强的 Sonnet 系列模型之一,适合编码、代理与专业工作场景", "ui.claude.model.haiku.desc": "Anthropic 速度最快、成本最低的模型之一,适合低延迟与高吞吐场景并保持接近前沿的能力", @@ -229,7 +229,7 @@ "ui.launcher.web.home": "首页", "ui.launcher.web.home_title": "主菜单", "ui.launcher.web.information": "信息", - "ui.launcher.web.logs_general": "常规", + "ui.launcher.web.logs_general": "平台", "ui.launcher.web.main_menu": "主菜单", "ui.launcher.web.marketplace": "市场", "ui.connectivity.offline_title": "网络连接不可用", @@ -291,9 +291,6 @@ "ui.settings.engine.browse": "浏览", "ui.settings.engine.config_unavailable": "引擎配置不可用(Tauri 未连接)", "ui.settings.engine.context_size": "上下文窗口", - "ui.settings.engine.compute_mode": "计算设备", - "ui.settings.engine.compute_mode_cpu": "CPU", - "ui.settings.engine.compute_mode_gpu": "GPU", "ui.settings.engine.core_config": "核心配置", "ui.settings.engine.extra_args": "附加参数", "ui.settings.engine.extra_args.add_all": "全部添加", @@ -308,16 +305,20 @@ "ui.settings.engine.extra_args.recommended": "推荐", "ui.settings.engine.extra_args.remove": "移除", "ui.settings.engine.generation_presets": "生成预设", + "ui.settings.engine.generation_settings": "生成设置", "ui.settings.engine.group_batch": "批量与种子", "ui.settings.engine.group_sampling": "采样", "ui.settings.engine.group_size": "图像尺寸", "ui.settings.engine.model_not_selected": "未选择模型", + "ui.settings.engine.model_profiles": "模型配置", "ui.settings.engine.model_path": "模型路径 (*.gguf, *.safetensors)", "ui.settings.engine.image_model_path": "主图像模型路径 (*.gguf, *.safetensors)", "ui.settings.engine.image_model_path_hint": "主 diffusion 模型文件。", "ui.settings.engine.extra_args_hint": "传给 sd.cpp 的高级启动参数。", - "ui.settings.engine.performance_mode": "性能模式", - "ui.settings.engine.performance_mode_title": "生成期间关闭启动器", + "ui.settings.engine.profile_save": "保存当前", + "ui.settings.engine.profile_save_button": "保存", + "ui.settings.engine.profile_save_desc": "保存模型、生成设置和启动参数。", + "ui.settings.engine.profile_select_model_first": "请先选择模型", "ui.settings.engine.thinking_level": "思考强度", "ui.settings.engine.thinking_level_desc": "控制模型在回答前进行多少推理。", "ui.settings.engine.max_output_tokens": "最大输出 Token", @@ -327,8 +328,10 @@ "ui.settings.engine.sd_clip_skip": "Clip Skip", "ui.settings.engine.sd_denoising_strength": "去噪强度", "ui.settings.engine.sd_height": "高度 (px)", - "ui.settings.engine.sd_negative_prompt": "负向提示词前缀", - "ui.settings.engine.sd_positive_prompt": "正向提示词前缀", + "ui.settings.engine.sd_negative_prompt": "负向提示词", + "ui.settings.engine.sd_negative_prompt_placeholder": "要避免的内容:模糊、低质量、水印、变形。", + "ui.settings.engine.sd_positive_prompt": "正向提示词", + "ui.settings.engine.sd_positive_prompt_placeholder": "描述图像风格、主体、光照和细节。", "ui.settings.engine.sd_sampler": "采样器", "ui.settings.engine.sd_scheduler": "调度器", "ui.settings.engine.sd_seed": "随机种子", @@ -366,5 +369,113 @@ "ui.launcher.engine.comfyui.name": "ComfyUI", "ui.launcher.engine.comfyui.desc": "面向最高画质与控制力的节点式图像工作流引擎。", "ui.gpt.model.gpt55.desc": "面向复杂专业工作负载的前沿模型,具备更强推理、更高可靠性和更好的高难任务 token 效率", - "ui.gpt.model.gpt55pro.desc": "面向复杂高风险工作负载的高能力模型,优化深度推理与准确性" + "ui.gpt.model.gpt55pro.desc": "面向复杂高风险工作负载的高能力模型,优化深度推理与准确性", + "ui.settings.engine.sdcpp_flags.title": "手动 sd.cpp 参数", + "ui.settings.engine.sdcpp_flags.subtitle": "追加到 sd-server 的启动参数。", + "ui.settings.engine.sdcpp_flag.threads_8": "设置工作线程数量。", + "ui.settings.engine.sdcpp_flag.model_path": "覆盖主模型路径。", + "ui.settings.engine.sdcpp_flag.diffusion_model_path": "设置 diffusion 模型路径。", + "ui.settings.engine.sdcpp_flag.vae_path": "设置 VAE 模型路径。", + "ui.settings.engine.sdcpp_flag.taesd_path": "设置 TAESD 模型路径。", + "ui.settings.engine.sdcpp_flag.control_net_path": "设置 ControlNet 模型路径。", + "ui.settings.engine.sdcpp_flag.embd_dir_path": "加载 textual inversion 嵌入。", + "ui.settings.engine.sdcpp_flag.stacked_id_embd_dir_path": "加载 stacked ID 嵌入。", + "ui.settings.engine.sdcpp_flag.input_id_images_dir_path": "加载输入 ID 图像。", + "ui.settings.engine.sdcpp_flag.lora_model_dir_path": "包含 LoRA 模型的目录。", + "ui.settings.engine.sdcpp_flag.vae_decode_only": "仅用 VAE 解码 latent 图像。", + "ui.settings.engine.sdcpp_flag.vae_encode_only": "将图像编码到 latent 空间。", + "ui.settings.engine.sdcpp_flag.control_image_path": "ControlNet 使用的图像。", + "ui.settings.engine.sdcpp_flag.output_path": "设置输出图像路径。", + "ui.settings.engine.sdcpp_flag.output_video_path": "设置输出视频路径。", + "ui.settings.engine.sdcpp_flag.init_img_path": "为 img2img 使用初始图像。", + "ui.settings.engine.sdcpp_flag.mask_path": "为 inpainting 使用蒙版图像。", + "ui.settings.engine.sdcpp_flag.ref_image_path": "使用参考图像。", + "ui.settings.engine.sdcpp_flag.clip_l_path": "设置 CLIP-L 模型路径。", + "ui.settings.engine.sdcpp_flag.clip_g_path": "设置 CLIP-G 模型路径。", + "ui.settings.engine.sdcpp_flag.clip_vision_path": "设置 CLIP-Vision 模型路径。", + "ui.settings.engine.sdcpp_flag.t5xxl_path": "设置 T5-XXL 模型路径。", + "ui.settings.engine.sdcpp_flag.llm_path": "设置 LLM 模型路径。", + "ui.settings.engine.sdcpp_flag.diffusion_fa": "为 diffusion 启用 flash attention。", + "ui.settings.engine.sdcpp_flag.fa": "全局启用 flash attention。", + "ui.settings.engine.sdcpp_flag.no_fallback": "禁用 fallback 执行路径。", + "ui.settings.engine.sdcpp_flag.mmap": "从磁盘内存映射模型权重。", + "ui.settings.engine.sdcpp_flag.no_mmap": "禁用内存映射。", + "ui.settings.engine.sdcpp_flag.offload_to_cpu": "将模型工作卸载到 CPU。", + "ui.settings.engine.sdcpp_flag.clip_on_cpu": "在 CPU 上运行 CLIP。", + "ui.settings.engine.sdcpp_flag.vae_on_cpu": "在 CPU 上运行 VAE。", + "ui.settings.engine.sdcpp_flag.vae_tiling": "使用分块 VAE 解码以减少显存占用。", + "ui.settings.engine.sdcpp_flag.free_params_immediately": "加载后立即释放模型参数。", + "ui.settings.engine.sdcpp_flag.keep_clip_on_cpu": "将 CLIP 权重保留在 CPU。", + "ui.settings.engine.sdcpp_flag.keep_control_net_cpu": "将 ControlNet 权重保留在 CPU。", + "ui.settings.engine.sdcpp_flag.keep_vae_on_cpu": "将 VAE 权重保留在 CPU。", + "ui.settings.engine.sdcpp_flag.control_net_cpu": "使用 ControlNet 时在 CPU 上运行。", + "ui.settings.engine.sdcpp_flag.canny": "为 ControlNet 应用 Canny 预处理。", + "ui.settings.engine.sdcpp_flag.color": "应用颜色预处理或彩色输出。", + "ui.settings.engine.sdcpp_flag.cpu_params": "将参数保存在普通 CPU 内存中。", + "ui.settings.engine.sdcpp_flag.normalize_input": "归一化输入图像值。", + "ui.settings.engine.sdcpp_flag.upscale_model_path": "设置 ESRGAN 放大模型路径。", + "ui.settings.engine.sdcpp_flag.upscale_repeats_2": "重复放大处理。", + "ui.settings.engine.sdcpp_flag.type_q8_0": "设置权重精度/类型。", + "ui.settings.engine.sdcpp_flag.rng_cuda": "在 NVIDIA 系统上优先使用 CUDA RNG。", + "ui.settings.engine.sdcpp_flag.sampling_method_euler_a": "设置采样方法。", + "ui.settings.engine.sdcpp_flag.schedule_karras": "设置调度器。", + "ui.settings.engine.sdcpp_flag.prediction_v": "设置 prediction 模式。", + "ui.settings.engine.sdcpp_flag.clip_skip_2": "跳过最后的 CLIP 层。", + "ui.settings.engine.sdcpp_flag.cfg_scale_7": "设置 CFG 比例。", + "ui.settings.engine.sdcpp_flag.guidance_3_5": "设置 guidance 比例。", + "ui.settings.engine.sdcpp_flag.eta_0": "设置 DDIM eta。", + "ui.settings.engine.sdcpp_flag.steps_30": "设置默认采样步数。", + "ui.settings.engine.sdcpp_flag.strength_0_75": "设置 img2img 去噪强度。", + "ui.settings.engine.sdcpp_flag.pm_style_strength_20": "设置 PhotoMaker 风格强度。", + "ui.settings.engine.sdcpp_flag.control_strength_0_9": "设置 ControlNet 强度。", + "ui.settings.engine.sdcpp_flag.width_1024": "设置默认输出宽度。", + "ui.settings.engine.sdcpp_flag.height_1024": "设置默认输出高度。", + "ui.settings.engine.sdcpp_flag.batch_count_1": "设置批次数量。", + "ui.settings.engine.sdcpp_flag.video_frames_16": "设置生成视频帧数。", + "ui.settings.engine.sdcpp_flag.fps_24": "设置生成视频 FPS。", + "ui.settings.engine.sdcpp_flag.motion_bucket_id_127": "设置 SVD motion bucket。", + "ui.settings.engine.sdcpp_flag.augmentation_level_0": "设置 SVD augmentation 级别。", + "ui.settings.engine.sdcpp_flag.sample_start_0": "设置 sample 起始值。", + "ui.settings.engine.sdcpp_flag.sample_end_1": "设置 sample 结束值。", + "ui.settings.engine.sdcpp_flag.slg_scale_0": "设置 skip-layer guidance 比例。", + "ui.settings.engine.sdcpp_flag.skip_layers_7_8_9": "设置 skip-layer guidance 层。", + "ui.settings.engine.sdcpp_flag.skip_layer_start_0_01": "设置 skip-layer 起始比例。", + "ui.settings.engine.sdcpp_flag.skip_layer_end_0_2": "设置 skip-layer 结束比例。", + "ui.settings.engine.sdcpp_flag.seed_42": "设置生成 seed。", + "ui.settings.engine.sdcpp_flag.negative_prompt_text": "设置默认负向提示词。", + "ui.settings.engine.sdcpp_flag.prompt_text": "设置默认正向提示词。", + "ui.settings.engine.sdcpp_flag.cfg_negative_prompt_text": "设置 CFG 负向提示词。", + "ui.settings.engine.sdcpp_flag.vae_tile_size_32x32": "设置 VAE tile 大小。", + "ui.settings.engine.sdcpp_flag.vae_tile_overlap_0_5": "设置 VAE tile 重叠。", + "ui.settings.engine.sdcpp_flag.vae_relative_tile_size_0_5x0_5": "设置相对 VAE tile 大小。", + "ui.settings.engine.sdcpp_flag.clip_g_layers_0": "设置 CLIP-G 层数。", + "ui.settings.engine.sdcpp_flag.clip_l_layers_0": "设置 CLIP-L 层数。", + "ui.settings.engine.sdcpp_flag.t5xxl_layers_0": "设置 T5-XXL 层数。", + "ui.settings.engine.sdcpp_flag.diffusion_model_layers_0": "设置 diffusion 层数。", + "ui.settings.engine.sdcpp_flag.vae_layers_0": "设置 VAE 层数。", + "ui.settings.engine.sdcpp_flag.verbose": "启用详细日志。", + "ui.settings.engine.sdcpp_flag.quiet": "减少日志输出。", + "ui.settings.engine.sdcpp_flag.preview_none": "禁用预览图输出。", + "ui.settings.engine.sdcpp_flag.preview_path_path": "设置预览图路径。", + "ui.settings.engine.sdcpp_flag.diffusion_on_cpu": "在 CPU 上运行 diffusion 模型。", + "ui.settings.engine.sdcpp_flag.vae_on_gpu": "尽可能在 GPU 上运行 VAE。", + "ui.settings.engine.sdcpp_flag.clip_on_gpu": "尽可能在 GPU 上运行 CLIP。", + "ui.settings.engine.sdcpp_flag.control_net_on_gpu": "在 GPU 上运行 ControlNet。", + "ui.settings.engine.sdcpp_flag.chroma_disable_ds": "禁用 Chroma 下采样。", + "ui.settings.engine.sdcpp_flag.chroma_enable_t5_mask": "启用 Chroma T5 mask。", + "ui.settings.engine.sdcpp_flag.chroma_t5_mask_pad_1": "设置 Chroma T5 mask padding。", + "ui.settings.engine.sdcpp_flag.flow_shift_3": "设置 flow shift 值。", + "ui.settings.engine.sdcpp_flag.timestep_shift_250": "设置 timestep shift 值。", + "ui.settings.engine.sdcpp_flag.diffusion_cpu_params": "将 diffusion 参数保留在 CPU。", + "ui.settings.engine.sdcpp_flag.vae_cpu_params": "将 VAE 参数保留在 CPU。", + "ui.settings.engine.sdcpp_flag.clip_cpu_params": "将 CLIP 参数保留在 CPU。", + "ui.settings.engine.sdcpp_flag.control_net_cpu_params": "将 ControlNet 参数保留在 CPU。", + "ui.settings.engine.sdcpp_flag.rng_std_default": "使用标准 RNG。", + "ui.settings.engine.sdcpp_flag.sampler_rng_cuda": "专门为 sampler 使用 CUDA RNG。", + "ui.settings.engine.sdcpp_flag.load_id_weights_path": "加载 ID weights 文件。", + "ui.settings.engine.sdcpp_flag.photo_maker_path": "设置 PhotoMaker 模型路径。", + "ui.settings.engine.sdcpp_flag.photo_maker_vae_path": "设置 PhotoMaker VAE 路径。", + "ui.settings.engine.sdcpp_flag.style_strength_20": "设置 PhotoMaker 风格强度。", + "ui.settings.engine.sdcpp_flag.taesd_decode": "使用 TAESD 解码器。", + "ui.settings.engine.sdcpp_flag.taesd_encode": "使用 TAESD 编码器。" } diff --git a/src-tauri/src/api/ai/mod.rs b/src-tauri/src/api/ai/mod.rs index df81da11..5ec61524 100644 --- a/src-tauri/src/api/ai/mod.rs +++ b/src-tauri/src/api/ai/mod.rs @@ -1,4 +1,3 @@ -use crate::app::window::{create_main_window, show_and_focus_window}; use crate::domain::ai::{ self, ChatSessionManager, ai_service, ai_service::{ChatRequest, ChatResponse}, @@ -8,15 +7,14 @@ use crate::domain::engine::manager::EngineManager; use crate::domain::engine::types::Capability; use crate::domain::system::config_service::ConfigService; use crate::errors::AppError; -use crate::infrastructure::config::ui_state::UiStateService; use crate::infrastructure::crypto::secure_storage::SecureStorage; use base64::{Engine as _, engine::general_purpose::STANDARD}; use dashmap::DashMap; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::Arc; +use tauri::State; use tauri::ipc::Channel; -use tauri::{Manager, State, Window}; use tokio::sync::oneshot; #[cfg(target_os = "windows")] @@ -197,16 +195,6 @@ impl StreamSink for TauriStreamSink { } } -#[derive(Clone)] -struct BackgroundImageGenerationContext { - sessions: Arc, - config_service: Arc, - engine_manager: Arc, - image_generation_state: Arc, - settings_service: crate::infrastructure::config::settings::SettingsService, - ui_state_service: UiStateService, -} - fn ensure_request_id(request: &mut ChatRequest) -> String { let request_id = request .request_id @@ -282,66 +270,6 @@ pub(crate) async fn fill_chat_request_api_key( Ok(()) } -fn build_background_image_generation_context( - sessions: &State<'_, Arc>, - config_service: &State<'_, Arc>, - engine_manager: &State<'_, Arc>, - image_generation_state: &State<'_, Arc>, - settings_service: &State<'_, crate::infrastructure::config::settings::SettingsService>, - ui_state_service: &State<'_, UiStateService>, -) -> BackgroundImageGenerationContext { - BackgroundImageGenerationContext { - sessions: Arc::clone(sessions.inner()), - config_service: Arc::clone(config_service.inner()), - engine_manager: Arc::clone(engine_manager.inner()), - image_generation_state: Arc::clone(image_generation_state.inner()), - settings_service: settings_service.inner().clone(), - ui_state_service: ui_state_service.inner().clone(), - } -} - -fn spawn_background_image_generation( - app_handle: tauri::AppHandle, - request: ai::ImageGenerationRequest, - context: BackgroundImageGenerationContext, -) { - tauri::async_runtime::spawn(async move { - crate::app::tray::set_background_generation_active(&app_handle, "Generating image..."); - let result = ai_service::process_image_request( - request, - &context.sessions, - &context.config_service, - &context.engine_manager, - &context.image_generation_state, - &context.settings_service, - ) - .await; - - if let Err(error) = &result { - tracing::error!("Background image generation failed: {error}"); - } - - reveal_chat_window_after_background_generation(&context.ui_state_service).await; - crate::app::tray::clear_background_generation(&app_handle); - restore_or_create_main_window(&app_handle); - }); -} - -async fn reveal_chat_window_after_background_generation(ui_state_service: &UiStateService) { - let mut ui_state = ui_state_service.get_ui_state().await.unwrap_or_default(); - ui_state.last_page = Some("chat".to_string()); - ui_state.pending_chat_reveal = true; - let _ = ui_state_service.save_ui_state(&ui_state).await; -} - -fn restore_or_create_main_window(app_handle: &tauri::AppHandle) { - if let Some(window) = app_handle.get_webview_window("main") { - show_and_focus_window(&window); - } else if let Some(window) = create_main_window(app_handle) { - show_and_focus_window(&window); - } -} - async fn cancel_comfyui_job( provider: &str, image_generation_state: &crate::domain::ai::ImageGenerationState, @@ -671,34 +599,6 @@ pub async fn generate_image( .await } -#[tauri::command] -#[specta::specta] -#[allow(clippy::too_many_arguments)] -/// Starts image generation as a detached backend task and restores the window on completion. -pub async fn generate_image_background( - app: tauri::AppHandle, - _window: Window, - request: ai::ImageGenerationRequest, - sessions: State<'_, Arc>, - config_service: State<'_, Arc>, - engine_manager: State<'_, Arc>, - image_generation_state: State<'_, Arc>, - settings_service: State<'_, crate::infrastructure::config::settings::SettingsService>, - ui_state_service: State<'_, UiStateService>, -) -> Result<(), AppError> { - let context = build_background_image_generation_context( - &sessions, - &config_service, - &engine_manager, - &image_generation_state, - &settings_service, - &ui_state_service, - ); - spawn_background_image_generation(app, request, context); - - Ok(()) -} - #[tauri::command] #[specta::specta] /// Cancels the current image generation request for the selected provider. @@ -733,9 +633,10 @@ pub async fn get_image_generation_preview( .and_then(|snapshot| snapshot.speed.clone()); let has_status = merged_progress.is_some() || step.is_some() || total.is_some() || speed.is_some(); + let has_active_job = image_generation_state.is_active("sdcpp").await; let Some(path) = engine_manager.active_image_preview_path().await else { - return Ok(if has_status { + return Ok(if has_status || has_active_job { Some(ImageGenerationPreview { data_url: String::new(), updated_at_ms: log_progress diff --git a/src-tauri/src/api/engine/mod.rs b/src-tauri/src/api/engine/mod.rs index ba6de1a7..ce3de61a 100644 --- a/src-tauri/src/api/engine/mod.rs +++ b/src-tauri/src/api/engine/mod.rs @@ -8,6 +8,7 @@ use crate::domain::engine::config::{ build_default_engine_config, merge_user_engine_config, normalize_engine_config, }; use crate::domain::engine::manager::EngineManager; +use crate::domain::engine::manager::canonical_engine_id; use crate::domain::engine::types::{ Capability, EngineConfig, EngineDefinition, EngineState, EngineStatus, }; @@ -93,6 +94,7 @@ pub async fn get_engine_config( engine_id: String, engine_manager: State<'_, Arc>, ) -> Result { + let engine_id = canonical_engine_id(&engine_id).to_string(); let def = engine_manager .get_definition(&engine_id) .await @@ -113,6 +115,7 @@ pub async fn get_engine_settings_payload( engine_id: String, engine_manager: State<'_, Arc>, ) -> Result { + let engine_id = canonical_engine_id(&engine_id).to_string(); let def = engine_manager .get_definition(&engine_id) .await @@ -135,6 +138,8 @@ pub async fn set_engine_config( config: crate::domain::engine::types::EngineConfig, engine_manager: State<'_, Arc>, ) -> Result<(), AppError> { + let mut config = config; + config.engine_id = canonical_engine_id(&config.engine_id).to_string(); let def = engine_manager .get_definition(&config.engine_id) .await diff --git a/src-tauri/src/api/system/logs.rs b/src-tauri/src/api/system/logs.rs index 8360ab89..e7e528f4 100644 --- a/src-tauri/src/api/system/logs.rs +++ b/src-tauri/src/api/system/logs.rs @@ -67,11 +67,21 @@ pub fn get_logs(since: f64) -> Result, AppError> { Ok(logs::get_frontend_logs_since(since)) } +#[tauri::command] +#[specta::specta] +/// Retrieves log entries for a single console view since a given timestamp. +#[allow(clippy::needless_pass_by_value)] +pub fn get_console_logs(view_id: String, since: f64) -> Result, AppError> { + let view_id = canonical_console_view_id(&view_id); + Ok(logs::get_frontend_logs_for_view(&view_id, since)) +} + #[tauri::command] #[specta::specta] /// Clears all stored log entries pub fn clear_logs() -> Result<(), AppError> { logs::clear_logs(); + clear_all_console_log_files(crate::utils::paths::LOG_DIR.as_path())?; Ok(()) } @@ -131,7 +141,11 @@ pub async fn get_console_overview( #[specta::specta] /// Adds a single log entry to the log store pub fn add_log(msg: &str, source: &str, level: &str) -> Result<(), AppError> { - logs::add_log(msg, source, level); + if source.trim().eq_ignore_ascii_case("frontend") { + trace_frontend_log(level, msg); + } else { + logs::add_log(msg, source, level); + } Ok(()) } /// Batch log entry from frontend @@ -148,11 +162,21 @@ pub struct BatchLogEntry { /// Adds multiple log entries in batch from frontend pub fn log_batch(logs: Vec) -> Result<(), AppError> { for log in logs { - logs::add_log(&log.message, "Frontend", &log.level); + trace_frontend_log(&log.level, &log.message); } Ok(()) } +fn trace_frontend_log(level: &str, message: &str) { + match level.trim().to_ascii_lowercase().as_str() { + "error" => tracing::error!(target: "frontend", message = message), + "warn" | "warning" => tracing::warn!(target: "frontend", message = message), + "debug" => tracing::debug!(target: "frontend", message = message), + "trace" => tracing::trace!(target: "frontend", message = message), + _ => tracing::info!(target: "frontend", message = message), + } +} + const fn describe_status(status: ConsoleRuntimeStatus) -> &'static str { match status { ConsoleRuntimeStatus::Running => "Running", @@ -162,10 +186,17 @@ const fn describe_status(status: ConsoleRuntimeStatus) -> &'static str { } } -fn canonical_engine_id(engine_id: &str) -> &str { - match engine_id { - "stable-diffusion" => "sdcpp", - value => value, +fn canonical_engine_id(engine_id: &str) -> String { + let key = engine_id + .trim() + .to_ascii_lowercase() + .replace([' ', '_'], "-"); + match key.as_str() { + "stable-diffusion" + | "stable-diffusion.cpp" + | "stable-diffusion-cpp" + | "stable.diffusion.cpp" => "sdcpp".to_string(), + _ => engine_id.trim().to_string(), } } @@ -181,6 +212,14 @@ fn resolve_console_log_target(view_id: &str) -> PathBuf { crate::utils::paths::LOG_DIR.clone() } +fn canonical_console_view_id(view_id: &str) -> String { + if let Some(engine_id) = view_id.strip_prefix("engine:") { + return format!("engine:{}", canonical_engine_id(engine_id)); + } + + view_id.trim().to_string() +} + fn clear_console_log_target(view_id: &str, target: &Path) -> Result<(), AppError> { if view_id == "general" { clear_log_file(&target.join("axelate.log"))?; @@ -216,6 +255,29 @@ fn clear_log_file(path: &Path) -> Result<(), AppError> { Ok(()) } +fn clear_all_console_log_files(root: &Path) -> Result<(), AppError> { + if !root.exists() { + return Ok(()); + } + + for entry in fs::read_dir(root)? { + let path = entry?.path(); + if path.is_dir() { + clear_all_console_log_files(&path)?; + continue; + } + + if path + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("log")) + { + clear_log_file(&path)?; + } + } + + Ok(()) +} + impl ConsoleOverviewBuilder { async fn build( engine_state: &crate::domain::engine::types::EngineState, @@ -224,7 +286,10 @@ impl ConsoleOverviewBuilder { ) -> ConsoleOverview { let module_labels = Self::collect_module_labels(&ui_state.selected_modules); let module_ids = Self::collect_module_ids(logs, &module_labels); - let engine_labels = Self::collect_engine_labels(engine_state); + let mut engine_labels = Self::collect_engine_labels(engine_state); + engine_labels.extend(Self::collect_selected_engine_labels( + &ui_state.selected_modules, + )); let views = Self::build_views(&engine_labels, &module_labels, &module_ids); let status_items = Self::build_status_items(engine_state, &engine_labels, &module_labels, &module_ids) @@ -240,8 +305,21 @@ impl ConsoleOverviewBuilder { modules: &std::collections::HashMap, ) -> BTreeMap { modules - .values() - .map(|module| (module.id.clone(), module.name.clone())) + .iter() + .filter(|(category, module)| category.as_str() == "services" && module.type_ != "api") + .map(|(_, module)| (module.id.clone(), module.name.clone())) + .collect() + } + + fn collect_selected_engine_labels( + modules: &std::collections::HashMap, + ) -> BTreeMap { + modules + .iter() + .filter(|(category, module)| { + matches!(category.as_str(), "ai_text" | "ai_image") && module.type_ != "api" + }) + .map(|(_, module)| (canonical_engine_id(&module.id), module.name.clone())) .collect() } @@ -262,18 +340,18 @@ impl ConsoleOverviewBuilder { fn collect_engine_labels( state: &crate::domain::engine::types::EngineState, ) -> BTreeMap { - match state { - crate::domain::engine::types::EngineState::Ready { slots } => slots - .iter() - .map(|slot| { - ( - canonical_engine_id(&slot.engine.id).to_string(), - slot.engine.name.clone(), - ) - }) - .collect(), - _ => BTreeMap::new(), + let mut labels = BTreeMap::new(); + + if let crate::domain::engine::types::EngineState::Ready { slots } = state { + labels.extend(slots.iter().map(|slot| { + ( + canonical_engine_id(&slot.engine.id), + slot.engine.name.clone(), + ) + })); } + + labels } fn build_views( @@ -286,10 +364,10 @@ impl ConsoleOverviewBuilder { let mut view_labels = BTreeSet::new(); views.push(ConsoleLogView { id: "general".to_string(), - label: "General".to_string(), + label: "Platform".to_string(), }); view_ids.insert("general".to_string()); - view_labels.insert(Self::normalize_view_label("General")); + view_labels.insert(Self::normalize_view_label("Platform")); for (id, label) in engine_labels { Self::push_unique_view( @@ -349,6 +427,22 @@ impl ConsoleOverviewBuilder { module_ids: &BTreeSet, ) -> Vec { let mut status_items = Self::build_engine_status_items(engine_state); + let known_status_ids = status_items + .iter() + .map(|item| item.id.clone()) + .collect::>(); + for (engine_id, label) in engine_labels { + let status_id = format!("engine:{engine_id}"); + if !known_status_ids.contains(&status_id) { + status_items.push(ConsoleStatusItem { + id: status_id, + label: label.clone(), + kind: "engine".to_string(), + status: ConsoleRuntimeStatus::Stopped, + detail: describe_status(ConsoleRuntimeStatus::Stopped).to_string(), + }); + } + } for module_id in module_ids { status_items.push( Self::build_module_status_item(module_id, engine_labels, module_labels).await, @@ -423,7 +517,7 @@ impl ConsoleOverviewBuilder { let label_key = Self::normalize_view_label(&slot.engine.name); let id = label_to_id .entry(label_key) - .or_insert_with(|| canonical_engine_id(&slot.engine.id).to_string()) + .or_insert_with(|| canonical_engine_id(&slot.engine.id)) .clone(); let detail = ConsoleLabelFormatter::format_capability(slot.capability); items diff --git a/src-tauri/src/domain/ai/ai_dispatch.rs b/src-tauri/src/domain/ai/ai_dispatch.rs index 7c31a01f..3e7acd8f 100644 --- a/src-tauri/src/domain/ai/ai_dispatch.rs +++ b/src-tauri/src/domain/ai/ai_dispatch.rs @@ -1,6 +1,7 @@ use super::session::ChatSessionManager; use super::types::{ChatMessage, ChatRequest, ChatResponse}; use crate::domain::engine::config::{build_default_engine_config, merge_user_engine_config}; +use crate::domain::engine::manager::canonical_engine_id; use crate::infrastructure::config::engine_settings::load_engine_config_map; #[derive(Clone, Copy)] @@ -105,7 +106,9 @@ pub(super) async fn active_local_engine_status( crate::domain::engine::types::EngineState::Ready { slots } => slots .into_iter() .find(|slot| { - slot.capability == capability && slot.engine.id == provider && slot.engine.healthy + slot.capability == capability + && canonical_engine_id(&slot.engine.id) == canonical_engine_id(provider) + && slot.engine.healthy }) .map(|slot| slot.engine) .ok_or_else(|| { @@ -123,7 +126,8 @@ pub(super) async fn build_engine_config( definition: &crate::domain::engine::types::EngineDefinition, ) -> Result { let saved = load_engine_config_map().await?; - Ok(saved.get(&definition.id).map_or_else( + let canonical_id = canonical_engine_id(&definition.id); + Ok(saved.get(canonical_id).map_or_else( || build_default_engine_config(definition), |config| merge_user_engine_config(definition, config), )) diff --git a/src-tauri/src/domain/ai/ai_service.rs b/src-tauri/src/domain/ai/ai_service.rs index 4713eb5b..92258a18 100644 --- a/src-tauri/src/domain/ai/ai_service.rs +++ b/src-tauri/src/domain/ai/ai_service.rs @@ -514,8 +514,10 @@ pub async fn process_image_request_without_engine_autostart( mod tests { #![allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] use super::*; - use crate::domain::ai::image_service::{ + use crate::domain::ai::image_comfyui::{ normalize_comfyui_sampler, normalize_comfyui_scheduler, parse_comfyui_checkpoint_list, + }; + use crate::domain::ai::image_settings::{ resolve_f32_setting, resolve_string_setting, resolve_u32_setting, }; use crate::models::AppSettings; diff --git a/src-tauri/src/domain/ai/image_cloud.rs b/src-tauri/src/domain/ai/image_cloud.rs new file mode 100644 index 00000000..f6feb3e5 --- /dev/null +++ b/src-tauri/src/domain/ai/image_cloud.rs @@ -0,0 +1,47 @@ +//! Cloud image generation through OpenRouter-compatible image models. + +use std::time::Duration; + +use super::image_http::{build_image_client, parse_image_response_body}; +use super::image_payload::build_cloud_image_payload; +use super::image_response::parse_openrouter_generated_images; +use super::types::ImageGenerationRequest; +use crate::errors::AppError; +use crate::infrastructure::crypto::secure_storage::SecureStorage; + +pub(super) fn is_cloud_image_provider(provider: &str) -> bool { + matches!(provider, "gemini-image" | "gpt-image" | "seedream-image") +} + +pub(super) async fn process_cloud_image_request( + request: &ImageGenerationRequest, +) -> Result, AppError> { + let api_key = SecureStorage::get_key_async("openrouter_api_key".to_string()) + .await? + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| AppError::Validation("OpenRouter API key is missing".to_string()))?; + + let client = build_image_client(Duration::from_secs(180))?; + let response = client + .post("https://openrouter.ai/api/v1/chat/completions") + .header(reqwest::header::AUTHORIZATION, format!("Bearer {api_key}")) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .json(&build_cloud_image_payload(request)) + .send() + .await + .map_err(|error| AppError::External { + request_id: None, + message: format!("Cloud image request failed: {error}"), + })?; + + let body = parse_image_response_body(response).await?; + let images = parse_openrouter_generated_images(&body); + if images.is_empty() { + return Err(AppError::External { + request_id: None, + message: "Cloud image provider returned no images".to_string(), + }); + } + + Ok(images) +} diff --git a/src-tauri/src/domain/ai/image_comfyui.rs b/src-tauri/src/domain/ai/image_comfyui.rs new file mode 100644 index 00000000..dfb96818 --- /dev/null +++ b/src-tauri/src/domain/ai/image_comfyui.rs @@ -0,0 +1,598 @@ +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use std::time::{Duration, Instant}; + +use super::image_http::build_image_client; +use super::image_settings::resolve_string_setting; +use super::types::ImageGenerationRequest; +use crate::domain::ai::ImageGenerationState; +use crate::errors::AppError; +use crate::infrastructure::config::settings::SettingsService; +use crate::models::AppSettings; + +struct ImageRequestSettingsContext { + settings: AppSettings, + settings_key: String, +} + +struct ComfyUiRequestContext { + base_url: String, + checkpoint: String, + sampler: String, + scheduler: String, + seed: u64, + steps: u32, + cfg_scale: f32, + width: u32, + height: u32, + batch_size: u32, + negative_prompt: String, + prompt_id: String, + client_id: String, +} + +pub(super) async fn process_comfyui_request( + request: &ImageGenerationRequest, + image_generation_state: &ImageGenerationState, + settings_service: &SettingsService, +) -> Result, AppError> { + let settings_context = load_image_request_settings_context(request, settings_service).await?; + let client = build_image_client(Duration::from_secs(120))?; + let comfyui = build_comfyui_request_context(request, &settings_context, &client).await?; + let workflow = build_comfyui_workflow( + &request.prompt, + &comfyui.negative_prompt, + &comfyui.checkpoint, + comfyui.seed, + comfyui.steps, + comfyui.cfg_scale, + comfyui.width, + comfyui.height, + comfyui.batch_size, + &comfyui.sampler, + &comfyui.scheduler, + ); + + image_generation_state + .begin( + &request.provider, + &comfyui.base_url, + Some(comfyui.prompt_id.clone()), + ) + .await; + + let mut active_prompt_id = comfyui.prompt_id.clone(); + let result = async { + let queue_body = queue_comfyui_prompt(&client, &comfyui, workflow).await?; + + if let Some(server_prompt_id) = queue_body.get("prompt_id").and_then(|value| value.as_str()) + && !server_prompt_id.trim().is_empty() + { + active_prompt_id = server_prompt_id.to_string(); + image_generation_state + .update_prompt_id(&request.provider, active_prompt_id.clone()) + .await; + } + + if let Some(message) = extract_comfyui_queue_error(&queue_body) { + return Err(AppError::External { + request_id: None, + message, + }); + } + + wait_for_comfyui_images( + &client, + &comfyui.base_url, + &request.provider, + &active_prompt_id, + image_generation_state, + ) + .await + } + .await; + + image_generation_state + .clear(&request.provider, Some(active_prompt_id.as_str())) + .await; + + result +} + +async fn load_image_request_settings_context( + request: &ImageGenerationRequest, + settings_service: &SettingsService, +) -> Result { + Ok(ImageRequestSettingsContext { + settings: settings_service.get_settings().await?, + settings_key: request + .settings_key + .clone() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| request.provider.clone()), + }) +} + +async fn build_comfyui_request_context( + request: &ImageGenerationRequest, + settings_context: &ImageRequestSettingsContext, + client: &reqwest::Client, +) -> Result { + let base_url = normalize_comfyui_base_url( + resolve_string_setting( + &settings_context.settings, + &settings_context.settings_key, + &request.provider, + "base_url", + ) + .as_deref() + .unwrap_or("http://127.0.0.1:8188"), + ); + + Ok(ComfyUiRequestContext { + checkpoint: resolve_comfyui_checkpoint( + request, + &settings_context.settings, + &settings_context.settings_key, + &base_url, + client, + ) + .await?, + base_url, + sampler: normalize_comfyui_sampler(request.sampler.as_deref()), + scheduler: normalize_comfyui_scheduler(request.scheduler.as_deref()), + seed: normalize_comfyui_seed(request.seed), + steps: request.steps.unwrap_or(24), + cfg_scale: request.cfg_scale.unwrap_or(7.0), + width: request.width.unwrap_or(832), + height: request.height.unwrap_or(1216), + batch_size: request.batch_size.unwrap_or(1), + negative_prompt: request.negative_prompt.clone().unwrap_or_default(), + prompt_id: uuid::Uuid::new_v4().to_string(), + client_id: uuid::Uuid::new_v4().to_string(), + }) +} + +async fn resolve_comfyui_checkpoint( + request: &ImageGenerationRequest, + settings: &AppSettings, + settings_key: &str, + base_url: &str, + client: &reqwest::Client, +) -> Result { + if !request.model.trim().is_empty() && request.model != "default" { + return Ok(normalize_comfyui_checkpoint(&request.model)); + } + + if let Some(saved_checkpoint) = + resolve_string_setting(settings, settings_key, &request.provider, "checkpoint") + { + return Ok(normalize_comfyui_checkpoint(&saved_checkpoint)); + } + + let available_checkpoints = fetch_comfyui_checkpoints(client, base_url).await?; + if let Some(checkpoint) = available_checkpoints.first() { + return Ok(normalize_comfyui_checkpoint(checkpoint)); + } + + Err(AppError::Config( + "ComfyUI does not expose any checkpoints yet. Install a model in ComfyUI and try again." + .to_string(), + )) +} + +fn normalize_comfyui_base_url(raw: &str) -> String { + let trimmed = raw.trim().trim_end_matches('/'); + if trimmed.is_empty() { + return "http://127.0.0.1:8188".to_string(); + } + + if trimmed.starts_with("http://") || trimmed.starts_with("https://") { + return trimmed.to_string(); + } + + format!("http://{trimmed}") +} + +fn normalize_comfyui_checkpoint(raw: &str) -> String { + raw.trim() + .replace('\\', "/") + .split('/') + .next_back() + .unwrap_or(raw) + .trim() + .to_string() +} + +pub(super) fn normalize_comfyui_sampler(value: Option<&str>) -> String { + match value.unwrap_or("euler").trim().to_lowercase().as_str() { + "euler a" | "euler_a" | "euler ancestral" | "euler_ancestral" => { + "euler_ancestral".to_string() + } + "euler" => "euler".to_string(), + "heun" => "heun".to_string(), + "heunpp2" => "heunpp2".to_string(), + "dpm2" | "dpm 2" | "dpm_2" => "dpm_2".to_string(), + "dpm2 a" | "dpm2_a" | "dpm 2 ancestral" | "dpm_2_ancestral" => { + "dpm_2_ancestral".to_string() + } + "lms" => "lms".to_string(), + "dpm fast" | "dpm_fast" => "dpm_fast".to_string(), + "dpm adaptive" | "dpm_adaptive" => "dpm_adaptive".to_string(), + "dpm++ 2s a" | "dpm++2s_a" | "dpmpp_2s_a" | "dpmpp_2s_ancestral" => { + "dpmpp_2s_ancestral".to_string() + } + "dpm++ sde" | "dpmpp_sde" => "dpmpp_sde".to_string(), + "dpm++ sde gpu" | "dpmpp_sde_gpu" => "dpmpp_sde_gpu".to_string(), + "dpm++ 2m" | "dpm++2m" | "dpmpp_2m" => "dpmpp_2m".to_string(), + "dpm++ 3m sde" | "dpm++3m sde" | "dpmpp_3m_sde" => "dpmpp_3m_sde".to_string(), + "dpm++ 3m sde gpu" | "dpm++3m sde gpu" | "dpmpp_3m_sde_gpu" => { + "dpmpp_3m_sde_gpu".to_string() + } + "ddpm" => "ddpm".to_string(), + "lcm" => "lcm".to_string(), + "ipndm" => "ipndm".to_string(), + "ipndm_v" => "ipndm_v".to_string(), + "deis" => "deis".to_string(), + "ddim" => "ddim".to_string(), + "uni pc" | "uni_pc" => "uni_pc".to_string(), + "uni pc bh2" | "uni_pc_bh2" => "uni_pc_bh2".to_string(), + other => other.to_string(), + } +} + +pub(super) fn normalize_comfyui_scheduler(value: Option<&str>) -> String { + match value.unwrap_or("karras").trim().to_lowercase().as_str() { + "default" | "auto" | "karras" => "karras".to_string(), + "normal" => "normal".to_string(), + "simple" => "simple".to_string(), + "sgm uniform" | "sgm_uniform" => "sgm_uniform".to_string(), + "exponential" => "exponential".to_string(), + "ddim uniform" | "ddim_uniform" => "ddim_uniform".to_string(), + "beta" => "beta".to_string(), + "linear quadratic" | "linear_quadratic" => "linear_quadratic".to_string(), + "kl optimal" | "kl_optimal" => "kl_optimal".to_string(), + other => other.to_string(), + } +} + +fn normalize_comfyui_seed(value: Option) -> u64 { + match value { + Some(seed) if seed >= 0 => u64::from(seed.unsigned_abs()), + _ => rand::random::(), + } +} + +async fn fetch_comfyui_checkpoints( + client: &reqwest::Client, + base_url: &str, +) -> Result, AppError> { + let response = client + .get(format!("{base_url}/models/checkpoints")) + .send() + .await + .map_err(|error| AppError::External { + request_id: None, + message: format!("Failed to query ComfyUI checkpoints: {error}"), + })?; + + if !response.status().is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(AppError::External { + request_id: None, + message: format!("ComfyUI checkpoints request failed: {body}"), + }); + } + + let payload: serde_json::Value = response.json().await.map_err(|error| AppError::External { + request_id: None, + message: format!("Failed to parse ComfyUI checkpoint list: {error}"), + })?; + + Ok(parse_comfyui_checkpoint_list(&payload)) +} + +pub(super) fn parse_comfyui_checkpoint_list(payload: &serde_json::Value) -> Vec { + fn extract_checkpoint_name(value: &serde_json::Value) -> Option { + if let Some(name) = value.as_str() { + let trimmed = name.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + + let object = value.as_object()?; + for key in ["name", "filename", "path"] { + if let Some(candidate) = object.get(key).and_then(|entry| entry.as_str()) { + let trimmed = candidate.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + } + + None + } + + let values = if let Some(items) = payload.as_array() { + items.iter().collect::>() + } else if let Some(items) = payload.get("models").and_then(|value| value.as_array()) { + items.iter().collect::>() + } else if let Some(items) = payload.get("files").and_then(|value| value.as_array()) { + items.iter().collect::>() + } else { + Vec::new() + }; + + let mut seen = std::collections::HashSet::new(); + values + .into_iter() + .filter_map(extract_checkpoint_name) + .filter(|value| seen.insert(value.clone())) + .collect() +} + +#[allow(clippy::too_many_arguments)] +fn build_comfyui_workflow( + prompt: &str, + negative_prompt: &str, + checkpoint: &str, + seed: u64, + steps: u32, + cfg_scale: f32, + width: u32, + height: u32, + batch_size: u32, + sampler: &str, + scheduler: &str, +) -> serde_json::Value { + serde_json::json!({ + "3": { + "class_type": "KSampler", + "inputs": { + "cfg": cfg_scale, + "denoise": 1.0, + "latent_image": ["5", 0], + "model": ["4", 0], + "negative": ["7", 0], + "positive": ["6", 0], + "sampler_name": sampler, + "scheduler": scheduler, + "seed": seed, + "steps": steps + } + }, + "4": { + "class_type": "CheckpointLoaderSimple", + "inputs": { + "ckpt_name": checkpoint + } + }, + "5": { + "class_type": "EmptyLatentImage", + "inputs": { + "batch_size": batch_size, + "height": height, + "width": width + } + }, + "6": { + "class_type": "CLIPTextEncode", + "inputs": { + "clip": ["4", 1], + "text": prompt + } + }, + "7": { + "class_type": "CLIPTextEncode", + "inputs": { + "clip": ["4", 1], + "text": negative_prompt + } + }, + "8": { + "class_type": "VAEDecode", + "inputs": { + "samples": ["3", 0], + "vae": ["4", 2] + } + }, + "9": { + "class_type": "SaveImage", + "inputs": { + "filename_prefix": "Axelate", + "images": ["8", 0] + } + } + }) +} + +async fn queue_comfyui_prompt( + client: &reqwest::Client, + context: &ComfyUiRequestContext, + workflow: serde_json::Value, +) -> Result { + let response = client + .post(format!("{}/prompt", context.base_url)) + .json(&serde_json::json!({ + "prompt": workflow, + "client_id": context.client_id, + "prompt_id": context.prompt_id, + })) + .send() + .await + .map_err(|error| AppError::External { + request_id: None, + message: format!("Failed to queue ComfyUI prompt: {error}"), + })?; + + if !response.status().is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(AppError::External { + request_id: None, + message: format!("ComfyUI queue request failed: {body}"), + }); + } + + response.json().await.map_err(|error| AppError::External { + request_id: None, + message: format!("Failed to parse ComfyUI queue response: {error}"), + }) +} + +async fn wait_for_comfyui_images( + client: &reqwest::Client, + base_url: &str, + provider: &str, + prompt_id: &str, + image_generation_state: &ImageGenerationState, +) -> Result, AppError> { + let deadline = Instant::now() + Duration::from_secs(600); + + loop { + if image_generation_state + .is_cancelled(provider, Some(prompt_id)) + .await + { + return Err(AppError::External { + request_id: None, + message: "Image generation cancelled".to_string(), + }); + } + + let response = client + .get(format!("{base_url}/history/{prompt_id}")) + .send() + .await + .map_err(|error| AppError::External { + request_id: None, + message: format!("Failed to poll ComfyUI history: {error}"), + })?; + + if response.status().is_success() { + let history_body: serde_json::Value = + response.json().await.map_err(|error| AppError::External { + request_id: None, + message: format!("Failed to parse ComfyUI history: {error}"), + })?; + + if let Some(entry) = history_body.get(prompt_id) { + let images = fetch_comfyui_history_images(client, base_url, entry).await?; + if !images.is_empty() { + return Ok(images); + } + } + } + + if Instant::now() >= deadline { + return Err(AppError::External { + request_id: None, + message: "ComfyUI image generation timed out".to_string(), + }); + } + + tokio::time::sleep(Duration::from_millis(700)).await; + } +} + +async fn fetch_comfyui_history_images( + client: &reqwest::Client, + base_url: &str, + history_entry: &serde_json::Value, +) -> Result, AppError> { + let mut images = Vec::new(); + let Some(outputs) = history_entry + .get("outputs") + .and_then(|value| value.as_object()) + else { + return Ok(images); + }; + + for node_output in outputs.values() { + let Some(node_images) = node_output.get("images").and_then(|value| value.as_array()) else { + continue; + }; + + for image_meta in node_images { + let Some(filename) = image_meta.get("filename").and_then(|value| value.as_str()) else { + continue; + }; + + let subfolder = image_meta + .get("subfolder") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + let image_type = image_meta + .get("type") + .and_then(|value| value.as_str()) + .unwrap_or("output"); + let mut image_url = + reqwest::Url::parse(&format!("{base_url}/view")).map_err(|error| { + AppError::External { + request_id: None, + message: format!("Failed to build ComfyUI image URL: {error}"), + } + })?; + { + let mut query = image_url.query_pairs_mut(); + query.append_pair("filename", filename); + if !subfolder.is_empty() { + query.append_pair("subfolder", subfolder); + } + query.append_pair("type", image_type); + } + + let response = + client + .get(image_url) + .send() + .await + .map_err(|error| AppError::External { + request_id: None, + message: format!("Failed to fetch ComfyUI image: {error}"), + })?; + + if !response.status().is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(AppError::External { + request_id: None, + message: format!("ComfyUI image download failed: {body}"), + }); + } + + let mime_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or("image/png") + .to_string(); + let bytes = response.bytes().await.map_err(|error| AppError::External { + request_id: None, + message: format!("Failed to read ComfyUI image bytes: {error}"), + })?; + + images.push(format!( + "data:{mime_type};base64,{}", + STANDARD.encode(bytes) + )); + } + } + + Ok(images) +} + +fn extract_comfyui_queue_error(body: &serde_json::Value) -> Option { + if let Some(error_message) = body.get("error").and_then(|value| value.as_str()) { + return Some(format!("ComfyUI queue error: {error_message}")); + } + + let node_errors = body.get("node_errors")?; + if !node_errors.is_object() + || node_errors + .as_object() + .is_some_and(serde_json::Map::is_empty) + { + return None; + } + + Some(format!("ComfyUI node validation failed: {node_errors}")) +} diff --git a/src-tauri/src/domain/ai/image_generation_state.rs b/src-tauri/src/domain/ai/image_generation_state.rs index 1306d87c..e4f31bc2 100644 --- a/src-tauri/src/domain/ai/image_generation_state.rs +++ b/src-tauri/src/domain/ai/image_generation_state.rs @@ -91,6 +91,14 @@ impl ImageGenerationState { None } + /// Returns whether a matching provider currently has an active image job. + pub async fn is_active(&self, provider: &str) -> bool { + let guard = self.inner.lock().await; + guard + .as_ref() + .is_some_and(|job| provider_matches(&job.provider, provider) && !job.cancelled) + } + /// Updates the prompt identifier for the current active job. pub async fn update_prompt_id(&self, provider: &str, prompt_id: String) { let mut guard = self.inner.lock().await; diff --git a/src-tauri/src/domain/ai/image_http.rs b/src-tauri/src/domain/ai/image_http.rs new file mode 100644 index 00000000..0a6f6a8d --- /dev/null +++ b/src-tauri/src/domain/ai/image_http.rs @@ -0,0 +1,34 @@ +//! Shared HTTP helpers for image generation adapters. + +use std::time::Duration; + +use crate::errors::AppError; + +pub(super) fn build_image_client(timeout: Duration) -> Result { + reqwest::Client::builder() + .timeout(timeout) + .pool_idle_timeout(Duration::from_secs(90)) + .pool_max_idle_per_host(4) + .build() + .map_err(|error| AppError::External { + request_id: None, + message: error.to_string(), + }) +} + +pub(super) async fn parse_image_response_body( + response: reqwest::Response, +) -> Result { + if !response.status().is_success() { + let err_text = response.text().await.unwrap_or_default(); + return Err(AppError::External { + request_id: None, + message: format!("Image generation failed: {err_text}"), + }); + } + + response.json().await.map_err(|error| AppError::External { + request_id: None, + message: format!("Failed to parse image response: {error}"), + }) +} diff --git a/src-tauri/src/domain/ai/image_local.rs b/src-tauri/src/domain/ai/image_local.rs new file mode 100644 index 00000000..ae1f3e75 --- /dev/null +++ b/src-tauri/src/domain/ai/image_local.rs @@ -0,0 +1,354 @@ +//! Local image engine dispatch for sd.cpp and OpenAI-compatible image APIs. + +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use super::ai_dispatch::{LocalEngineAccess, active_local_engine_status, build_engine_config}; +use super::ai_service::stop_conflicting_local_engine; +use super::image_http::{build_image_client, parse_image_response_body}; +use super::image_payload::{build_local_image_payload, build_sdcpp_native_image_payload}; +use super::image_response::{ + ImageResponseFormat, parse_generated_images, parse_sdcpp_generated_images, + summarize_image_response_shape, +}; +use super::types::ImageGenerationRequest; +use crate::domain::ai::ImageGenerationState; +use crate::domain::engine::manager::{EngineManager, resolve_sdcpp_preview_path}; +use crate::domain::engine::types::{Capability, EngineDefinition}; +use crate::errors::AppError; + +struct PreparedImageDispatch { + base_url: String, + request_url: String, + api: LocalImageApi, + response_format: ImageResponseFormat, + preview_path: Option, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum LocalImageApi { + SdcppNative, + OpenAiCompatible, +} + +pub(super) async fn process_local_image_request( + request: &ImageGenerationRequest, + engine_manager: &EngineManager, + image_generation_state: &ImageGenerationState, + local_engine_access: LocalEngineAccess, +) -> Result, AppError> { + let dispatch = + prepare_local_image_dispatch(request, engine_manager, local_engine_access).await?; + image_generation_state + .begin(&request.provider, &dispatch.base_url, None) + .await; + + let result = + execute_local_image_request(request, dispatch, engine_manager, image_generation_state) + .await; + image_generation_state.clear(&request.provider, None).await; + + result +} + +async fn prepare_local_image_dispatch( + request: &ImageGenerationRequest, + engine_manager: &EngineManager, + local_engine_access: LocalEngineAccess, +) -> Result { + let Some(definition) = engine_manager.get_definition(&request.provider).await else { + return Err(AppError::External { + request_id: None, + message: "Cloud image generation is not yet supported. Please use a local engine." + .into(), + }); + }; + + tracing::info!( + provider = %request.provider, + "Detected local engine for image generation" + ); + + let (base_url, preview_path) = + resolve_local_image_endpoint(request, engine_manager, local_engine_access, &definition) + .await?; + let api = local_image_api(&request.provider); + let response_format = image_response_format(api); + let request_url = build_image_generation_url(&base_url, api); + + Ok(PreparedImageDispatch { + base_url, + request_url, + api, + response_format, + preview_path, + }) +} + +fn local_image_api(provider: &str) -> LocalImageApi { + if matches!(provider, "sdcpp" | "stable-diffusion") { + LocalImageApi::SdcppNative + } else { + LocalImageApi::OpenAiCompatible + } +} + +async fn resolve_local_image_endpoint( + request: &ImageGenerationRequest, + engine_manager: &EngineManager, + local_engine_access: LocalEngineAccess, + definition: &EngineDefinition, +) -> Result<(String, Option), AppError> { + match local_engine_access { + LocalEngineAccess::AutoStart => { + let mut config = build_engine_config(definition).await?; + + if !request.model.is_empty() && request.model != "default" { + config.model_path = Some(request.model.clone()); + } + + if config.model_path.as_deref() == Some("default") { + config.model_path = None; + } + + let preview_path = resolve_sdcpp_preview_path(&config.extra_args); + stop_conflicting_local_engine(engine_manager, Capability::Image).await?; + let status = engine_manager.start(config).await?; + + Ok((status.endpoint, preview_path)) + } + LocalEngineAccess::RequireRunning => { + let status = + active_local_engine_status(engine_manager, &request.provider, Capability::Image) + .await?; + let preview_path = engine_manager.active_image_preview_path().await; + Ok((status.endpoint, preview_path)) + } + } +} + +const fn image_response_format(api: LocalImageApi) -> ImageResponseFormat { + match api { + LocalImageApi::SdcppNative => ImageResponseFormat::SdApi, + LocalImageApi::OpenAiCompatible => ImageResponseFormat::OpenAiCompatible, + } +} + +fn build_image_generation_url(base_url: &str, api: LocalImageApi) -> String { + match api { + LocalImageApi::SdcppNative => format!("{base_url}/sdcpp/v1/img_gen"), + LocalImageApi::OpenAiCompatible => format!("{base_url}/v1/images/generations"), + } +} + +async fn execute_local_image_request( + request: &ImageGenerationRequest, + dispatch: PreparedImageDispatch, + engine_manager: &EngineManager, + image_generation_state: &ImageGenerationState, +) -> Result, AppError> { + let client = build_image_client(Duration::from_secs(999_999))?; + + if let Some(preview_path) = dispatch.preview_path.as_deref() { + clear_preview_file(preview_path).await; + } + + if dispatch.api == LocalImageApi::SdcppNative { + return execute_sdcpp_native_image_request( + request, + &dispatch, + engine_manager, + image_generation_state, + &client, + ) + .await; + } + + let payload = build_local_image_payload(request); + tracing::info!( + "Sending image generation request to {}", + dispatch.request_url + ); + let response = client + .post(&dispatch.request_url) + .json(&payload) + .send() + .await + .map_err(|error| AppError::External { + request_id: None, + message: format!( + "Local image engine request failed at {}: {error}. The engine may have stopped, closed the connection, or run out of memory while generating.", + dispatch.request_url + ), + })?; + + let body = parse_image_response_body(response).await?; + let images = parse_generated_images(&body, dispatch.response_format); + if images.is_empty() { + return Err(AppError::External { + request_id: None, + message: format!( + "Local image engine returned no images. Response shape was: {}", + summarize_image_response_shape(&body) + ), + }); + } + + Ok(images) +} + +async fn execute_sdcpp_native_image_request( + request: &ImageGenerationRequest, + dispatch: &PreparedImageDispatch, + engine_manager: &EngineManager, + image_generation_state: &ImageGenerationState, + client: &reqwest::Client, +) -> Result, AppError> { + let payload = build_sdcpp_native_image_payload(request); + tracing::info!( + "Submitting stable-diffusion.cpp native image job to {}", + dispatch.request_url + ); + let response = client + .post(&dispatch.request_url) + .json(&payload) + .send() + .await + .map_err(|error| AppError::External { + request_id: None, + message: format!( + "Local image engine request failed at {}: {error}. The engine may have stopped, closed the connection, or run out of memory while generating.", + dispatch.request_url + ), + })?; + + let body = parse_image_response_body(response).await?; + let job_id = extract_sdcpp_job_id(&body).ok_or_else(|| AppError::External { + request_id: None, + message: format!( + "stable-diffusion.cpp did not return a native job id. Response shape was: {}", + summarize_image_response_shape(&body) + ), + })?; + image_generation_state + .update_prompt_id(&request.provider, job_id.clone()) + .await; + + wait_for_sdcpp_native_images( + client, + &dispatch.base_url, + &request.provider, + &job_id, + engine_manager, + image_generation_state, + ) + .await +} + +fn extract_sdcpp_job_id(body: &serde_json::Value) -> Option { + body.get("id") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) +} + +async fn wait_for_sdcpp_native_images( + client: &reqwest::Client, + base_url: &str, + provider: &str, + job_id: &str, + engine_manager: &EngineManager, + image_generation_state: &ImageGenerationState, +) -> Result, AppError> { + let deadline = Instant::now() + Duration::from_secs(999_999); + let job_url = format!("{}/sdcpp/v1/jobs/{job_id}", base_url.trim_end_matches('/')); + + loop { + if image_generation_state + .is_cancelled(provider, Some(job_id)) + .await + { + return Err(AppError::External { + request_id: None, + message: "Image generation cancelled".to_string(), + }); + } + + let response = match client.get(&job_url).send().await { + Ok(response) => response, + Err(error) => { + let message = format!("Failed to poll stable-diffusion.cpp job: {error}"); + engine_manager + .stop_slot_after_error(Capability::Image, &message) + .await; + return Err(AppError::External { + request_id: None, + message, + }); + } + }; + let body = parse_image_response_body(response).await?; + let status = body + .get("status") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + + match status { + "completed" => { + let images = parse_sdcpp_generated_images(&body); + if !images.is_empty() { + return Ok(images); + } + return Err(AppError::External { + request_id: None, + message: format!( + "stable-diffusion.cpp completed without images. Response shape was: {}", + summarize_image_response_shape(&body) + ), + }); + } + "failed" | "cancelled" => { + return Err(AppError::External { + request_id: None, + message: extract_sdcpp_job_error(&body) + .unwrap_or_else(|| format!("stable-diffusion.cpp job {status}")), + }); + } + "queued" | "generating" => {} + _ => { + return Err(AppError::External { + request_id: None, + message: format!("stable-diffusion.cpp returned unknown job status: {status}"), + }); + } + } + + if Instant::now() >= deadline { + return Err(AppError::External { + request_id: None, + message: "stable-diffusion.cpp image generation timed out".to_string(), + }); + } + + tokio::time::sleep(Duration::from_millis(700)).await; + } +} + +fn extract_sdcpp_job_error(body: &serde_json::Value) -> Option { + body.get("error") + .and_then(|error| error.get("message")) + .and_then(serde_json::Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) +} + +async fn clear_preview_file(path: &Path) { + if let Err(error) = tokio::fs::remove_file(path).await + && error.kind() != std::io::ErrorKind::NotFound + { + tracing::debug!( + "Failed to clear stale preview file {}: {error}", + path.display() + ); + } +} diff --git a/src-tauri/src/domain/ai/image_payload.rs b/src-tauri/src/domain/ai/image_payload.rs new file mode 100644 index 00000000..11b4fb5c --- /dev/null +++ b/src-tauri/src/domain/ai/image_payload.rs @@ -0,0 +1,304 @@ +//! Image generation payload normalization. + +use super::types::ImageGenerationRequest; + +pub(super) fn build_sdcpp_native_image_payload( + request: &ImageGenerationRequest, +) -> serde_json::Value { + serde_json::json!({ + "prompt": request.prompt, + "negative_prompt": request.negative_prompt.clone().unwrap_or_default(), + "clip_skip": request.clip_skip.unwrap_or(-1), + "width": request.width.unwrap_or(512), + "height": request.height.unwrap_or(512), + "seed": request.seed.unwrap_or(-1), + "batch_count": request.batch_size.unwrap_or(1), + "strength": request.denoising_strength.unwrap_or(0.75), + "sample_params": { + "scheduler": normalize_sdcpp_scheduler(request.scheduler.as_deref()), + "sample_method": normalize_sdcpp_sampler(request.sampler.as_deref()), + "sample_steps": request.steps.unwrap_or(20), + "guidance": { + "txt_cfg": request.cfg_scale.unwrap_or(7.0) + } + }, + "output_format": "png", + "output_compression": 100 + }) +} + +pub(super) fn build_local_image_payload(request: &ImageGenerationRequest) -> serde_json::Value { + let sampler_name = request + .sampler + .clone() + .unwrap_or_else(|| "euler_a".to_string()); + let scheduler = request.scheduler.clone().unwrap_or_default(); + + serde_json::json!({ + "prompt": request.prompt, + "steps": request.steps.unwrap_or(20), + "cfg_scale": request.cfg_scale.unwrap_or(7.0), + "width": request.width.unwrap_or(512), + "height": request.height.unwrap_or(512), + "sampler_name": sampler_name, + "scheduler": scheduler, + "seed": request.seed.unwrap_or(-1), + "batch_size": request.batch_size.unwrap_or(1), + "clip_skip": request.clip_skip.unwrap_or(-1), + "negative_prompt": request.negative_prompt.clone().unwrap_or_default() + }) +} + +pub(super) fn build_cloud_image_payload(request: &ImageGenerationRequest) -> serde_json::Value { + let model = resolve_cloud_image_model(request); + let mut payload = serde_json::json!({ + "model": model, + "messages": [ + { + "role": "user", + "content": request.prompt + } + ], + "modalities": resolve_openrouter_modalities(model) + }); + + if let Some(session_id) = request.session_id.as_ref().map(|value| value.trim()) + && !session_id.is_empty() + && let Some(payload_object) = payload.as_object_mut() + { + payload_object.insert( + "session_id".to_string(), + serde_json::Value::String(session_id.to_string()), + ); + } + + if let Some(image_config) = build_openrouter_image_config(request) + && let Some(payload_object) = payload.as_object_mut() + { + payload_object.insert("image_config".to_string(), image_config); + } + + payload +} + +fn resolve_cloud_image_model(request: &ImageGenerationRequest) -> &str { + if !request.model.trim().is_empty() && request.model != "default" { + return request.model.as_str(); + } + + match request.provider.as_str() { + "gpt-image" => "openai/gpt-5-image", + "seedream-image" => "bytedance-seed/seedream-4.5", + _ => "google/gemini-3.1-flash-image-preview", + } +} + +fn resolve_openrouter_modalities(model: &str) -> &'static [&'static str] { + if supports_text_with_generated_images(model) { + &["image", "text"] + } else { + &["image"] + } +} + +fn supports_text_with_generated_images(model: &str) -> bool { + let normalized = model.trim().to_ascii_lowercase(); + + normalized.starts_with("google/gemini-") + || normalized.starts_with("openai/gpt-5-image") + || normalized.starts_with("openai/gpt-image") +} + +fn build_openrouter_image_config(request: &ImageGenerationRequest) -> Option { + let aspect_ratio = resolve_aspect_ratio(request.width, request.height)?; + Some(serde_json::json!({ + "aspect_ratio": aspect_ratio + })) +} + +fn resolve_aspect_ratio(width: Option, height: Option) -> Option<&'static str> { + let (width, height) = (width?, height?); + match (width, height) { + (1024, 1024) | (512, 512) => Some("1:1"), + (1152, 896) | (1216, 832) => Some("4:3"), + (896, 1152) | (832, 1216) => Some("3:4"), + (1344, 768) | (1536, 864) => Some("16:9"), + (768, 1344) | (864, 1536) => Some("9:16"), + _ => None, + } +} + +fn normalize_sdcpp_sampler(value: Option<&str>) -> String { + match value.unwrap_or("euler a").trim().to_lowercase().as_str() { + "dpm++ 2m" | "dpmpp_2m" => "dpm++2m".to_string(), + "dpm++ 2m v2" | "dpm++2m v2" | "dpm++2m_v2" | "dpmpp_2m_v2" => "dpm++2mv2".to_string(), + "dpm++ 2s a" | "dpmpp_2s_a" => "dpm++2s_a".to_string(), + "euler a" | "euler_a" => "euler_a".to_string(), + "er sde" | "er_sde" => "er_sde".to_string(), + "res 2s" | "res_2s" => "res_2s".to_string(), + "res multistep" | "res_multistep" => "res_multistep".to_string(), + "ddim trailing" | "ddim_trailing" => "ddim_trailing".to_string(), + other => other.replace(' ', "_"), + } +} + +fn normalize_sdcpp_scheduler(value: Option<&str>) -> String { + match value.unwrap_or("discrete").trim().to_lowercase().as_str() { + "sgm uniform" | "sgm_uniform" => "sgm_uniform".to_string(), + "kl optimal" | "kl_optimal" => "kl_optimal".to_string(), + "bong tangent" | "bong_tangent" => "bong_tangent".to_string(), + other => other.replace(' ', "_"), + } +} + +#[cfg(test)] +mod tests { + use super::{build_cloud_image_payload, build_sdcpp_native_image_payload}; + use crate::domain::ai::ImageGenerationRequest; + use serde_json::json; + + fn make_cloud_request(model: &str) -> ImageGenerationRequest { + ImageGenerationRequest { + provider: "gpt-image".to_string(), + prompt: "draw a cat".to_string(), + original_prompt: None, + model: model.to_string(), + settings_key: None, + session_id: None, + steps: None, + cfg_scale: None, + denoising_strength: None, + width: None, + height: None, + sampler: None, + seed: None, + clip_skip: None, + negative_prompt: None, + batch_size: None, + scheduler: None, + } + } + + #[test] + fn build_cloud_image_payload_uses_image_only_modalities_for_flux_models() { + let payload = + build_cloud_image_payload(&make_cloud_request("black-forest-labs/flux.2-max")); + + assert_eq!(payload.get("modalities"), Some(&json!(["image"]))); + } + + #[test] + fn build_cloud_image_payload_uses_image_only_modalities_for_seedream_models() { + let payload = build_cloud_image_payload(&make_cloud_request("bytedance-seed/seedream-4.5")); + + assert_eq!(payload.get("modalities"), Some(&json!(["image"]))); + } + + #[test] + fn build_cloud_image_payload_keeps_text_output_for_gemini_image_models() { + let payload = + build_cloud_image_payload(&make_cloud_request("google/gemini-3.1-flash-image-preview")); + + assert_eq!(payload.get("modalities"), Some(&json!(["image", "text"]))); + } + + #[test] + fn build_cloud_image_payload_keeps_text_output_for_gpt_image_models() { + let payload = build_cloud_image_payload(&make_cloud_request("openai/gpt-5-image-mini")); + + assert_eq!(payload.get("modalities"), Some(&json!(["image", "text"]))); + } + + #[test] + fn build_cloud_image_payload_uses_provider_specific_default_model() { + let payload = build_cloud_image_payload(&ImageGenerationRequest { + provider: "gpt-image".to_string(), + model: "default".to_string(), + ..make_cloud_request("default") + }); + + assert_eq!(payload.get("model"), Some(&json!("openai/gpt-5-image"))); + + let payload = build_cloud_image_payload(&ImageGenerationRequest { + provider: "seedream-image".to_string(), + model: String::new(), + ..make_cloud_request("") + }); + + assert_eq!( + payload.get("model"), + Some(&json!("bytedance-seed/seedream-4.5")) + ); + } + + #[test] + fn builds_native_sdcpp_image_payload() { + let payload = build_sdcpp_native_image_payload(&ImageGenerationRequest { + provider: "sdcpp".to_string(), + prompt: "draw a cat".to_string(), + original_prompt: None, + model: "default".to_string(), + settings_key: None, + session_id: None, + steps: Some(30), + cfg_scale: Some(8.5), + denoising_strength: Some(0.42), + width: Some(896), + height: Some(1152), + sampler: Some("Euler A".to_string()), + seed: Some(42), + clip_skip: Some(2), + negative_prompt: Some("blurry".to_string()), + batch_size: Some(1), + scheduler: Some("Karras".to_string()), + }); + + assert_eq!(payload.get("prompt"), Some(&json!("draw a cat"))); + assert_eq!(payload.get("width"), Some(&json!(896))); + assert_eq!( + payload.pointer("/sample_params/sample_steps"), + Some(&json!(30)) + ); + assert_eq!( + payload.pointer("/sample_params/sample_method"), + Some(&json!("euler_a")) + ); + assert_eq!( + payload.pointer("/sample_params/scheduler"), + Some(&json!("karras")) + ); + assert_eq!( + payload.pointer("/sample_params/guidance/txt_cfg"), + Some(&json!(8.5)) + ); + let strength = payload + .get("strength") + .and_then(serde_json::Value::as_f64) + .unwrap_or_default(); + assert!((strength - 0.42).abs() < 0.001); + } + + #[test] + fn normalizes_sdcpp_er_sde_sampler() { + assert_eq!( + build_sdcpp_native_image_payload(&ImageGenerationRequest { + sampler: Some("ER SDE".to_string()), + ..make_cloud_request("default") + }) + .pointer("/sample_params/sample_method"), + Some(&json!("er_sde")) + ); + } + + #[test] + fn normalizes_sdcpp_dpmpp_2m_v2_sampler_to_official_name() { + assert_eq!( + build_sdcpp_native_image_payload(&ImageGenerationRequest { + sampler: Some("DPM++ 2M v2".to_string()), + ..make_cloud_request("default") + }) + .pointer("/sample_params/sample_method"), + Some(&json!("dpm++2mv2")) + ); + } +} diff --git a/src-tauri/src/domain/ai/image_response.rs b/src-tauri/src/domain/ai/image_response.rs new file mode 100644 index 00000000..0c421acb --- /dev/null +++ b/src-tauri/src/domain/ai/image_response.rs @@ -0,0 +1,204 @@ +//! Image response normalization for local and cloud adapters. + +#[derive(Clone, Copy)] +pub(super) enum ImageResponseFormat { + SdApi, + OpenAiCompatible, +} + +pub(super) fn parse_generated_images( + body: &serde_json::Value, + response_format: ImageResponseFormat, +) -> Vec { + match response_format { + ImageResponseFormat::SdApi => parse_sdcpp_generated_images(body), + ImageResponseFormat::OpenAiCompatible => body + .get("data") + .and_then(|value| value.as_array()) + .into_iter() + .flat_map(|items| items.iter()) + .filter_map(|item| { + item.get("b64_json") + .and_then(|value| value.as_str()) + .map(|b64| format!("data:image/png;base64,{b64}")) + .or_else(|| { + item.get("url") + .and_then(|value| value.as_str()) + .map(str::to_string) + }) + }) + .collect(), + } +} + +pub(super) fn parse_sdcpp_generated_images(body: &serde_json::Value) -> Vec { + let output_format = body + .get("result") + .and_then(|value| value.get("output_format")) + .or_else(|| body.get("output_format")) + .and_then(serde_json::Value::as_str) + .unwrap_or("png"); + + parse_image_items(body.get("images"), output_format) + .into_iter() + .chain(parse_image_items( + body.get("result").and_then(|value| value.get("images")), + output_format, + )) + .chain( + body.get("result") + .and_then(|value| value.get("b64_json")) + .and_then(serde_json::Value::as_str) + .map(|b64| data_url_from_b64(output_format, b64)), + ) + .collect() +} + +fn parse_image_items(value: Option<&serde_json::Value>, output_format: &str) -> Vec { + value + .and_then(serde_json::Value::as_array) + .into_iter() + .flat_map(|items| items.iter()) + .filter_map(|item| { + item.as_str() + .map(|b64| data_url_from_b64(output_format, b64)) + .or_else(|| { + item.get("b64_json") + .and_then(serde_json::Value::as_str) + .map(|b64| data_url_from_b64(output_format, b64)) + }) + .or_else(|| { + item.get("url") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + }) + }) + .collect() +} + +fn data_url_from_b64(output_format: &str, b64: &str) -> String { + let format = output_format + .trim() + .trim_start_matches('.') + .to_ascii_lowercase(); + let mime = match format.as_str() { + "jpg" | "jpeg" => "image/jpeg", + "webp" => "image/webp", + "gif" => "image/gif", + _ => "image/png", + }; + format!("data:{mime};base64,{b64}") +} + +pub(super) fn summarize_image_response_shape(body: &serde_json::Value) -> String { + let Some(object) = body.as_object() else { + return body + .as_str() + .map_or_else(|| body.to_string(), std::string::ToString::to_string); + }; + + object + .iter() + .map(|(key, value)| { + let kind = if value.is_array() { + "array" + } else if value.is_object() { + "object" + } else if value.is_string() { + "string" + } else if value.is_number() { + "number" + } else if value.is_boolean() { + "boolean" + } else { + "null" + }; + format!("{key}:{kind}") + }) + .collect::>() + .join(", ") +} + +pub(super) fn parse_openrouter_generated_images(body: &serde_json::Value) -> Vec { + body.get("choices") + .and_then(|value| value.as_array()) + .into_iter() + .flat_map(|items| items.iter()) + .filter_map(|item| item.get("message")) + .flat_map(extract_images_from_openrouter_message) + .collect() +} + +fn extract_images_from_openrouter_message(message: &serde_json::Value) -> Vec { + if let Some(images) = message.get("images").and_then(|value| value.as_array()) { + return images + .iter() + .filter_map(extract_openrouter_image_url) + .collect(); + } + + if let Some(content) = message.get("content").and_then(|value| value.as_array()) { + return content + .iter() + .filter_map(|item| { + item.get("image_url") + .and_then(|value| value.get("url")) + .and_then(|value| value.as_str()) + .map(str::to_string) + }) + .collect(); + } + + Vec::new() +} + +fn extract_openrouter_image_url(item: &serde_json::Value) -> Option { + item.get("image_url") + .and_then(|value| value.get("url")) + .and_then(|value| value.as_str()) + .map(str::to_string) + .or_else(|| { + item.get("imageUrl") + .and_then(|value| value.get("url")) + .and_then(|value| value.as_str()) + .map(str::to_string) + }) +} + +#[cfg(test)] +mod tests { + use super::{ImageResponseFormat, parse_generated_images}; + use serde_json::json; + + #[test] + fn parses_stable_diffusion_webui_style_images() { + let images = parse_generated_images( + &json!({ + "images": ["ZmFrZQ=="], + "parameters": {}, + "info": "{}" + }), + ImageResponseFormat::SdApi, + ); + + assert_eq!(images, vec!["data:image/png;base64,ZmFrZQ=="]); + } + + #[test] + fn parses_sdcpp_webui_result_images() { + let images = parse_generated_images( + &json!({ + "kind": "img_gen", + "result": { + "output_format": "webp", + "images": [ + { "b64_json": "ZmFrZQ==" } + ] + } + }), + ImageResponseFormat::SdApi, + ); + + assert_eq!(images, vec!["data:image/webp;base64,ZmFrZQ=="]); + } +} diff --git a/src-tauri/src/domain/ai/image_service.rs b/src-tauri/src/domain/ai/image_service.rs index 8e20e9d9..d44a7f62 100644 --- a/src-tauri/src/domain/ai/image_service.rs +++ b/src-tauri/src/domain/ai/image_service.rs @@ -1,59 +1,16 @@ -use base64::{Engine as _, engine::general_purpose::STANDARD}; -use std::path::Path; -use std::time::{Duration, Instant}; - -use super::ai_dispatch::{LocalEngineAccess, active_local_engine_status, build_engine_config}; +use super::ai_dispatch::LocalEngineAccess; use super::ai_service::stop_conflicting_local_engine; +use super::image_cloud::{is_cloud_image_provider, process_cloud_image_request}; +use super::image_comfyui::process_comfyui_request; +use super::image_local::process_local_image_request; +use super::image_settings::apply_image_request_defaults; use super::session::ChatSessionManager; use super::types::{ChatMessage, ChatReply, ImageGenerationRequest, ImageGenerationResponse}; use crate::domain::ai::ImageGenerationState; -use crate::domain::engine::manager::{EngineManager, resolve_sdcpp_preview_path}; +use crate::domain::engine::manager::EngineManager; use crate::domain::engine::types::Capability; use crate::errors::AppError; use crate::infrastructure::config::settings::SettingsService; -use crate::infrastructure::crypto::secure_storage::SecureStorage; -use crate::models::AppSettings; - -struct PreparedImageDispatch { - base_url: String, - request_url: String, - api: LocalImageApi, - response_format: ImageResponseFormat, - preview_path: Option, -} - -struct ImageRequestSettingsContext { - settings: AppSettings, - settings_key: String, -} - -struct ComfyUiRequestContext { - base_url: String, - checkpoint: String, - sampler: String, - scheduler: String, - seed: u64, - steps: u32, - cfg_scale: f32, - width: u32, - height: u32, - batch_size: u32, - negative_prompt: String, - prompt_id: String, - client_id: String, -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum LocalImageApi { - SdcppNative, - OpenAiCompatible, -} - -#[derive(Clone, Copy)] -enum ImageResponseFormat { - SdApi, - OpenAiCompatible, -} pub(super) async fn process_image_request( request: ImageGenerationRequest, @@ -112,14 +69,13 @@ async fn process_image_request_with_local_engine_access( } else if is_cloud_image_provider(&request.provider) { process_cloud_image_request(&request).await? } else { - let dispatch = - prepare_local_image_dispatch(&request, engine_manager, local_engine_access).await?; - image_generation_state - .begin(&request.provider, &dispatch.base_url, None) - .await; - let result = execute_local_image_request(&request, dispatch, image_generation_state).await; - image_generation_state.clear(&request.provider, None).await; - result? + process_local_image_request( + &request, + engine_manager, + image_generation_state, + local_engine_access, + ) + .await? }; if let Some(session_id) = request.session_id.as_deref() @@ -159,1279 +115,6 @@ async fn process_image_request_with_local_engine_access( }) } -async fn prepare_local_image_dispatch( - request: &ImageGenerationRequest, - engine_manager: &EngineManager, - local_engine_access: LocalEngineAccess, -) -> Result { - let Some(definition) = engine_manager.get_definition(&request.provider).await else { - return Err(AppError::External { - request_id: None, - message: "Cloud image generation is not yet supported. Please use a local engine." - .into(), - }); - }; - - tracing::info!( - provider = %request.provider, - "Detected local engine for image generation" - ); - - let (base_url, preview_path) = - resolve_local_image_endpoint(request, engine_manager, local_engine_access, &definition) - .await?; - let api = local_image_api(&request.provider); - let response_format = image_response_format(api); - let request_url = build_image_generation_url(&base_url, api); - - Ok(PreparedImageDispatch { - base_url, - request_url, - api, - response_format, - preview_path, - }) -} - -fn is_cloud_image_provider(provider: &str) -> bool { - matches!(provider, "gemini-image" | "gpt-image" | "seedream-image") -} - -fn local_image_api(provider: &str) -> LocalImageApi { - if matches!(provider, "sdcpp" | "stable-diffusion") { - LocalImageApi::SdcppNative - } else { - LocalImageApi::OpenAiCompatible - } -} - -async fn resolve_local_image_endpoint( - request: &ImageGenerationRequest, - engine_manager: &EngineManager, - local_engine_access: LocalEngineAccess, - definition: &crate::domain::engine::types::EngineDefinition, -) -> Result<(String, Option), AppError> { - match local_engine_access { - LocalEngineAccess::AutoStart => { - let mut config = build_engine_config(definition).await?; - - if !request.model.is_empty() && request.model != "default" { - config.model_path = Some(request.model.clone()); - } - - if config.model_path.as_deref() == Some("default") { - config.model_path = None; - } - - let preview_path = resolve_sdcpp_preview_path(&config.extra_args); - stop_conflicting_local_engine(engine_manager, Capability::Image).await?; - let status = engine_manager.start(config).await?; - - Ok((status.endpoint, preview_path)) - } - LocalEngineAccess::RequireRunning => { - let status = - active_local_engine_status(engine_manager, &request.provider, Capability::Image) - .await?; - let preview_path = engine_manager.active_image_preview_path().await; - Ok((status.endpoint, preview_path)) - } - } -} - -const fn image_response_format(api: LocalImageApi) -> ImageResponseFormat { - match api { - LocalImageApi::SdcppNative => ImageResponseFormat::SdApi, - LocalImageApi::OpenAiCompatible => ImageResponseFormat::OpenAiCompatible, - } -} - -fn build_image_generation_url(base_url: &str, api: LocalImageApi) -> String { - match api { - LocalImageApi::SdcppNative => format!("{base_url}/sdcpp/v1/img_gen"), - LocalImageApi::OpenAiCompatible => format!("{base_url}/v1/images/generations"), - } -} - -async fn execute_local_image_request( - request: &ImageGenerationRequest, - dispatch: PreparedImageDispatch, - image_generation_state: &ImageGenerationState, -) -> Result, AppError> { - let client = build_image_client(Duration::from_secs(999_999))?; - - if let Some(preview_path) = dispatch.preview_path.as_deref() { - clear_preview_file(preview_path).await; - } - - if dispatch.api == LocalImageApi::SdcppNative { - return execute_sdcpp_native_image_request( - request, - &dispatch, - image_generation_state, - &client, - ) - .await; - } - - let payload = build_local_image_payload(request); - tracing::info!( - "Sending image generation request to {}", - dispatch.request_url - ); - let response = client - .post(&dispatch.request_url) - .json(&payload) - .send() - .await - .map_err(|error| AppError::External { - request_id: None, - message: format!( - "Local image engine request failed at {}: {error}. The engine may have stopped, closed the connection, or run out of memory while generating.", - dispatch.request_url - ), - })?; - - let body = parse_image_response_body(response).await?; - let images = parse_generated_images(&body, dispatch.response_format); - if images.is_empty() { - return Err(AppError::External { - request_id: None, - message: format!( - "Local image engine returned no images. Response shape was: {}", - summarize_image_response_shape(&body) - ), - }); - } - - Ok(images) -} - -async fn execute_sdcpp_native_image_request( - request: &ImageGenerationRequest, - dispatch: &PreparedImageDispatch, - image_generation_state: &ImageGenerationState, - client: &reqwest::Client, -) -> Result, AppError> { - let payload = build_sdcpp_native_image_payload(request); - tracing::info!( - "Submitting stable-diffusion.cpp native image job to {}", - dispatch.request_url - ); - let response = client - .post(&dispatch.request_url) - .json(&payload) - .send() - .await - .map_err(|error| AppError::External { - request_id: None, - message: format!( - "Local image engine request failed at {}: {error}. The engine may have stopped, closed the connection, or run out of memory while generating.", - dispatch.request_url - ), - })?; - - let body = parse_image_response_body(response).await?; - let job_id = extract_sdcpp_job_id(&body).ok_or_else(|| AppError::External { - request_id: None, - message: format!( - "stable-diffusion.cpp did not return a native job id. Response shape was: {}", - summarize_image_response_shape(&body) - ), - })?; - image_generation_state - .update_prompt_id(&request.provider, job_id.clone()) - .await; - - wait_for_sdcpp_native_images( - client, - &dispatch.base_url, - &request.provider, - &job_id, - image_generation_state, - ) - .await -} - -fn extract_sdcpp_job_id(body: &serde_json::Value) -> Option { - body.get("id") - .and_then(serde_json::Value::as_str) - .filter(|value| !value.trim().is_empty()) - .map(str::to_string) -} - -async fn wait_for_sdcpp_native_images( - client: &reqwest::Client, - base_url: &str, - provider: &str, - job_id: &str, - image_generation_state: &ImageGenerationState, -) -> Result, AppError> { - let deadline = Instant::now() + Duration::from_secs(999_999); - let job_url = format!("{}/sdcpp/v1/jobs/{job_id}", base_url.trim_end_matches('/')); - - loop { - if image_generation_state - .is_cancelled(provider, Some(job_id)) - .await - { - return Err(AppError::External { - request_id: None, - message: "Image generation cancelled".to_string(), - }); - } - - let response = client - .get(&job_url) - .send() - .await - .map_err(|error| AppError::External { - request_id: None, - message: format!("Failed to poll stable-diffusion.cpp job: {error}"), - })?; - let body = parse_image_response_body(response).await?; - let status = body - .get("status") - .and_then(serde_json::Value::as_str) - .unwrap_or_default(); - - match status { - "completed" => { - let images = parse_sdcpp_generated_images(&body); - if !images.is_empty() { - return Ok(images); - } - return Err(AppError::External { - request_id: None, - message: format!( - "stable-diffusion.cpp completed without images. Response shape was: {}", - summarize_image_response_shape(&body) - ), - }); - } - "failed" | "cancelled" => { - return Err(AppError::External { - request_id: None, - message: extract_sdcpp_job_error(&body) - .unwrap_or_else(|| format!("stable-diffusion.cpp job {status}")), - }); - } - "queued" | "generating" => {} - _ => { - return Err(AppError::External { - request_id: None, - message: format!("stable-diffusion.cpp returned unknown job status: {status}"), - }); - } - } - - if Instant::now() >= deadline { - return Err(AppError::External { - request_id: None, - message: "stable-diffusion.cpp image generation timed out".to_string(), - }); - } - - tokio::time::sleep(Duration::from_millis(700)).await; - } -} - -fn extract_sdcpp_job_error(body: &serde_json::Value) -> Option { - body.get("error") - .and_then(|error| error.get("message")) - .and_then(serde_json::Value::as_str) - .filter(|value| !value.trim().is_empty()) - .map(str::to_string) -} - -fn build_sdcpp_native_image_payload(request: &ImageGenerationRequest) -> serde_json::Value { - serde_json::json!({ - "prompt": request.prompt, - "negative_prompt": request.negative_prompt.clone().unwrap_or_default(), - "clip_skip": request.clip_skip.unwrap_or(-1), - "width": request.width.unwrap_or(512), - "height": request.height.unwrap_or(512), - "seed": request.seed.unwrap_or(-1), - "batch_count": request.batch_size.unwrap_or(1), - "sample_params": { - "scheduler": normalize_sdcpp_scheduler(request.scheduler.as_deref()), - "sample_method": normalize_sdcpp_sampler(request.sampler.as_deref()), - "sample_steps": request.steps.unwrap_or(20), - "strength": request.denoising_strength.unwrap_or(0.75), - "guidance": { - "txt_cfg": request.cfg_scale.unwrap_or(7.0) - } - }, - "output_format": "png", - "output_compression": 100 - }) -} - -fn build_local_image_payload(request: &ImageGenerationRequest) -> serde_json::Value { - let sampler_name = request - .sampler - .clone() - .unwrap_or_else(|| "euler_a".to_string()); - let scheduler = request.scheduler.clone().unwrap_or_default(); - - serde_json::json!({ - "prompt": request.prompt, - "steps": request.steps.unwrap_or(20), - "cfg_scale": request.cfg_scale.unwrap_or(7.0), - "width": request.width.unwrap_or(512), - "height": request.height.unwrap_or(512), - "sampler_name": sampler_name, - "scheduler": scheduler, - "seed": request.seed.unwrap_or(-1), - "batch_size": request.batch_size.unwrap_or(1), - "clip_skip": request.clip_skip.unwrap_or(-1), - "negative_prompt": request.negative_prompt.clone().unwrap_or_default() - }) -} - -async fn process_cloud_image_request( - request: &ImageGenerationRequest, -) -> Result, AppError> { - let api_key = SecureStorage::get_key_async("openrouter_api_key".to_string()) - .await? - .filter(|value| !value.trim().is_empty()) - .ok_or_else(|| AppError::Validation("OpenRouter API key is missing".to_string()))?; - - let client = build_image_client(Duration::from_secs(180))?; - let response = client - .post("https://openrouter.ai/api/v1/chat/completions") - .header(reqwest::header::AUTHORIZATION, format!("Bearer {api_key}")) - .header(reqwest::header::CONTENT_TYPE, "application/json") - .json(&build_cloud_image_payload(request)) - .send() - .await - .map_err(|error| AppError::External { - request_id: None, - message: format!("Cloud image request failed: {error}"), - })?; - - let body = parse_image_response_body(response).await?; - let images = parse_openrouter_generated_images(&body); - if images.is_empty() { - return Err(AppError::External { - request_id: None, - message: "Cloud image provider returned no images".to_string(), - }); - } - - Ok(images) -} - -fn build_cloud_image_payload(request: &ImageGenerationRequest) -> serde_json::Value { - let model = resolve_cloud_image_model(request); - let mut payload = serde_json::json!({ - "model": model, - "messages": [ - { - "role": "user", - "content": request.prompt - } - ], - "modalities": resolve_openrouter_modalities(model) - }); - - if let Some(session_id) = request.session_id.as_ref().map(|value| value.trim()) - && !session_id.is_empty() - && let Some(payload_object) = payload.as_object_mut() - { - payload_object.insert( - "session_id".to_string(), - serde_json::Value::String(session_id.to_string()), - ); - } - - if let Some(image_config) = build_openrouter_image_config(request) { - if let Some(payload_object) = payload.as_object_mut() { - payload_object.insert("image_config".to_string(), image_config); - } - } - - payload -} - -fn resolve_cloud_image_model(request: &ImageGenerationRequest) -> &str { - if !request.model.trim().is_empty() && request.model != "default" { - return request.model.as_str(); - } - - match request.provider.as_str() { - "gpt-image" => "openai/gpt-5-image", - "seedream-image" => "bytedance-seed/seedream-4.5", - _ => "google/gemini-3.1-flash-image-preview", - } -} - -fn resolve_openrouter_modalities(model: &str) -> &'static [&'static str] { - if supports_text_with_generated_images(model) { - &["image", "text"] - } else { - &["image"] - } -} - -fn supports_text_with_generated_images(model: &str) -> bool { - let normalized = model.trim().to_ascii_lowercase(); - - normalized.starts_with("google/gemini-") - || normalized.starts_with("openai/gpt-5-image") - || normalized.starts_with("openai/gpt-image") -} - -fn build_openrouter_image_config(request: &ImageGenerationRequest) -> Option { - let aspect_ratio = resolve_aspect_ratio(request.width, request.height)?; - Some(serde_json::json!({ - "aspect_ratio": aspect_ratio - })) -} - -fn resolve_aspect_ratio(width: Option, height: Option) -> Option<&'static str> { - let (width, height) = (width?, height?); - match (width, height) { - (1024, 1024) | (512, 512) => Some("1:1"), - (1152, 896) | (1216, 832) => Some("4:3"), - (896, 1152) | (832, 1216) => Some("3:4"), - (1344, 768) | (1536, 864) => Some("16:9"), - (768, 1344) | (864, 1536) => Some("9:16"), - _ => None, - } -} - -fn build_image_client(timeout: Duration) -> Result { - reqwest::Client::builder() - .timeout(timeout) - .build() - .map_err(|error| AppError::External { - request_id: None, - message: error.to_string(), - }) -} - -async fn parse_image_response_body( - response: reqwest::Response, -) -> Result { - if !response.status().is_success() { - let err_text = response.text().await.unwrap_or_default(); - return Err(AppError::External { - request_id: None, - message: format!("Image generation failed: {err_text}"), - }); - } - - response.json().await.map_err(|error| AppError::External { - request_id: None, - message: format!("Failed to parse image response: {error}"), - }) -} - -fn parse_generated_images( - body: &serde_json::Value, - response_format: ImageResponseFormat, -) -> Vec { - match response_format { - ImageResponseFormat::SdApi => parse_sdcpp_generated_images(body), - ImageResponseFormat::OpenAiCompatible => body - .get("data") - .and_then(|value| value.as_array()) - .into_iter() - .flat_map(|items| items.iter()) - .filter_map(|item| { - item.get("b64_json") - .and_then(|value| value.as_str()) - .map(|b64| format!("data:image/png;base64,{b64}")) - .or_else(|| { - item.get("url") - .and_then(|value| value.as_str()) - .map(str::to_string) - }) - }) - .collect(), - } -} - -fn parse_sdcpp_generated_images(body: &serde_json::Value) -> Vec { - let output_format = body - .get("result") - .and_then(|value| value.get("output_format")) - .or_else(|| body.get("output_format")) - .and_then(serde_json::Value::as_str) - .unwrap_or("png"); - - parse_image_items(body.get("images"), output_format) - .into_iter() - .chain(parse_image_items( - body.get("result").and_then(|value| value.get("images")), - output_format, - )) - .chain( - body.get("result") - .and_then(|value| value.get("b64_json")) - .and_then(serde_json::Value::as_str) - .map(|b64| data_url_from_b64(output_format, b64)), - ) - .collect() -} - -fn parse_image_items(value: Option<&serde_json::Value>, output_format: &str) -> Vec { - value - .and_then(serde_json::Value::as_array) - .into_iter() - .flat_map(|items| items.iter()) - .filter_map(|item| { - item.as_str() - .map(|b64| data_url_from_b64(output_format, b64)) - .or_else(|| { - item.get("b64_json") - .and_then(serde_json::Value::as_str) - .map(|b64| data_url_from_b64(output_format, b64)) - }) - .or_else(|| { - item.get("url") - .and_then(serde_json::Value::as_str) - .map(str::to_string) - }) - }) - .collect() -} - -fn data_url_from_b64(output_format: &str, b64: &str) -> String { - let format = output_format - .trim() - .trim_start_matches('.') - .to_ascii_lowercase(); - let mime = match format.as_str() { - "jpg" | "jpeg" => "image/jpeg", - "webp" => "image/webp", - "gif" => "image/gif", - _ => "image/png", - }; - format!("data:{mime};base64,{b64}") -} - -fn summarize_image_response_shape(body: &serde_json::Value) -> String { - let Some(object) = body.as_object() else { - return body - .as_str() - .map_or_else(|| body.to_string(), std::string::ToString::to_string); - }; - - object - .iter() - .map(|(key, value)| { - let kind = if value.is_array() { - "array" - } else if value.is_object() { - "object" - } else if value.is_string() { - "string" - } else if value.is_number() { - "number" - } else if value.is_boolean() { - "boolean" - } else { - "null" - }; - format!("{key}:{kind}") - }) - .collect::>() - .join(", ") -} - -fn parse_openrouter_generated_images(body: &serde_json::Value) -> Vec { - body.get("choices") - .and_then(|value| value.as_array()) - .into_iter() - .flat_map(|items| items.iter()) - .filter_map(|item| item.get("message")) - .flat_map(extract_images_from_openrouter_message) - .collect() -} - -fn extract_images_from_openrouter_message(message: &serde_json::Value) -> Vec { - if let Some(images) = message.get("images").and_then(|value| value.as_array()) { - return images - .iter() - .filter_map(extract_openrouter_image_url) - .collect(); - } - - if let Some(content) = message.get("content").and_then(|value| value.as_array()) { - return content - .iter() - .filter_map(|item| { - item.get("image_url") - .and_then(|value| value.get("url")) - .and_then(|value| value.as_str()) - .map(str::to_string) - }) - .collect(); - } - - Vec::new() -} - -fn extract_openrouter_image_url(item: &serde_json::Value) -> Option { - item.get("image_url") - .and_then(|value| value.get("url")) - .and_then(|value| value.as_str()) - .map(str::to_string) - .or_else(|| { - item.get("imageUrl") - .and_then(|value| value.get("url")) - .and_then(|value| value.as_str()) - .map(str::to_string) - }) -} - -async fn process_comfyui_request( - request: &ImageGenerationRequest, - image_generation_state: &ImageGenerationState, - settings_service: &SettingsService, -) -> Result, AppError> { - let settings_context = load_image_request_settings_context(request, settings_service).await?; - let client = build_image_client(Duration::from_secs(120))?; - let comfyui = build_comfyui_request_context(request, &settings_context, &client).await?; - let workflow = build_comfyui_workflow( - &request.prompt, - &comfyui.negative_prompt, - &comfyui.checkpoint, - comfyui.seed, - comfyui.steps, - comfyui.cfg_scale, - comfyui.width, - comfyui.height, - comfyui.batch_size, - &comfyui.sampler, - &comfyui.scheduler, - ); - - image_generation_state - .begin( - &request.provider, - &comfyui.base_url, - Some(comfyui.prompt_id.clone()), - ) - .await; - - let mut active_prompt_id = comfyui.prompt_id.clone(); - let result = async { - let queue_body = queue_comfyui_prompt(&client, &comfyui, workflow).await?; - - if let Some(server_prompt_id) = queue_body.get("prompt_id").and_then(|value| value.as_str()) - && !server_prompt_id.trim().is_empty() - { - active_prompt_id = server_prompt_id.to_string(); - image_generation_state - .update_prompt_id(&request.provider, active_prompt_id.clone()) - .await; - } - - if let Some(message) = extract_comfyui_queue_error(&queue_body) { - return Err(AppError::External { - request_id: None, - message, - }); - } - - wait_for_comfyui_images( - &client, - &comfyui.base_url, - &request.provider, - &active_prompt_id, - image_generation_state, - ) - .await - } - .await; - - image_generation_state - .clear(&request.provider, Some(active_prompt_id.as_str())) - .await; - - result -} - -async fn resolve_comfyui_checkpoint( - request: &ImageGenerationRequest, - settings: &AppSettings, - settings_key: &str, - base_url: &str, - client: &reqwest::Client, -) -> Result { - if !request.model.trim().is_empty() && request.model != "default" { - return Ok(normalize_comfyui_checkpoint(&request.model)); - } - - if let Some(saved_checkpoint) = - resolve_string_setting(settings, settings_key, &request.provider, "checkpoint") - { - return Ok(normalize_comfyui_checkpoint(&saved_checkpoint)); - } - - let available_checkpoints = fetch_comfyui_checkpoints(client, base_url).await?; - if let Some(checkpoint) = available_checkpoints.first() { - return Ok(normalize_comfyui_checkpoint(checkpoint)); - } - - Err(AppError::Config( - "ComfyUI does not expose any checkpoints yet. Install a model in ComfyUI and try again." - .to_string(), - )) -} - -fn normalize_comfyui_base_url(raw: &str) -> String { - let trimmed = raw.trim().trim_end_matches('/'); - if trimmed.is_empty() { - return "http://127.0.0.1:8188".to_string(); - } - - if trimmed.starts_with("http://") || trimmed.starts_with("https://") { - return trimmed.to_string(); - } - - format!("http://{trimmed}") -} - -fn normalize_comfyui_checkpoint(raw: &str) -> String { - raw.trim() - .replace('\\', "/") - .split('/') - .next_back() - .unwrap_or(raw) - .trim() - .to_string() -} - -pub(super) fn normalize_comfyui_sampler(value: Option<&str>) -> String { - match value.unwrap_or("euler").trim().to_lowercase().as_str() { - "euler a" | "euler_a" | "euler ancestral" | "euler_ancestral" => { - "euler_ancestral".to_string() - } - "euler" => "euler".to_string(), - "heun" => "heun".to_string(), - "heunpp2" => "heunpp2".to_string(), - "dpm2" | "dpm 2" | "dpm_2" => "dpm_2".to_string(), - "dpm2 a" | "dpm2_a" | "dpm 2 ancestral" | "dpm_2_ancestral" => { - "dpm_2_ancestral".to_string() - } - "lms" => "lms".to_string(), - "dpm fast" | "dpm_fast" => "dpm_fast".to_string(), - "dpm adaptive" | "dpm_adaptive" => "dpm_adaptive".to_string(), - "dpm++ 2s a" | "dpm++2s_a" | "dpmpp_2s_a" | "dpmpp_2s_ancestral" => { - "dpmpp_2s_ancestral".to_string() - } - "dpm++ sde" | "dpmpp_sde" => "dpmpp_sde".to_string(), - "dpm++ sde gpu" | "dpmpp_sde_gpu" => "dpmpp_sde_gpu".to_string(), - "dpm++ 2m" | "dpm++2m" | "dpmpp_2m" => "dpmpp_2m".to_string(), - "dpm++ 3m sde" | "dpm++3m sde" | "dpmpp_3m_sde" => "dpmpp_3m_sde".to_string(), - "dpm++ 3m sde gpu" | "dpm++3m sde gpu" | "dpmpp_3m_sde_gpu" => { - "dpmpp_3m_sde_gpu".to_string() - } - "ddpm" => "ddpm".to_string(), - "lcm" => "lcm".to_string(), - "ipndm" => "ipndm".to_string(), - "ipndm_v" => "ipndm_v".to_string(), - "deis" => "deis".to_string(), - "ddim" => "ddim".to_string(), - "uni pc" | "uni_pc" => "uni_pc".to_string(), - "uni pc bh2" | "uni_pc_bh2" => "uni_pc_bh2".to_string(), - other => other.to_string(), - } -} - -pub(super) fn normalize_comfyui_scheduler(value: Option<&str>) -> String { - match value.unwrap_or("karras").trim().to_lowercase().as_str() { - "default" | "auto" | "karras" => "karras".to_string(), - "normal" => "normal".to_string(), - "simple" => "simple".to_string(), - "sgm uniform" | "sgm_uniform" => "sgm_uniform".to_string(), - "exponential" => "exponential".to_string(), - "ddim uniform" | "ddim_uniform" => "ddim_uniform".to_string(), - "beta" => "beta".to_string(), - "linear quadratic" | "linear_quadratic" => "linear_quadratic".to_string(), - "kl optimal" | "kl_optimal" => "kl_optimal".to_string(), - other => other.to_string(), - } -} - -fn normalize_comfyui_seed(value: Option) -> u64 { - match value { - Some(seed) if seed >= 0 => u64::from(seed.unsigned_abs()), - _ => rand::random::(), - } -} - -async fn fetch_comfyui_checkpoints( - client: &reqwest::Client, - base_url: &str, -) -> Result, AppError> { - let response = client - .get(format!("{base_url}/models/checkpoints")) - .send() - .await - .map_err(|error| AppError::External { - request_id: None, - message: format!("Failed to query ComfyUI checkpoints: {error}"), - })?; - - if !response.status().is_success() { - let body = response.text().await.unwrap_or_default(); - return Err(AppError::External { - request_id: None, - message: format!("ComfyUI checkpoints request failed: {body}"), - }); - } - - let payload: serde_json::Value = response.json().await.map_err(|error| AppError::External { - request_id: None, - message: format!("Failed to parse ComfyUI checkpoint list: {error}"), - })?; - - Ok(parse_comfyui_checkpoint_list(&payload)) -} - -pub(super) fn parse_comfyui_checkpoint_list(payload: &serde_json::Value) -> Vec { - fn extract_checkpoint_name(value: &serde_json::Value) -> Option { - if let Some(name) = value.as_str() { - let trimmed = name.trim(); - if !trimmed.is_empty() { - return Some(trimmed.to_string()); - } - } - - let object = value.as_object()?; - for key in ["name", "filename", "path"] { - if let Some(candidate) = object.get(key).and_then(|entry| entry.as_str()) { - let trimmed = candidate.trim(); - if !trimmed.is_empty() { - return Some(trimmed.to_string()); - } - } - } - - None - } - - let values = if let Some(items) = payload.as_array() { - items.iter().collect::>() - } else if let Some(items) = payload.get("models").and_then(|value| value.as_array()) { - items.iter().collect::>() - } else if let Some(items) = payload.get("files").and_then(|value| value.as_array()) { - items.iter().collect::>() - } else { - Vec::new() - }; - - let mut seen = std::collections::HashSet::new(); - values - .into_iter() - .filter_map(extract_checkpoint_name) - .filter(|value| seen.insert(value.clone())) - .collect() -} - -#[allow(clippy::too_many_arguments)] -fn build_comfyui_workflow( - prompt: &str, - negative_prompt: &str, - checkpoint: &str, - seed: u64, - steps: u32, - cfg_scale: f32, - width: u32, - height: u32, - batch_size: u32, - sampler: &str, - scheduler: &str, -) -> serde_json::Value { - serde_json::json!({ - "3": { - "class_type": "KSampler", - "inputs": { - "cfg": cfg_scale, - "denoise": 1.0, - "latent_image": ["5", 0], - "model": ["4", 0], - "negative": ["7", 0], - "positive": ["6", 0], - "sampler_name": sampler, - "scheduler": scheduler, - "seed": seed, - "steps": steps - } - }, - "4": { - "class_type": "CheckpointLoaderSimple", - "inputs": { - "ckpt_name": checkpoint - } - }, - "5": { - "class_type": "EmptyLatentImage", - "inputs": { - "batch_size": batch_size, - "height": height, - "width": width - } - }, - "6": { - "class_type": "CLIPTextEncode", - "inputs": { - "clip": ["4", 1], - "text": prompt - } - }, - "7": { - "class_type": "CLIPTextEncode", - "inputs": { - "clip": ["4", 1], - "text": negative_prompt - } - }, - "8": { - "class_type": "VAEDecode", - "inputs": { - "samples": ["3", 0], - "vae": ["4", 2] - } - }, - "9": { - "class_type": "SaveImage", - "inputs": { - "filename_prefix": "Axelate", - "images": ["8", 0] - } - } - }) -} - -async fn wait_for_comfyui_images( - client: &reqwest::Client, - base_url: &str, - provider: &str, - prompt_id: &str, - image_generation_state: &ImageGenerationState, -) -> Result, AppError> { - let deadline = Instant::now() + Duration::from_secs(600); - - loop { - if image_generation_state - .is_cancelled(provider, Some(prompt_id)) - .await - { - return Err(AppError::External { - request_id: None, - message: "Image generation cancelled".to_string(), - }); - } - - let response = client - .get(format!("{base_url}/history/{prompt_id}")) - .send() - .await - .map_err(|error| AppError::External { - request_id: None, - message: format!("Failed to poll ComfyUI history: {error}"), - })?; - - if response.status().is_success() { - let history_body: serde_json::Value = - response.json().await.map_err(|error| AppError::External { - request_id: None, - message: format!("Failed to parse ComfyUI history: {error}"), - })?; - - if let Some(entry) = history_body.get(prompt_id) { - let images = fetch_comfyui_history_images(client, base_url, entry).await?; - if !images.is_empty() { - return Ok(images); - } - } - } - - if Instant::now() >= deadline { - return Err(AppError::External { - request_id: None, - message: "ComfyUI image generation timed out".to_string(), - }); - } - - tokio::time::sleep(Duration::from_millis(700)).await; - } -} - -async fn fetch_comfyui_history_images( - client: &reqwest::Client, - base_url: &str, - history_entry: &serde_json::Value, -) -> Result, AppError> { - let mut images = Vec::new(); - let Some(outputs) = history_entry - .get("outputs") - .and_then(|value| value.as_object()) - else { - return Ok(images); - }; - - for node_output in outputs.values() { - let Some(node_images) = node_output.get("images").and_then(|value| value.as_array()) else { - continue; - }; - - for image_meta in node_images { - let Some(filename) = image_meta.get("filename").and_then(|value| value.as_str()) else { - continue; - }; - - let subfolder = image_meta - .get("subfolder") - .and_then(|value| value.as_str()) - .unwrap_or_default(); - let image_type = image_meta - .get("type") - .and_then(|value| value.as_str()) - .unwrap_or("output"); - let mut image_url = - reqwest::Url::parse(&format!("{base_url}/view")).map_err(|error| { - AppError::External { - request_id: None, - message: format!("Failed to build ComfyUI image URL: {error}"), - } - })?; - { - let mut query = image_url.query_pairs_mut(); - query.append_pair("filename", filename); - if !subfolder.is_empty() { - query.append_pair("subfolder", subfolder); - } - query.append_pair("type", image_type); - } - - let response = - client - .get(image_url) - .send() - .await - .map_err(|error| AppError::External { - request_id: None, - message: format!("Failed to fetch ComfyUI image: {error}"), - })?; - - if !response.status().is_success() { - let body = response.text().await.unwrap_or_default(); - return Err(AppError::External { - request_id: None, - message: format!("ComfyUI image download failed: {body}"), - }); - } - - let mime_type = response - .headers() - .get(reqwest::header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .unwrap_or("image/png") - .to_string(); - let bytes = response.bytes().await.map_err(|error| AppError::External { - request_id: None, - message: format!("Failed to read ComfyUI image bytes: {error}"), - })?; - - images.push(format!( - "data:{mime_type};base64,{}", - STANDARD.encode(bytes) - )); - } - } - - Ok(images) -} - -fn extract_comfyui_queue_error(body: &serde_json::Value) -> Option { - if let Some(error_message) = body.get("error").and_then(|value| value.as_str()) { - return Some(format!("ComfyUI queue error: {error_message}")); - } - - let node_errors = body.get("node_errors")?; - if !node_errors.is_object() - || node_errors - .as_object() - .is_some_and(serde_json::Map::is_empty) - { - return None; - } - - Some(format!("ComfyUI node validation failed: {node_errors}")) -} - -async fn apply_image_request_defaults( - mut request: ImageGenerationRequest, - settings_service: &SettingsService, -) -> Result { - let settings_context = load_image_request_settings_context(&request, settings_service).await?; - apply_saved_image_defaults( - &mut request, - &settings_context.settings, - &settings_context.settings_key, - ); - - Ok(request) -} - -async fn load_image_request_settings_context( - request: &ImageGenerationRequest, - settings_service: &SettingsService, -) -> Result { - Ok(ImageRequestSettingsContext { - settings: settings_service.get_settings().await?, - settings_key: request - .settings_key - .clone() - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| request.provider.clone()), - }) -} - -async fn build_comfyui_request_context( - request: &ImageGenerationRequest, - settings_context: &ImageRequestSettingsContext, - client: &reqwest::Client, -) -> Result { - let base_url = normalize_comfyui_base_url( - resolve_string_setting( - &settings_context.settings, - &settings_context.settings_key, - &request.provider, - "base_url", - ) - .as_deref() - .unwrap_or("http://127.0.0.1:8188"), - ); - - Ok(ComfyUiRequestContext { - checkpoint: resolve_comfyui_checkpoint( - request, - &settings_context.settings, - &settings_context.settings_key, - &base_url, - client, - ) - .await?, - base_url, - sampler: normalize_comfyui_sampler(request.sampler.as_deref()), - scheduler: normalize_comfyui_scheduler(request.scheduler.as_deref()), - seed: normalize_comfyui_seed(request.seed), - steps: request.steps.unwrap_or(24), - cfg_scale: request.cfg_scale.unwrap_or(7.0), - width: request.width.unwrap_or(832), - height: request.height.unwrap_or(1216), - batch_size: request.batch_size.unwrap_or(1), - negative_prompt: request.negative_prompt.clone().unwrap_or_default(), - prompt_id: uuid::Uuid::new_v4().to_string(), - client_id: uuid::Uuid::new_v4().to_string(), - }) -} - -async fn queue_comfyui_prompt( - client: &reqwest::Client, - context: &ComfyUiRequestContext, - workflow: serde_json::Value, -) -> Result { - let response = client - .post(format!("{}/prompt", context.base_url)) - .json(&serde_json::json!({ - "prompt": workflow, - "client_id": context.client_id, - "prompt_id": context.prompt_id, - })) - .send() - .await - .map_err(|error| AppError::External { - request_id: None, - message: format!("Failed to queue ComfyUI prompt: {error}"), - })?; - - if !response.status().is_success() { - let body = response.text().await.unwrap_or_default(); - return Err(AppError::External { - request_id: None, - message: format!("ComfyUI queue request failed: {body}"), - }); - } - - response.json().await.map_err(|error| AppError::External { - request_id: None, - message: format!("Failed to parse ComfyUI queue response: {error}"), - }) -} - -fn apply_saved_image_defaults( - request: &mut ImageGenerationRequest, - settings: &AppSettings, - settings_key: &str, -) { - if let Some(prefix) = - resolve_string_setting(settings, settings_key, &request.provider, "positive_prompt") - { - request.prompt = format!("{prefix}, {}", request.prompt); - } - - request.negative_prompt = request.negative_prompt.take().or_else(|| { - resolve_string_setting(settings, settings_key, &request.provider, "negative_prompt") - }); - request.steps = request - .steps - .or_else(|| resolve_u32_setting(settings, settings_key, &request.provider, "steps")); - request.cfg_scale = request - .cfg_scale - .or_else(|| resolve_f32_setting(settings, settings_key, &request.provider, "cfg_scale")); - request.denoising_strength = request.denoising_strength.or_else(|| { - resolve_f32_setting( - settings, - settings_key, - &request.provider, - "denoising_strength", - ) - }); - request.width = request - .width - .or_else(|| resolve_u32_setting(settings, settings_key, &request.provider, "width")); - request.height = request - .height - .or_else(|| resolve_u32_setting(settings, settings_key, &request.provider, "height")); - request.sampler = request - .sampler - .take() - .or_else(|| resolve_string_setting(settings, settings_key, &request.provider, "sampler")); - request.seed = request - .seed - .or_else(|| resolve_i32_setting(settings, settings_key, &request.provider, "seed")); - request.batch_size = request - .batch_size - .or_else(|| resolve_u32_setting(settings, settings_key, &request.provider, "batch_size")); - request.scheduler = request - .scheduler - .take() - .or_else(|| resolve_string_setting(settings, settings_key, &request.provider, "scheduler")); - request.clip_skip = request - .clip_skip - .or_else(|| resolve_i32_setting(settings, settings_key, &request.provider, "clip_skip")); -} - -async fn clear_preview_file(path: &Path) { - if let Err(error) = tokio::fs::remove_file(path).await - && error.kind() != std::io::ErrorKind::NotFound - { - tracing::debug!( - "Failed to clear stale preview file {}: {error}", - path.display() - ); - } -} - fn build_generated_image_content(images: &[String]) -> serde_json::Value { serde_json::Value::Array( images @@ -1447,293 +130,3 @@ fn build_generated_image_content(images: &[String]) -> serde_json::Value { .collect(), ) } - -pub(super) fn resolve_string_setting( - settings: &AppSettings, - settings_key: &str, - provider_id: &str, - suffix: &str, -) -> Option { - resolve_setting_value(settings, settings_key, provider_id, suffix).and_then(|value| { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } - }) -} - -pub(super) fn resolve_u32_setting( - settings: &AppSettings, - settings_key: &str, - provider_id: &str, - suffix: &str, -) -> Option { - resolve_setting_value(settings, settings_key, provider_id, suffix) - .and_then(|value| value.parse::().ok()) -} - -fn resolve_i32_setting( - settings: &AppSettings, - settings_key: &str, - provider_id: &str, - suffix: &str, -) -> Option { - resolve_setting_value(settings, settings_key, provider_id, suffix) - .and_then(|value| value.parse::().ok()) -} - -pub(super) fn resolve_f32_setting( - settings: &AppSettings, - settings_key: &str, - provider_id: &str, - suffix: &str, -) -> Option { - resolve_setting_value(settings, settings_key, provider_id, suffix) - .and_then(|value| value.parse::().ok()) -} - -fn resolve_setting_value( - settings: &AppSettings, - settings_key: &str, - provider_id: &str, - suffix: &str, -) -> Option { - for key in build_setting_candidates(settings_key, suffix) { - if let Some(value) = settings.extra_settings.get(&key) { - return Some(value.clone()); - } - } - - if settings_key != provider_id { - for key in build_setting_candidates(provider_id, suffix) { - if let Some(value) = settings.extra_settings.get(&key) { - return Some(value.clone()); - } - } - } - - None -} - -fn build_setting_candidates(prefix: &str, suffix: &str) -> [String; 3] { - [ - format!("{prefix}_{suffix}"), - format!("{prefix}_{}", suffix.to_lowercase()), - format!("{prefix}_{}", suffix.replace('_', "")), - ] -} - -fn normalize_sdcpp_sampler(value: Option<&str>) -> String { - match value.unwrap_or("euler a").trim().to_lowercase().as_str() { - "euler a" | "euler_a" => "euler_a".to_string(), - "euler" => "euler".to_string(), - "heun" => "heun".to_string(), - "dpm2" => "dpm2".to_string(), - "dpm++ 2s a" | "dpm++2s_a" | "dpmpp_2s_a" => "dpm++2s_a".to_string(), - "dpm++ 2m" | "dpm++2m" | "dpmpp_2m" => "dpm++2m".to_string(), - "dpm++ 2m v2" | "dpm++2mv2" | "dpmpp_2mv2" => "dpm++2mv2".to_string(), - "ipndm" => "ipndm".to_string(), - "ipndm_v" => "ipndm_v".to_string(), - "er sde" | "er_sde" => "er_sde".to_string(), - "lcm" => "lcm".to_string(), - "ddim trailing" | "ddim_trailing" => "ddim_trailing".to_string(), - "tcd" => "tcd".to_string(), - "res multistep" | "res_multistep" => "res_multistep".to_string(), - "res 2s" | "res_2s" => "res_2s".to_string(), - other => other.to_string(), - } -} - -fn normalize_sdcpp_scheduler(value: Option<&str>) -> String { - match value.unwrap_or("discrete").trim().to_lowercase().as_str() { - "default" | "normal" | "discrete" => "discrete".to_string(), - "karras" => "karras".to_string(), - "exponential" => "exponential".to_string(), - "ays" => "ays".to_string(), - "gits" => "gits".to_string(), - "smoothstep" => "smoothstep".to_string(), - "sgm uniform" | "sgm_uniform" | "ddim_uniform" => "sgm_uniform".to_string(), - "simple" => "simple".to_string(), - "kl optimal" | "kl_optimal" => "kl_optimal".to_string(), - "lcm" => "lcm".to_string(), - "bong tangent" | "bong_tangent" => "bong_tangent".to_string(), - other => other.to_string(), - } -} - -#[cfg(test)] -mod tests { - use super::{ - ImageResponseFormat, build_cloud_image_payload, build_sdcpp_native_image_payload, - parse_generated_images, - }; - use crate::domain::ai::ImageGenerationRequest; - use serde_json::json; - - fn make_cloud_request(model: &str) -> ImageGenerationRequest { - ImageGenerationRequest { - provider: "gpt-image".to_string(), - prompt: "draw a cat".to_string(), - original_prompt: None, - model: model.to_string(), - settings_key: None, - session_id: None, - steps: None, - cfg_scale: None, - denoising_strength: None, - width: None, - height: None, - sampler: None, - seed: None, - clip_skip: None, - negative_prompt: None, - batch_size: None, - scheduler: None, - } - } - - #[test] - fn build_cloud_image_payload_uses_image_only_modalities_for_flux_models() { - let payload = - build_cloud_image_payload(&make_cloud_request("black-forest-labs/flux.2-max")); - - assert_eq!(payload.get("modalities"), Some(&json!(["image"]))); - } - - #[test] - fn build_cloud_image_payload_uses_image_only_modalities_for_seedream_models() { - let payload = build_cloud_image_payload(&make_cloud_request("bytedance-seed/seedream-4.5")); - - assert_eq!(payload.get("modalities"), Some(&json!(["image"]))); - } - - #[test] - fn build_cloud_image_payload_keeps_text_output_for_gemini_image_models() { - let payload = - build_cloud_image_payload(&make_cloud_request("google/gemini-3.1-flash-image-preview")); - - assert_eq!(payload.get("modalities"), Some(&json!(["image", "text"]))); - } - - #[test] - fn build_cloud_image_payload_keeps_text_output_for_gpt_image_models() { - let payload = build_cloud_image_payload(&make_cloud_request("openai/gpt-5-image-mini")); - - assert_eq!(payload.get("modalities"), Some(&json!(["image", "text"]))); - } - - #[test] - fn build_cloud_image_payload_uses_provider_specific_default_model() { - let payload = build_cloud_image_payload(&ImageGenerationRequest { - provider: "gpt-image".to_string(), - model: "default".to_string(), - ..make_cloud_request("default") - }); - - assert_eq!(payload.get("model"), Some(&json!("openai/gpt-5-image"))); - - let payload = build_cloud_image_payload(&ImageGenerationRequest { - provider: "seedream-image".to_string(), - model: String::new(), - ..make_cloud_request("") - }); - - assert_eq!( - payload.get("model"), - Some(&json!("bytedance-seed/seedream-4.5")) - ); - } - - #[test] - fn parses_stable_diffusion_webui_style_images() { - let images = parse_generated_images( - &json!({ - "images": ["ZmFrZQ=="], - "parameters": {}, - "info": "{}" - }), - ImageResponseFormat::SdApi, - ); - - assert_eq!(images, vec!["data:image/png;base64,ZmFrZQ=="]); - } - - #[test] - fn builds_native_sdcpp_image_payload() { - let payload = build_sdcpp_native_image_payload(&ImageGenerationRequest { - provider: "sdcpp".to_string(), - prompt: "draw a cat".to_string(), - original_prompt: None, - model: "default".to_string(), - settings_key: None, - session_id: None, - steps: Some(30), - cfg_scale: Some(8.5), - denoising_strength: Some(0.42), - width: Some(896), - height: Some(1152), - sampler: Some("Euler A".to_string()), - seed: Some(42), - clip_skip: Some(2), - negative_prompt: Some("blurry".to_string()), - batch_size: Some(1), - scheduler: Some("Karras".to_string()), - }); - - assert_eq!(payload.get("prompt"), Some(&json!("draw a cat"))); - assert_eq!(payload.get("width"), Some(&json!(896))); - assert_eq!( - payload.pointer("/sample_params/sample_steps"), - Some(&json!(30)) - ); - assert_eq!( - payload.pointer("/sample_params/sample_method"), - Some(&json!("euler_a")) - ); - assert_eq!( - payload.pointer("/sample_params/scheduler"), - Some(&json!("karras")) - ); - assert_eq!( - payload.pointer("/sample_params/guidance/txt_cfg"), - Some(&json!(8.5)) - ); - let strength = payload - .pointer("/sample_params/strength") - .and_then(serde_json::Value::as_f64) - .unwrap_or_default(); - assert!((strength - 0.42).abs() < 0.001); - } - - #[test] - fn normalizes_sdcpp_er_sde_sampler() { - assert_eq!( - build_sdcpp_native_image_payload(&ImageGenerationRequest { - sampler: Some("ER SDE".to_string()), - ..make_cloud_request("default") - }) - .pointer("/sample_params/sample_method"), - Some(&json!("er_sde")) - ); - } - - #[test] - fn parses_sdcpp_webui_result_images() { - let images = parse_generated_images( - &json!({ - "kind": "img_gen", - "result": { - "output_format": "webp", - "images": [ - { "b64_json": "ZmFrZQ==" } - ] - } - }), - ImageResponseFormat::SdApi, - ); - - assert_eq!(images, vec!["data:image/webp;base64,ZmFrZQ=="]); - } -} diff --git a/src-tauri/src/domain/ai/image_settings.rs b/src-tauri/src/domain/ai/image_settings.rs new file mode 100644 index 00000000..9c8285fa --- /dev/null +++ b/src-tauri/src/domain/ai/image_settings.rs @@ -0,0 +1,169 @@ +//! Saved image generation settings resolution. + +use super::types::ImageGenerationRequest; +use crate::errors::AppError; +use crate::infrastructure::config::settings::SettingsService; +use crate::models::AppSettings; + +struct ImageRequestSettingsContext { + settings: AppSettings, + settings_key: String, +} + +pub(super) async fn apply_image_request_defaults( + mut request: ImageGenerationRequest, + settings_service: &SettingsService, +) -> Result { + let settings_context = load_image_request_settings_context(&request, settings_service).await?; + apply_saved_image_defaults( + &mut request, + &settings_context.settings, + &settings_context.settings_key, + ); + + Ok(request) +} + +async fn load_image_request_settings_context( + request: &ImageGenerationRequest, + settings_service: &SettingsService, +) -> Result { + Ok(ImageRequestSettingsContext { + settings: settings_service.get_settings().await?, + settings_key: request + .settings_key + .clone() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| request.provider.clone()), + }) +} + +fn apply_saved_image_defaults( + request: &mut ImageGenerationRequest, + settings: &AppSettings, + settings_key: &str, +) { + if let Some(prefix) = + resolve_string_setting(settings, settings_key, &request.provider, "positive_prompt") + { + request.prompt = format!("{prefix}, {}", request.prompt); + } + + request.negative_prompt = request.negative_prompt.take().or_else(|| { + resolve_string_setting(settings, settings_key, &request.provider, "negative_prompt") + }); + request.steps = request + .steps + .or_else(|| resolve_u32_setting(settings, settings_key, &request.provider, "steps")); + request.cfg_scale = request + .cfg_scale + .or_else(|| resolve_f32_setting(settings, settings_key, &request.provider, "cfg_scale")); + request.denoising_strength = request.denoising_strength.or_else(|| { + resolve_f32_setting( + settings, + settings_key, + &request.provider, + "denoising_strength", + ) + }); + request.width = request + .width + .or_else(|| resolve_u32_setting(settings, settings_key, &request.provider, "width")); + request.height = request + .height + .or_else(|| resolve_u32_setting(settings, settings_key, &request.provider, "height")); + request.sampler = request + .sampler + .take() + .or_else(|| resolve_string_setting(settings, settings_key, &request.provider, "sampler")); + request.seed = request + .seed + .or_else(|| resolve_i32_setting(settings, settings_key, &request.provider, "seed")); + request.batch_size = request + .batch_size + .or_else(|| resolve_u32_setting(settings, settings_key, &request.provider, "batch_size")); + request.scheduler = request + .scheduler + .take() + .or_else(|| resolve_string_setting(settings, settings_key, &request.provider, "scheduler")); + request.clip_skip = request + .clip_skip + .or_else(|| resolve_i32_setting(settings, settings_key, &request.provider, "clip_skip")); +} + +pub(super) fn resolve_string_setting( + settings: &AppSettings, + settings_key: &str, + provider_id: &str, + suffix: &str, +) -> Option { + resolve_setting_value(settings, settings_key, provider_id, suffix).and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) +} + +pub(super) fn resolve_u32_setting( + settings: &AppSettings, + settings_key: &str, + provider_id: &str, + suffix: &str, +) -> Option { + resolve_setting_value(settings, settings_key, provider_id, suffix) + .and_then(|value| value.parse::().ok()) +} + +fn resolve_i32_setting( + settings: &AppSettings, + settings_key: &str, + provider_id: &str, + suffix: &str, +) -> Option { + resolve_setting_value(settings, settings_key, provider_id, suffix) + .and_then(|value| value.parse::().ok()) +} + +pub(super) fn resolve_f32_setting( + settings: &AppSettings, + settings_key: &str, + provider_id: &str, + suffix: &str, +) -> Option { + resolve_setting_value(settings, settings_key, provider_id, suffix) + .and_then(|value| value.parse::().ok()) +} + +fn resolve_setting_value( + settings: &AppSettings, + settings_key: &str, + provider_id: &str, + suffix: &str, +) -> Option { + for key in build_setting_candidates(settings_key, suffix) { + if let Some(value) = settings.extra_settings.get(&key) { + return Some(value.clone()); + } + } + + if settings_key != provider_id { + for key in build_setting_candidates(provider_id, suffix) { + if let Some(value) = settings.extra_settings.get(&key) { + return Some(value.clone()); + } + } + } + + None +} + +fn build_setting_candidates(prefix: &str, suffix: &str) -> [String; 3] { + [ + format!("{prefix}_{suffix}"), + format!("{prefix}_{}", suffix.to_lowercase()), + format!("{prefix}_{}", suffix.replace('_', "")), + ] +} diff --git a/src-tauri/src/domain/ai/mod.rs b/src-tauri/src/domain/ai/mod.rs index 775bb757..5f658bc8 100644 --- a/src-tauri/src/domain/ai/mod.rs +++ b/src-tauri/src/domain/ai/mod.rs @@ -3,9 +3,19 @@ mod ai_dispatch; pub mod ai_service; /// Custom model management service pub mod custom_model_service; +mod image_cloud; +mod image_comfyui; /// Shared state for active image-generation requests pub mod image_generation_state; +mod image_http; +mod image_local; +mod image_payload; +mod image_response; mod image_service; +mod image_settings; +mod provider_http; +mod provider_payload; +mod provider_response; /// Chat session persistence and management pub mod session; mod session_context; diff --git a/src-tauri/src/domain/ai/provider_http.rs b/src-tauri/src/domain/ai/provider_http.rs new file mode 100644 index 00000000..e1eeba82 --- /dev/null +++ b/src-tauri/src/domain/ai/provider_http.rs @@ -0,0 +1,42 @@ +//! Shared HTTP helpers for AI provider adapters. +//! +//! Mirrors the Open WebUI idea of keeping transport concerns separate from +//! provider routing and response normalization. + +use reqwest::{Client, StatusCode}; + +pub(super) fn build_provider_client() -> Client { + Client::builder() + .connect_timeout(std::time::Duration::from_secs(8)) + .pool_idle_timeout(std::time::Duration::from_secs(90)) + .pool_max_idle_per_host(8) + .build() + .unwrap_or_else(|_| Client::new()) +} + +pub(super) const fn should_retry_status(status: StatusCode) -> bool { + matches!( + status, + StatusCode::TOO_MANY_REQUESTS + | StatusCode::BAD_GATEWAY + | StatusCode::SERVICE_UNAVAILABLE + | StatusCode::GATEWAY_TIMEOUT + ) +} + +pub(super) fn should_retry_error(error: &reqwest::Error) -> bool { + error.is_connect() || error.is_timeout() +} + +pub(super) fn retry_delay(attempt: u32, status: StatusCode) -> std::time::Duration { + let capped_attempt = attempt.max(1); + let base_ms = if status == StatusCode::TOO_MANY_REQUESTS { + 700u64 + } else { + 350u64 + }; + let backoff_multiplier = 2u64.saturating_pow(capped_attempt.saturating_sub(1)); + let jitter_ms = rand::random_range(0..150u64); + + std::time::Duration::from_millis(base_ms * backoff_multiplier + jitter_ms) +} diff --git a/src-tauri/src/domain/ai/provider_payload.rs b/src-tauri/src/domain/ai/provider_payload.rs new file mode 100644 index 00000000..fdf18c0b --- /dev/null +++ b/src-tauri/src/domain/ai/provider_payload.rs @@ -0,0 +1,230 @@ +//! Provider payload normalization. +//! +//! Axelate speaks an OpenAI-compatible chat shape internally. This module owns +//! the provider-specific request differences, similar to Open WebUI's payload +//! conversion layer. + +use super::types::{ChatRequest, WebSearchOptions}; + +pub(super) fn is_local_base_url(base_url: &str) -> bool { + base_url.contains("localhost") || base_url.contains("127.0.0.1") +} + +pub(super) fn build_chat_completion_payload( + req: &ChatRequest, + stream: bool, + is_local: bool, +) -> serde_json::Map { + let mut payload = serde_json::Map::new(); + payload.insert( + "model".to_string(), + serde_json::Value::String(req.model.clone()), + ); + + let mapped_messages: Vec = req + .messages + .iter() + .map(|message| { + serde_json::json!({ + "role": message.role, + "content": message.content, + }) + }) + .collect(); + payload.insert( + "messages".to_string(), + serde_json::Value::Array(mapped_messages), + ); + payload.insert("stream".to_string(), serde_json::Value::Bool(stream)); + + if stream && !is_local { + payload.insert( + "stream_options".to_string(), + serde_json::json!({ "include_usage": true }), + ); + } + + if let Some(level) = &req.thinking_level + && level != "off" + && !is_local + { + payload.insert( + "reasoning".to_string(), + serde_json::json!({ "effort": level }), + ); + } + + let max_tokens = serde_json::json!(req.max_tokens.unwrap_or(8192)); + if is_local { + payload.insert("max_tokens".to_string(), max_tokens); + } else { + payload.insert("max_completion_tokens".to_string(), max_tokens); + } + + if !is_local + && let Some(session_id) = req.session_id.as_ref().map(|value| value.trim()) + && !session_id.is_empty() + { + payload.insert( + "session_id".to_string(), + serde_json::Value::String(session_id.to_string()), + ); + } + + if !is_local + && should_attach_web_search(req) + && let Some(web_search) = req.web_search.as_ref() + { + payload.insert( + "tools".to_string(), + serde_json::Value::Array(vec![build_web_search_tool(web_search)]), + ); + payload.insert( + "tool_choice".to_string(), + serde_json::Value::String("auto".to_string()), + ); + } + + payload +} + +pub(super) fn should_attach_web_search(req: &ChatRequest) -> bool { + if !req + .web_search + .as_ref() + .is_some_and(|web_search| web_search.enabled) + { + return false; + } + + let Some(last_user_message) = req + .messages + .iter() + .rev() + .find(|message| message.role == "user") + else { + return false; + }; + let text = extract_message_text(&last_user_message.content).to_lowercase(); + let text = text.trim(); + if text.is_empty() { + return false; + } + + const WEB_SEARCH_TRIGGERS: &[&str] = &[ + "актуаль", + "интернет", + "найди", + "новост", + "погугли", + "поиск", + "посмотри в сети", + "свеж", + "сейчас", + "сегодня", + "ссылка", + "site:", + "today", + "latest", + "current", + "recent", + "news", + "search", + "browse", + "web", + "internet", + "look up", + "price", + "weather", + ]; + + text.starts_with("http://") + || text.starts_with("https://") + || text.contains(" http://") + || text.contains(" https://") + || WEB_SEARCH_TRIGGERS + .iter() + .any(|trigger| text.contains(trigger)) +} + +pub(super) fn extract_message_text(content: &serde_json::Value) -> String { + match content { + serde_json::Value::String(text) => text.clone(), + serde_json::Value::Array(parts) => parts + .iter() + .filter_map(|part| { + (part.get("type")?.as_str()? == "text") + .then(|| part.get("text")?.as_str()) + .flatten() + .map(ToOwned::to_owned) + }) + .collect::>() + .join("\n"), + _ => String::new(), + } +} + +pub(super) fn build_web_search_tool(options: &WebSearchOptions) -> serde_json::Value { + let mut parameters = serde_json::Map::new(); + parameters.insert( + "engine".to_string(), + serde_json::Value::String( + options + .engine + .clone() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "auto".to_string()), + ), + ); + parameters.insert( + "max_results".to_string(), + serde_json::json!(options.max_results.unwrap_or(5).clamp(1, 25)), + ); + parameters.insert( + "max_total_results".to_string(), + serde_json::json!(options.max_total_results.unwrap_or(10).max(1)), + ); + parameters.insert( + "search_context_size".to_string(), + serde_json::Value::String( + options + .search_context_size + .clone() + .filter(|value| matches!(value.as_str(), "low" | "medium" | "high")) + .unwrap_or_else(|| "medium".to_string()), + ), + ); + + if !options.allowed_domains.is_empty() { + parameters.insert( + "allowed_domains".to_string(), + serde_json::Value::Array( + options + .allowed_domains + .iter() + .filter(|value| !value.trim().is_empty()) + .map(|value| serde_json::Value::String(value.trim().to_string())) + .collect(), + ), + ); + } + + if !options.excluded_domains.is_empty() { + parameters.insert( + "excluded_domains".to_string(), + serde_json::Value::Array( + options + .excluded_domains + .iter() + .filter(|value| !value.trim().is_empty()) + .map(|value| serde_json::Value::String(value.trim().to_string())) + .collect(), + ), + ); + } + + serde_json::json!({ + "type": "openrouter:web_search", + "parameters": parameters, + }) +} diff --git a/src-tauri/src/domain/ai/provider_response.rs b/src-tauri/src/domain/ai/provider_response.rs new file mode 100644 index 00000000..2fe3c6a7 --- /dev/null +++ b/src-tauri/src/domain/ai/provider_response.rs @@ -0,0 +1,187 @@ +//! Provider response normalization. +//! +//! Converts OpenAI-compatible, Ollama-like, and llama.cpp-like response shapes +//! into Axelate's chat DTOs. + +use reqwest::StatusCode; + +use super::provider_payload::extract_message_text; +use super::types::{ChatReply, ChatResponse, TokenUsage}; + +pub(super) fn parse_non_stream_response( + body: &serde_json::Value, + message_id: String, + model: String, +) -> ChatResponse { + let usage = extract_token_usage(body); + + let reply_text = body + .get("choices") + .and_then(|choices| choices.as_array()) + .and_then(|choices| choices.first()) + .and_then(|choice| choice.get("message")) + .and_then(|message| message.get("content")) + .map(extract_message_text) + .unwrap_or_default(); + + if reply_text.trim().is_empty() { + return ChatResponse { + id: message_id, + ok: false, + reply: None, + error: Some("Empty response body from AI provider".to_string()), + model: Some(model), + thought_signature: None, + usage, + }; + } + + ChatResponse { + id: message_id, + ok: true, + reply: Some(ChatReply { + text: reply_text, + role: "assistant".to_string(), + }), + error: None, + model: Some(model), + thought_signature: None, + usage, + } +} + +pub(super) fn build_api_error_response( + message_id: String, + model: String, + status: StatusCode, + error_text: &str, +) -> ChatResponse { + ChatResponse { + id: message_id, + ok: false, + reply: None, + error: Some(format!("API Error {status}: {error_text}")), + model: Some(model), + thought_signature: None, + usage: None, + } +} + +pub(super) fn extract_stream_error_message(json: &serde_json::Value) -> Option { + json.get("error") + .and_then(extract_error_message) + .or_else(|| json.get("errors").and_then(extract_error_message)) +} + +pub(super) fn extract_error_message(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::String(message) => { + let trimmed = message.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + } + serde_json::Value::Array(items) => items.iter().find_map(extract_error_message), + serde_json::Value::Object(object) => ["message", "detail", "error"] + .iter() + .find_map(|key| object.get(*key).and_then(extract_error_message)), + _ => None, + } +} + +pub(super) fn extract_stream_text(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::String(text) => Some(text.clone()), + serde_json::Value::Array(parts) => { + let text = parts + .iter() + .filter_map(|part| { + part.get("text") + .and_then(|candidate| candidate.as_str()) + .or_else(|| part.get("content").and_then(|candidate| candidate.as_str())) + }) + .collect::>() + .join(""); + + (!text.is_empty()).then_some(text) + } + serde_json::Value::Object(object) => object + .get("text") + .and_then(|candidate| candidate.as_str()) + .map(ToOwned::to_owned), + _ => None, + } +} + +pub(super) fn extract_token_usage(json: &serde_json::Value) -> Option { + let usage = json.get("usage").unwrap_or(json); + let timings = json.get("timings"); + + let prompt_tokens = read_usage_count( + usage, + timings, + &[ + "prompt_tokens", + "input_tokens", + "prompt_eval_count", + "prompt_n", + ], + ); + let completion_tokens = read_usage_count( + usage, + timings, + &[ + "completion_tokens", + "output_tokens", + "eval_count", + "predicted_n", + ], + ); + let total_tokens = read_usage_count(usage, timings, &["total_tokens"]) + .or_else(|| (prompt_tokens.is_some() || completion_tokens.is_some()).then_some(0)) + .map(|total| { + if total > 0 { + total + } else { + prompt_tokens.unwrap_or(0) + completion_tokens.unwrap_or(0) + } + }); + + match (prompt_tokens, completion_tokens, total_tokens) { + (None, None, None) => None, + (prompt_tokens, completion_tokens, total_tokens) => Some(TokenUsage { + prompt_tokens: prompt_tokens.unwrap_or(0), + completion_tokens: completion_tokens.unwrap_or(0), + total_tokens: total_tokens.unwrap_or(0), + }), + } +} + +fn read_usage_count( + usage: &serde_json::Value, + timings: Option<&serde_json::Value>, + keys: &[&str], +) -> Option { + keys.iter() + .find_map(|key| usage.get(*key).and_then(json_number_to_u32)) + .or_else(|| { + timings.and_then(|timings| { + keys.iter() + .find_map(|key| timings.get(*key).and_then(json_number_to_u32)) + }) + }) +} + +fn json_number_to_u32(value: &serde_json::Value) -> Option { + value + .as_u64() + .and_then(|value| u32::try_from(value).ok()) + .or_else(|| { + value.as_f64().and_then(|value| { + let rounded = value.round(); + if rounded.is_finite() && rounded >= 0.0 && rounded <= f64::from(u32::MAX) { + rounded.to_string().parse::().ok() + } else { + None + } + }) + }) +} diff --git a/src-tauri/src/domain/ai/streaming.rs b/src-tauri/src/domain/ai/streaming.rs index 397d6c4b..385ff669 100644 --- a/src-tauri/src/domain/ai/streaming.rs +++ b/src-tauri/src/domain/ai/streaming.rs @@ -10,7 +10,10 @@ use reqwest::{Client, StatusCode}; use std::sync::Arc; use tokio::sync::mpsc; -use super::types::{ChatReply, ChatRequest, ChatResponse, TokenUsage, WebSearchOptions}; +use super::provider_http; +use super::provider_payload; +use super::provider_response; +use super::types::{ChatReply, ChatRequest, ChatResponse, TokenUsage}; // ================================================================================== // Stream Protocol @@ -162,10 +165,7 @@ impl OpenAiCompatibleProvider { pub fn new(base_url: &str) -> Self { Self { base_url: base_url.to_string(), - client: Client::builder() - .connect_timeout(std::time::Duration::from_secs(8)) - .build() - .unwrap_or_else(|_| Client::new()), + client: provider_http::build_provider_client(), } } @@ -189,7 +189,7 @@ impl OpenAiCompatibleProvider { if !res.status().is_success() { let status = res.status(); let error_text = res.text().await.unwrap_or_default(); - return Ok(build_api_error_response( + return Ok(provider_response::build_api_error_response( message_id, req.model, status, @@ -204,7 +204,9 @@ impl OpenAiCompatibleProvider { } })?; - Ok(parse_non_stream_response(&body, message_id, req.model)) + Ok(provider_response::parse_non_stream_response( + &body, message_id, req.model, + )) } fn prepare_request_execution( @@ -216,7 +218,7 @@ impl OpenAiCompatibleProvider { Ok(RequestExecution { endpoint: format!("{}/chat/completions", self.base_url.trim_end_matches('/')), api_key: resolve_api_key(req, &self.base_url)?, - payload: build_request_payload(req, stream, is_local), + payload: provider_payload::build_chat_completion_payload(req, stream, is_local), }) } @@ -250,16 +252,19 @@ impl OpenAiCompatibleProvider { return Ok(resp); } let status = resp.status(); - if should_retry_status(status) && attempts <= MAX_RETRIES { - tokio::time::sleep(retry_delay(attempts, status)).await; + if provider_http::should_retry_status(status) && attempts <= MAX_RETRIES { + tokio::time::sleep(provider_http::retry_delay(attempts, status)).await; continue; } return Ok(resp); } Err(error) => { - if should_retry_error(&error) && attempts <= MAX_RETRIES { - tokio::time::sleep(retry_delay(attempts, StatusCode::REQUEST_TIMEOUT)) - .await; + if provider_http::should_retry_error(&error) && attempts <= MAX_RETRIES { + tokio::time::sleep(provider_http::retry_delay( + attempts, + StatusCode::REQUEST_TIMEOUT, + )) + .await; continue; } return Err(crate::errors::AppError::External { @@ -272,35 +277,8 @@ impl OpenAiCompatibleProvider { } } -const fn should_retry_status(status: StatusCode) -> bool { - matches!( - status, - StatusCode::TOO_MANY_REQUESTS - | StatusCode::BAD_GATEWAY - | StatusCode::SERVICE_UNAVAILABLE - | StatusCode::GATEWAY_TIMEOUT - ) -} - -fn should_retry_error(error: &reqwest::Error) -> bool { - error.is_connect() || error.is_timeout() -} - -fn retry_delay(attempt: u32, status: StatusCode) -> std::time::Duration { - let capped_attempt = attempt.max(1); - let base_ms = if status == StatusCode::TOO_MANY_REQUESTS { - 700u64 - } else { - 350u64 - }; - let backoff_multiplier = 2u64.saturating_pow(capped_attempt.saturating_sub(1)); - let jitter_ms = rand::random_range(0..150u64); - - std::time::Duration::from_millis(base_ms * backoff_multiplier + jitter_ms) -} - fn is_local_base_url(base_url: &str) -> bool { - base_url.contains("localhost") || base_url.contains("127.0.0.1") + provider_payload::is_local_base_url(base_url) } fn resolve_accept_header(payload: &serde_json::Map) -> &'static str { @@ -330,284 +308,6 @@ fn resolve_api_key(req: &ChatRequest, base_url: &str) -> Result serde_json::Map { - let mut payload = serde_json::Map::new(); - payload.insert( - "model".to_string(), - serde_json::Value::String(req.model.clone()), - ); - - let mapped_messages: Vec = req - .messages - .iter() - .map(|message| { - serde_json::json!({ - "role": message.role, - "content": message.content, - }) - }) - .collect(); - payload.insert( - "messages".to_string(), - serde_json::Value::Array(mapped_messages), - ); - payload.insert("stream".to_string(), serde_json::Value::Bool(stream)); - - if stream && !is_local { - payload.insert( - "stream_options".to_string(), - serde_json::json!({ "include_usage": true }), - ); - } - - if let Some(level) = &req.thinking_level - && level != "off" - && !is_local - { - payload.insert( - "reasoning".to_string(), - serde_json::json!({ "effort": level }), - ); - } - - let max_tokens = serde_json::json!(req.max_tokens.unwrap_or(8192)); - if is_local { - payload.insert("max_tokens".to_string(), max_tokens); - } else { - payload.insert("max_completion_tokens".to_string(), max_tokens); - } - - if !is_local - && let Some(session_id) = req.session_id.as_ref().map(|value| value.trim()) - && !session_id.is_empty() - { - payload.insert( - "session_id".to_string(), - serde_json::Value::String(session_id.to_string()), - ); - } - - if !is_local - && should_attach_web_search(req) - && let Some(web_search) = req.web_search.as_ref() - { - payload.insert( - "tools".to_string(), - serde_json::Value::Array(vec![build_web_search_tool(web_search)]), - ); - payload.insert( - "tool_choice".to_string(), - serde_json::Value::String("auto".to_string()), - ); - } - - payload -} - -fn should_attach_web_search(req: &ChatRequest) -> bool { - if !req - .web_search - .as_ref() - .is_some_and(|web_search| web_search.enabled) - { - return false; - } - - let Some(last_user_message) = req - .messages - .iter() - .rev() - .find(|message| message.role == "user") - else { - return false; - }; - let text = extract_message_text(&last_user_message.content).to_lowercase(); - let text = text.trim(); - if text.is_empty() { - return false; - } - - const WEB_SEARCH_TRIGGERS: &[&str] = &[ - "актуаль", - "интернет", - "найди", - "новост", - "погугли", - "поиск", - "посмотри в сети", - "свеж", - "сейчас", - "сегодня", - "ссылка", - "site:", - "today", - "latest", - "current", - "recent", - "news", - "search", - "browse", - "web", - "internet", - "look up", - "price", - "weather", - ]; - - text.starts_with("http://") - || text.starts_with("https://") - || text.contains(" http://") - || text.contains(" https://") - || WEB_SEARCH_TRIGGERS - .iter() - .any(|trigger| text.contains(trigger)) -} - -fn extract_message_text(content: &serde_json::Value) -> String { - match content { - serde_json::Value::String(text) => text.clone(), - serde_json::Value::Array(parts) => parts - .iter() - .filter_map(|part| { - (part.get("type")?.as_str()? == "text") - .then(|| part.get("text")?.as_str()) - .flatten() - .map(ToOwned::to_owned) - }) - .collect::>() - .join("\n"), - _ => String::new(), - } -} - -fn parse_non_stream_response( - body: &serde_json::Value, - message_id: String, - model: String, -) -> ChatResponse { - let usage = extract_token_usage(body); - - let reply_text = body - .get("choices") - .and_then(|choices| choices.as_array()) - .and_then(|choices| choices.first()) - .and_then(|choice| choice.get("message")) - .and_then(|message| message.get("content")) - .map(extract_message_text) - .unwrap_or_default(); - - if reply_text.trim().is_empty() { - return ChatResponse { - id: message_id, - ok: false, - reply: None, - error: Some("Empty response body from AI provider".to_string()), - model: Some(model), - thought_signature: None, - usage, - }; - } - - ChatResponse { - id: message_id, - ok: true, - reply: Some(ChatReply { - text: reply_text, - role: "assistant".to_string(), - }), - error: None, - model: Some(model), - thought_signature: None, - usage, - } -} - -fn build_api_error_response( - message_id: String, - model: String, - status: StatusCode, - error_text: &str, -) -> ChatResponse { - ChatResponse { - id: message_id, - ok: false, - reply: None, - error: Some(format!("API Error {status}: {error_text}")), - model: Some(model), - thought_signature: None, - usage: None, - } -} - -fn build_web_search_tool(options: &WebSearchOptions) -> serde_json::Value { - let mut parameters = serde_json::Map::new(); - parameters.insert( - "engine".to_string(), - serde_json::Value::String( - options - .engine - .clone() - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| "auto".to_string()), - ), - ); - parameters.insert( - "max_results".to_string(), - serde_json::json!(options.max_results.unwrap_or(5).clamp(1, 25)), - ); - parameters.insert( - "max_total_results".to_string(), - serde_json::json!(options.max_total_results.unwrap_or(10).max(1)), - ); - parameters.insert( - "search_context_size".to_string(), - serde_json::Value::String( - options - .search_context_size - .clone() - .filter(|value| matches!(value.as_str(), "low" | "medium" | "high")) - .unwrap_or_else(|| "medium".to_string()), - ), - ); - - if !options.allowed_domains.is_empty() { - parameters.insert( - "allowed_domains".to_string(), - serde_json::Value::Array( - options - .allowed_domains - .iter() - .filter(|value| !value.trim().is_empty()) - .map(|value| serde_json::Value::String(value.trim().to_string())) - .collect(), - ), - ); - } - - if !options.excluded_domains.is_empty() { - parameters.insert( - "excluded_domains".to_string(), - serde_json::Value::Array( - options - .excluded_domains - .iter() - .filter(|value| !value.trim().is_empty()) - .map(|value| serde_json::Value::String(value.trim().to_string())) - .collect(), - ), - ); - } - - serde_json::json!({ - "type": "openrouter:web_search", - "parameters": parameters, - }) -} - #[async_trait] impl AiProvider for OpenAiCompatibleProvider { async fn generate_stream( @@ -633,7 +333,7 @@ impl AiProvider for OpenAiCompatibleProvider { if !res.status().is_success() { let status = res.status(); let error_text = res.text().await.unwrap_or_default(); - return Ok(build_api_error_response( + return Ok(provider_response::build_api_error_response( message_id, req.model, status, @@ -820,11 +520,11 @@ fn handle_stream_json_line( return StreamChunkResult::Error("AI stream returned malformed JSON chunk".to_string()); }; - if let Some(message) = extract_stream_error_message(&json) { + if let Some(message) = provider_response::extract_stream_error_message(&json) { return StreamChunkResult::Error(message); } - if let Some(usage) = extract_token_usage(&json) { + if let Some(usage) = provider_response::extract_token_usage(&json) { state.final_usage = Some(usage); } @@ -834,14 +534,17 @@ fn handle_stream_json_line( .and_then(|choices| choices.first()); if let Some(choice) = choice { - if let Some(message) = choice.get("error").and_then(extract_error_message) { + if let Some(message) = choice + .get("error") + .and_then(provider_response::extract_error_message) + { return StreamChunkResult::Error(message); } if let Some(finish_reason) = choice.get("finish_reason").and_then(|value| value.as_str()) { if finish_reason.eq_ignore_ascii_case("error") { return StreamChunkResult::Error( - extract_error_message(choice) + provider_response::extract_error_message(choice) .unwrap_or_else(|| "AI provider reported a streaming error".to_string()), ); } @@ -868,11 +571,11 @@ fn handle_stream_json_line( if let Some(reasoning) = delta .and_then(|d| d.get("reasoning_content")) - .and_then(extract_stream_text) + .and_then(provider_response::extract_stream_text) .or_else(|| { delta .and_then(|d| d.get("reasoning")) - .and_then(extract_stream_text) + .and_then(provider_response::extract_stream_text) }) { sink.emit(StreamEvent::ThoughtChunk { @@ -883,28 +586,34 @@ fn handle_stream_json_line( let content = delta .and_then(|d| d.get("content")) - .and_then(extract_stream_text) + .and_then(provider_response::extract_stream_text) .or_else(|| { choice .and_then(|value| value.get("text")) - .and_then(extract_stream_text) + .and_then(provider_response::extract_stream_text) .or_else(|| { choice .and_then(|value| value.get("content")) - .and_then(extract_stream_text) + .and_then(provider_response::extract_stream_text) }) }) - .or_else(|| json.get("content").and_then(extract_stream_text)) + .or_else(|| { + json.get("content") + .and_then(provider_response::extract_stream_text) + }) .or_else(|| { json.get("message") .and_then(|message| message.get("content")) - .and_then(extract_stream_text) + .and_then(provider_response::extract_stream_text) + }) + .or_else(|| { + json.get("response") + .and_then(provider_response::extract_stream_text) }) - .or_else(|| json.get("response").and_then(extract_stream_text)) .or_else(|| { json.get("token") .and_then(|token| token.get("text")) - .and_then(extract_stream_text) + .and_then(provider_response::extract_stream_text) }); if let Some(content) = content { @@ -918,135 +627,14 @@ fn handle_stream_json_line( StreamChunkResult::Continue } -fn extract_stream_text(value: &serde_json::Value) -> Option { - match value { - serde_json::Value::String(text) => Some(text.clone()), - serde_json::Value::Array(parts) => { - let text = parts - .iter() - .filter_map(|part| { - part.get("text") - .and_then(|candidate| candidate.as_str()) - .or_else(|| part.get("content").and_then(|candidate| candidate.as_str())) - }) - .collect::>() - .join(""); - - (!text.is_empty()).then_some(text) - } - serde_json::Value::Object(object) => object - .get("text") - .and_then(|candidate| candidate.as_str()) - .map(ToOwned::to_owned), - _ => None, - } -} - -fn extract_token_usage(json: &serde_json::Value) -> Option { - let usage = json.get("usage").unwrap_or(json); - let timings = json.get("timings"); - - let prompt_tokens = read_usage_count( - usage, - timings, - &[ - "prompt_tokens", - "input_tokens", - "prompt_eval_count", - "prompt_n", - ], - ); - let completion_tokens = read_usage_count( - usage, - timings, - &[ - "completion_tokens", - "output_tokens", - "eval_count", - "predicted_n", - ], - ); - let total_tokens = read_usage_count(usage, timings, &["total_tokens"]) - .or_else(|| (prompt_tokens.is_some() || completion_tokens.is_some()).then_some(0)) - .map(|total| { - if total > 0 { - total - } else { - prompt_tokens.unwrap_or(0) + completion_tokens.unwrap_or(0) - } - }); - - match (prompt_tokens, completion_tokens, total_tokens) { - (None, None, None) => None, - (prompt_tokens, completion_tokens, total_tokens) => Some(TokenUsage { - prompt_tokens: prompt_tokens.unwrap_or(0), - completion_tokens: completion_tokens.unwrap_or(0), - total_tokens: total_tokens.unwrap_or(0), - }), - } -} - -fn read_usage_count( - usage: &serde_json::Value, - timings: Option<&serde_json::Value>, - keys: &[&str], -) -> Option { - keys.iter() - .find_map(|key| usage.get(*key).and_then(json_number_to_u32)) - .or_else(|| { - timings.and_then(|timings| { - keys.iter() - .find_map(|key| timings.get(*key).and_then(json_number_to_u32)) - }) - }) -} - -fn json_number_to_u32(value: &serde_json::Value) -> Option { - value - .as_u64() - .and_then(|value| u32::try_from(value).ok()) - .or_else(|| { - value.as_f64().and_then(|value| { - let rounded = value.round(); - if rounded.is_finite() && rounded >= 0.0 && rounded <= f64::from(u32::MAX) { - rounded.to_string().parse::().ok() - } else { - None - } - }) - }) -} - -fn extract_stream_error_message(json: &serde_json::Value) -> Option { - json.get("error") - .and_then(extract_error_message) - .or_else(|| json.get("errors").and_then(extract_error_message)) -} - -fn extract_error_message(value: &serde_json::Value) -> Option { - match value { - serde_json::Value::String(message) => { - let trimmed = message.trim(); - (!trimmed.is_empty()).then(|| trimmed.to_string()) - } - serde_json::Value::Array(items) => items.iter().find_map(extract_error_message), - serde_json::Value::Object(object) => ["message", "detail", "error"] - .iter() - .find_map(|key| object.get(*key).and_then(extract_error_message)), - _ => None, - } -} - #[cfg(test)] mod tests { #![allow(clippy::expect_used, clippy::indexing_slicing)] - use super::{ - StreamChunkResult, StreamingAccumulator, build_request_payload, build_web_search_tool, - is_local_base_url, process_stream_chunk, retry_delay, should_retry_status, - }; + use super::{StreamChunkResult, StreamingAccumulator, is_local_base_url, process_stream_chunk}; use crate::domain::ai::{ChatMessage, ChatRequest}; use crate::domain::ai::{StreamEvent, StreamSink, WebSearchOptions}; + use crate::domain::ai::{provider_http, provider_payload}; use reqwest::StatusCode; use serde_json::json; @@ -1082,7 +670,7 @@ mod tests { #[test] fn build_web_search_tool_applies_defaults() { - let tool = build_web_search_tool(&WebSearchOptions { + let tool = provider_payload::build_web_search_tool(&WebSearchOptions { enabled: true, ..Default::default() }); @@ -1096,7 +684,7 @@ mod tests { #[test] fn build_web_search_tool_keeps_domain_filters() { - let tool = build_web_search_tool(&WebSearchOptions { + let tool = provider_payload::build_web_search_tool(&WebSearchOptions { enabled: true, allowed_domains: vec!["openai.com".to_string()], excluded_domains: vec!["reddit.com".to_string()], @@ -1116,23 +704,32 @@ mod tests { #[test] fn retry_policy_is_limited_to_interactive_safe_cases() { - assert!(should_retry_status(StatusCode::TOO_MANY_REQUESTS)); - assert!(should_retry_status(StatusCode::SERVICE_UNAVAILABLE)); - assert!(should_retry_status(StatusCode::BAD_GATEWAY)); - assert!(should_retry_status(StatusCode::GATEWAY_TIMEOUT)); - assert!(!should_retry_status(StatusCode::INTERNAL_SERVER_ERROR)); - assert!(!should_retry_status(StatusCode::FORBIDDEN)); + assert!(provider_http::should_retry_status( + StatusCode::TOO_MANY_REQUESTS + )); + assert!(provider_http::should_retry_status( + StatusCode::SERVICE_UNAVAILABLE + )); + assert!(provider_http::should_retry_status(StatusCode::BAD_GATEWAY)); + assert!(provider_http::should_retry_status( + StatusCode::GATEWAY_TIMEOUT + )); + assert!(!provider_http::should_retry_status( + StatusCode::INTERNAL_SERVER_ERROR + )); + assert!(!provider_http::should_retry_status(StatusCode::FORBIDDEN)); } #[test] fn retry_delay_stays_short_for_chat_requests() { - assert!(retry_delay(1, StatusCode::SERVICE_UNAVAILABLE).as_millis() < 500); - assert!(retry_delay(1, StatusCode::TOO_MANY_REQUESTS).as_millis() < 900); + assert!(provider_http::retry_delay(1, StatusCode::SERVICE_UNAVAILABLE).as_millis() < 500); + assert!(provider_http::retry_delay(1, StatusCode::TOO_MANY_REQUESTS).as_millis() < 900); } #[test] fn build_request_payload_uses_cloud_token_field_and_session_id() { - let payload = build_request_payload(&sample_request(), true, false); + let payload = + provider_payload::build_chat_completion_payload(&sample_request(), true, false); assert_eq!(payload.get("max_completion_tokens"), Some(&json!(2048))); assert_eq!(payload.get("session_id"), Some(&json!("session-1"))); @@ -1148,7 +745,7 @@ mod tests { ..Default::default() }); - let payload = build_request_payload(&request, true, false); + let payload = provider_payload::build_chat_completion_payload(&request, true, false); assert!(payload.get("tool_choice").is_none()); assert!(payload.get("tools").is_none()); @@ -1163,7 +760,7 @@ mod tests { ..Default::default() }); - let payload = build_request_payload(&request, true, false); + let payload = provider_payload::build_chat_completion_payload(&request, true, false); assert_eq!(payload.get("tool_choice"), Some(&json!("auto"))); assert_eq!( @@ -1177,7 +774,8 @@ mod tests { #[test] fn build_request_payload_keeps_local_compatibility_fields() { - let payload = build_request_payload(&sample_request(), true, true); + let payload = + provider_payload::build_chat_completion_payload(&sample_request(), true, true); assert_eq!(payload.get("max_tokens"), Some(&json!(2048))); assert!(payload.get("max_completion_tokens").is_none()); diff --git a/src-tauri/src/domain/engine/engine_args.rs b/src-tauri/src/domain/engine/engine_args.rs index 7522686c..8163337f 100644 --- a/src-tauri/src/domain/engine/engine_args.rs +++ b/src-tauri/src/domain/engine/engine_args.rs @@ -2,6 +2,15 @@ use std::path::PathBuf; use super::types::{EngineComputeMode, EngineConfig}; +const SDCPP_UNSUPPORTED_FLAGS: [&str; 4] = [ + "--diffusion-on-cpu", + "--vae-on-gpu", + "--clip-on-gpu", + "--control-net-on-gpu", +]; +const SDCPP_SERVER_UNSUPPORTED_FLAGS: [&str; 3] = + ["--preview", "--preview-path", "--preview-interval"]; + fn is_qwen_model(model_path: Option<&str>) -> bool { model_path.is_some_and(|path| path.to_ascii_lowercase().contains("qwen")) } @@ -37,25 +46,6 @@ fn push_arg_if_missing( } } -fn extract_arg_value(args: &[String], candidates: &[&str]) -> Option { - for (index, arg) in args.iter().enumerate() { - for candidate in candidates { - if arg == candidate { - if let Some(value) = args.get(index + 1) { - return Some(value.clone()); - } - } - - let prefix = format!("{candidate}="); - if let Some(value) = arg.strip_prefix(&prefix) { - return Some(value.to_string()); - } - } - } - - None -} - fn push_llamacpp_compute_args(args: &mut Vec, config: &EngineConfig) { match config.compute_mode { EngineComputeMode::Gpu => { @@ -71,36 +61,48 @@ fn push_llamacpp_compute_args(args: &mut Vec, config: &EngineConfig) { } } -fn push_sdcpp_compute_args(args: &mut Vec, config: &EngineConfig) { - if config.compute_mode != EngineComputeMode::Cpu { - return; - } - - push_arg_if_missing(args, &config.extra_args, &["--offload-to-cpu"], None); - push_arg_if_missing(args, &config.extra_args, &["--clip-on-cpu"], None); - push_arg_if_missing(args, &config.extra_args, &["--vae-on-cpu"], None); +fn sdcpp_extra_args(config: &EngineConfig) -> Vec { + config + .extra_args + .iter() + .filter(|arg| { + !SDCPP_UNSUPPORTED_FLAGS.iter().any(|flag| { + arg.as_str() == *flag + || arg + .strip_prefix(flag) + .is_some_and(|suffix| suffix.starts_with('=')) + }) && !SDCPP_SERVER_UNSUPPORTED_FLAGS.iter().any(|flag| { + arg.as_str() == *flag + || arg + .strip_prefix(flag) + .is_some_and(|suffix| suffix.starts_with('=')) + }) + }) + .cloned() + .collect() } /// Resolves the explicit stable-diffusion.cpp preview output path from extra arguments. -pub fn resolve_sdcpp_preview_path(extra_args: &[String]) -> Option { - extract_arg_value(extra_args, &["--preview-path"]).map(PathBuf::from) +pub const fn resolve_sdcpp_preview_path(extra_args: &[String]) -> Option { + let _ = extra_args; + None } -pub(super) fn sdcpp_preview_enabled(extra_args: &[String]) -> bool { - extract_arg_value(extra_args, &["--preview"]) - .is_none_or(|value| !value.trim().eq_ignore_ascii_case("none")) +pub(super) const fn sdcpp_preview_enabled(extra_args: &[String]) -> bool { + let _ = extra_args; + false } pub(super) fn build_sdcpp_args(config: &EngineConfig, port: u16) -> Vec { let mut args = vec!["--listen-port".to_string(), port.to_string()]; + let extra_args = sdcpp_extra_args(config); if let Some(model_path) = config.model_path.as_deref() { args.push("--model".to_string()); args.push(model_path.to_string()); } - push_sdcpp_compute_args(&mut args, config); - args.extend(config.extra_args.clone()); + args.extend(extra_args); args } @@ -139,5 +141,6 @@ pub(super) fn build_llamacpp_args(config: &EngineConfig, port: u16) -> Vec Option { || normalized.contains("failed to allocate compute") { return Some( - "Not enough memory to start the local model. Reduce context size or GPU layers, or use a smaller model." + "Not enough memory to start the local model. Reduce context size, switch compute mode, or use a smaller model." .to_string(), ); } @@ -149,3 +149,24 @@ pub(super) async fn wait_for_health(endpoint: &str) -> Result<(), AppError> { ), }) } + +pub(super) async fn is_endpoint_healthy(endpoint: &str) -> bool { + let Ok(client) = reqwest::Client::builder() + .timeout(Duration::from_millis(900)) + .build() + else { + return false; + }; + + for health_url in [ + format!("{endpoint}/health"), + format!("{endpoint}/v1/models"), + format!("{endpoint}/"), + ] { + if matches!(client.get(&health_url).send().await, Ok(resp) if resp.status().is_success()) { + return true; + } + } + + false +} diff --git a/src-tauri/src/domain/engine/manager.rs b/src-tauri/src/domain/engine/manager.rs index c4b7abbc..f3c08292 100644 --- a/src-tauri/src/domain/engine/manager.rs +++ b/src-tauri/src/domain/engine/manager.rs @@ -19,7 +19,8 @@ use crate::errors::AppError; use super::engine_args::{build_llamacpp_args, build_sdcpp_args, sdcpp_preview_enabled}; use super::engine_runtime::{ - diagnose_engine_start_failure, find_available_local_port, spawn_log_reader, wait_for_health, + diagnose_engine_start_failure, find_available_local_port, is_endpoint_healthy, + spawn_log_reader, wait_for_health, }; use super::events::EngineEventEmitter; use super::types::{ @@ -91,11 +92,13 @@ impl EngineManager { /// Checks if a definition exists for the given engine ID pub async fn has_definition(&self, id: &str) -> bool { + let id = canonical_engine_id(id); self.definitions.lock().await.iter().any(|d| d.id == id) } /// Gets the definition for an engine ID pub async fn get_definition(&self, id: &str) -> Option { + let id = canonical_engine_id(id); self.definitions .lock() .await @@ -106,6 +109,8 @@ impl EngineManager { /// Gets the current engine state (all active slots) pub async fn state(&self) -> EngineState { + self.prune_dead_slots().await; + let slots = self.slots.lock().await; if slots.is_empty() { return EngineState::Idle; @@ -130,6 +135,8 @@ impl EngineManager { /// Gets the endpoint for a given capability (if an active engine supports it) pub async fn endpoint_for(&self, capability: Capability) -> Option { + self.prune_dead_slots().await; + let slots = self.slots.lock().await; slots.get(&capability).and_then(|engine| { if engine.healthy { @@ -142,16 +149,19 @@ impl EngineManager { /// Checks if any active engine supports a capability pub async fn supports(&self, capability: Capability) -> bool { + self.prune_dead_slots().await; self.slots.lock().await.contains_key(&capability) } /// Checks if any engine is currently active pub async fn is_active(&self) -> bool { + self.prune_dead_slots().await; !self.slots.lock().await.is_empty() } /// Gets all active engine IDs pub async fn active_ids(&self) -> Vec { + self.prune_dead_slots().await; self.slots .lock() .await @@ -189,6 +199,8 @@ impl EngineManager { /// launcher on a single active local engine by default. pub async fn start(&self, config: EngineConfig) -> Result { let _lifecycle_guard = self.lifecycle_lock.lock().await; + let mut config = config; + config.engine_id = canonical_engine_id(&config.engine_id).to_string(); let definition = self.find_definition(&config.engine_id).await?; let primary_cap = definition .capabilities @@ -196,6 +208,68 @@ impl EngineManager { .copied() .unwrap_or(Capability::Text); // Check if this exact engine AND model is already running in this slot + { + let mut slots = self.slots.lock().await; + if let Some(existing) = slots.get_mut(&primary_cap) { + if existing.definition.id == config.engine_id + && existing.config.model_path == config.model_path + { + match existing.process.try_wait() { + Ok(Some(status)) => { + warn!( + engine = %config.engine_id, + slot = ?primary_cap, + exit_status = %status, + "Dropping stale engine slot because process already exited" + ); + slots.remove(&primary_cap); + } + Err(error) => { + warn!( + engine = %config.engine_id, + slot = ?primary_cap, + error = %error, + "Dropping stale engine slot because process status check failed" + ); + slots.remove(&primary_cap); + } + Ok(None) => { + let status = EngineStatus { + id: existing.definition.id.clone(), + name: existing.definition.name.clone(), + capabilities: existing.definition.capabilities.clone(), + endpoint: existing.endpoint.clone(), + healthy: existing.healthy, + }; + let endpoint = existing.endpoint.clone(); + drop(slots); + + if is_endpoint_healthy(&endpoint).await { + info!(engine = %config.engine_id, slot = ?primary_cap, "Engine and model already running in slot"); + return Ok(status); + } + + warn!( + engine = %config.engine_id, + slot = ?primary_cap, + endpoint = %endpoint, + "Dropping stale engine slot because health check failed" + ); + let mut slots = self.slots.lock().await; + let stale = slots.remove(&primary_cap); + drop(slots); + if let Some(stale) = stale { + Self::kill_engine(stale).await; + } + } + } + } else { + // Different engine or model: hot-swap below. + } + } + } + + // Re-check after stale cleanup; a different caller may have started it while we probed. { let slots = self.slots.lock().await; if let Some(existing) = slots.get(&primary_cap) { @@ -278,7 +352,7 @@ impl EngineManager { cmd.arg("--port").arg(selected_port.to_string()); } - if config.engine_id != "sdcpp" { + if config.engine_id != "sdcpp" && config.engine_id != "llamacpp" { if let Some(ref model) = config.model_path { cmd.arg("--model").arg(model); } @@ -288,6 +362,12 @@ impl EngineManager { } } + if config.engine_id == "llamacpp" { + if let Some(ref model) = config.model_path { + cmd.arg("--model").arg(model); + } + } + // Pipe engine stdout/stderr to files in logs directory let log_dir = crate::utils::paths::ENGINE_LOGS_DIR.join(canonical_engine_log_id(&config.engine_id)); @@ -348,6 +428,18 @@ impl EngineManager { // Wait for health check match wait_for_health(&endpoint).await { Ok(()) => { + if let Ok(Some(status)) = running.process.try_wait() { + let message = format!( + "Engine '{}' exited during startup: {status}", + running.definition.id + ); + warn!(engine = %running.definition.id, %status, "Engine exited during startup"); + self.emitter.emit_error(&running.definition.id, &message); + return Err(AppError::External { + request_id: None, + message, + }); + } running.healthy = true; info!(engine = %running.definition.id, "Engine is healthy"); self.emitter.emit_ready(&running.definition.id, &endpoint); @@ -381,6 +473,28 @@ impl EngineManager { Ok(status) } + /// Emits an error for the engine in a slot, then stops and removes that slot. + pub async fn stop_slot_after_error(&self, capability: Capability, message: &str) { + let engine_id = { + let slots = self.slots.lock().await; + slots + .get(&capability) + .map(|engine| engine.definition.id.clone()) + }; + + if let Some(engine_id) = engine_id { + self.emitter.emit_error(&engine_id, message); + } + + if let Err(error) = self.stop_slot(capability).await { + warn!( + slot = ?capability, + error = %error, + "Failed to stop engine slot after runtime error" + ); + } + } + /// Stop all running engines pub async fn stop(&self) -> Result<(), AppError> { let _lifecycle_guard = self.lifecycle_lock.lock().await; @@ -412,8 +526,48 @@ impl EngineManager { info!(engine = %engine.definition.id, "Engine stopped"); } + async fn prune_dead_slots(&self) { + let mut dead = Vec::new(); + { + let mut slots = self.slots.lock().await; + for (capability, engine) in slots.iter_mut() { + match engine.process.try_wait() { + Ok(Some(status)) => { + warn!( + engine = %engine.definition.id, + slot = ?capability, + exit_status = %status, + "Pruning dead engine slot" + ); + dead.push((*capability, engine.definition.id.clone())); + } + Err(error) => { + warn!( + engine = %engine.definition.id, + slot = ?capability, + error = %error, + "Pruning engine slot after process status check failed" + ); + dead.push((*capability, engine.definition.id.clone())); + } + Ok(None) => {} + } + } + + for (capability, _) in &dead { + slots.remove(capability); + } + } + + for (_, engine_id) in dead { + self.emitter + .emit_error(&engine_id, "Local engine process exited."); + } + } + /// Find an engine definition by ID async fn find_definition(&self, id: &str) -> Result { + let id = canonical_engine_id(id); let definitions = self.definitions.lock().await; definitions .iter() @@ -423,19 +577,25 @@ impl EngineManager { } } -fn canonical_engine_log_id(engine_id: &str) -> &str { +/// Returns the registry id used internally for known engine aliases. +pub fn canonical_engine_id(engine_id: &str) -> &str { match engine_id { "stable-diffusion" => "sdcpp", value => value, } } +fn canonical_engine_log_id(engine_id: &str) -> &str { + canonical_engine_id(engine_id) +} + #[cfg(test)] mod tests { #![allow(clippy::unwrap_used, clippy::panic)] use super::*; use crate::domain::engine::engine_runtime::classify_engine_start_failure; + use crate::domain::engine::events::NoopEmitter; use crate::domain::engine::types::EngineComputeMode; use crate::domain::system::ports::ENGINE_LOCAL_PORT_RANGE; use std::net::TcpListener; @@ -534,7 +694,7 @@ mod tests { assert_eq!( message.as_deref(), Some( - "Not enough memory to start the local model. Reduce context size or GPU layers, or use a smaller model." + "Not enough memory to start the local model. Reduce context size, switch compute mode, or use a smaller model." ) ); } @@ -565,14 +725,69 @@ mod tests { ); } + #[tokio::test] + async fn resolves_stable_diffusion_alias_to_sdcpp_definition() { + let manager = EngineManager::new(Arc::new(NoopEmitter)); + manager + .register_definitions(vec![EngineDefinition { + id: "sdcpp".to_string(), + name: "Stable Diffusion.cpp".to_string(), + desc: String::new(), + icon: String::new(), + capabilities: vec![Capability::Image], + binary: Some("sd-server".to_string()), + repo_url: None, + version: "1.0.0".to_string(), + default_port: 8082, + default_context_size: 4096, + config_schema: None, + installed: false, + managed_externally: false, + }]) + .await; + + let Some(resolved) = manager.get_definition("stable-diffusion").await else { + panic!("stable-diffusion alias should resolve to sdcpp"); + }; + + assert_eq!(resolved.id, "sdcpp"); + } + #[test] - fn builds_sdcpp_cpu_mode_args() { + fn sdcpp_keeps_user_supplied_cpu_extra_args() { let mut config = sample_sdcpp_config(Some("C:/models/sd15.safetensors")); - config.compute_mode = EngineComputeMode::Cpu; + config.extra_args = vec![ + "--offload-to-cpu".to_string(), + "--clip-on-cpu".to_string(), + "--vae-on-cpu".to_string(), + "--mmap".to_string(), + ]; + let args = build_sdcpp_args(&config, 8082); assert!(args.contains(&"--offload-to-cpu".to_string())); assert!(args.contains(&"--clip-on-cpu".to_string())); assert!(args.contains(&"--vae-on-cpu".to_string())); + assert!(args.contains(&"--mmap".to_string())); + } + + #[test] + fn sdcpp_filters_cli_only_preview_flags_from_server_args() { + let mut config = sample_sdcpp_config(Some("C:/models/sd15.safetensors")); + config.extra_args = vec![ + "--preview".to_string(), + "vae".to_string(), + "--preview-path".to_string(), + "C:/tmp/preview.png".to_string(), + "--preview-interval=1".to_string(), + ]; + + let args = build_sdcpp_args(&config, 8082); + + assert!(!args.contains(&"--preview".to_string())); + assert!(!args.contains(&"--preview-path".to_string())); + assert!(!args.contains(&"--preview-interval=1".to_string())); + assert!(!sdcpp_preview_enabled(&config.extra_args)); + assert!(resolve_sdcpp_preview_path(&config.extra_args).is_none()); } } diff --git a/src-tauri/src/domain/modules/github_release_selection.rs b/src-tauri/src/domain/modules/github_release_selection.rs index 1011ed6f..36523138 100644 --- a/src-tauri/src/domain/modules/github_release_selection.rs +++ b/src-tauri/src/domain/modules/github_release_selection.rs @@ -6,6 +6,35 @@ use super::github_releases::{ Asset, HardwareProfile, Platform, PlatformArch, PlatformOs, ReleaseAsset, }; +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)] +enum CudaTrack { + Cuda12, + Cuda13, +} + +impl CudaTrack { + const fn min_driver_major(self) -> u32 { + match self { + Self::Cuda12 => 525, + Self::Cuda13 => 580, + } + } + + const fn base_score(self) -> i32 { + match self { + Self::Cuda12 => 450, + Self::Cuda13 => 500, + } + } + + const fn runtime_score(self) -> i32 { + match self { + Self::Cuda12 => 180, + Self::Cuda13 => 200, + } + } +} + pub(super) async fn detect_hardware_profile() -> HardwareProfile { let probe = probe_gpu_info().await; HardwareProfile::from_probe(&probe) @@ -32,14 +61,24 @@ pub(super) fn select_release_assets( && platform.os == PlatformOs::Windows && hardware.accelerator == AcceleratorClass::NvidiaCuda { + let supported_cuda_candidates = main_candidates + .iter() + .copied() + .filter(|idx| { + assets + .get(*idx) + .and_then(|asset| detect_cuda_track(&asset.name)) + .is_some_and(|track| hardware.supports_cuda_track(track)) + }) + .collect::>(); let has_cuda_main = main_candidates.iter().copied().any(|idx| { assets .get(idx) .and_then(|asset| detect_cuda_track(&asset.name)) - .is_some() + .is_some_and(|track| hardware.supports_cuda_track(track)) }); - for main_idx in &main_candidates { + for main_idx in &supported_cuda_candidates { let main = assets.get(*main_idx)?; if let Some(cuda_track) = detect_cuda_track(&main.name) && let Some(runtime_idx) = runtime_candidates.iter().copied().find(|idx| { @@ -53,19 +92,20 @@ pub(super) fn select_release_assets( asset_to_release_asset(main)?, ]); } + } - if detect_cuda_track(&main.name).is_some() { - continue; - } + if has_cuda_main { + return None; + } - if !has_cuda_main { + for main_idx in &main_candidates { + let main = assets.get(*main_idx)?; + if detect_cuda_track(&main.name).is_none() { return Some(vec![asset_to_release_asset(main)?]); } } - if has_cuda_main { - return None; - } + return None; } let selected_main = main_candidates.first().copied()?; @@ -217,8 +257,12 @@ fn main_score(module_id: &str, name: &str, hardware: HardwareProfile) -> i32 { match hardware.accelerator { AcceleratorClass::NvidiaCuda => { - if detect_cuda_track(&lower).is_some() { - score += 1_000; + if let Some(cuda_track) = detect_cuda_track(&lower) { + score += if hardware.supports_cuda_track(cuda_track) { + 1_000 + cuda_track.base_score() + } else { + -1_500 + }; } if lower.contains("vulkan") || lower.contains("rocm") @@ -329,11 +373,7 @@ fn comfyui_main_score(lower: &str, hardware: HardwareProfile) -> i32 { pub(super) fn base_main_score(lower: &str) -> i32 { let mut score = 0; if let Some(cuda_track) = detect_cuda_track(lower) { - score += match cuda_track { - "cuda13" => 500, - "cuda12" => 450, - _ => 400, - }; + score += cuda_track.base_score(); } if lower.contains("vulkan") { @@ -389,23 +429,18 @@ fn has_avx_marker(lower: &str) -> bool { } fn runtime_score(name: &str) -> i32 { - match detect_cuda_track(name) { - Some("cuda13") => 200, - Some("cuda12") => 180, - Some(_) => 160, - None => 0, - } + detect_cuda_track(name).map_or(0, CudaTrack::runtime_score) } -fn detect_cuda_track(name: &str) -> Option<&'static str> { +fn detect_cuda_track(name: &str) -> Option { let lower = name.to_ascii_lowercase(); if lower.contains("cuda-12") || lower.contains("cuda12") || lower.contains("cu12") { - return Some("cuda12"); + return Some(CudaTrack::Cuda12); } if lower.contains("cuda-13") || lower.contains("cuda13") || lower.contains("cu13") { - return Some("cuda13"); + return Some(CudaTrack::Cuda13); } None @@ -452,4 +487,11 @@ impl HardwareProfile { _ => false, } } + + fn supports_cuda_track(&self, track: CudaTrack) -> bool { + match self.cuda_driver_major { + Some(driver_major) => driver_major >= track.min_driver_major(), + None => track == CudaTrack::Cuda12, + } + } } diff --git a/src-tauri/src/domain/modules/github_releases.rs b/src-tauri/src/domain/modules/github_releases.rs index 86261b06..709ffdc3 100644 --- a/src-tauri/src/domain/modules/github_releases.rs +++ b/src-tauri/src/domain/modules/github_releases.rs @@ -258,8 +258,8 @@ mod tests { let hardware = HardwareProfile { accelerator: AcceleratorClass::NvidiaCuda, cpu_tier: CpuInstructionTier::Avx2, - cuda_driver_major: None, - cuda_driver_minor: None, + cuda_driver_major: Some(580), + cuda_driver_minor: Some(0), }; let incomplete_latest = vec![ @@ -298,8 +298,8 @@ mod tests { let hardware = HardwareProfile { accelerator: AcceleratorClass::NvidiaCuda, cpu_tier: CpuInstructionTier::Avx2, - cuda_driver_major: None, - cuda_driver_minor: None, + cuda_driver_major: Some(580), + cuda_driver_minor: Some(0), }; let assets = vec![ asset("cudart-llama-bin-win-cuda-12.4-x64.zip"), @@ -331,8 +331,8 @@ mod tests { let hardware = HardwareProfile { accelerator: AcceleratorClass::NvidiaCuda, cpu_tier: CpuInstructionTier::Avx2, - cuda_driver_major: None, - cuda_driver_minor: None, + cuda_driver_major: Some(580), + cuda_driver_minor: Some(0), }; let assets = vec![ asset("cudart-llama-bin-win-cuda-12.4-x64.zip"), @@ -358,6 +358,68 @@ mod tests { ); } + #[test] + fn prefers_cuda12_when_cuda_driver_version_is_unknown() { + let platform = Platform { + os: PlatformOs::Windows, + arch: PlatformArch::X64, + }; + let hardware = HardwareProfile { + accelerator: AcceleratorClass::NvidiaCuda, + cpu_tier: CpuInstructionTier::Avx2, + cuda_driver_major: None, + cuda_driver_minor: None, + }; + let assets = vec![ + asset("cudart-llama-bin-win-cuda-12.4-x64.zip"), + asset("cudart-llama-bin-win-cuda-13.1-x64.zip"), + asset("llama-b8461-bin-win-cuda-12.4-x64.zip"), + asset("llama-b8461-bin-win-cuda-13.1-x64.zip"), + asset("llama-b8461-bin-win-cpu-x64.zip"), + ]; + + let selected = select_release_assets("llamacpp", platform, hardware, &assets) + .expect("expected a compatible llama.cpp bundle"); + + assert_eq!(selected.len(), 2); + assert_eq!( + selected.first().map(|asset| asset.name.as_str()), + Some("cudart-llama-bin-win-cuda-12.4-x64.zip") + ); + assert_eq!( + selected.get(1).map(|asset| asset.name.as_str()), + Some("llama-b8461-bin-win-cuda-12.4-x64.zip") + ); + } + + #[test] + fn falls_back_to_cpu_when_only_unsupported_cuda_track_is_available() { + let platform = Platform { + os: PlatformOs::Windows, + arch: PlatformArch::X64, + }; + let hardware = HardwareProfile { + accelerator: AcceleratorClass::NvidiaCuda, + cpu_tier: CpuInstructionTier::Avx2, + cuda_driver_major: Some(550), + cuda_driver_minor: Some(0), + }; + let assets = vec![ + asset("cudart-llama-bin-win-cuda-13.1-x64.zip"), + asset("llama-b8461-bin-win-cuda-13.1-x64.zip"), + asset("llama-b8461-bin-win-cpu-x64.zip"), + ]; + + let selected = select_release_assets("llamacpp", platform, hardware, &assets) + .expect("expected CPU fallback when CUDA 13 is unsupported"); + + assert_eq!(selected.len(), 1); + assert_eq!( + selected.first().map(|asset| asset.name.as_str()), + Some("llama-b8461-bin-win-cpu-x64.zip") + ); + } + #[test] fn never_selects_runtime_only_asset_as_install_bundle() { let platform = Platform { @@ -404,8 +466,8 @@ mod tests { let hardware = HardwareProfile { accelerator: AcceleratorClass::NvidiaCuda, cpu_tier: CpuInstructionTier::Avx2, - cuda_driver_major: None, - cuda_driver_minor: None, + cuda_driver_major: Some(580), + cuda_driver_minor: Some(0), }; let assets = vec![ asset("llama-b8726-bin-win-cuda-13.1-x64.zip"), diff --git a/src-tauri/src/domain/system/hardware_probe.rs b/src-tauri/src/domain/system/hardware_probe.rs index a32b606f..7baa18f3 100644 --- a/src-tauri/src/domain/system/hardware_probe.rs +++ b/src-tauri/src/domain/system/hardware_probe.rs @@ -86,17 +86,12 @@ impl GpuInfo { "cuda" => AcceleratorClass::NvidiaCuda, "hip" => AcceleratorClass::AmdGpu, "sycl" => AcceleratorClass::IntelGpu, - "vulkan" => { - if gpu_name_brand(&self.name) == GpuBrand::Amd { - AcceleratorClass::AmdGpu - } else if gpu_name_brand(&self.name) == GpuBrand::Intel { - AcceleratorClass::IntelGpu - } else if self.detected { - AcceleratorClass::GenericGpu - } else { - AcceleratorClass::CpuOnly - } - } + "vulkan" => match gpu_name_brand(&self.name) { + GpuBrand::Amd => AcceleratorClass::AmdGpu, + GpuBrand::Intel => AcceleratorClass::IntelGpu, + _ if self.detected => AcceleratorClass::GenericGpu, + _ => AcceleratorClass::CpuOnly, + }, "cpu" => AcceleratorClass::CpuOnly, _ => { if self.detected { @@ -293,8 +288,9 @@ fn gpu_probe_from_names(names: &[String]) -> GpuInfo { .or_else(|| names.first().cloned()) .unwrap_or_else(|| "Integrated / No GPU".to_string()); - let backend = preferred_backend_for_gpu_name(&primary_name); - let detected = gpu_name_brand(&primary_name) != GpuBrand::Software; + let brand = gpu_name_brand(&primary_name); + let backend = preferred_backend_for_gpu_brand(brand); + let detected = brand != GpuBrand::Software; let (cuda_driver_major, cuda_driver_minor) = if backend == "cuda" { detect_cuda_driver_version() } else { @@ -315,8 +311,8 @@ fn gpu_probe_from_names(names: &[String]) -> GpuInfo { } } -fn preferred_backend_for_gpu_name(name: &str) -> &'static str { - match gpu_name_brand(name) { +const fn preferred_backend_for_gpu_brand(brand: GpuBrand) -> &'static str { + match brand { GpuBrand::Nvidia => "cuda", GpuBrand::Amd => "hip", GpuBrand::Intel => "sycl", diff --git a/src-tauri/src/infrastructure/config/engine_settings.rs b/src-tauri/src/infrastructure/config/engine_settings.rs index 6eb15aa6..d971424f 100644 --- a/src-tauri/src/infrastructure/config/engine_settings.rs +++ b/src-tauri/src/infrastructure/config/engine_settings.rs @@ -36,7 +36,11 @@ pub async fn load_engine_config_map() -> Result { /// Saves persisted engine configuration map atomically. pub async fn save_engine_config_map(map: &EngineConfigMap) -> Result<(), AppError> { let path = &*FILE_ENGINE_CONFIG; - let tmp = path.with_extension("tmp"); + let tmp = path.with_extension(format!( + "tmp-{}-{}", + std::process::id(), + chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default() + )); if let Some(dir) = path.parent() { tokio::fs::create_dir_all(dir) diff --git a/src/app/events.ts b/src/app/events.ts index 855f3ecf..613ae885 100644 --- a/src/app/events.ts +++ b/src/app/events.ts @@ -154,8 +154,21 @@ export class EventHandler { return; } + const attachMenuAction = target.closest('[data-chat-attach-action]'); + if (attachMenuAction instanceof HTMLElement) { + const action = attachMenuAction.dataset['chatAttachAction']; + if (action === 'file') { + await this._core.chatController.pickChatFilesFromMenu(); + return; + } + if (action === 'image') { + await this._core.chatController.sendImageGenerationFromMenu(); + return; + } + } + if (target.closest('#chat-attach-btn') !== null) { - await this._core.chatController.pickChatFiles(); + this._core.chatController.toggleAttachMenu(); return; } diff --git a/src/features/ai/services/AIBridge.test.ts b/src/features/ai/services/AIBridge.test.ts index 1fe3d69b..54680f39 100644 --- a/src/features/ai/services/AIBridge.test.ts +++ b/src/features/ai/services/AIBridge.test.ts @@ -284,7 +284,7 @@ describe('AIBridge', () => { expect(mockInvoke).not.toHaveBeenCalledWith('stop_engine', expect.any(Object)); }); - it('should stop conflicting local engine slots only for local providers', async () => { + it('should not stop engine slots when selecting a local provider', async () => { mockInvoke.mockImplementation(async (cmd: string) => { await Promise.resolve(); if (cmd === 'get_engine_config') return { context_size: 4096 }; @@ -293,9 +293,7 @@ describe('AIBridge', () => { await aiBridge.startProvider('llamacpp'); - expect(mockInvoke).toHaveBeenCalledWith('stop_engine_slot', { - capability: 'image', - }); + expect(mockInvoke).not.toHaveBeenCalledWith('stop_engine_slot', expect.any(Object)); }); it('should NOT fallback to localStorage when backend returns null', async () => { diff --git a/src/features/ai/services/AIBridge.ts b/src/features/ai/services/AIBridge.ts index 5d71bf98..48f91962 100644 --- a/src/features/ai/services/AIBridge.ts +++ b/src/features/ai/services/AIBridge.ts @@ -6,6 +6,7 @@ import type { IChunkHandler, IImageGenerationPreview, } from '../types/aiTypes'; +import type { AIBridgeSendMessageOptions } from './AIBridgeMessageController'; import { AIProviderManager } from './AIProviderManager'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; import { AIChatTransport, type IChatTransport } from './AIChatTransport'; @@ -141,13 +142,6 @@ export class AIBridge implements IAIBridge { if (started && this._context?.tauriProvider.isTauri() === true) { this._inactivityController.reset(); - if (!this._providerPolicy.isCloudProvider(providerId)) { - await this._runtime.stopCrossSlotEngines({ - context: this._context, - providerId, - providerPolicy: this._providerPolicy, - }); - } await this._refreshLocalContextWindow(providerId); } @@ -187,6 +181,20 @@ export class AIBridge implements IAIBridge { } } + public async stopEngineSlot(capability: 'text' | 'image' | 'vision'): Promise { + const providerId = this._manager.activeProviderId; + await this._runtime.stopEngineSlot(this._context, capability); + if ( + providerId !== null && + ((capability === 'image' && + this._providerPolicy.isManagedLocalImageEngine(providerId)) || + (capability === 'text' && this._providerPolicy.isLocalTextProvider(providerId))) + ) { + this._manager.stopProvider(); + this._engineStatus.setEngineState(providerId, 'idle'); + } + } + public isActive(): boolean { return this._manager.isActive(); } @@ -205,8 +213,19 @@ export class AIBridge implements IAIBridge { source: MessageSource = 'chat', attachments: { name: string; type: string; data_base64: string }[] = [], history: IChatMessage[] = [], + options: AIBridgeSendMessageOptions = {}, ): Promise { - return await this._messageController.sendMessage(text, source, attachments, history); + return await this._messageController.sendMessage( + text, + source, + attachments, + history, + options, + ); + } + + public async prepareImagePrompt(text: string): Promise { + return await this._messageController.prepareImagePrompt(text); } public onMessage(listenerId: string, handler: MessageHandler): void { diff --git a/src/features/ai/services/AIBridgeMessageController.test.ts b/src/features/ai/services/AIBridgeMessageController.test.ts index 3a30f73a..b78f5cc3 100644 --- a/src/features/ai/services/AIBridgeMessageController.test.ts +++ b/src/features/ai/services/AIBridgeMessageController.test.ts @@ -11,7 +11,6 @@ function createTextController() { const transport = { send: vi.fn().mockResolvedValue({ ok: true, text: 'done' }), generateImage: vi.fn(), - generateImageBackground: vi.fn(), }; const events = { broadcastResponse: vi.fn(), @@ -66,7 +65,6 @@ function createImageController() { generateImage: vi .fn() .mockResolvedValue({ ok: true, images: ['data:image/png;base64,abc'] }), - generateImageBackground: vi.fn(), }; const events = { broadcastResponse: vi.fn(), diff --git a/src/features/ai/services/AIBridgeMessageController.ts b/src/features/ai/services/AIBridgeMessageController.ts index 995a41a5..9427a61e 100644 --- a/src/features/ai/services/AIBridgeMessageController.ts +++ b/src/features/ai/services/AIBridgeMessageController.ts @@ -32,6 +32,10 @@ type AIBridgeMessageControllerDeps = { onSuccessfulResponse: () => void; }; +export type AIBridgeSendMessageOptions = { + originalPrompt?: string; +}; + export class AIBridgeMessageController { constructor(private readonly _deps: AIBridgeMessageControllerDeps) {} @@ -40,6 +44,7 @@ export class AIBridgeMessageController { source: MessageSource, attachments: { name: string; type: string; data_base64: string }[], history: IChatMessage[], + options: AIBridgeSendMessageOptions = {}, ): Promise { if (this._deps.manager.activeProviderId === null) { return this._handleMissingProvider(source); @@ -58,7 +63,7 @@ export class AIBridgeMessageController { const isImageProvider = this._deps.providerPolicy.isImageProvider(providerId); if (isImageProvider) { - return await this._sendImageMessage(providerId, text, source); + return await this._sendImageMessage(providerId, text, source, options); } return await this._sendTextMessage(providerId, text, attachments, history, source); @@ -72,51 +77,77 @@ export class AIBridgeMessageController { } } + public async prepareImagePrompt(text: string): Promise { + if (this._deps.manager.activeProviderId === null) { + return this._handleMissingProvider('service'); + } + + await this._deps.manager.refreshActiveApiKey(); + if (this._deps.manager.apiKey === null && this._deps.manager.isActive() === false) { + return this._handleMissingApiKey('service'); + } + + try { + const providerId = this._deps.manager.activeProviderId; + const backendProviderId = resolveCustomProviderBackendId(providerId); + const requestOptions = this._deps.providerPolicy.buildRequestOptions({ + hasApiKey: this._deps.manager.apiKey !== null, + maxOutputTokens: Math.min(this._deps.manager.maxOutputTokens ?? 320, 420), + thinkingLevel: 'off', + webSearchEnabled: false, + }); + const request = constructChatRequest( + [], + { + role: 'user', + content: text, + }, + [], + { + providerId: backendProviderId, + model: this._deps.manager.model || 'default', + apiKey: null, + sessionId: '', + ...requestOptions, + }, + ); + + const response = await this._deps.transport.sendSilent(request); + return this._withModelContext(response, providerId, request.model); + } catch (error: unknown) { + const errorMsg = + error instanceof Error + ? error.message + : this._deps.translate('ui.ai.communication_failure', 'Communication failure'); + this._deps.tracer.error('[AIBridge] Silent prompt preparation failed:', error); + return { ok: false, error: errorMsg }; + } + } + private async _sendImageMessage( providerId: string, text: string, source: MessageSource, + options: AIBridgeSendMessageOptions, ): Promise { const context = this._deps.getContext(); - const settings = context?.settingsService.getSettings() as - | Record - | undefined; const selectedImageModule = context?.stateStore.getSelectedModule('ai_image'); const settingsKey = selectedImageModule?.id ?? providerId; - const performanceMode = this._deps.providerPolicy.isImagePerformanceModeEnabled( - settings, - settingsKey, - ); const backendProviderId = resolveCustomProviderBackendId(providerId); + const originalPrompt = options.originalPrompt?.trim(); const request: IImageGenerationRequest = { provider: backendProviderId, prompt: text, - original_prompt: text, - model: this._deps.manager.model || 'default', + original_prompt: + originalPrompt !== undefined && originalPrompt !== '' ? originalPrompt : text, + model: this._deps.manager.model, settings_key: settingsKey, session_id: this._deps.manager.sessionId, }; this._deps.events.broadcastReplaceChunk('image status=starting\n'); - if (performanceMode) { - const backgroundResponse = await this._deps.transport.generateImageBackground(request); - if (!backgroundResponse.ok) { - return this._handleTransportResponse( - this._withModelContext(backgroundResponse, providerId, request.model), - source, - ); - } - - this._deps.showToast( - this._deps.translate('ui.ai.performance_mode_active', 'Performance mode active'), - 'success', - ); - await context?.windowService.close(); - return { ok: true, text: '' }; - } - this._deps.onLongActivityStart(); const imageResponse = await this._deps.transport.generateImage(request).finally(() => { this._deps.onLongActivityEnd(); diff --git a/src/features/ai/services/AIBridgeProviderPolicy.test.ts b/src/features/ai/services/AIBridgeProviderPolicy.test.ts index 28694471..77aee9d1 100644 --- a/src/features/ai/services/AIBridgeProviderPolicy.test.ts +++ b/src/features/ai/services/AIBridgeProviderPolicy.test.ts @@ -51,24 +51,4 @@ describe('AIBridgeProviderPolicy', () => { }), ).toEqual({}); }); - - it('should resolve performance mode from module-specific or global settings', () => { - expect( - policy.isImagePerformanceModeEnabled( - { - comfyui_performance_mode: 'true', - }, - 'comfyui', - ), - ).toBe(true); - expect( - policy.isImagePerformanceModeEnabled( - { - sdcpp_performance_mode: true, - }, - 'stable-diffusion', - ), - ).toBe(true); - expect(policy.isImagePerformanceModeEnabled({}, 'comfyui')).toBe(false); - }); }); diff --git a/src/features/ai/services/AIBridgeProviderPolicy.ts b/src/features/ai/services/AIBridgeProviderPolicy.ts index 3005b88c..95f7f5dd 100644 --- a/src/features/ai/services/AIBridgeProviderPolicy.ts +++ b/src/features/ai/services/AIBridgeProviderPolicy.ts @@ -64,30 +64,4 @@ export class AIBridgeProviderPolicy { return requestOptions; } - - public isImagePerformanceModeEnabled( - settings: Record | undefined, - settingsKey: string, - ): boolean { - return ( - this._readBooleanSetting(settings, `${settingsKey}_performance_mode`) || - this._readBooleanSetting(settings, 'sdcpp_performance_mode') - ); - } - - private _readBooleanSetting( - settings: Record | undefined, - key: string, - ): boolean { - const value = settings?.[key]; - if (typeof value === 'boolean') { - return value; - } - - if (typeof value === 'string') { - return value.trim().toLowerCase() === 'true'; - } - - return false; - } } diff --git a/src/features/ai/types/IAIBridge.ts b/src/features/ai/types/IAIBridge.ts index 2c958cb2..323b4cb9 100644 --- a/src/features/ai/types/IAIBridge.ts +++ b/src/features/ai/types/IAIBridge.ts @@ -7,6 +7,10 @@ import type { IImageGenerationPreview, } from './aiTypes'; +export type IAIBridgeSendMessageOptions = { + originalPrompt?: string; +}; + export interface IAIBridge { isActive(): boolean; getActiveProvider(): { id: string; name: string } | null; @@ -15,9 +19,11 @@ export interface IAIBridge { source?: MessageSource, attachments?: { name: string; type: string; data_base64: string }[], history?: IChatMessage[], + options?: IAIBridgeSendMessageOptions, ): Promise; startProvider(providerId: string): Promise; stopProvider(): void; + stopEngineSlot(capability: 'text' | 'image' | 'vision'): Promise; clearHistory(): Promise; getHistory(): Promise; cancelTextGeneration(): Promise; diff --git a/src/features/chat/chat.ts b/src/features/chat/chat.ts index c02f3e3b..5052db13 100644 --- a/src/features/chat/chat.ts +++ b/src/features/chat/chat.ts @@ -364,7 +364,9 @@ export class ChatController { this._generationController.startImagePreviewPolling(handle); }, cancelTextGeneration: async () => { - const providerId = this._state.currentGenerationProviderId ?? this._aiBridge.getState().activeProviderId; + const providerId = + this._state.currentGenerationProviderId ?? + this._aiBridge.getState().activeProviderId; if (this._generationController.isImageProvider(providerId)) { this._generationController.stopImagePreviewPolling(); await this._aiBridge.cancelImageGeneration(); @@ -385,7 +387,8 @@ export class ChatController { setSending: (value) => { this._state.isSending = value; if (value) { - this._state.currentGenerationProviderId = this._aiBridge.getState().activeProviderId; + this._state.currentGenerationProviderId = + this._aiBridge.getState().activeProviderId; } else { this._state.currentGenerationProviderId = null; } diff --git a/src/features/chat/controllers/ChatGenerationController.ts b/src/features/chat/controllers/ChatGenerationController.ts index 4f2287dd..23c478a1 100644 --- a/src/features/chat/controllers/ChatGenerationController.ts +++ b/src/features/chat/controllers/ChatGenerationController.ts @@ -228,7 +228,6 @@ export class ChatGenerationController { await this.handleSuccessfulChatResponse(response, streamingHandle, imageHandle); } - private async handleSuccessfulChatResponse( response: IChatResponse, streamingHandle?: StreamingMessageHandle | null, diff --git a/src/features/chat/controllers/ChatSendController.test.ts b/src/features/chat/controllers/ChatSendController.test.ts index 7907ef93..4a8a4b4f 100644 --- a/src/features/chat/controllers/ChatSendController.test.ts +++ b/src/features/chat/controllers/ChatSendController.test.ts @@ -23,6 +23,9 @@ describe('ChatSendController', () => { const aiBridge = { getState: vi.fn(() => ({ activeProviderId: 'gpt', isRunning: true })), onChunk: vi.fn(), + startProvider: vi.fn().mockResolvedValue(true), + prepareImagePrompt: vi.fn().mockResolvedValue({ ok: true, text: 'prepared prompt' }), + stopEngineSlot: vi.fn().mockResolvedValue(undefined), }; const sendMessage = vi.fn().mockResolvedValue({ ok: true, message: 'done' }); const options = { @@ -51,12 +54,14 @@ describe('ChatSendController', () => { appendUserMessage: vi.fn(), getSelectedModule: vi.fn(), getPreferredAiCategory: vi.fn(() => 'ai_text' as const), + isForceImageGeneration: vi.fn(() => false), + clearForceImageGeneration: vi.fn(), handleResponse: vi.fn().mockResolvedValue(undefined), cleanupStreamingState: vi.fn(), stopImagePreviewPolling: vi.fn(), startImagePreviewPolling: vi.fn(), cancelTextGeneration: vi.fn().mockResolvedValue(true), - isImageProvider: vi.fn(() => false), + isImageProvider: vi.fn((_providerId: string | null) => false), lockUi: vi.fn(() => ({ input: null, sendBtn: null, @@ -76,6 +81,7 @@ describe('ChatSendController', () => { options, aiBridge, streamingHandle, + imageHandle, sendMessage, }; }; @@ -171,4 +177,64 @@ describe('ChatSendController', () => { expect(options.handleResponse).not.toHaveBeenCalled(); expect(options.handleError).not.toHaveBeenCalled(); }); + + it('stops the image engine after a successful image send', async () => { + const { controller, options, aiBridge } = createController(); + aiBridge.getState.mockReturnValue({ activeProviderId: 'sdcpp', isRunning: true }); + options.isImageProvider.mockReturnValue(true); + const input = document.createElement('textarea'); + input.value = 'draw image'; + + await controller.sendChat(input); + + expect(options.startImagePreviewPolling).toHaveBeenCalledOnce(); + expect(options.handleResponse).toHaveBeenCalledOnce(); + expect(aiBridge.stopEngineSlot).toHaveBeenCalledWith('image'); + }); + + it('stops the image engine after an image send throws', async () => { + const { controller, options, aiBridge, sendMessage } = createController(); + aiBridge.getState.mockReturnValue({ activeProviderId: 'sdcpp', isRunning: true }); + options.isImageProvider.mockReturnValue(true); + sendMessage.mockRejectedValueOnce(new Error('generation failed')); + const input = document.createElement('textarea'); + input.value = 'draw image'; + + await controller.sendChat(input); + + expect(options.handleError).toHaveBeenCalled(); + expect(aiBridge.stopEngineSlot).toHaveBeenCalledWith('image'); + }); + + it('prepares image prompts with the selected text provider before local image generation', async () => { + const { controller, options, aiBridge, sendMessage } = createController(); + aiBridge.getState + .mockReturnValueOnce({ activeProviderId: 'sdcpp', isRunning: true }) + .mockReturnValueOnce({ activeProviderId: 'sdcpp', isRunning: true }) + .mockReturnValueOnce({ activeProviderId: 'text-model', isRunning: true }) + .mockReturnValue({ activeProviderId: 'sdcpp', isRunning: true }); + vi.mocked(options.isImageProvider).mockImplementation( + (providerId: string | null) => providerId === 'sdcpp', + ); + options.getSelectedModule.mockImplementation((category: 'ai_text' | 'ai_image') => + category === 'ai_text' ? { id: 'text-model' } : { id: 'sdcpp' }, + ); + aiBridge.prepareImagePrompt.mockResolvedValueOnce({ + ok: true, + text: 'cinematic cat, rain, neon', + }); + sendMessage.mockResolvedValueOnce({ ok: true, message: 'done' }); + const input = document.createElement('textarea'); + input.value = 'сгенерируй кота под дождем'; + + await controller.sendChat(input); + + expect(aiBridge.startProvider).toHaveBeenNthCalledWith(1, 'text-model'); + expect(aiBridge.startProvider).toHaveBeenNthCalledWith(2, 'sdcpp'); + expect(aiBridge.prepareImagePrompt).toHaveBeenCalledOnce(); + expect(sendMessage).toHaveBeenCalledOnce(); + expect(sendMessage).toHaveBeenCalledWith('cinematic cat, rain, neon', [], [], { + originalPrompt: 'сгенерируй кота под дождем', + }); + }); }); diff --git a/src/features/chat/controllers/ChatSendController.ts b/src/features/chat/controllers/ChatSendController.ts index 9c47b838..3084687c 100644 --- a/src/features/chat/controllers/ChatSendController.ts +++ b/src/features/chat/controllers/ChatSendController.ts @@ -6,6 +6,7 @@ import type { IChatMessage, IChatAttachment } from '../types/chatTypes'; import type { IApp } from '@/shared/types/coreTypes'; import { ChatAutoStartHelper } from '../services/ChatAutoStartHelper'; import { ChatSendFlow } from '../services/ChatSendFlow'; +import { AIBridgeProviderPolicy } from '@/features/ai/services/AIBridgeProviderPolicy'; type ChatSendLogger = Pick; @@ -31,7 +32,11 @@ type ImageGenerationHandle = { }; type ChatSendControllerOptions = { - aiBridge: AIBridge; + aiBridge: AIBridge & { + prepareImagePrompt?: ( + text: string, + ) => Promise<{ ok: boolean; text?: string; error?: string }>; + }; fileHandler: Pick; service: ChatService; getHistory: () => IChatMessage[]; @@ -50,6 +55,8 @@ type ChatSendControllerOptions = { appendUserMessage: (text: string, attachments: IChatAttachment[], tokens: number) => void; getSelectedModule: (category: 'ai_text' | 'ai_image') => Partial | undefined; getPreferredAiCategory: () => 'ai_text' | 'ai_image'; + isForceImageGeneration: () => boolean; + clearForceImageGeneration: () => void; handleResponse: ( response: Awaited>, streamingHandle: StreamingMessageHandle | null, @@ -58,7 +65,7 @@ type ChatSendControllerOptions = { cleanupStreamingState: (listenerId: string, typingId: string) => void; stopImagePreviewPolling: () => void; startImagePreviewPolling: (handle: ImageGenerationHandle) => void; - cancelTextGeneration: () => Promise; + cancelTextGeneration: (providerId: string | null) => Promise; isImageProvider: (providerId: string | null) => boolean; lockUi: (input: HTMLTextAreaElement | null) => { input: HTMLTextAreaElement | null; @@ -85,6 +92,8 @@ export class ChatSendController { >(); private _isDestroyed = false; private _cancelRequested = false; + private _activeProviderId: string | null = null; + private readonly _providerPolicy = new AIBridgeProviderPolicy(); constructor(private readonly _options: ChatSendControllerOptions) { this._autoStartHelper = new ChatAutoStartHelper({ @@ -117,7 +126,7 @@ export class ChatSendController { } this._cancelRequested = true; - await this._options.cancelTextGeneration(); + await this._options.cancelTextGeneration(this._activeProviderId); } public validateInput(text: string): boolean { @@ -136,15 +145,19 @@ export class ChatSendController { const uiElements = this._options.lockUi(input); const typingId = `typing-${String(Date.now())}`; const listenerId = `chat-stream-${String(Date.now())}`; - const activeProviderId = this._options.aiBridge.getState().activeProviderId; - const isImageProvider = this._options.isImageProvider(activeProviderId); + let activeProviderId = this._options.aiBridge.getState().activeProviderId; + let isImageProvider = + this._options.isForceImageGeneration() || + this._options.isImageProvider(activeProviderId); this._cancelRequested = false; + this._activeProviderId = activeProviderId; this._options.setSending(true); this._activeStreamingStates.set(listenerId, { listenerId, typingId }); let streamingHandle: StreamingMessageHandle | null = null; let imageHandle: ImageGenerationHandle | null = null; + let shouldStopImageEngine = false; try { const sendPlan = await this._sendFlow.prepare(text); @@ -155,12 +168,44 @@ export class ChatSendController { this._options.appendUserMessage(text, sendPlan.attachments, sendPlan.tokenCount); this._options.pushUserMessage(sendPlan.userContent); + let imagePrompt = sendPlan.combinedText; + if (isImageProvider) { + const preparedPrompt = await this._prepareImagePromptWithTextProvider( + sendPlan.combinedText, + ); + if (this._wasDestroyed()) return false; + imagePrompt = preparedPrompt; + + const imageProviderId = this._getSelectedModuleId('ai_image'); + if ( + imageProviderId !== null && + this._options.aiBridge.getState().activeProviderId !== imageProviderId + ) { + const started = await this._options.aiBridge.startProvider(imageProviderId); + if (!started) { + throw new Error( + this._options.translate( + 'ui.ai.provider_activation_failed', + 'Provider activation failed', + ), + ); + } + } + + activeProviderId = this._options.aiBridge.getState().activeProviderId; + this._activeProviderId = activeProviderId; + isImageProvider = this._options.isImageProvider(activeProviderId); + } + const ensureStreamingHandle = (): StreamingMessageHandle => { streamingHandle ??= this._options.createStreamingHandle(typingId); return streamingHandle; }; if (isImageProvider) { + shouldStopImageEngine = + activeProviderId !== null && + this._providerPolicy.isManagedLocalImageEngine(activeProviderId); imageHandle = this._options.createImageHandle(); this._options.startImagePreviewPolling(imageHandle); } else { @@ -183,9 +228,10 @@ export class ChatSendController { ); const response = await this._options.service.sendMessage( - sendPlan.combinedText, + imagePrompt, sendPlan.historyHead, sendPlan.attachments, + isImageProvider ? { originalPrompt: sendPlan.combinedText } : {}, ); this._cleanupStreamingState(listenerId, typingId); @@ -216,11 +262,88 @@ export class ChatSendController { if (imageHandle !== null) { this._options.stopImagePreviewPolling(); } + if (shouldStopImageEngine) { + await this._stopImageEngineAfterCompletion(); + } if (!this._wasDestroyed()) { this._options.unlockUi(uiElements); } this._options.setSending(false); + this._activeProviderId = null; + this._options.clearForceImageGeneration(); + } + } + + private async _prepareImagePromptWithTextProvider(prompt: string): Promise { + const textProviderId = this._getSelectedModuleId('ai_text'); + const imageProviderId = this._getSelectedModuleId('ai_image'); + if ( + textProviderId === null || + imageProviderId === null || + textProviderId === imageProviderId || + prompt.trim() === '' + ) { + return prompt; + } + + const currentProviderId = this._options.aiBridge.getState().activeProviderId; + if (currentProviderId !== textProviderId) { + const started = await this._options.aiBridge.startProvider(textProviderId); + if (!started) { + return prompt; + } + } + + const response = + typeof this._options.aiBridge.prepareImagePrompt === 'function' + ? await this._options.aiBridge.prepareImagePrompt( + this._buildImagePromptRewriteRequest(prompt), + ) + : { ok: false }; + if (!response.ok) { + return prompt; } + + const prepared = this._extractPreparedPrompt(response); + return prepared === '' ? prompt : this._stripPromptEnvelope(prepared); + } + + private _extractPreparedPrompt(response: { + ok: boolean; + text?: string; + message?: string; + reply?: { text?: string }; + }): string { + return (response.text ?? response.message ?? response.reply?.text ?? '').trim(); + } + + private _buildImagePromptRewriteRequest(prompt: string): string { + return [ + 'You are preparing a prompt for Stable Diffusion.', + 'Task: translate the user request into English, preserve the exact subject and intent, and lightly enhance it with useful visual details.', + 'Rules:', + '- Remove command words like generate, draw, create, please, сгенерируй, нарисуй, сделай.', + '- Do not invent extra people, objects, actions, identities, or locations that the user did not ask for.', + '- You may add concise visual quality details: composition, lighting, camera, mood, texture, style, and render quality.', + '- Keep it as one prompt, 12-45 words.', + '- Return only the final prompt text. No quotes, no markdown, no explanation.', + '', + `User request: ${prompt}`, + ].join('\n'); + } + + private _stripPromptEnvelope(prompt: string): string { + return prompt + .replace(/^```(?:text|markdown)?\s*/iu, '') + .replace(/```\s*$/u, '') + .replace(/^["'`]+|["'`]+$/gu, '') + .trim(); + } + + private _getSelectedModuleId(category: 'ai_text' | 'ai_image'): string | null { + const module = this._options.getSelectedModule(category); + const id = module?.id; + return typeof id === 'string' && id.trim() !== '' ? id : null; } private _wasDestroyed(): boolean { @@ -246,4 +369,12 @@ export class ChatSendController { public async tryAutoStartAi(prompt?: string): Promise { return await this._autoStartHelper.startSelectedModule(prompt); } + + private async _stopImageEngineAfterCompletion(): Promise { + try { + await this._options.aiBridge.stopEngineSlot('image'); + } catch { + /* stopping the local image engine must not replace the generation result */ + } + } } diff --git a/src/features/chat/services/ChatActivationCoordinator.ts b/src/features/chat/services/ChatActivationCoordinator.ts index 429351aa..86d220bb 100644 --- a/src/features/chat/services/ChatActivationCoordinator.ts +++ b/src/features/chat/services/ChatActivationCoordinator.ts @@ -17,8 +17,11 @@ export class ChatActivationCoordinator { this._deps.uiStateHelper.clearInactiveAiErrorTimeout(); } - public async ensureActive(input: HTMLTextAreaElement | null): Promise { - const prompt = input?.value.trim() ?? ''; + public async ensureActive( + input: HTMLTextAreaElement | null, + promptOverride?: string, + ): Promise { + const prompt = promptOverride ?? input?.value.trim() ?? ''; const selectedProviderId = this._deps.getSelectedProviderId(prompt); const { activeProviderId } = this._deps.aiBridge.getState(); diff --git a/src/features/chat/services/ChatControllerFactory.ts b/src/features/chat/services/ChatControllerFactory.ts index 1e660bb9..94519f57 100644 --- a/src/features/chat/services/ChatControllerFactory.ts +++ b/src/features/chat/services/ChatControllerFactory.ts @@ -124,6 +124,8 @@ type ChatSendFactoryDeps = { appendUserMessage: (text: string, attachments: IChatAttachment[], tokens: number) => void; getSelectedModule: (category: 'ai_text' | 'ai_image') => Partial | undefined; getPreferredAiCategory: () => 'ai_text' | 'ai_image'; + isForceImageGeneration: () => boolean; + clearForceImageGeneration: () => void; handleResponse: ( response: IChatResponse, streamingHandle?: ReturnType | null, @@ -134,7 +136,7 @@ type ChatSendFactoryDeps = { startImagePreviewPolling: ( handle: ReturnType, ) => void; - cancelTextGeneration: () => Promise; + cancelTextGeneration: (providerId: string | null) => Promise; isImageProvider: (providerId: string | null) => boolean; lockUi: (input: HTMLTextAreaElement | null) => { input: HTMLTextAreaElement | null; @@ -286,6 +288,10 @@ export class ChatControllerFactory { }, getSelectedModule: (category) => deps.getSelectedModule(category), getPreferredAiCategory: () => deps.getPreferredAiCategory(), + isForceImageGeneration: () => deps.isForceImageGeneration(), + clearForceImageGeneration: () => { + deps.clearForceImageGeneration(); + }, handleResponse: async (response, streamingHandle, imageHandle) => await deps.handleResponse(response, streamingHandle, imageHandle), cleanupStreamingState: (listenerId, typingId) => { @@ -297,7 +303,7 @@ export class ChatControllerFactory { startImagePreviewPolling: (handle) => { deps.startImagePreviewPolling(handle); }, - cancelTextGeneration: async () => await deps.cancelTextGeneration(), + cancelTextGeneration: async (providerId) => await deps.cancelTextGeneration(providerId), isImageProvider: (providerId) => deps.isImageProvider(providerId), lockUi: (input) => deps.lockUi(input), unlockUi: (els) => { diff --git a/src/features/chat/services/ChatService.ts b/src/features/chat/services/ChatService.ts index 84633396..0e643983 100644 --- a/src/features/chat/services/ChatService.ts +++ b/src/features/chat/services/ChatService.ts @@ -4,6 +4,9 @@ import type { IAIBridge } from '@/features/ai/types/IAIBridge'; import type { I18nService } from '@/infrastructure/i18n/I18nService'; type ChatServiceLogger = Pick; +export type ChatSendOptions = { + originalPrompt?: string; +}; function parseGeneratedImages( images: string[] | undefined, @@ -50,6 +53,7 @@ export class ChatService { text: string, history: IChatMessage[], attachments: IChatAttachment[], + options: ChatSendOptions = {}, ): Promise { // Validation if ((text === '' || text.trim() === '') && attachments.length === 0) { @@ -69,7 +73,13 @@ export class ChatService { try { // Send through AIBridge - const response = await this._aiBridge.sendMessage(text, 'chat', attachments, history); + const response = await this._aiBridge.sendMessage( + text, + 'chat', + attachments, + history, + options, + ); if (!response.ok) { const result: IChatResponse = { diff --git a/src/features/chat/services/VoiceInputService.test.ts b/src/features/chat/services/VoiceInputService.test.ts index 4606d00d..53de2b5a 100644 --- a/src/features/chat/services/VoiceInputService.test.ts +++ b/src/features/chat/services/VoiceInputService.test.ts @@ -50,6 +50,7 @@ describe('VoiceInputService', () => { listen: vi.fn(), isTauri: vi.fn(() => true), }; + Object.assign(bridge, { hasCapability: vi.fn(() => true) }); }); const createService = (getLang: () => string = () => 'en') => diff --git a/src/features/chat/services/VoiceInputService.ts b/src/features/chat/services/VoiceInputService.ts index dbf04653..618a2077 100644 --- a/src/features/chat/services/VoiceInputService.ts +++ b/src/features/chat/services/VoiceInputService.ts @@ -84,6 +84,7 @@ export class VoiceInputService { const sessionId = ++this._sessionId; this._onStateChange = callbacks.onStateChange ?? null; this._onError = callbacks.onError ?? null; + this._nativeRecognitionActive = true; this._setState('starting'); this._setState('listening'); @@ -105,6 +106,7 @@ export class VoiceInputService { ); }); this._sessionId += 1; + this._nativeRecognitionActive = false; if (this.isActive()) { this._setState('stopping'); } @@ -112,7 +114,6 @@ export class VoiceInputService { } private async _recognize(sessionId: number, onResult: VoiceResultCallback): Promise { - this._nativeRecognitionActive = true; try { const language = this._getCurrentLang(); this._tracer.info(`[VoiceInputService] Native recognition language: ${language}`); @@ -132,7 +133,9 @@ export class VoiceInputService { try { onResult(text); } catch (err) { - this._tracer.error(`[VoiceInputService] onResult handler threw: ${String(err)}`); + this._tracer.error( + `[VoiceInputService] onResult handler threw: ${String(err)}`, + ); } } this._finishSession('ended'); @@ -146,7 +149,9 @@ export class VoiceInputService { this._onError?.(payload); this._finishSession(payload.code === 'startup_failed' ? 'startup_failed' : 'error'); } finally { - this._nativeRecognitionActive = false; + if (this._sessionId === sessionId) { + this._nativeRecognitionActive = false; + } } } diff --git a/src/features/chat/ui/ChatImageController.ts b/src/features/chat/ui/ChatImageController.ts index 45ef327c..5197ede9 100644 --- a/src/features/chat/ui/ChatImageController.ts +++ b/src/features/chat/ui/ChatImageController.ts @@ -55,14 +55,36 @@ export class ChatImageController { `); private readonly _boundImageViewerKeydown: (event: KeyboardEvent) => void; + private readonly _boundImageViewerWheel: (event: WheelEvent) => void; private _imageViewerOverlay: HTMLElement | null = null; private _imageViewerImage: HTMLImageElement | null = null; + private _imageViewerPrevButton: HTMLButtonElement | null = null; + private _imageViewerNextButton: HTMLButtonElement | null = null; + private _imageViewerCounter: HTMLElement | null = null; + private _imageViewerSources: string[] = []; + private _imageViewerIndex = 0; public constructor(private readonly _deps: ChatImageControllerDeps) { this._boundImageViewerKeydown = (event: KeyboardEvent) => { + if (event.ctrlKey && ['+', '-', '=', '0'].includes(event.key)) { + event.preventDefault(); + return; + } if (event.key === 'Escape') { this.closeImageViewer(); + return; } + if (event.key === 'ArrowLeft') { + this._showAdjacentImage(-1); + return; + } + if (event.key === 'ArrowRight') { + this._showAdjacentImage(1); + } + }; + this._boundImageViewerWheel = (event: WheelEvent) => { + if (!event.ctrlKey) return; + event.preventDefault(); }; } @@ -71,13 +93,18 @@ export class ChatImageController { this._imageViewerOverlay?.remove(); this._imageViewerOverlay = null; this._imageViewerImage = null; + this._imageViewerPrevButton = null; + this._imageViewerNextButton = null; + this._imageViewerCounter = null; + this._imageViewerSources = []; + this._imageViewerIndex = 0; } public handleImageClick(event: MouseEvent): boolean { const target = event.target; if (!(target instanceof HTMLElement)) return false; - let image = target.closest('.chat-img, .chat-attachment-img'); + let image = target.closest('.chat-img, .chat-generated-image, .chat-attachment-img'); if (!(image instanceof HTMLImageElement)) { image = target @@ -110,7 +137,7 @@ export class ChatImageController { const saveBtn = document.createElement('button'); saveBtn.type = 'button'; saveBtn.className = 'chat-save-image-btn'; - saveBtn.title = this._deps.translate('ui.chat.save_image', 'Save Image'); + this._syncImageActionLabel(saveBtn, 'ui.chat.save_image', 'Save Image'); saveBtn.innerHTML = ChatImageController._downloadIcon; saveBtn.addEventListener('contextmenu', (event) => { event.preventDefault(); @@ -214,32 +241,73 @@ export class ChatImageController { +
+ +
`); const closeButton = overlay.querySelector('.chat-image-viewer-close'); const label = this._deps.translate('ui.chat.close_image_preview', 'Close image preview'); closeButton?.setAttribute('aria-label', label); + closeButton?.setAttribute('title', label); + + const prevButton = overlay.querySelector('.chat-image-viewer-prev'); + const nextButton = overlay.querySelector('.chat-image-viewer-next'); + prevButton?.setAttribute( + 'aria-label', + this._deps.translate('ui.chat.previous_image', 'Previous image'), + ); + nextButton?.setAttribute( + 'aria-label', + this._deps.translate('ui.chat.next_image', 'Next image'), + ); overlay.addEventListener('click', (event) => { const eventTarget = event.target; if (!(eventTarget instanceof HTMLElement)) return; if ( eventTarget === overlay || + eventTarget.closest('.chat-image-viewer-stage') instanceof HTMLElement || eventTarget.closest('.chat-image-viewer-close') instanceof HTMLElement ) { this.closeImageViewer(); } }); + prevButton?.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + this._showAdjacentImage(-1); + }); + nextButton?.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + this._showAdjacentImage(1); + }); + this._resolveImageViewerHost().appendChild(overlay); this._imageViewerOverlay = overlay; this._imageViewerImage = overlay.querySelector('.chat-image-viewer-img'); + this._imageViewerPrevButton = prevButton; + this._imageViewerNextButton = nextButton; + this._imageViewerCounter = overlay.querySelector('.chat-image-viewer-counter'); this._imageViewerImage?.setAttribute( 'alt', this._deps.translate('ui.chat.image_preview', 'Image preview'), ); + this._imageViewerImage?.addEventListener('click', (event) => { + event.stopPropagation(); + }); } private openImageViewer(src: string): void { @@ -253,10 +321,20 @@ export class ChatImageController { return; } - this._imageViewerImage.src = src; + this._imageViewerSources = this._collectImageViewerSources(); + const sourceIndex = this._imageViewerSources.indexOf(src); + if (sourceIndex === -1) { + this._imageViewerSources = [src]; + this._imageViewerIndex = 0; + } else { + this._imageViewerIndex = sourceIndex; + } + + this._setImageViewerSource(src, 0); this._imageViewerOverlay.classList.remove('hidden'); document.body.classList.add('chat-image-viewer-open'); document.addEventListener('keydown', this._boundImageViewerKeydown); + document.addEventListener('wheel', this._boundImageViewerWheel, { passive: false }); } private closeImageViewer(): void { @@ -266,6 +344,79 @@ export class ChatImageController { this._imageViewerImage?.removeAttribute('src'); document.body.classList.remove('chat-image-viewer-open'); document.removeEventListener('keydown', this._boundImageViewerKeydown); + document.removeEventListener('wheel', this._boundImageViewerWheel); + } + + private _collectImageViewerSources(): string[] { + const sources: string[] = []; + const seen = new Set(); + document + .querySelectorAll( + '#chat-messages img.chat-img, #chat-messages img.chat-generated-image, #chat-messages img.chat-attachment-img, #chat-attachments img.chat-attachment-img', + ) + .forEach((image) => { + const src = this._resolveImageSource(image); + if (src === null || seen.has(src)) return; + seen.add(src); + sources.push(src); + }); + return sources; + } + + private _resolveImageSource(image: HTMLImageElement): string | null { + const currentSrc = image.currentSrc.trim(); + const attributeSrc = image.getAttribute('src'); + const fallbackSrc = image.src.trim(); + const src = + currentSrc.length > 0 + ? image.currentSrc + : attributeSrc !== null && attributeSrc.trim().length > 0 + ? attributeSrc + : fallbackSrc; + return src.trim().length > 0 ? src : null; + } + + private _showAdjacentImage(direction: -1 | 1): void { + if (this._imageViewerSources.length <= 1) return; + const nextIndex = + (this._imageViewerIndex + direction + this._imageViewerSources.length) % + this._imageViewerSources.length; + this._imageViewerIndex = nextIndex; + this._setImageViewerSource(this._imageViewerSources[nextIndex] ?? '', direction); + } + + private _setImageViewerSource(src: string, direction: -1 | 0 | 1): void { + if (!(this._imageViewerImage instanceof HTMLImageElement)) return; + if (src.trim().length === 0) return; + + this._imageViewerImage.classList.remove( + 'is-entering-forward', + 'is-entering-backward', + 'is-opening', + ); + this._imageViewerImage.src = src; + const animationClass = + direction > 0 + ? 'is-entering-forward' + : direction < 0 + ? 'is-entering-backward' + : 'is-opening'; + requestAnimationFrame(() => { + this._imageViewerImage?.classList.add(animationClass); + }); + this._syncImageViewerNavigation(); + } + + private _syncImageViewerNavigation(): void { + const hasMany = this._imageViewerSources.length > 1; + this._imageViewerPrevButton?.classList.toggle('hidden', !hasMany); + this._imageViewerNextButton?.classList.toggle('hidden', !hasMany); + if (this._imageViewerCounter instanceof HTMLElement) { + this._imageViewerCounter.classList.toggle('hidden', !hasMany); + this._imageViewerCounter.textContent = hasMany + ? `${this._imageViewerIndex + 1} / ${this._imageViewerSources.length}` + : ''; + } } private _promoteSaveButtonToFolder( @@ -282,7 +433,7 @@ export class ChatImageController { saveBtn.classList.add('chat-open-image-folder-btn'); saveBtn.dataset['filePath'] = filePath; saveBtn.dataset['folderPath'] = folderPath; - saveBtn.title = this._deps.translate('ui.chat.open_image_folder', 'Open image folder'); + this._syncImageActionLabel(saveBtn, 'ui.chat.open_image_folder', 'Open image folder'); saveBtn.innerHTML = ChatImageController._folderIcon; }, ChatImageController._imageResetDelayMs); } @@ -298,7 +449,7 @@ export class ChatImageController { saveBtn.classList.add('chat-save-image-btn'); delete saveBtn.dataset['filePath']; delete saveBtn.dataset['folderPath']; - saveBtn.title = this._deps.translate('ui.chat.save_image', 'Save Image'); + this._syncImageActionLabel(saveBtn, 'ui.chat.save_image', 'Save Image'); saveBtn.innerHTML = ChatImageController._downloadIcon; } @@ -317,10 +468,17 @@ export class ChatImageController { saveBtn.classList.add('chat-open-image-folder-btn'); saveBtn.dataset['filePath'] = filePath; saveBtn.dataset['folderPath'] = folderPath; - saveBtn.title = this._deps.translate('ui.chat.open_image_folder', 'Open image folder'); + this._syncImageActionLabel(saveBtn, 'ui.chat.open_image_folder', 'Open image folder'); saveBtn.innerHTML = ChatImageController._folderIcon; } + private _syncImageActionLabel(saveBtn: HTMLButtonElement, key: string, fallback: string): void { + const label = this._deps.translate(key, fallback); + saveBtn.title = label; + saveBtn.dataset['tooltip'] = label; + saveBtn.setAttribute('aria-label', label); + } + private _animateFolderButtonReset(saveBtn: HTMLButtonElement): void { if (!saveBtn.classList.contains('chat-open-image-folder-btn')) return; if (saveBtn.classList.contains('is-resetting')) return; diff --git a/src/features/chat/ui/ChatImageGenerationMessage.ts b/src/features/chat/ui/ChatImageGenerationMessage.ts index 84c64343..352fbff9 100644 --- a/src/features/chat/ui/ChatImageGenerationMessage.ts +++ b/src/features/chat/ui/ChatImageGenerationMessage.ts @@ -31,9 +31,6 @@ type CreateChatImageGenerationMessageDeps = { image: ChatImagePayload | null, ) => { actionBar: HTMLElement; copyBtn: HTMLElement; editBtn: HTMLElement | null } | null; scheduleBubbleImageActions: (bubble: HTMLElement, actionBar: HTMLElement) => void; - opts: { - onCancel: () => void | Promise; - }; }; type ImageGenerationProgress = { @@ -44,6 +41,17 @@ type ImageGenerationProgress = { elapsed: string | null; }; +const normalizeGeneratedCaption = (text: string, translate: ChatTranslate): string => { + const trimmed = text.trim(); + const readyLabels = new Set([ + translate('ui.chat.image_ready', 'Generated image').trim(), + 'Generated image', + 'Image ready', + 'Изображение готово', + ]); + return readyLabels.has(trimmed) ? '' : trimmed; +}; + const parseImageGenerationProgress = (text: string): ImageGenerationProgress => { let percent = 0; let step: number | null = null; @@ -81,22 +89,31 @@ export function createChatImageGenerationMessage( deps: CreateChatImageGenerationMessageDeps, ): ImageGenerationMessageHandle { const row = document.createElement('div'); - row.className = 'chat-row bot'; + row.className = 'chat-row bot chat-row--generated-image'; - const bubble = deps.createMessageBubble({ mediaFirst: true }); + const bubble = deps.createMessageBubble({}); bubble.classList.add('chat-image-generation'); const media = document.createElement('div'); media.className = 'chat-generated-media hidden'; const image = document.createElement('img'); - image.className = 'chat-img chat-generated-image'; + image.className = 'chat-generated-image'; image.alt = 'Generated preview'; image.width = 512; image.height = 512; image.decoding = 'async'; media.appendChild(image); + const syncMediaSizeToImage = (): void => { + const naturalWidth = image.naturalWidth; + const naturalHeight = image.naturalHeight; + if (naturalWidth <= 0 || naturalHeight <= 0) return; + + media.style.aspectRatio = `${String(naturalWidth)} / ${String(naturalHeight)}`; + }; + image.addEventListener('load', syncMediaSizeToImage); + const status = document.createElement('div'); status.className = 'chat-generated-status'; status.textContent = deps.translate('ui.chat.image_generating', 'Rendering image'); @@ -119,36 +136,8 @@ export function createChatImageGenerationMessage( const caption = document.createElement('div'); caption.className = 'chat-generated-caption markdown-body hidden'; - const controls = document.createElement('div'); - controls.className = 'chat-generated-controls'; - - const cancelBtn = document.createElement('button'); - cancelBtn.type = 'button'; - cancelBtn.className = 'chat-generated-control is-cancel'; - cancelBtn.textContent = deps.translate('ui.chat.image_cancel', 'Cancel'); - cancelBtn.title = deps.translate('ui.chat.image_cancel', 'Cancel'); - cancelBtn.setAttribute('aria-label', deps.translate('ui.chat.image_cancel', 'Cancel')); - - const invokeControl = (button: HTMLButtonElement, action: () => void | Promise): void => { - button.disabled = true; - Promise.resolve(action()) - .catch((error: unknown) => { - deps.tracer.error('[ChatUI] Image generation control failed', error); - }) - .finally(() => { - if (!deps.isDestroyed() && button.isConnected) { - button.disabled = false; - } - }); - }; - - cancelBtn.addEventListener('click', () => { - handle.cancel(); - invokeControl(cancelBtn, deps.opts.onCancel); - }); - controls.append(cancelBtn); - bubble.append(media, statusRow, progress, caption, controls); - row.appendChild(bubble); + bubble.append(statusRow, progress, caption); + row.append(media, bubble); deps.appendRow(row); deps.scrollToBottom(); @@ -160,6 +149,12 @@ export function createChatImageGenerationMessage( let finalImage: ChatImagePayload | null = null; let isCancelled = false; + const detachMediaFromBubble = (): void => { + if (media.parentElement === bubble) { + row.insertBefore(media, bubble); + } + }; + const setProgressFromStatus = (text: string): void => { const { elapsed, percent, speed, step, total } = parseImageGenerationProgress(text); progressFill.style.width = `${String(percent)}%`; @@ -183,16 +178,13 @@ export function createChatImageGenerationMessage( const showPreview = (dataUrl: string): void => { if (dataUrl.trim() === '') return; if (image.src === dataUrl) return; + detachMediaFromBubble(); image.src = dataUrl; + syncMediaSizeToImage(); media.classList.remove('hidden'); - bubble.classList.add('chat-bubble--media'); deps.scrollToBottom(true); }; - const hideControls = (): void => { - controls.classList.add('hidden'); - }; - const hideProgress = (): void => { progress.classList.add('hidden'); progressSummary.classList.add('hidden'); @@ -205,9 +197,10 @@ export function createChatImageGenerationMessage( return; } actions ??= deps.appendMessageActions(content, 'assistant', finalImage); - if (actions !== null && !bubble.contains(actions.actionBar)) { - bubble.appendChild(actions.actionBar); - deps.scheduleBubbleImageActions(bubble, actions.actionBar); + if (actions !== null && !row.contains(actions.actionBar)) { + const target = bubble.classList.contains('has-no-caption') ? media : bubble; + target.appendChild(actions.actionBar); + deps.scheduleBubbleImageActions(target, actions.actionBar); } }; @@ -233,11 +226,15 @@ export function createChatImageGenerationMessage( progressSummary.textContent = '100%'; progress.classList.add('is-complete'); - caption.textContent = result.text; - caption.classList.toggle('hidden', result.text.trim() === ''); + const captionText = normalizeGeneratedCaption(result.text, deps.translate); + caption.textContent = captionText; + const hasCaption = captionText !== ''; + caption.classList.toggle('hidden', !hasCaption); + bubble.classList.toggle('has-caption', hasCaption); + bubble.classList.toggle('has-no-caption', !hasCaption); + row.classList.add('is-complete'); bubble.classList.add('is-complete'); - hideControls(); ensureImageActions(result.text); deps.scrollToBottom(); }, @@ -249,7 +246,6 @@ export function createChatImageGenerationMessage( status.textContent = message; hideProgress(); caption.classList.add('hidden'); - hideControls(); deps.scrollToBottom(); }, cancel: ( @@ -261,7 +257,6 @@ export function createChatImageGenerationMessage( status.textContent = message; hideProgress(); caption.classList.add('hidden'); - hideControls(); deps.scrollToBottom(); }, discard: () => { diff --git a/src/features/chat/ui/ChatTranslationRefresher.ts b/src/features/chat/ui/ChatTranslationRefresher.ts index a5d47622..303123f8 100644 --- a/src/features/chat/ui/ChatTranslationRefresher.ts +++ b/src/features/chat/ui/ChatTranslationRefresher.ts @@ -50,18 +50,11 @@ export function refreshChatTranslations( refreshMessageActions(translate); document.querySelectorAll('.chat-save-image-btn').forEach((btn) => { - btn.title = translate('ui.chat.save_image', 'Save Image'); + syncTooltipButton(btn, translate('ui.chat.save_image', 'Save Image')); }); document.querySelectorAll('.chat-open-image-folder-btn').forEach((btn) => { - btn.title = translate('ui.chat.open_image_folder', 'Open image folder'); - }); - - document.querySelectorAll('.chat-generated-control.is-cancel').forEach((btn) => { - const label = translate('ui.chat.image_cancel', 'Cancel'); - btn.textContent = label; - btn.title = label; - btn.setAttribute('aria-label', label); + syncTooltipButton(btn, translate('ui.chat.open_image_folder', 'Open image folder')); }); const viewerClose = document.querySelector('.chat-image-viewer-close'); @@ -73,6 +66,19 @@ export function refreshChatTranslations( viewerClose.title = translate('ui.chat.close_image_preview', 'Close image preview'); } + syncButtonLabel( + document.querySelector('.chat-image-viewer-prev'), + translate, + 'ui.chat.previous_image', + 'Previous image', + ); + syncButtonLabel( + document.querySelector('.chat-image-viewer-next'), + translate, + 'ui.chat.next_image', + 'Next image', + ); + document.querySelectorAll('.media-remove').forEach((btn) => { btn.title = translate('ui.launcher.web.remove_attachment', 'Remove attachment'); }); @@ -100,3 +106,9 @@ function syncButtonLabel( button.title = title; button.setAttribute('aria-label', ariaLabel); } + +function syncTooltipButton(button: HTMLElement, label: string): void { + button.title = label; + button.dataset['tooltip'] = label; + button.setAttribute('aria-label', label); +} diff --git a/src/features/chat/ui/ChatUI.test.ts b/src/features/chat/ui/ChatUI.test.ts index 93855c2f..b4f641e7 100644 --- a/src/features/chat/ui/ChatUI.test.ts +++ b/src/features/chat/ui/ChatUI.test.ts @@ -86,7 +86,7 @@ function requireSaveImageButton(): HTMLButtonElement { } function requireChatImage(): HTMLImageElement { - const image = document.querySelector('.chat-img'); + const image = document.querySelector('.chat-img, .chat-generated-image'); if (!(image instanceof HTMLImageElement)) { throw new TypeError('chat image not found'); } @@ -104,6 +104,16 @@ function requireImageViewer(): { overlay: HTMLElement; preview: HTMLImageElement return { overlay, preview }; } +function requireImageViewerNav(): { prev: HTMLButtonElement; next: HTMLButtonElement } { + const prev = document.querySelector('.chat-image-viewer-prev'); + const next = document.querySelector('.chat-image-viewer-next'); + if (!(prev instanceof HTMLButtonElement) || !(next instanceof HTMLButtonElement)) { + throw new TypeError('image viewer navigation not found'); + } + + return { prev, next }; +} + async function renderAssistantImage(ui: ChatUI, initialize = false): Promise { renderImageChatBody(); if (initialize) { @@ -229,9 +239,19 @@ describe('ChatUI lifecycle', () => { expect((document.querySelector('.chat-save-image-btn') as HTMLButtonElement).title).toBe( 't:ui.chat.save_image:Save Image', ); + expect( + (document.querySelector('.chat-save-image-btn') as HTMLButtonElement).dataset[ + 'tooltip' + ], + ).toBe('t:ui.chat.save_image:Save Image'); expect( (document.querySelector('.chat-open-image-folder-btn') as HTMLButtonElement).title, ).toBe('t:ui.chat.open_image_folder:Open image folder'); + expect( + (document.querySelector('.chat-open-image-folder-btn') as HTMLButtonElement).dataset[ + 'tooltip' + ], + ).toBe('t:ui.chat.open_image_folder:Open image folder'); expect((document.querySelector('.media-remove') as HTMLButtonElement).title).toBe( 't:ui.launcher.web.remove_attachment:Remove attachment', ); @@ -331,9 +351,7 @@ describe('ChatUI lifecycle', () => { document.body.innerHTML = '
'; ui = createChatUI(); - const handle = ui.createImageGenerationMessage({ - onCancel: vi.fn(), - }); + const handle = ui.createImageGenerationMessage(); handle.finalize({ text: 'caption', @@ -341,22 +359,59 @@ describe('ChatUI lifecycle', () => { }); expect(document.querySelector('.chat-generated-control.is-regenerate')).toBeNull(); + expect(document.querySelector('.chat-generated-controls')).toBeNull(); + expect(document.querySelector('.chat-generated-caption')?.textContent).toBe('caption'); + expect((document.querySelector('.chat-generated-image') as HTMLImageElement).src).toContain( + 'data:image/png;base64,ZmFrZQ==', + ); + }); + + it('should suppress default generated image ready caption', () => { + document.body.innerHTML = '
'; + + ui = createChatUI(); + const handle = ui.createImageGenerationMessage(); + + handle.finalize({ + text: 'Generated image', + images: [{ mime: 'image/png', data_base64: 'ZmFrZQ==' }], + }); + + expect(document.querySelector('.chat-generated-caption')?.textContent).toBe(''); expect( - document.querySelector('.chat-generated-controls')?.classList.contains('hidden'), + document.querySelector('.chat-image-generation')?.classList.contains('has-no-caption'), ).toBe(true); - expect(document.querySelector('.chat-generated-caption')?.textContent).toBe('caption'); + }); + + it('should restore assistant image history with generated image layout', () => { + document.body.innerHTML = '
'; + + ui = createChatUI(); + ui.renderHistory([ + { + role: 'assistant', + content: 'Generated image', + opts: { + images: [{ mime: 'image/png', data_base64: 'ZmFrZQ==' }], + }, + }, + ]); + + expect(document.querySelector('.chat-row--generated-image')).not.toBeNull(); + expect(document.querySelector('.chat-bubble--media')).toBeNull(); expect((document.querySelector('.chat-generated-image') as HTMLImageElement).src).toContain( 'data:image/png;base64,ZmFrZQ==', ); + expect( + document.querySelector('.chat-image-generation')?.classList.contains('has-no-caption'), + ).toBe(true); }); it('should render image progress percent and speed separately from status text', () => { document.body.innerHTML = '
'; ui = createChatUI(); - const handle = ui.createImageGenerationMessage({ - onCancel: vi.fn(), - }); + const handle = ui.createImageGenerationMessage(); handle.setStatus('image status=running percent=40 step=8 total=20 speed=1.25it/s'); @@ -375,9 +430,7 @@ describe('ChatUI lifecycle', () => { document.body.innerHTML = '
'; ui = createChatUI(); - const handle = ui.createImageGenerationMessage({ - onCancel: vi.fn(), - }); + const handle = ui.createImageGenerationMessage(); handle.setStatus('image status=running elapsed=12s'); @@ -393,16 +446,9 @@ describe('ChatUI lifecycle', () => { document.body.innerHTML = '
'; ui = createChatUI(); - const handle = ui.createImageGenerationMessage({ - onCancel: vi.fn(), - }); - - const cancelBtn = document.querySelector('.chat-generated-control.is-cancel'); - if (!(cancelBtn instanceof HTMLButtonElement)) { - throw new Error('cancel button not found'); - } + const handle = ui.createImageGenerationMessage(); - cancelBtn.click(); + handle.cancel(); handle.fail('error sending request for url (http://localhost:8082/sdapi/v1/txt2img)'); expect( @@ -584,6 +630,95 @@ describe('ChatUI lifecycle', () => { expect(preview.getAttribute('src')).toBeNull(); }); + it('should close image preview when clicking empty viewer stage', async () => { + ui = createChatUI(); + await renderAssistantImage(ui, true); + + const image = requireChatImage(); + image.click(); + await flushPromises(); + + const { overlay, preview } = requireImageViewer(); + const stage = document.querySelector('.chat-image-viewer-stage'); + if (!(stage instanceof HTMLElement)) { + throw new TypeError('image viewer stage not found'); + } + + stage.click(); + await flushPromises(); + + expect(overlay.classList.contains('hidden')).toBe(true); + expect(preview.getAttribute('src')).toBeNull(); + }); + + it('should switch between chat images from the image viewer', async () => { + renderImageChatBody(); + ui = createChatUI(); + await ui.init(); + + ui.appendMessage('assistant', 'image', { + images: [ + { mime: 'image/png', data_base64: 'b25l' }, + { mime: 'image/png', data_base64: 'dHdv' }, + ], + skipAnimation: true, + }); + + const firstImage = document.querySelector('.chat-img'); + if (!(firstImage instanceof HTMLImageElement)) { + throw new TypeError('first chat image not found'); + } + + firstImage.click(); + await flushPromises(); + + const { overlay, preview } = requireImageViewer(); + const { next, prev } = requireImageViewerNav(); + + expect(overlay.classList.contains('hidden')).toBe(false); + expect(preview.src.startsWith('data:image/png;base64,b25l')).toBe(true); + + next.click(); + await flushPromises(); + + expect(overlay.classList.contains('hidden')).toBe(false); + expect(preview.src.startsWith('data:image/png;base64,dHdv')).toBe(true); + + prev.click(); + await flushPromises(); + + expect(preview.src.startsWith('data:image/png;base64,b25l')).toBe(true); + }); + + it('should prevent launcher zoom shortcuts while image preview is open', async () => { + ui = createChatUI(); + await renderAssistantImage(ui, true); + + const image = requireChatImage(); + image.click(); + await flushPromises(); + + const wheelEvent = new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + ctrlKey: true, + deltaY: 100, + }); + document.dispatchEvent(wheelEvent); + + expect(wheelEvent.defaultPrevented).toBe(true); + + const keyEvent = new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + ctrlKey: true, + key: '+', + }); + document.dispatchEvent(keyEvent); + + expect(keyEvent.defaultPrevented).toBe(true); + }); + it('should not open image preview for thumbnails without a usable source', async () => { renderImageChatBody(); document diff --git a/src/features/chat/ui/ChatUI.ts b/src/features/chat/ui/ChatUI.ts index 50d99928..32731230 100644 --- a/src/features/chat/ui/ChatUI.ts +++ b/src/features/chat/ui/ChatUI.ts @@ -235,17 +235,26 @@ export class ChatUI { ): void { this._prepareContainer(); + const rawImages = opts['images']; + const primaryImage = this._getPrimaryImage(rawImages); + const isSingleAssistantImage = + role === 'assistant' && Array.isArray(rawImages) && rawImages.length === 1; + if (isSingleAssistantImage && primaryImage !== null) { + const handle = this.createImageGenerationMessage(); + handle.finalize({ + text: safeExtractText(content, this._translate), + images: rawImages as ChatImagePayload[], + }); + return; + } + const row = document.createElement('div'); row.className = `chat-row ${role === 'user' ? 'user' : 'bot'}`; const safeContent = safeExtractText(content, this._translate); const bubble = this._createMessageBubble(opts); - const actions = this._appendMessageActions( - safeContent, - role, - this._getPrimaryImage(opts['images']), - ); + const actions = this._appendMessageActions(safeContent, role, primaryImage); const textNode = this._createMessageTextNode(safeContent, opts); bubble.appendChild(textNode); @@ -307,12 +316,9 @@ export class ChatUI { }); } - public createImageGenerationMessage(opts: { - onCancel: () => void | Promise; - }): ImageGenerationMessageHandle { + public createImageGenerationMessage(): ImageGenerationMessageHandle { this._prepareContainer(); return createChatImageGenerationMessage({ - opts, translate: this._translate, isDestroyed: () => this._isDestroyed, tracer: this._deps.tracer, diff --git a/src/features/console/services/ConsoleLogService.test.ts b/src/features/console/services/ConsoleLogService.test.ts index 3ec2179d..5c722b08 100644 --- a/src/features/console/services/ConsoleLogService.test.ts +++ b/src/features/console/services/ConsoleLogService.test.ts @@ -1,24 +1,17 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ConsoleLogService, type ILogEntry } from './ConsoleLogService'; import type { IBridge } from '@/shared/types/IBridge'; import { createMockBridge } from '@/test/mocks/mockBridge'; import * as invokeModule from '@/shared/api/invoke'; -function setupTauri(bridge: IBridge, isTauri = true, invokeReturn?: unknown) { + +function setupTauri(bridge: IBridge, isTauri = true): void { vi.mocked(bridge.isTauri).mockReturnValue(isTauri); - if (invokeReturn !== undefined) { - vi.mocked(bridge.invoke).mockResolvedValue(invokeReturn); - } } describe('ConsoleLogService', () => { let bridge: IBridge; let service: ConsoleLogService; - const mockLogs: ILogEntry[] = [ - { timestamp: 100, source: 'TEST', level: 'INFO', message: 'Test log 1' }, - { timestamp: 200, source: 'TEST', level: 'ERROR', message: 'Test log 2' }, - ]; - beforeEach(() => { vi.restoreAllMocks(); bridge = createMockBridge(); @@ -28,633 +21,111 @@ describe('ConsoleLogService', () => { }); }); - it('should fetch logs via generic bridge when isTauri is true', async () => { - setupTauri(bridge, true, mockLogs); - - const logs = await service.fetchLogs(); - - expect(bridge.invoke).toHaveBeenCalledWith('get_logs', { since: 0 }); - expect(logs).toHaveLength(2); - expect(logs[0]?.message).toBe('Test log 1'); - }); - - it('should trust backend-filtered log payloads without extra frontend filtering', async () => { - const filteredLogs: ILogEntry[] = [ - { timestamp: 200, source: 'TEST', level: 'ERROR', message: 'Real Error' }, - ]; - setupTauri(bridge, true, filteredLogs); - - const logs = await service.fetchLogs(); - - expect(logs).toHaveLength(1); - expect(logs[0]?.message).toBe('Real Error'); - }); - - it('should clear logs via bridge', async () => { - setupTauri(bridge, true); - - await service.clearLogs(); - - expect(bridge.invoke).toHaveBeenCalledWith('clear_console_logs', { viewId: 'general' }); - expect(service.getLogs()).toHaveLength(0); - }); - - it('should fetch logs via bridge mock when bridge is not Tauri', async () => { - setupTauri(bridge, false); - vi.mocked(bridge.invoke).mockResolvedValue(mockLogs); - - const logs = await service.fetchLogs(); - - expect(bridge.invoke).toHaveBeenCalledWith('get_logs', { since: 0 }); - expect(logs).toHaveLength(2); - }); - - it.each([ - [ - 'Tauri invoke error', - true, - () => vi.mocked(bridge.invoke).mockRejectedValue(new Error('Backend down')), - ], - [ - 'non-Tauri bridge error', - false, - () => vi.mocked(bridge.invoke).mockRejectedValue(new Error('Network')), - ], - ])('should return empty array on fetchLogs %s', async (_, isTauriFlag, setupMock) => { - setupTauri(bridge, isTauriFlag); - setupMock(); - const logs = await service.fetchLogs(); - expect(logs).toHaveLength(0); - }); - - it('should clear logs via bridge when not Tauri', async () => { - setupTauri(bridge, false); - vi.mocked(bridge.invoke).mockResolvedValue(null); - const result = await service.clearLogs(); - expect(result).toBe(true); - expect(bridge.invoke).toHaveBeenCalledWith('clear_console_logs', { viewId: 'general' }); - }); - - it('should clear only the selected engine view', async () => { + it('fetches only the requested console view in Tauri mode', async () => { setupTauri(bridge, true); - vi.spyOn(invokeModule, 'invokeSafe').mockResolvedValue({ - status: 'ok', - data: { - views: [ - { id: 'general', label: 'General' }, - { id: 'engine:sdcpp', label: 'Stable Diffusion.cpp' }, - ], - status_items: [], - }, - }); - vi.mocked(bridge.invoke).mockImplementation((command) => { - if (command === 'get_logs') { - return Promise.resolve([ - { timestamp: 1, source: 'frontend', level: 'ERROR', message: 'launcher' }, - { timestamp: 2, source: 'sdcpp', level: 'INFO', message: 'engine' }, - ]); - } - return Promise.resolve(undefined); - }); - - await service.fetchLogs(); - await service.getAvailableViews(); - await service.clearLogs('engine:stable-diffusion'); - - expect(bridge.invoke).toHaveBeenCalledWith('clear_console_logs', { - viewId: 'engine:sdcpp', - }); - expect(service.getLogsForView('general')).toEqual([ - expect.objectContaining({ message: 'launcher' }), - ]); - expect(service.getLogsForView('engine:sdcpp')).toEqual([]); - }); - - it('should return false on clearLogs error', async () => { - setupTauri(bridge, true); - vi.mocked(bridge.invoke).mockRejectedValue(new Error('Clear fail')); - const result = await service.clearLogs(); - expect(result).toBe(false); - }); - - it('should handle empty or non-array processLogs', async () => { - setupTauri(bridge, true, []); - const logs = await service.fetchLogs(); - expect(logs).toHaveLength(0); - }); - - it('should truncate logs beyond 2000', async () => { - setupTauri(bridge, true); - // Fill logs with 2001 entries across multiple fetches - const batch = Array.from({ length: 1500 }, (_, i) => ({ - timestamp: i, - source: 'TEST', - level: 'INFO', - message: `Log ${i}`, - })); - vi.mocked(bridge.invoke).mockResolvedValue(batch); - await service.fetchLogs(); - // Add another batch to exceed 2000 - const batch2 = Array.from({ length: 600 }, (_, i) => ({ - timestamp: 1500 + i, - source: 'TEST', - level: 'INFO', - message: `Log ${1500 + i}`, - })); - vi.mocked(bridge.invoke).mockResolvedValue(batch2); - await service.fetchLogs(); - expect(service.getLogs().length).toBeLessThanOrEqual(1000); - }); - - it('should handle logs with null/undefined message and source (L63-64)', async () => { - const logsWithNulls: ILogEntry[] = [ + vi.mocked(bridge.invoke).mockResolvedValue([ { timestamp: 100, - source: undefined as unknown as string, + source: 'module:axelate-telegram-parser', level: 'INFO', - message: undefined as unknown as string, + message: '2026-04-29 14:33:32 [INFO] telegram_parser: started', + module_id: 'axelate-telegram-parser', }, - ]; - setupTauri(bridge, true, logsWithNulls); - const logs = await service.fetchLogs(); - // Should not crash — null/undefined safely coerced to '' - expect(logs).toHaveLength(1); - }); - - it('should return empty when backend returns no new logs', async () => { - setupTauri(bridge, true, []); - const logs = await service.fetchLogs(); - expect(logs).toHaveLength(0); - }); - - it('should handle non-array bridge payloads as empty logs', async () => { - setupTauri(bridge, false, '' as unknown as ILogEntry[]); - const logs = await service.fetchLogs(); - expect(logs).toHaveLength(0); - }); - - it('should use previous lastTimestamp when last log has no timestamp (L93)', async () => { - setupTauri(bridge, true); - // First call sets lastTimestamp - const firstBatch: ILogEntry[] = [ - { timestamp: 500, source: 'APP', level: 'INFO', message: 'ok' }, - ]; - vi.mocked(bridge.invoke).mockResolvedValueOnce(firstBatch); - await service.fetchLogs(); - - // Second call has a log without a timestamp at the end - const secondBatch: ILogEntry[] = [ - { - timestamp: undefined as unknown as number, - source: 'SYS', - level: 'WARN', - message: 'no ts', - }, - ]; - vi.mocked(bridge.invoke).mockResolvedValueOnce(secondBatch); - const logs = await service.fetchLogs(); - expect(logs).toHaveLength(1); - }); - - it('should subscribe to engine events and append engine logs in Tauri mode', async () => { - const unlisten = vi.fn(); - const listeners = new Map void>(); + ] satisfies ILogEntry[]); - setupTauri(bridge, true); - vi.mocked(bridge.listen).mockImplementation( - (event: string, callback: (payload: T) => void) => { - listeners.set(event, callback as (payload: unknown) => void); - return Promise.resolve(unlisten); - }, - ); - - await service.init(); + const logs = await service.fetchLogs('module:axelate-telegram-parser'); - listeners.get('ai:engine:starting')?.({ engine_id: 'llamacpp' }); - listeners.get('ai:engine:log')?.({ - engine_id: 'llamacpp', - line: 'ready line', + expect(bridge.invoke).toHaveBeenCalledWith('get_console_logs', { + viewId: 'module:axelate-telegram-parser', + since: 0, }); - listeners.get('ai:engine:error')?.({ - engine_id: 'llamacpp', - message: 'boom', - }); - - expect(service.getLogsForView('engine:llamacpp')).toEqual([ - expect.objectContaining({ - source: 'llamacpp', - level: 'info', - message: 'Engine is starting...', - }), - expect.objectContaining({ - source: 'llamacpp', - level: 'info', - message: 'ready line', - }), + expect(logs).toEqual([ expect.objectContaining({ - source: 'llamacpp', - level: 'error', - message: 'boom', + module_id: 'axelate-telegram-parser', + message: 'started', + scope: 'telegram_parser', }), ]); - - service.destroy(); - expect(unlisten).toHaveBeenCalledTimes(4); + expect(service.getLogsForView('module:axelate-telegram-parser')).toHaveLength(1); + expect(service.getLogsForView('general')).toEqual([]); }); - it('should ignore noisy engine events', async () => { - const listeners = new Map void>(); - + it('tracks timestamps per view without cross-view filtering', async () => { setupTauri(bridge, true); - vi.mocked(bridge.listen).mockImplementation( - (event: string, callback: (payload: T) => void) => { - listeners.set(event, callback as (payload: unknown) => void); - return Promise.resolve(vi.fn()); - }, - ); - - await service.init(); - listeners.get('ai:engine:log')?.({ - engine_id: 'llamacpp', - line: '[AIBridge] Stream chunk received', - }); + vi.mocked(bridge.invoke) + .mockResolvedValueOnce([ + { timestamp: 10, source: 'frontend', level: 'INFO', message: 'platform' }, + ] satisfies ILogEntry[]) + .mockResolvedValueOnce([ + { timestamp: 20, source: 'sdcpp', level: 'INFO', message: 'engine' }, + ] satisfies ILogEntry[]) + .mockResolvedValueOnce([ + { timestamp: 12, source: 'frontend', level: 'INFO', message: 'platform later' }, + ] satisfies ILogEntry[]); - expect(service.getLogsForView('engine:llamacpp')).toEqual([]); - }); + await service.fetchLogs('general'); + await service.fetchLogs('engine:sdcpp'); + await service.fetchLogs('general'); - it('should expose General plus ready engine tabs', async () => { - setupTauri(bridge, true); - const invokeSafeSpy = vi.spyOn(invokeModule, 'invokeSafe').mockResolvedValue({ - status: 'ok', - data: { - views: [ - { id: 'general', label: 'General' }, - { id: 'engine:llamacpp', label: 'LLaMA.cpp' }, - ], - status_items: [], - }, + expect(bridge.invoke).toHaveBeenNthCalledWith(1, 'get_console_logs', { + viewId: 'general', + since: 0, }); - - const views = await service.getAvailableViews(); - - expect(invokeSafeSpy).toHaveBeenCalledWith('get_console_overview'); - expect(views).toEqual([ - { id: 'general', label: 'General' }, - { id: 'engine:llamacpp', label: 'LLaMA.cpp' }, - ]); - }); - - it('should trust backend-computed views when running in tauri', async () => { - setupTauri(bridge, true); - vi.spyOn(invokeModule, 'invokeSafe').mockResolvedValue({ - status: 'ok', - data: { - views: [ - { id: 'general', label: 'General' }, - { id: 'module:sample-integration', label: 'Sample Integration' }, - ], - status_items: [], - }, + expect(bridge.invoke).toHaveBeenNthCalledWith(2, 'get_console_logs', { + viewId: 'engine:sdcpp', + since: 0, }); - vi.mocked(bridge.invoke).mockImplementation((command) => { - if (command === 'get_logs') { - return Promise.resolve([ - { - timestamp: 1, - source: 'module:sample-integration', - level: 'INFO', - message: 'Started', - module_id: 'sample-integration', - }, - ]); - } - return Promise.resolve(undefined); + expect(bridge.invoke).toHaveBeenNthCalledWith(3, 'get_console_logs', { + viewId: 'general', + since: 10, }); - - await service.fetchLogs(); - const views = await service.getAvailableViews(); - - expect(views).toEqual([ - { id: 'general', label: 'General' }, - { id: 'module:sample-integration', label: 'Sample Integration' }, - ]); }); - it('should keep General limited to useful launcher logs and route module logs to module tabs', async () => { + it('uses backend-computed views without hiding custom providers', async () => { setupTauri(bridge, true); vi.spyOn(invokeModule, 'invokeSafe').mockResolvedValue({ status: 'ok', data: { views: [ - { id: 'general', label: 'General' }, - { id: 'engine:llamacpp', label: 'LLaMA.cpp' }, - { id: 'module:llamacpp', label: 'Llamacpp' }, + { id: 'general', label: 'Platform' }, + { id: 'module:openrouter-custom-text', label: 'Custom' }, + { id: 'module:axelate-telegram-parser', label: 'Parser' }, ], status_items: [], }, }); - vi.mocked(bridge.invoke).mockImplementation((command) => { - if (command === 'get_logs') { - return Promise.resolve([ - { - timestamp: 1, - source: 'frontend', - level: 'INFO', - message: '[NavigationService] Navigating to: console', - }, - { - timestamp: 2, - source: 'frontend', - level: 'INFO', - message: '[AIBridge] Starting provider: llamacpp', - module_id: 'llamacpp', - }, - { - timestamp: 3, - source: 'llamacpp', - level: 'INFO', - message: 'ready line', - }, - { - timestamp: 4, - source: 'frontend', - level: 'WARN', - message: '[WindowService] setSize failed', - }, - ]); - } - return Promise.resolve(undefined); - }); - await service.fetchLogs(); - await service.getAvailableViews(); - - expect(service.getLogsForView('general')).toEqual([ - expect.objectContaining({ - source: 'frontend', - message: 'setSize failed', - }), - ]); - expect(service.getLogsForView('module:llamacpp')).toEqual([ - expect.objectContaining({ - source: 'frontend', - message: 'Starting provider: llamacpp', - }), - ]); - expect(service.getLogsForView('engine:llamacpp')).toEqual([ - expect.objectContaining({ - source: 'llamacpp', - message: 'ready line', - }), + await expect(service.getAvailableViews()).resolves.toEqual([ + { id: 'general', label: 'Platform' }, + { id: 'module:openrouter-custom-text', label: 'Custom' }, + { id: 'module:axelate-telegram-parser', label: 'Parser' }, ]); }); - it('should filter noisy startup and progress logs before rendering', async () => { + it('clears only the requested view', async () => { setupTauri(bridge, true); - vi.mocked(bridge.invoke).mockImplementation((command) => { - if (command === 'get_logs') { - return Promise.resolve([ - { - timestamp: 1, - source: 'frontend', - level: 'INFO', - message: '[CoreRuntimeSupport] Critical services hydrated.', - }, - { - timestamp: 2, - source: 'frontend', - level: 'INFO', - message: '[CatalogService] Catalog initialized. AI: 10, Services: 1', - }, - { - timestamp: 3, - source: 'sdcpp', - level: 'INFO', - message: '2026-04-24 07:00:00 [INFO] |====> | 8/28 - 1.03it/s', - }, - { - timestamp: 4, - source: 'frontend', - level: 'ERROR', - message: '[CatalogService] Failed to load catalog: boom', - }, - ]); - } - return Promise.resolve(undefined); - }); - - await service.fetchLogs(); + vi.mocked(bridge.invoke) + .mockResolvedValueOnce([ + { timestamp: 1, source: 'frontend', level: 'INFO', message: 'platform' }, + ] satisfies ILogEntry[]) + .mockResolvedValueOnce(undefined); - expect(service.getLogsForView('general')).toEqual([ - expect.objectContaining({ - level: 'ERROR', - message: 'Failed to load catalog: boom', - }), - ]); - expect(service.getLogsForView('engine:sdcpp')).toEqual([]); - }); + await service.fetchLogs('general'); + const cleared = await service.clearLogs('general'); - it('should route stable-diffusion alias logs into the sdcpp engine tab', async () => { - setupTauri(bridge, true); - vi.spyOn(invokeModule, 'invokeSafe').mockResolvedValue({ - status: 'ok', - data: { - views: [ - { id: 'general', label: 'General' }, - { id: 'engine:sdcpp', label: 'Stable Diffusion.cpp' }, - ], - status_items: [], - }, - }); - vi.mocked(bridge.invoke).mockImplementation((command) => { - if (command === 'get_logs') { - return Promise.resolve([ - { - timestamp: 1, - source: 'stable-diffusion', - level: 'INFO', - message: '2026-04-24 07:00:00 [INFO] loaded model', - }, - ]); - } - return Promise.resolve(undefined); + expect(cleared).toBe(true); + expect(bridge.invoke).toHaveBeenLastCalledWith('clear_console_logs', { + viewId: 'general', }); - - await service.fetchLogs(); - await service.getAvailableViews(); - - expect(service.getLogsForView('engine:sdcpp')).toEqual([ - expect.objectContaining({ - source: 'sdcpp', - message: 'loaded model', - }), - ]); expect(service.getLogsForView('general')).toEqual([]); }); - it('should collapse duplicate engine views by label', async () => { - setupTauri(bridge, true); - vi.spyOn(invokeModule, 'invokeSafe').mockResolvedValue({ - status: 'ok', - data: { - views: [ - { id: 'general', label: 'General' }, - { id: 'engine:first-backend', label: 'Shared Engine' }, - { id: 'engine:second-backend', label: 'Shared Engine' }, - { id: 'engine:other-backend', label: 'Other Engine' }, - ], - status_items: [], - }, - }); - - const views = await service.getAvailableViews(); - - expect(views).toEqual([ - { id: 'general', label: 'General' }, - { id: 'engine:first-backend', label: 'Shared Engine' }, - { id: 'engine:other-backend', label: 'Other Engine' }, - ]); - }); - - it('should dedupe engine logs received from live events and runtime files', async () => { - const listeners = new Map void>(); - - setupTauri(bridge, true); - vi.mocked(bridge.listen).mockImplementation( - (event: string, callback: (payload: T) => void) => { - listeners.set(event, callback as (payload: unknown) => void); - return Promise.resolve(vi.fn()); - }, - ); - vi.spyOn(invokeModule, 'invokeSafe').mockResolvedValue({ - status: 'ok', - data: { - views: [ - { id: 'general', label: 'General' }, - { id: 'engine:sdcpp', label: 'Stable Diffusion.cpp' }, - ], - status_items: [], - }, - }); - vi.mocked(bridge.invoke).mockImplementation((command) => { - if (command === 'get_logs') { - return Promise.resolve([ - { - timestamp: 2, - source: 'sdcpp', - level: 'INFO', - message: '2026-04-24 07:00:00 [INFO] 8/28 - 1.03it/s', - }, - ]); - } - return Promise.resolve(undefined); - }); - - await service.init(); - listeners.get('ai:engine:log')?.({ - engine_id: 'stable-diffusion', - line: '8/28 - 1.03it/s', - }); - await service.fetchLogs(); - await service.getAvailableViews(); - - expect(service.getLogsForView('engine:sdcpp')).toEqual([ - expect.objectContaining({ - source: 'sdcpp', - message: '8/28 - 1.03it/s', - }), - ]); - }); - - it('should build runtime status items for engines and modules', async () => { + it('opens canonical engine log folders', async () => { setupTauri(bridge, true); - vi.spyOn(invokeModule, 'invokeSafe').mockResolvedValue({ - status: 'ok', - data: { - views: [ - { id: 'general', label: 'General' }, - { id: 'engine:llamacpp', label: 'LLaMA.cpp' }, - { id: 'module:llamacpp', label: 'Llamacpp' }, - ], - status_items: [ - { - id: 'engine:llamacpp', - label: 'LLaMA.cpp', - kind: 'engine', - status: 'running', - detail: 'text', - }, - { - id: 'module:llamacpp', - label: 'Llamacpp', - kind: 'module', - status: 'running', - detail: 'Running', - }, - ], - }, - }); - const items = await service.getStatusItems(); - - expect(items).toEqual([ - { - id: 'engine:llamacpp', - label: 'LLaMA.cpp', - kind: 'engine', - status: 'running', - detail: 'text', - }, - { - id: 'module:llamacpp', - label: 'Llamacpp', - kind: 'module', - status: 'running', - detail: 'Running', - }, - ]); - }); - - it('should cache module paths and open module folder', async () => { - setupTauri(bridge, true); - vi.mocked(bridge.invoke).mockImplementation((command) => { - if (command === 'get_module_path') { - return Promise.resolve('C:/modules/llamacpp'); - } - if (command === 'plugin:shell|open') { - return Promise.resolve(undefined); - } - return Promise.resolve(undefined); - }); - - const firstPath = await service.getModulePath('llamacpp'); - const secondPath = await service.getModulePath('llamacpp'); - const opened = await service.openModuleFolder('llamacpp'); - - expect(firstPath).toBe('C:/modules/llamacpp'); - expect(secondPath).toBe('C:/modules/llamacpp'); - expect(opened).toBe(true); - expect(bridge.invoke).toHaveBeenCalledWith('plugin:shell|open', { - path: 'C:/modules/llamacpp', - }); - expect( - vi - .mocked(bridge.invoke) - .mock.calls.filter(([command]) => command === 'get_module_path'), - ).toHaveLength(1); - }); - - it('should open the selected logs folder', async () => { - setupTauri(bridge, true); - vi.mocked(bridge.invoke).mockImplementation((command) => { - if (command === 'open_console_log_target') { - return Promise.resolve(undefined); - } - return Promise.resolve(undefined); - }); + vi.mocked(bridge.invoke).mockResolvedValue(undefined); - const opened = await service.openLogsFolder('engine:stable-diffusion'); + await expect(service.openLogsFolder('engine:stable-diffusion')).resolves.toBe(true); - expect(opened).toBe(true); expect(bridge.invoke).toHaveBeenCalledWith('open_console_log_target', { viewId: 'engine:sdcpp', }); diff --git a/src/features/console/services/ConsoleLogService.ts b/src/features/console/services/ConsoleLogService.ts index 2885343b..3a9f6394 100644 --- a/src/features/console/services/ConsoleLogService.ts +++ b/src/features/console/services/ConsoleLogService.ts @@ -46,82 +46,37 @@ type ConsoleOverviewPayload = { }>; }; -type EngineEventPayloadMap = { - 'ai:engine:log': { engine_id: string; line: string }; - 'ai:engine:starting': { engine_id: string }; - 'ai:engine:ready': { engine_id: string; endpoint: string }; - 'ai:engine:error': { engine_id: string; message: string }; -}; - -type EngineEventName = keyof EngineEventPayloadMap; type ConsoleLogServiceLogger = Pick; export class ConsoleLogService { - private static readonly _MAX_LOG_COUNT = 1000; - private static readonly _TRIM_THRESHOLD = 2000; - private static readonly _NOISE_PATTERNS = [ - /\[AIBridge\] Stream chunk received/i, - /\[AIBridge\] Thought chunk received/i, - /\bCritical services hydrated\b/i, - /\bCore Ready\b/i, - /\bReady\.\s*$/i, - /\bLoading\s+[a-z-]+\.{3}$/i, - /\bLanguage changed to\b.*\bnotifications dispatched\b/i, - /\bSettings container found\b.*\bInitializing renderers\b/i, - /\bGeneralSettingsRenderer\b.*\bInitializing\b/i, - /\bInitializing taskbar toggles\b/i, - /\bInitializing monitor toggles\b/i, - /\bStarted listening to system_stats\b/i, - /\bNavigation initialized\b/i, - /\bPage\s+[a-z0-9._-]+\b/i, - /\bRestore page\s+[a-z0-9._-]+\b/i, - /\bNavigating to:\s*[a-z0-9._-]+\b/i, - /\bResolution changed:\b/i, - /\bFetched\s+\d+\s+modules\b/i, - /\bMapping config\b/i, - /\bAfter mapping\b/i, - /\b_ensureValidConfig passed\b/i, - /\bCatalog hydrated successfully\b/i, - /\bCatalog initialized\b/i, - /\bTransport initialized\b/i, - /\bListening for engine events\b/i, - /^\s*\|?[=>\s]+\|?\s*\d+\s*\/\s*\d+\s*-\s*\d+(?:\.\d+)?\s*(?:it\/s|s\/it)\s*$/i, - ]; - - private logs: ILogEntry[] = []; - private lastTimestamp = 0; - private readonly _engineUnlisteners: Array<() => void> = []; + private static readonly _MAX_LOG_COUNT_PER_VIEW = 1200; + + private readonly _logsByView = new Map(); + private readonly _lastTimestampByView = new Map(); private readonly _modulePathCache = new Map(); - private readonly _knownEngineIds = new Set(); - private readonly _knownModuleIds = new Set(); private readonly _normalizer = new ConsoleLogNormalizer(); - private _initialized = false; constructor( private readonly bridge: IBridge, private readonly _tracer: ConsoleLogServiceLogger, ) {} - public async init(): Promise { - if (this._initialized || !this.bridge.isTauri()) { - return; - } - - this._initialized = true; - await this._registerEngineListeners(); + public init(): Promise { + return Promise.resolve(); } public destroy(): void { - this._engineUnlisteners.splice(0).forEach((unlisten) => { - unlisten(); - }); - this._initialized = false; + this._logsByView.clear(); + this._lastTimestampByView.clear(); } - public async fetchLogs(): Promise { + public async fetchLogs(viewId = 'general'): Promise { + const normalizedViewId = this._canonicalViewId(viewId); + const since = this._lastTimestampByView.get(normalizedViewId) ?? 0; + try { - const logs = await this._fetchTauriLogs(); - return this._processLogs(logs); + const logs = await this._fetchTauriLogs(normalizedViewId, since); + return this._appendLogs(normalizedViewId, logs); } catch (error) { this._tracer.error('[ConsoleLogService] Fetch logs failed:', error); return []; @@ -130,7 +85,8 @@ export class ConsoleLogService { public async clearLogs(viewId = 'general'): Promise { const normalizedViewId = this._canonicalViewId(viewId); - this.logs = this.logs.filter((entry) => !this._isLogInView(entry, normalizedViewId)); + this._logsByView.set(normalizedViewId, []); + this._lastTimestampByView.set(normalizedViewId, 0); try { await this.bridge.invoke('clear_console_logs', { viewId: normalizedViewId }); @@ -141,24 +97,36 @@ export class ConsoleLogService { } } + public async clearAllLogs(): Promise { + this._logsByView.clear(); + this._lastTimestampByView.clear(); + + try { + await this.bridge.invoke('clear_logs'); + return true; + } catch (error) { + this._tracer.error('[ConsoleLogService] Clear all logs failed:', error); + return false; + } + } + public getLogs(): ILogEntry[] { - return this.logs; + return [...this._logsByView.values()].flat(); + } + + public getLogsForView(viewId: string): ILogEntry[] { + return this._logsByView.get(this._canonicalViewId(viewId)) ?? []; } public async getAvailableViews(): Promise { if (!this.bridge.isTauri()) { - const views: IConsoleLogView[] = [{ id: 'general', label: 'General' }]; - const moduleLabels = new Map(); - this._hydrateModuleMetadata(moduleLabels); - return [...views, ...this._buildModuleViews(moduleLabels)]; + return [{ id: 'general', label: 'Platform' }]; } try { const result = await invokeSafe('get_console_overview'); if (result.status === 'ok') { - const views = this._normalizeViews(result.data.views); - this._hydrateKnownRuntimeIds(views); - return views; + return this._normalizeViews(result.data.views); } } catch (error) { this._tracer.warn( @@ -166,103 +134,7 @@ export class ConsoleLogService { ); } - const views: IConsoleLogView[] = [{ id: 'general', label: 'General' }]; - const moduleLabels = new Map(); - this._hydrateModuleMetadata(moduleLabels); - return [...views, ...this._buildModuleViews(moduleLabels)]; - } - - private _hydrateModuleMetadata(moduleLabels: Map): void { - for (const log of this.logs) { - const moduleId = this._getModuleId(log); - if (moduleId === null) { - continue; - } - - if (!moduleLabels.has(moduleId)) { - moduleLabels.set(moduleId, this._getModuleLabel(moduleId)); - } - } - } - - private _buildModuleViews(moduleLabels: ReadonlyMap): IConsoleLogView[] { - this._knownModuleIds.clear(); - - return [...moduleLabels.entries()].map(([moduleId, label]) => { - this._knownModuleIds.add(moduleId); - return { - id: `module:${moduleId}`, - label, - }; - }); - } - - private _normalizeViews(views: readonly IConsoleLogView[]): IConsoleLogView[] { - const seenIds = new Set(); - const seenLabels = new Set(); - const normalizedViews: IConsoleLogView[] = []; - - for (const view of views) { - const id = this._canonicalViewId(view.id); - const labelKey = this._normalizeViewLabel(view.label); - if (seenIds.has(id) || seenLabels.has(labelKey)) { - continue; - } - - seenIds.add(id); - seenLabels.add(labelKey); - normalizedViews.push({ ...view, id }); - } - - return normalizedViews; - } - - private _canonicalViewId(viewId: string): string { - const engineView = viewId.match(/^engine:(.+)$/); - if (engineView !== null) { - return `engine:${this._canonicalEngineId(engineView[1] ?? '')}`; - } - - return viewId; - } - - private _normalizeViewLabel(label: string): string { - return label.trim().toLowerCase().replaceAll(/\s+/gu, ' '); - } - - private _getModuleLabel(moduleId: string): string { - return moduleId - .replace(/^axelate-/, '') - .split('-') - .filter(Boolean) - .map((part) => part[0]?.toUpperCase() + part.slice(1)) - .join(' '); - } - - public getLogsForView(viewId: string): ILogEntry[] { - if (viewId === 'general') { - return this.logs.filter( - (entry) => this._getModuleId(entry) === null && !this._isKnownEngineLog(entry), - ); - } - - const moduleView = viewId.match(/^module:(.+)$/); - if (moduleView !== null) { - const moduleId = moduleView[1] ?? ''; - return this.logs.filter((entry) => this._getModuleId(entry) === moduleId); - } - - const engineView = viewId.match(/^engine:(.+)$/); - if (engineView !== null) { - const engineId = this._canonicalEngineId(engineView[1] ?? ''); - return this.logs.filter( - (entry) => - this._getModuleId(entry) === null && - this._canonicalEngineId(entry.source) === engineId, - ); - } - - return this.logs.filter((entry) => this._getModuleId(entry) === viewId); + return [{ id: 'general', label: 'Platform' }]; } public async getStatusItems(): Promise { @@ -273,11 +145,17 @@ export class ConsoleLogService { try { const result = await invokeSafe('get_console_overview'); if (result.status === 'ok') { - return this._mapOverviewStatusItems(result.data); + return result.data.status_items.map((item) => ({ + id: this._canonicalViewId(item.id), + label: item.label, + kind: item.kind === 'module' ? 'module' : 'engine', + status: this._toRuntimeStatus(item.status), + detail: item.detail, + })); } } catch (error) { this._tracer.warn( - `[ConsoleLogService] Failed to resolve engine status: ${String(error)}`, + `[ConsoleLogService] Failed to resolve runtime status: ${String(error)}`, ); } @@ -341,218 +219,94 @@ export class ConsoleLogService { } } - private async _registerEngineListeners(): Promise { - await this._listenToEngineEvent('ai:engine:log', (payload) => { - this._pushLog(payload.line, this._canonicalEngineId(payload.engine_id), 'info'); - }); - await this._listenToEngineEvent('ai:engine:starting', (payload) => { - this._pushLog( - 'Engine is starting...', - this._canonicalEngineId(payload.engine_id), - 'info', - ); - }); - await this._listenToEngineEvent('ai:engine:ready', (payload) => { - this._pushLog( - `Engine is ready at ${payload.endpoint}`, - this._canonicalEngineId(payload.engine_id), - 'info', - ); - }); - await this._listenToEngineEvent('ai:engine:error', (payload) => { - this._pushLog(payload.message, this._canonicalEngineId(payload.engine_id), 'error'); - }); - } - - private async _listenToEngineEvent( - eventName: TEvent, - handler: (payload: EngineEventPayloadMap[TEvent]) => void, - ): Promise { - const unlisten = await this.bridge.listen( - eventName, - handler, - ); - this._engineUnlisteners.push(unlisten); - } + private async _fetchTauriLogs(viewId: string, since: number): Promise { + if (!this.bridge.isTauri()) { + return await this.bridge.invoke('get_logs', { since }); + } - private async _fetchTauriLogs(): Promise { - return await this.bridge.invoke('get_logs', { - since: this.lastTimestamp, - }); + return await this.bridge.invoke('get_console_logs', { viewId, since }); } - private _processLogs(newLogs: ILogEntry[]): ILogEntry[] { + private _appendLogs(viewId: string, newLogs: ILogEntry[]): ILogEntry[] { if (!Array.isArray(newLogs) || newLogs.length === 0) { return []; } - this.lastTimestamp = newLogs.at(-1)?.timestamp ?? this.lastTimestamp; - const seenBatchKeys = new Set(); - const visibleLogs = newLogs - .filter((entry) => !this._isNoise(entry)) - .map((entry) => - this._normalizer.normalize({ - ...entry, - source: this._canonicalRuntimeSource(entry.source), - }), - ) - .filter((entry) => { - if (this._isNoise(entry)) { - return false; - } - const key = this._dedupeKey(entry); - if (seenBatchKeys.has(key) || this._hasDuplicateLog(entry)) { - return false; - } - seenBatchKeys.add(key); - return true; - }); - if (visibleLogs.length === 0) { - return []; - } - - this.logs.push(...visibleLogs); - this._trimLogs(); - return visibleLogs; - } + const normalizedLogs = newLogs.map((entry) => this._normalizer.normalize(entry)); + const previousLogs = this._logsByView.get(viewId) ?? []; + const existingKeys = new Set(previousLogs.map((entry) => this._dedupeKey(entry))); + const appendedLogs = normalizedLogs.filter((entry) => { + const key = this._dedupeKey(entry); + if (existingKeys.has(key)) { + return false; + } + existingKeys.add(key); + return true; + }); - private _isNoise(entry: ILogEntry): boolean { - const levelSource = entry.normalized_level ?? entry.level; - const level = levelSource.toUpperCase(); - if (level === 'ERROR' || level === 'WARN' || level === 'WARNING') { - return false; + if (appendedLogs.length === 0) { + this._lastTimestampByView.set( + viewId, + newLogs.at(-1)?.timestamp ?? this._lastTimestampByView.get(viewId) ?? 0, + ); + return []; } - return ConsoleLogService._NOISE_PATTERNS.some((pattern) => - pattern.test(String(entry.message)), + const nextLogs = [...previousLogs, ...appendedLogs].slice( + -ConsoleLogService._MAX_LOG_COUNT_PER_VIEW, ); + this._logsByView.set(viewId, nextLogs); + this._lastTimestampByView.set( + viewId, + newLogs.at(-1)?.timestamp ?? this._lastTimestampByView.get(viewId) ?? 0, + ); + return appendedLogs; } - private _pushLog(message: string, source: string, level: string): void { - const normalizedSource = this._canonicalRuntimeSource(source); - this._knownEngineIds.add(normalizedSource); - const entry: ILogEntry = { - timestamp: Date.now() / 1000, - source: normalizedSource, - level, - message, - }; - - if (this._isNoise(entry)) { - return; - } - - const normalized = this._normalizer.normalize(entry); - if (this._hasDuplicateLog(normalized)) { - return; - } - - this.logs.push(normalized); - this._trimLogs(); - } - - private _trimLogs(): void { - if (this.logs.length > ConsoleLogService._TRIM_THRESHOLD) { - this.logs = this.logs.slice(-ConsoleLogService._MAX_LOG_COUNT); - } - } - - private _mapOverviewStatusItems(payload: ConsoleOverviewPayload): IConsoleStatusItem[] { - return payload.status_items.map((item) => ({ - id: item.id, - label: item.label, - kind: item.kind === 'module' ? 'module' : 'engine', - status: this._toRuntimeStatus(item.status), - detail: item.detail, - })); - } - - private _hydrateKnownRuntimeIds(views: readonly IConsoleLogView[]): void { + private _normalizeViews(views: readonly IConsoleLogView[]): IConsoleLogView[] { + const byId = new Map(); for (const view of views) { - const engineId = view.id.match(/^engine:(.+)$/)?.[1]?.trim(); - if (engineId !== undefined && engineId !== '') { - this._knownEngineIds.add(this._canonicalEngineId(engineId)); - } - - const moduleId = view.id.match(/^module:(.+)$/)?.[1]?.trim(); - if (moduleId !== undefined && moduleId !== '') { - this._knownModuleIds.add(moduleId); + const id = this._canonicalViewId(view.id); + if (id === '') { + continue; } + byId.set(id, { ...view, id }); } + return [...byId.values()]; } - private _isKnownEngineLog(entry: ILogEntry): boolean { - return this._knownEngineIds.has(this._canonicalEngineId(entry.source)); - } - - private _isLogInView(entry: ILogEntry, viewId: string): boolean { - if (viewId === 'general') { - return this._getModuleId(entry) === null && !this._isKnownEngineLog(entry); - } - - const moduleView = viewId.match(/^module:(.+)$/); - if (moduleView !== null) { - return this._getModuleId(entry) === (moduleView[1] ?? ''); - } - - const engineView = viewId.match(/^engine:(.+)$/); + private _canonicalViewId(viewId: string): string { + const trimmed = viewId.trim(); + const engineView = trimmed.match(/^engine:(.+)$/); if (engineView !== null) { - const engineId = this._canonicalEngineId(engineView[1] ?? ''); - return ( - this._getModuleId(entry) === null && - this._canonicalEngineId(entry.source) === engineId - ); - } - - return this._getModuleId(entry) === viewId; - } - - private _getModuleId(entry: ILogEntry): string | null { - const moduleId = entry.module_id?.trim(); - if (moduleId !== undefined && moduleId !== '') { - this._knownModuleIds.add(moduleId); - return moduleId; - } - - const source = this._canonicalEngineId(entry.source); - if (this._knownEngineIds.has(source)) { - return null; - } - - if (source !== '' && this._knownModuleIds.has(source)) { - return source; + return `engine:${this._canonicalEngineId(engineView[1] ?? '')}`; } - - return null; + return trimmed; } - private _canonicalRuntimeSource(source: unknown): string { - const trimmed = typeof source === 'string' ? source.trim() : ''; - if (trimmed.startsWith('module:')) { - return trimmed; + private _canonicalEngineId(engineId: string): string { + const key = engineId + .trim() + .toLowerCase() + .replaceAll(/[\s_]+/gu, '-'); + switch (key) { + case 'stable-diffusion': + case 'stable-diffusion.cpp': + case 'stable-diffusion-cpp': + case 'stable.diffusion.cpp': + return 'sdcpp'; + default: + return engineId.trim(); } - - return this._canonicalEngineId(trimmed); - } - - private _canonicalEngineId(engineId: unknown): string { - const normalized = typeof engineId === 'string' ? engineId.trim() : ''; - return normalized === 'stable-diffusion' ? 'sdcpp' : normalized; - } - - private _hasDuplicateLog(candidate: ILogEntry): boolean { - const candidateKey = this._dedupeKey(candidate); - return this.logs.some((entry) => { - return this._dedupeKey(entry) === candidateKey; - }); } private _dedupeKey(entry: ILogEntry): string { return [ - this._canonicalRuntimeSource(entry.source), + entry.timestamp, + entry.source, entry.module_id ?? '', entry.normalized_level ?? entry.level, - typeof entry.message === 'string' ? entry.message : '', + entry.message, ].join('\u0000'); } diff --git a/src/features/console/ui/ConsoleFilterControlHelper.ts b/src/features/console/ui/ConsoleFilterControlHelper.ts index 065e99e3..64571fea 100644 --- a/src/features/console/ui/ConsoleFilterControlHelper.ts +++ b/src/features/console/ui/ConsoleFilterControlHelper.ts @@ -3,6 +3,7 @@ type ConsoleFilterControlHelperDeps = { allLevels: readonly Level[]; registerCleanup: (cleanup: () => void) => void; onClearLogs: () => void; + onClearAllLogs: () => void; onCopyLogs: () => void; onOpenLogsFolder: () => void; onFiltersChanged: () => void; @@ -54,11 +55,27 @@ export class ConsoleFilterControlHelper { this._deps.onFiltersChanged(); } }; + const handleContextMenu = (event: Event) => { + const target = event.target; + if (!(target instanceof HTMLElement)) { + return; + } + + const clearButton = target.closest('#clear-logs-btn'); + if (!(clearButton instanceof HTMLButtonElement)) { + return; + } + + event.preventDefault(); + this._handleClearAllButton(clearButton); + }; controls.addEventListener('click', handleClick); + controls.addEventListener('contextmenu', handleContextMenu); this.syncButtons(); this._deps.registerCleanup(() => { controls.removeEventListener('click', handleClick); + controls.removeEventListener('contextmenu', handleContextMenu); this._resetClearConfirmation(); }); } @@ -143,6 +160,23 @@ export class ConsoleFilterControlHelper { }, 2200); } + private _handleClearAllButton(button: HTMLButtonElement): void { + if (button.dataset['confirmingAll'] === 'true') { + this._resetClearConfirmation(); + this._deps.onClearAllLogs(); + return; + } + + this._resetClearConfirmation(); + button.dataset['confirmingAll'] = 'true'; + button.classList.add('confirming'); + button.setAttribute('aria-label', 'Confirm clear all console logs'); + button.title = 'Right-click again to clear all logs'; + this._clearConfirmationTimeout = setTimeout(() => { + this._resetClearConfirmation(); + }, 2200); + } + private _resetClearConfirmation(): void { if (this._clearConfirmationTimeout !== null) { clearTimeout(this._clearConfirmationTimeout); @@ -155,6 +189,7 @@ export class ConsoleFilterControlHelper { } delete button.dataset['confirming']; + delete button.dataset['confirmingAll']; button.classList.remove('confirming'); button.setAttribute('aria-label', 'Clear Console'); button.title = 'Clear Console'; diff --git a/src/features/console/ui/ConsoleRefreshCoordinator.ts b/src/features/console/ui/ConsoleRefreshCoordinator.ts index ce46afda..fe73c7bb 100644 --- a/src/features/console/ui/ConsoleRefreshCoordinator.ts +++ b/src/features/console/ui/ConsoleRefreshCoordinator.ts @@ -2,6 +2,7 @@ import type { ConsoleLogService } from '../services/ConsoleLogService'; type ConsoleRefreshCoordinatorDeps = { service: Pick; + getActiveViewId: () => string; refreshLogViews: () => Promise; renderLogs: (clear?: boolean) => void; }; @@ -10,14 +11,14 @@ export class ConsoleRefreshCoordinator { public constructor(private readonly _deps: ConsoleRefreshCoordinatorDeps) {} public async refreshOnOpen(): Promise { - await this._deps.service.fetchLogs(); await this._deps.refreshLogViews(); + await this._deps.service.fetchLogs(this._deps.getActiveViewId()); this._deps.renderLogs(true); } public async refreshFromPolling(): Promise { - const newLogs = await this._deps.service.fetchLogs(); const viewsChanged = await this._deps.refreshLogViews(); + const newLogs = await this._deps.service.fetchLogs(this._deps.getActiveViewId()); if (viewsChanged || newLogs.length > 0) { this._deps.renderLogs(); diff --git a/src/features/console/ui/ConsoleUI.test.ts b/src/features/console/ui/ConsoleUI.test.ts index 09e4188f..c8dc0619 100644 --- a/src/features/console/ui/ConsoleUI.test.ts +++ b/src/features/console/ui/ConsoleUI.test.ts @@ -103,6 +103,7 @@ describe('ConsoleUI lifecycle', () => { init: ReturnType; destroy: ReturnType; clearLogs: ReturnType; + clearAllLogs: ReturnType; getLogs: ReturnType; getLogsForView: ReturnType; getAvailableViews: ReturnType; @@ -117,6 +118,7 @@ describe('ConsoleUI lifecycle', () => { init: vi.fn().mockResolvedValue(undefined), destroy: vi.fn(), clearLogs: vi.fn().mockResolvedValue(true), + clearAllLogs: vi.fn().mockResolvedValue(true), getLogs: vi.fn().mockReturnValue([]), getLogsForView: vi.fn().mockReturnValue([]), getAvailableViews: vi.fn().mockResolvedValue([{ id: 'general', label: 'General' }]), @@ -259,6 +261,30 @@ describe('ConsoleUI lifecycle', () => { vi.useRealTimers(); }); + it('should require a second right click before clearing all logs', async () => { + vi.useFakeTimers(); + const service = createServiceMock(); + + ui = new ConsoleUI(service, createDeps()); + ui.init(); + + const clearButton = document.getElementById('clear-logs-btn') as HTMLButtonElement; + clearButton.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true })); + + expect(clearButton.classList.contains('confirming')).toBe(true); + expect(service.clearAllLogs).not.toHaveBeenCalled(); + expect(service.clearLogs).not.toHaveBeenCalled(); + + clearButton.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true })); + await vi.runOnlyPendingTimersAsync(); + + expect(clearButton.classList.contains('confirming')).toBe(false); + expect(service.clearAllLogs).toHaveBeenCalledTimes(1); + expect(service.clearLogs).not.toHaveBeenCalled(); + + vi.useRealTimers(); + }); + it('should reset clear action confirmation after timeout', () => { vi.useFakeTimers(); const service = createServiceMock(); diff --git a/src/features/console/ui/ConsoleUI.ts b/src/features/console/ui/ConsoleUI.ts index f46fa3f5..2ca395e6 100644 --- a/src/features/console/ui/ConsoleUI.ts +++ b/src/features/console/ui/ConsoleUI.ts @@ -96,6 +96,9 @@ export class ConsoleUI { onClearLogs: () => { void this.clearLogs(); }, + onClearAllLogs: () => { + void this.clearAllLogs(); + }, onCopyLogs: () => { void this.copyLogs(); }, @@ -121,6 +124,7 @@ export class ConsoleUI { }); this._refreshCoordinator = new ConsoleRefreshCoordinator({ service: this.service, + getActiveViewId: () => this._viewState.activeViewId, refreshLogViews: async () => await this.refreshLogViews(), renderLogs: (clear) => { this.renderLogs(clear); @@ -252,6 +256,9 @@ export class ConsoleUI { this._viewState.activeViewId = view; this._activateTab('.console-tab', '.logs-pane', `logs-${view}`, btn); this.renderLogs(true); + void this.service.fetchLogs(view).then(() => { + this.renderLogs(true); + }); } public async clearLogs(): Promise { @@ -260,6 +267,12 @@ export class ConsoleUI { this._clipboardHelper.showLogsCleared(); } + public async clearAllLogs(): Promise { + await this.service.clearAllLogs(); + this.renderLogs(true); + this._clipboardHelper.showLogsCleared(); + } + public async openLogsFolder(): Promise { const opened = await this.service.openLogsFolder(this._viewState.activeViewId); if (!opened) { diff --git a/src/features/settings/ui/ModuleSettingsEngineFieldCatalog.test.ts b/src/features/settings/ui/ModuleSettingsEngineFieldCatalog.test.ts index ff9fb59c..6407cbc1 100644 --- a/src/features/settings/ui/ModuleSettingsEngineFieldCatalog.test.ts +++ b/src/features/settings/ui/ModuleSettingsEngineFieldCatalog.test.ts @@ -14,7 +14,6 @@ describe('ModuleSettingsEngineFieldCatalog', () => { }); expect(catalog.buildTextEngineFields(t).map((field) => field.key)).toEqual([ - 'compute_mode', 'context_size', 'llamacpp_system_prompt', ]); @@ -27,14 +26,44 @@ describe('ModuleSettingsEngineFieldCatalog', () => { expect(imageGroups.samplingFields.some((field) => field.key === 'sdcpp_sampler')).toBe( true, ); + expect( + imageGroups.samplingFields.find((field) => field.key === 'sdcpp_sampler')?.options, + ).toEqual([ + 'euler', + 'euler_a', + 'heun', + 'dpm2', + 'dpm++2s_a', + 'dpm++2m', + 'dpm++2mv2', + 'ipndm', + 'ipndm_v', + 'lcm', + 'ddim_trailing', + 'tcd', + 'res_multistep', + 'res_2s', + 'er_sde', + ]); + expect( + imageGroups.samplingFields.find((field) => field.key === 'sdcpp_scheduler')?.options, + ).toEqual([ + 'discrete', + 'karras', + 'exponential', + 'ays', + 'gits', + 'sgm_uniform', + 'simple', + 'smoothstep', + 'kl_optimal', + 'lcm', + 'bong_tangent', + ]); expect(catalog.buildImageExtraArgsField(t)).toMatchObject({ key: 'extra_args', showInfoButton: true, fullWidth: true, }); - expect(catalog.buildComputeModeField(t)).toMatchObject({ - key: 'compute_mode', - defaultValue: 'gpu', - }); }); }); diff --git a/src/features/settings/ui/ModuleSettingsEngineFieldCatalog.ts b/src/features/settings/ui/ModuleSettingsEngineFieldCatalog.ts index 3de9e1f4..bcbc2775 100644 --- a/src/features/settings/ui/ModuleSettingsEngineFieldCatalog.ts +++ b/src/features/settings/ui/ModuleSettingsEngineFieldCatalog.ts @@ -56,24 +56,8 @@ export class ModuleSettingsEngineFieldCatalog { }; } - public buildComputeModeField(t: TranslateFn): EngineFieldDefinition { - return { - label: t('ui.settings.engine.compute_mode', 'Compute Device'), - key: 'compute_mode', - type: 'select', - isEngineConfig: true, - options: ['gpu', 'cpu'], - optionLabels: { - gpu: t('ui.settings.engine.compute_mode_gpu', 'GPU'), - cpu: t('ui.settings.engine.compute_mode_cpu', 'CPU'), - }, - defaultValue: 'gpu', - }; - } - public buildTextEngineFields(t: TranslateFn): EngineFieldDefinition[] { return [ - this.buildComputeModeField(t), { label: t('ui.settings.engine.context_size', 'Context Window'), key: 'context_size', @@ -103,19 +87,25 @@ export class ModuleSettingsEngineFieldCatalog { return { promptFields: [ { - label: t('ui.settings.engine.sd_positive_prompt', 'Positive Prompt Prefix'), + label: t('ui.settings.engine.sd_positive_prompt', 'Positive Prompt'), key: `${appId}_positive_prompt`, type: 'textarea', isEngineConfig: false, - placeholder: 'e.g. score_9, score_8_up...', + placeholder: t( + 'ui.settings.engine.sd_positive_prompt_placeholder', + 'Describe the image style, subject, lighting, and details.', + ), defaultValue: '', }, { - label: t('ui.settings.engine.sd_negative_prompt', 'Negative Prompt Prefix'), + label: t('ui.settings.engine.sd_negative_prompt', 'Negative Prompt'), key: `${appId}_negative_prompt`, type: 'textarea', isEngineConfig: false, - placeholder: 'e.g. score_4, text, watermark...', + placeholder: t( + 'ui.settings.engine.sd_negative_prompt_placeholder', + 'Things to avoid: blurry, low quality, watermark, distortion.', + ), defaultValue: '', }, ], @@ -178,23 +168,40 @@ export class ModuleSettingsEngineFieldCatalog { type: 'select', isEngineConfig: false, options: [ - 'dpm++ 2m', - 'dpm++ 2m v2', - 'dpm++ 2s a', - 'euler a', + 'euler', + 'euler_a', 'heun', 'dpm2', - 'euler', + 'dpm++2s_a', + 'dpm++2m', + 'dpm++2mv2', 'ipndm', 'ipndm_v', - 'er sde', - 'ddim trailing', - 'res multistep', - 'res 2s', 'lcm', + 'ddim_trailing', 'tcd', + 'res_multistep', + 'res_2s', + 'er_sde', ], - defaultValue: 'euler a', + optionLabels: { + euler: 'Euler', + euler_a: 'Euler A', + heun: 'Heun', + dpm2: 'DPM2', + 'dpm++2s_a': 'DPM++ 2S A', + 'dpm++2m': 'DPM++ 2M', + 'dpm++2mv2': 'DPM++ 2M v2', + ipndm: 'IPNDM', + ipndm_v: 'IPNDM V', + lcm: 'LCM', + ddim_trailing: 'DDIM trailing', + tcd: 'TCD', + res_multistep: 'Res multistep', + res_2s: 'Res 2S', + er_sde: 'ER SDE', + }, + defaultValue: 'euler_a', }, { label: t('ui.settings.engine.sd_scheduler', 'Scheduler'), @@ -204,16 +211,29 @@ export class ModuleSettingsEngineFieldCatalog { options: [ 'discrete', 'karras', - 'sgm uniform', 'exponential', 'ays', 'gits', - 'smoothstep', - 'kl optimal', + 'sgm_uniform', 'simple', + 'smoothstep', + 'kl_optimal', 'lcm', - 'bong tangent', + 'bong_tangent', ], + optionLabels: { + discrete: 'Discrete', + karras: 'Karras', + exponential: 'Exponential', + ays: 'AYS', + gits: 'GITS', + sgm_uniform: 'SGM uniform', + simple: 'Simple', + smoothstep: 'Smoothstep', + kl_optimal: 'KL optimal', + lcm: 'LCM', + bong_tangent: 'Bong tangent', + }, defaultValue: 'discrete', }, ], diff --git a/src/features/settings/ui/ModuleSettingsEngineFieldController.ts b/src/features/settings/ui/ModuleSettingsEngineFieldController.ts index 073dc1ef..c395efab 100644 --- a/src/features/settings/ui/ModuleSettingsEngineFieldController.ts +++ b/src/features/settings/ui/ModuleSettingsEngineFieldController.ts @@ -13,7 +13,7 @@ type EngineFieldType = 'number' | 'text' | 'select' | 'password' | 'textarea'; type EngineInputElement = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; type ModuleSettingsEngineFieldControllerDeps = { - getSettings: () => Record; + getSettings: () => Record; setConfig: (config: EngineConfig) => Promise; debouncedSave: (key: string, value: string | number | boolean | null) => void; showSaveIndicator: () => void; @@ -107,6 +107,11 @@ export class ModuleSettingsEngineFieldController { browseBtn.textContent = this._deps.translate('ui.settings.engine.browse', 'Browse'); browseBtn.onclick = async () => { + if (browseBtn.disabled) { + return; + } + + browseBtn.disabled = true; try { const selected = await open({ multiple: false, @@ -121,10 +126,14 @@ export class ModuleSettingsEngineFieldController { input.dataset['fullPath'] = selected; input.value = this._deps.getModelFileName(selected); input.title = selected; - input.dispatchEvent(new Event('change')); + window.setTimeout(() => { + input.dispatchEvent(new Event('change')); + }, 0); } } catch (error: unknown) { this._deps.tracer.error('[ModuleSettingsUI] Failed to open file dialog', error); + } finally { + browseBtn.disabled = false; } }; diff --git a/src/features/settings/ui/ModuleSettingsEngineFieldRowRenderer.ts b/src/features/settings/ui/ModuleSettingsEngineFieldRowRenderer.ts index 1ec848ae..f87d17b7 100644 --- a/src/features/settings/ui/ModuleSettingsEngineFieldRowRenderer.ts +++ b/src/features/settings/ui/ModuleSettingsEngineFieldRowRenderer.ts @@ -146,10 +146,7 @@ export class ModuleSettingsEngineFieldRowRenderer { extraArgsControl.root.style.cursor = 'pointer'; extraArgsControl.root.addEventListener('click', (event) => { const target = event.target as Node; - if ( - target === extraArgsControl.root || - target === extraArgsControl.root.firstChild - ) { + if (target === extraArgsControl.root) { event.preventDefault(); event.stopPropagation(); this._deps.toggleInfoPopover(infoButton, options.appId, options.config); diff --git a/src/features/settings/ui/ModuleSettingsEngineFieldSupport.ts b/src/features/settings/ui/ModuleSettingsEngineFieldSupport.ts index ec5f0fca..25a13a56 100644 --- a/src/features/settings/ui/ModuleSettingsEngineFieldSupport.ts +++ b/src/features/settings/ui/ModuleSettingsEngineFieldSupport.ts @@ -2,7 +2,6 @@ type EngineFieldType = 'number' | 'text' | 'select' | 'password' | 'textarea'; type EngineInputElement = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; type EngineFieldValue = string | number | string[] | null | undefined; type ExtraArgsTranslate = (key: string, fallback: string) => string; -type PerformanceTranslate = (key: string, fallback: string) => string; export type EngineModelFileKind = 'model'; export type EngineModelFileFilter = { name: string; extensions: string[] }; @@ -11,7 +10,7 @@ type EngineFieldInitialOptions = { isEngineConfig: boolean; defaultValue?: number | string; config: Record | null; - settings: Record; + settings: Record; }; type EngineFieldParseOptions = { @@ -42,7 +41,7 @@ export type EngineExtraArgsControl = { root: HTMLDivElement; syncTokens: () => void; getGroups: () => string[]; - setGroups: (groups: string[]) => void; + setGroups: (groups: string[], options?: { emit?: boolean }) => void; }; export type EngineExtraArgDoc = { @@ -56,9 +55,95 @@ export type EngineExtraArgDocs = { items: EngineExtraArgDoc[]; }; +type EngineExtraArgDocSource = { + flag: string; + fallback: string; +}; + +const SDCPP_EXTRA_ARG_DOCS: EngineExtraArgDocSource[] = [ + { flag: '--threads 8', fallback: 'Set worker thread count.' }, + { flag: '--vae path', fallback: 'Set VAE model path.' }, + { flag: '--taesd path', fallback: 'Set TAESD model path.' }, + { flag: '--control-net path', fallback: 'Set ControlNet model path.' }, + { flag: '--embd-dir path', fallback: 'Load textual inversion embeddings.' }, + { flag: '--stacked-id-embd-dir path', fallback: 'Load stacked ID embeddings.' }, + { flag: '--input-id-images-dir path', fallback: 'Load input ID images.' }, + { flag: '--lora-model-dir path', fallback: 'Directory containing LoRA models.' }, + { flag: '--vae-decode-only', fallback: 'Decode a latent image with VAE only.' }, + { flag: '--vae-encode-only', fallback: 'Encode an image into latent space.' }, + { flag: '--control-image path', fallback: 'Image used by ControlNet.' }, + { flag: '--output-video path', fallback: 'Set output video path.' }, + { flag: '--init-img path', fallback: 'Use an initial image for img2img.' }, + { flag: '--mask path', fallback: 'Use a mask image for inpainting.' }, + { flag: '--ref-image path', fallback: 'Use a reference image.' }, + { flag: '--clip_l path', fallback: 'Set CLIP-L model path.' }, + { flag: '--clip_g path', fallback: 'Set CLIP-G model path.' }, + { flag: '--clip_vision path', fallback: 'Set CLIP-Vision model path.' }, + { flag: '--t5xxl path', fallback: 'Set T5-XXL model path.' }, + { flag: '--llm path', fallback: 'Set LLM model path.' }, + { flag: '--diffusion-fa', fallback: 'Enable flash attention for diffusion.' }, + { flag: '--fa', fallback: 'Enable flash attention globally.' }, + { flag: '--no-fallback', fallback: 'Disable fallback execution paths.' }, + { flag: '--mmap', fallback: 'Memory-map model weights from disk.' }, + { flag: '--no-mmap', fallback: 'Disable memory mapping.' }, + { flag: '--offload-to-cpu', fallback: 'Offload model work to CPU.' }, + { flag: '--clip-on-cpu', fallback: 'Run CLIP on CPU.' }, + { flag: '--vae-on-cpu', fallback: 'Run VAE on CPU.' }, + { flag: '--vae-tiling', fallback: 'Use tiled VAE decoding to reduce VRAM usage.' }, + { flag: '--free-params-immediately', fallback: 'Free model params after load.' }, + { flag: '--keep-clip-on-cpu', fallback: 'Keep CLIP weights on CPU.' }, + { flag: '--keep-control-net-cpu', fallback: 'Keep ControlNet weights on CPU.' }, + { flag: '--keep-vae-on-cpu', fallback: 'Keep VAE weights on CPU.' }, + { flag: '--control-net-cpu', fallback: 'Run ControlNet on CPU when ControlNet is used.' }, + { flag: '--canny', fallback: 'Apply Canny preprocessing for ControlNet.' }, + { flag: '--color', fallback: 'Apply color preprocessing or colored output.' }, + { flag: '--cpu-params', fallback: 'Keep parameters in regular CPU memory.' }, + { flag: '--normalize-input', fallback: 'Normalize input image values.' }, + { flag: '--upscale-model path', fallback: 'Set ESRGAN upscale model path.' }, + { flag: '--upscale-repeats 2', fallback: 'Repeat upscaling passes.' }, + { flag: '--type q8_0', fallback: 'Set weight precision/type.' }, + { flag: '--rng cuda', fallback: 'Prefer CUDA RNG on NVIDIA systems.' }, + { flag: '--prediction v', fallback: 'Set prediction mode.' }, + { flag: '--guidance 3.5', fallback: 'Set guidance scale.' }, + { flag: '--eta 0', fallback: 'Set DDIM eta.' }, + { flag: '--pm-style-strength 20', fallback: 'Set PhotoMaker style strength.' }, + { flag: '--control-strength 0.9', fallback: 'Set ControlNet strength.' }, + { flag: '--video-frames 16', fallback: 'Set generated video frame count.' }, + { flag: '--fps 24', fallback: 'Set generated video FPS.' }, + { flag: '--motion-bucket-id 127', fallback: 'Set SVD motion bucket.' }, + { flag: '--augmentation-level 0', fallback: 'Set SVD augmentation level.' }, + { flag: '--sample-start 0', fallback: 'Set sample start value.' }, + { flag: '--sample-end 1', fallback: 'Set sample end value.' }, + { flag: '--slg-scale 0', fallback: 'Set skip-layer guidance scale.' }, + { flag: '--skip-layers 7,8,9', fallback: 'Set skip-layer guidance layers.' }, + { flag: '--skip-layer-start 0.01', fallback: 'Set skip-layer start ratio.' }, + { flag: '--skip-layer-end 0.2', fallback: 'Set skip-layer end ratio.' }, + { flag: '--vae-tile-size 32x32', fallback: 'Set VAE tile size.' }, + { flag: '--vae-tile-overlap 0.5', fallback: 'Set VAE tile overlap.' }, + { flag: '--vae-relative-tile-size 0.5x0.5', fallback: 'Set relative VAE tile size.' }, + { flag: '--verbose', fallback: 'Enable verbose logging.' }, + { flag: '--quiet', fallback: 'Reduce logging output.' }, + { flag: '--chroma-disable-ds', fallback: 'Disable Chroma downsampling.' }, + { flag: '--chroma-enable-t5-mask', fallback: 'Enable Chroma T5 mask.' }, + { flag: '--chroma-t5-mask-pad 1', fallback: 'Set Chroma T5 mask padding.' }, + { flag: '--flow-shift 3', fallback: 'Set flow shift value.' }, + { flag: '--timestep-shift 250', fallback: 'Set shifted timestep value.' }, + { flag: '--diffusion-cpu-params', fallback: 'Keep diffusion params on CPU.' }, + { flag: '--vae-cpu-params', fallback: 'Keep VAE params on CPU.' }, + { flag: '--clip-cpu-params', fallback: 'Keep CLIP params on CPU.' }, + { flag: '--control-net-cpu-params', fallback: 'Keep ControlNet params on CPU.' }, + { flag: '--rng std_default', fallback: 'Use standard RNG.' }, + { flag: '--sampler-rng cuda', fallback: 'Use CUDA RNG specifically for the sampler.' }, + { flag: '--load-id-weights path', fallback: 'Load ID weights file.' }, + { flag: '--photo-maker path', fallback: 'Set PhotoMaker model path.' }, + { flag: '--photo-maker-vae path', fallback: 'Set PhotoMaker VAE path.' }, + { flag: '--style-strength 20', fallback: 'Set PhotoMaker style strength.' }, + { flag: '--taesd-decode', fallback: 'Use TAESD decoder.' }, + { flag: '--taesd-encode', fallback: 'Use TAESD encoder.' }, +]; + export type EngineRecommendedExtraArgsContext = { config?: { - compute_mode?: 'gpu' | 'cpu'; extra_args?: string[]; } | null; currentGroups?: string[]; @@ -171,64 +256,6 @@ export function syncEnginePromptTextareaHeights( requestAnimationFrameFn(sync); } -export function renderEnginePerformanceModeField( - container: HTMLElement, - appId: string, - settings: Record, - translate: PerformanceTranslate, - debouncedSave: (key: string, value: string | number | boolean | null) => void, -): void { - const row = document.createElement('div'); - row.className = 'local-engine-field-row'; - - const labelRow = document.createElement('div'); - labelRow.className = 'local-engine-label-row'; - - const label = document.createElement('label'); - label.textContent = translate('ui.settings.engine.performance_mode', 'Performance Mode'); - label.className = 'local-engine-field-label'; - labelRow.appendChild(label); - - const inputWrapper = document.createElement('div'); - inputWrapper.className = 'local-engine-performance-toggle'; - - const statusLabel = document.createElement('span'); - statusLabel.className = 'local-engine-performance-toggle-status local-engine-perf-status'; - - const switchLabel = document.createElement('label'); - switchLabel.className = 'switch'; - switchLabel.style.pointerEvents = 'none'; - - const checkbox = document.createElement('input'); - checkbox.type = 'checkbox'; - - const slider = document.createElement('span'); - slider.className = 'slider'; - - switchLabel.append(checkbox, slider); - inputWrapper.append(statusLabel, switchLabel); - - let enabled = String(settings[`${appId}_performance_mode`] ?? 'false').toLowerCase() === 'true'; - - const sync = () => { - statusLabel.textContent = enabled - ? translate('ui.common.enabled', 'Enabled') - : translate('ui.common.disabled', 'Disabled'); - inputWrapper.classList.toggle('is-enabled', enabled); - checkbox.checked = enabled; - }; - sync(); - - inputWrapper.addEventListener('click', () => { - enabled = !enabled; - sync(); - debouncedSave(`${appId}_performance_mode`, enabled); - }); - - row.append(labelRow, inputWrapper); - container.appendChild(row); -} - export function getEngineModelFileName(modelPath: string, notSelectedLabel: string): string { if (modelPath.trim() === '') { return notSelectedLabel; @@ -257,18 +284,25 @@ export function createEngineExtraArgsField(translate: ExtraArgsTranslate): Engin const root = document.createElement('div'); root.className = 'local-engine-tags-editor'; - const hiddenInput = document.createElement('input'); - hiddenInput.type = 'hidden'; - hiddenInput.className = 'local-engine-tags-value'; + const input = document.createElement('input'); + input.type = 'hidden'; + input.className = 'local-engine-extra-args-input local-engine-extra-args-value'; const chips = document.createElement('div'); - chips.className = 'local-engine-tags-chips'; + chips.className = 'local-engine-extra-args-chips'; + + const draftInput = document.createElement('input'); + draftInput.type = 'text'; + draftInput.className = 'local-engine-extra-args-draft'; + draftInput.placeholder = translate( + 'ui.settings.engine.extra_args.placeholder', + 'Add startup flags', + ); + + let groups: string[] = []; const parseGroups = (raw: string): string[] => { - const tokens = raw - .split(/\s+/) - .map((token) => token.trim()) - .filter((token) => token !== ''); + const tokens = tokenizeEngineExtraArgs(raw); const groups: string[] = []; for (let index = 0; index < tokens.length; index += 1) { @@ -296,103 +330,132 @@ export function createEngineExtraArgsField(translate: ExtraArgsTranslate): Engin const flattenGroups = (groups: string[]): string => groups - .flatMap((group) => - group - .split(/\s+/) - .map((token) => token.trim()) - .filter((token) => token !== ''), - ) + .flatMap((group) => tokenizeEngineExtraArgs(group)) + .map(formatEngineExtraArgToken) .join(' '); - const getGroups = (): string[] => parseGroups(hiddenInput.value); + const commitHiddenValue = () => { + input.value = flattenGroups(groups); + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); + }; - const syncTokens = () => { - const groups = getGroups(); + const renderChips = () => { chips.replaceChildren( ...groups.map((group, index) => { - const chip = document.createElement('button'); - chip.type = 'button'; - chip.className = 'local-engine-tag-chip'; - chip.title = translate('ui.settings.engine.extra_args.remove', 'Remove'); - chip.dataset['groupIndex'] = String(index); - - const label = document.createElement('span'); - label.className = 'local-engine-tag-chip-label'; - label.textContent = group; - - const remove = document.createElement('span'); - remove.className = 'local-engine-tag-chip-remove'; - remove.textContent = 'x'; - - chip.append(label, remove); + const chip = document.createElement('span'); + chip.className = 'local-engine-extra-arg-chip'; + + const edit = document.createElement('button'); + edit.type = 'button'; + edit.className = 'local-engine-extra-arg-edit'; + edit.textContent = group; + edit.title = group; + edit.addEventListener('click', () => { + groups.splice(index, 1); + draftInput.value = group; + renderChips(); + commitHiddenValue(); + draftInput.focus(); + }); + + const remove = document.createElement('button'); + remove.type = 'button'; + remove.className = 'local-engine-extra-arg-remove'; + remove.textContent = '×'; + remove.setAttribute( + 'aria-label', + translate('ui.settings.engine.extra_args.remove', 'Remove'), + ); + remove.addEventListener('click', () => { + groups.splice(index, 1); + renderChips(); + commitHiddenValue(); + }); + + chip.append(edit, remove); return chip; }), ); }; - const setGroups = (groups: string[]) => { - hiddenInput.value = flattenGroups(groups); - syncTokens(); - hiddenInput.dispatchEvent(new Event('input', { bubbles: true })); - hiddenInput.dispatchEvent(new Event('change', { bubbles: true })); - }; - - root.append(chips, hiddenInput); - chips.addEventListener('click', (event) => { - const target = event.target; - if (!(target instanceof HTMLElement)) { + const commitDraft = () => { + const nextGroups = parseGroups(draftInput.value); + if (nextGroups.length === 0) { return; } - const chip = target.closest('.local-engine-tag-chip'); - if (!(chip instanceof HTMLButtonElement)) { + const seen = new Set(groups); + nextGroups.forEach((group) => { + if (!seen.has(group)) { + seen.add(group); + groups.push(group); + } + }); + draftInput.value = ''; + renderChips(); + commitHiddenValue(); + }; + + const getGroups = (): string[] => [...groups, ...parseGroups(draftInput.value)]; + + const syncTokens = () => { + groups = parseGroups(input.value); + draftInput.value = ''; + renderChips(); + input.value = flattenGroups(groups); + }; + + const setGroups = (groups: string[], options: { emit?: boolean } = {}) => { + input.value = flattenGroups(groups); + syncTokens(); + if (options.emit === false) { return; } + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); + }; - const groupIndex = Number(chip.dataset['groupIndex']); - if (Number.isNaN(groupIndex)) { - return; + draftInput.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ',') { + event.preventDefault(); + commitDraft(); } + if (event.key === 'Backspace' && draftInput.value === '' && groups.length > 0) { + draftInput.value = groups.pop() ?? ''; + renderChips(); + commitHiddenValue(); + } + }); + draftInput.addEventListener('blur', commitDraft); - const updated = getGroups().filter((_, index) => index !== groupIndex); - setGroups(updated); + root.addEventListener('click', (event) => { + if (event.target === root || event.target === chips) { + draftInput.focus(); + } }); - return { input: hiddenInput, root, syncTokens, getGroups, setGroups }; + root.append(input, chips, draftInput); + + return { input, root, syncTokens, getGroups, setGroups }; } -export function getEngineExtraArgDocs(appId: string): EngineExtraArgDocs { +export function getEngineExtraArgDocs( + appId: string, + translate: ExtraArgsTranslate = (_key, fallback) => fallback, +): EngineExtraArgDocs { if (appId === 'sdcpp' || appId === 'stable-diffusion') { return { - title: 'Manual sd.cpp flags', - subtitle: 'Startup flags appended to sd-server.', - items: [ - { flag: '--fa', description: 'Enable flash attention globally.' }, - { - flag: '--vae-tiling', - description: 'Use tiled VAE decoding to reduce VRAM usage.', - }, - { - flag: '--diffusion-fa', - description: 'Enable flash attention for the diffusion model only.', - }, - { flag: '--mmap', description: 'Memory-map model weights from disk.' }, - { - flag: '--offload-to-cpu', - description: 'Keep more weights in RAM to reduce VRAM pressure.', - }, - { flag: '--clip-on-cpu', description: 'Run CLIP on CPU for low-VRAM setups.' }, - { flag: '--vae-on-cpu', description: 'Run VAE on CPU for low-VRAM setups.' }, - { - flag: '--control-net-cpu', - description: 'Run ControlNet on CPU when ControlNet is used.', - }, - { flag: '--rng cuda', description: 'Prefer CUDA RNG on NVIDIA systems.' }, - { - flag: '--sampler-rng cuda', - description: 'Use CUDA RNG specifically for the sampler.', - }, - ], + title: translate('ui.settings.engine.sdcpp_flags.title', 'Manual sd.cpp flags'), + subtitle: translate( + 'ui.settings.engine.sdcpp_flags.subtitle', + 'Startup flags appended to sd-server.', + ), + items: buildEngineExtraArgDocs( + 'ui.settings.engine.sdcpp_flag', + SDCPP_EXTRA_ARG_DOCS, + translate, + ), }; } @@ -410,6 +473,27 @@ export function getEngineExtraArgDocs(appId: string): EngineExtraArgDocs { }; } +function buildEngineExtraArgDocs( + prefix: string, + docs: EngineExtraArgDocSource[], + translate: ExtraArgsTranslate, +): EngineExtraArgDoc[] { + return [...docs] + .sort((left, right) => left.flag.localeCompare(right.flag, 'en', { sensitivity: 'base' })) + .map((doc) => ({ + flag: doc.flag, + description: translate(`${prefix}.${getEngineExtraArgKey(doc.flag)}`, doc.fallback), + })); +} + +function getEngineExtraArgKey(flag: string): string { + return flag + .toLowerCase() + .replace(/^--/, '') + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, ''); +} + export function getEngineRecommendedExtraArgs( appId: string, context: EngineRecommendedExtraArgsContext = {}, @@ -418,20 +502,7 @@ export function getEngineRecommendedExtraArgs( return ['--flash-attn']; } - const existing = new Set([ - ...(context.config?.extra_args ?? []), - ...(context.currentGroups ?? []), - ]); - const cpuPreferenceFlags = ['--offload-to-cpu', '--clip-on-cpu', '--vae-on-cpu']; - const prefersCpuOrLowVram = - context.config?.compute_mode === 'cpu' || - cpuPreferenceFlags.some((flag) => existing.has(flag)); - - if (prefersCpuOrLowVram) { - return ['--mmap', '--vae-tiling', '--offload-to-cpu', '--clip-on-cpu', '--vae-on-cpu']; - } - - const recommended = ['--diffusion-fa', '--fa', '--mmap']; + const recommended = ['--diffusion-fa', '--mmap']; if (isHighResolutionImageGeneration(appId, context.settings)) { recommended.push('--vae-tiling'); } @@ -488,7 +559,7 @@ export function formatEngineFieldSaveValue( ): string | number | string[] | null { if (key === 'extra_args') { if (typeof value === 'string') { - return value.trim() ? value.trim().split(/\s+/) : []; + return tokenizeEngineExtraArgs(value); } return []; } @@ -496,6 +567,47 @@ export function formatEngineFieldSaveValue( return value; } +export function tokenizeEngineExtraArgs(raw: string): string[] { + const tokens: string[] = []; + let current = ''; + let quote: '"' | "'" | null = null; + + for (const char of raw) { + if ((char === '"' || char === "'") && (quote === null || quote === char)) { + quote = quote === null ? char : null; + continue; + } + + if (quote === null && /\s/.test(char)) { + if (current !== '') { + tokens.push(current); + current = ''; + } + continue; + } + + current += char; + } + + if (current !== '') { + tokens.push(current); + } + + return tokens; +} + +function formatEngineExtraArgToken(token: string): string { + if (token === '') { + return '""'; + } + + if (!/\s|["']/.test(token)) { + return token; + } + + return `"${token.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; +} + function setInitialEngineConfigValue( input: EngineInputElement, key: string, @@ -523,10 +635,10 @@ function setInitialEngineSettingsValue( input: EngineInputElement, key: string, defaultValue: number | string | undefined, - settings: Record, + settings: Record, ): void { const value = settings[key]; - if (value !== undefined) { + if (value !== undefined && value !== null && !(value === 'null' && defaultValue === '')) { input.value = String(value); return; } diff --git a/src/features/settings/ui/ModuleSettingsEngineHtmlBuilder.ts b/src/features/settings/ui/ModuleSettingsEngineHtmlBuilder.ts index f672ce27..9f3cf85e 100644 --- a/src/features/settings/ui/ModuleSettingsEngineHtmlBuilder.ts +++ b/src/features/settings/ui/ModuleSettingsEngineHtmlBuilder.ts @@ -50,16 +50,6 @@ export class ModuleSettingsEngineHtmlBuilder { private _buildTextRuntimeSections(appId: string): string { return ` -
-
-
-

${this.escapeHtml( - this._translate('ui.settings.engine.compute_mode', 'Compute Device'), - )}

-
-
-
-
@@ -90,8 +80,8 @@ export class ModuleSettingsEngineHtmlBuilder {

${this.escapeHtml( this._translate( - 'ui.settings.engine.generation_presets', - 'Generation Presets', + 'ui.settings.engine.generation_settings', + 'Generation Settings', ), )}

diff --git a/src/features/settings/ui/ModuleSettingsEngineInfoPopover.ts b/src/features/settings/ui/ModuleSettingsEngineInfoPopover.ts index 12e3112d..2dafcacc 100644 --- a/src/features/settings/ui/ModuleSettingsEngineInfoPopover.ts +++ b/src/features/settings/ui/ModuleSettingsEngineInfoPopover.ts @@ -38,7 +38,7 @@ export type EngineInfoPopoverHandle = { export function createEngineInfoPopover(deps: EngineInfoPopoverDeps): EngineInfoPopoverHandle { const { anchor, appId, runtime } = deps; - const docs = getEngineExtraArgDocs(appId); + const docs = getEngineExtraArgDocs(appId, deps.translate); const popover = document.createElement('div'); popover.className = 'local-engine-args-popover'; popover.dataset['appId'] = appId; @@ -64,13 +64,6 @@ export function createEngineInfoPopover(deps: EngineInfoPopoverDeps): EngineInfo ); actions.appendChild(recommendedBtn); - const addAllBtn = document.createElement('button'); - addAllBtn.type = 'button'; - addAllBtn.className = 'local-engine-args-copy-all'; - addAllBtn.dataset['action'] = 'add-all'; - addAllBtn.textContent = deps.translate('ui.settings.engine.extra_args.add_all', 'Add all'); - actions.appendChild(addAllBtn); - const list = document.createElement('div'); list.className = 'local-engine-args-list'; @@ -105,6 +98,7 @@ export function createEngineInfoPopover(deps: EngineInfoPopoverDeps): EngineInfo }); popover.append(title, subtitle, actions, list); + const appendFlags = (flags: string[]) => { const added = deps.appendExtraArgs(appId, flags); if (flags.length > 1) { @@ -146,12 +140,6 @@ export function createEngineInfoPopover(deps: EngineInfoPopoverDeps): EngineInfo return; } - const addAllAction = target.closest('[data-action="add-all"]'); - if (addAllAction instanceof HTMLButtonElement) { - appendFlags(docs.items.map((item) => item.flag)); - return; - } - const addRecommendedAction = target.closest( '[data-action="add-recommended"]', ); @@ -198,13 +186,14 @@ export function createEngineInfoPopover(deps: EngineInfoPopoverDeps): EngineInfo const availableWidth = modalRect.width; const edgeGap = Math.max(16, Math.min(32, Math.round(availableWidth * 0.015))); const gap = Math.max(14, Math.min(20, Math.round(availableWidth * 0.008))); - const panelWidth = Math.max(300, Math.min(328, Math.round(availableWidth * 0.18))); + const panelWidth = Math.max(380, Math.min(460, Math.round(availableWidth * 0.24))); modal?.style.setProperty('--app-modal-edge-gap', `${edgeGap}px`); modal?.style.setProperty('--app-modal-popover-width', `${panelWidth}px`); modal?.style.setProperty('--app-modal-popover-spacing', `${gap}px`); }; updatePosition(); + modal?.classList.add('popover-open'); popover.style.opacity = '0'; popover.style.transition = 'opacity 0.22s cubic-bezier(0.22, 1, 0.36, 1)'; runtime.requestAnimationFrame(() => { @@ -229,14 +218,17 @@ export function createEngineInfoPopover(deps: EngineInfoPopoverDeps): EngineInfo globalThis.clearTimeout(settlePositionTimer); popover.classList.add('closing'); + modal?.classList.remove('popover-open'); + modal?.classList.add('popover-closing'); globalThis.setTimeout(() => { + modal?.classList.remove('popover-closing'); modal?.style.removeProperty('--app-modal-edge-gap'); modal?.style.removeProperty('--app-modal-popover-width'); modal?.style.removeProperty('--app-modal-popover-spacing'); popover.remove(); deps.onClose(); - }, 280); + }, 190); }; const handleDocumentClick = (event: MouseEvent) => { diff --git a/src/features/settings/ui/ModuleSettingsEngineRenderFlow.ts b/src/features/settings/ui/ModuleSettingsEngineRenderFlow.ts index 94e810a2..e37fbef0 100644 --- a/src/features/settings/ui/ModuleSettingsEngineRenderFlow.ts +++ b/src/features/settings/ui/ModuleSettingsEngineRenderFlow.ts @@ -12,7 +12,11 @@ type ModuleSettingsEngineRenderFlowDeps = { appId: string, config: EngineConfig | null, ) => void; - renderPerformanceModeFieldRow: (container: HTMLElement, appId: string) => void; + renderModelProfiles: ( + container: HTMLElement, + appId: string, + config: EngineConfig | null, + ) => void; renderFieldRow: ( container: HTMLElement, options: EngineFieldDefinition & { @@ -47,7 +51,6 @@ type ModuleSettingsEngineRenderOptions = { modelPlaceholder: string, isImage: boolean, ) => EngineFieldDefinition; - getComputeModeField: (translate: TranslateFn) => EngineFieldDefinition; getImageExtraArgsField: (translate: TranslateFn) => EngineFieldDefinition; }; @@ -76,7 +79,6 @@ export class ModuleSettingsEngineRenderFlow { modelPlaceholder, translate: options.translate, getCoreModelField: options.getCoreModelField, - getComputeModeField: options.getComputeModeField, getImageExtraArgsField: options.getImageExtraArgsField, }); @@ -108,7 +110,6 @@ export class ModuleSettingsEngineRenderFlow { modelPlaceholder: string; translate: TranslateFn; getCoreModelField: ModuleSettingsEngineRenderOptions['getCoreModelField']; - getComputeModeField: ModuleSettingsEngineRenderOptions['getComputeModeField']; getImageExtraArgsField: ModuleSettingsEngineRenderOptions['getImageExtraArgsField']; }): void { const coreField = options.getCoreModelField( @@ -125,16 +126,7 @@ export class ModuleSettingsEngineRenderFlow { appId: options.appId, config: options.config, }); - - const coreControls = document.createElement('div'); - coreControls.className = 'local-engine-core-controls'; - this._deps.renderFieldRow(coreControls, { - ...options.getComputeModeField(options.translate), - appId: options.appId, - config: options.config, - }); - this._deps.renderPerformanceModeFieldRow(coreControls, options.appId); - options.container.appendChild(coreControls); + this._deps.renderModelProfiles(options.container, options.appId, options.config); this._deps.renderFieldRow(options.container, { ...options.getImageExtraArgsField(options.translate), @@ -213,7 +205,6 @@ export class ModuleSettingsEngineRenderFlow { getTextFields: ModuleSettingsEngineRenderOptions['getTextFields']; }): void { const fieldTargets: Record = { - compute_mode: `#local-engine-compute-${options.appId}`, context_size: `#local-engine-context-${options.appId}`, llamacpp_system_prompt: `#local-engine-system-prompt-${options.appId}`, }; diff --git a/src/features/settings/ui/ModuleSettingsEngineRenderer.test.ts b/src/features/settings/ui/ModuleSettingsEngineRenderer.test.ts index b27ae45a..16a046c7 100644 --- a/src/features/settings/ui/ModuleSettingsEngineRenderer.test.ts +++ b/src/features/settings/ui/ModuleSettingsEngineRenderer.test.ts @@ -5,12 +5,12 @@ import { ModuleSettingsEngineRenderer } from './ModuleSettingsEngineRenderer'; import { ModuleSettingsEngineFieldController } from './ModuleSettingsEngineFieldController'; import { createEngineExtraArgsField, + getEngineExtraArgDocs, getEngineModelFileFilters, getEngineModelFileName, formatEngineFieldSaveValue, ModuleSettingsEngineInputFactory, parseEngineFieldValue, - renderEnginePerformanceModeField, setupInitialEngineFieldValue, } from './ModuleSettingsEngineFieldSupport'; import { ModuleSettingsEngineHtmlBuilder } from './ModuleSettingsEngineHtmlBuilder'; @@ -164,7 +164,7 @@ describe('ModuleSettingsEngineRenderer', () => { {} as never, ); - expect(imageHtml).toContain('t:ui.settings.engine.generation_presets:Generation Presets'); + expect(imageHtml).toContain('t:ui.settings.engine.generation_settings:Generation Settings'); expect(imageHtml).not.toContain('Auto download package'); expect(imageHtml).toContain( 't:ui.settings.engine.config_unavailable:Engine config unavailable (Tauri not connected)', @@ -223,55 +223,22 @@ describe('ModuleSettingsEngineRenderer', () => { expect(document.querySelectorAll('.local-engine-select-menu')).toHaveLength(0); }); - it('should render compute mode as a segmented control without a dropdown overlay', () => { - const { renderer } = createRendererHarness(); - const container = document.createElement('div'); - const config = { - engine_id: 'llamacpp', - compute_mode: 'gpu', - context_size: 4096, - model_path: null, - extra_args: [], - }; - - renderer._fieldRowRenderer.render(container, { - label: 'Compute Device', - key: 'compute_mode', - type: 'select', - isEngineConfig: true, - options: ['gpu', 'cpu'], - optionLabels: { gpu: 'GPU', cpu: 'CPU' }, - defaultValue: 'gpu', - appId: 'llamacpp', - config, - }); - - expect(container.querySelector('.local-engine-segmented-control')).toBeInstanceOf( - HTMLDivElement, - ); - expect(document.querySelector('.local-engine-select-menu')).toBeNull(); - - const cpuButton = container.querySelector( - '.local-engine-segmented-option[data-value="cpu"]', - ) as HTMLButtonElement; - cpuButton.click(); - - expect(config.compute_mode).toBe('cpu'); - expect(cpuButton.classList.contains('is-selected')).toBe(true); - }); - it('should localize extra args field labels and actions', () => { const control = createEngineExtraArgsField((key, fallback) => `t:${key}:${fallback}`); - const hiddenInput = control.root.querySelector('.local-engine-tags-value'); - expect(hiddenInput).toBeInstanceOf(HTMLInputElement); + const input = control.root.querySelector('.local-engine-extra-args-input'); + const draft = control.root.querySelector('.local-engine-extra-args-draft'); + expect(input).toBeInstanceOf(HTMLInputElement); + expect(draft).toBeInstanceOf(HTMLInputElement); + expect((draft as HTMLInputElement).placeholder).toBe( + 't:ui.settings.engine.extra_args.placeholder:Add startup flags', + ); control.setGroups(['--ctx-size 4096']); - const chip = control.root.querySelector('.local-engine-tag-chip'); - expect(chip).toBeInstanceOf(HTMLButtonElement); - expect((chip as HTMLButtonElement).title).toBe( - 't:ui.settings.engine.extra_args.remove:Remove', + expect((input as HTMLInputElement).value).toBe('--ctx-size 4096'); + expect(control.root.querySelector('.local-engine-extra-arg-chip')?.textContent).toContain( + '--ctx-size 4096', ); }); @@ -295,7 +262,7 @@ describe('ModuleSettingsEngineRenderer', () => { const popover = document.querySelector('.local-engine-args-popover') as HTMLElement; expect(popover.textContent).toContain('Manual llama.cpp flags'); - (popover.querySelector('.local-engine-args-copy-all') as HTMLButtonElement).click(); + (popover.querySelector('.local-engine-args-recommended') as HTMLButtonElement).click(); expect(showToast).toHaveBeenCalled(); const firstItem = popover.querySelector('.local-engine-args-item') as HTMLElement; @@ -336,7 +303,26 @@ describe('ModuleSettingsEngineRenderer', () => { const popover = document.querySelector('.local-engine-args-popover') as HTMLElement; (popover.querySelector('.local-engine-args-recommended') as HTMLButtonElement).click(); - expect(control.getGroups()).toEqual(['--diffusion-fa', '--fa', '--mmap', '--vae-tiling']); + expect(control.getGroups()).toEqual(['--diffusion-fa', '--mmap', '--vae-tiling']); + }); + + it('should expose official stable-diffusion.cpp startup flag names', () => { + const docs = getEngineExtraArgDocs('sdcpp'); + const flags = docs.items.map((item) => item.flag); + + expect(flags).toContain('--clip_l path'); + expect(flags).toContain('--clip_g path'); + expect(flags).toContain('--clip_vision path'); + expect(flags).toContain('--init-img path'); + expect(flags).toContain('--pm-style-strength 20'); + expect(flags).toContain('--vae-tile-size 32x32'); + expect(flags).toContain('--vae-tile-overlap 0.5'); + expect(flags).toContain('--vae-relative-tile-size 0.5x0.5'); + expect(flags).toContain('--timestep-shift 250'); + expect(flags).not.toContain('--clip-l path'); + expect(flags).not.toContain('--init-image path'); + expect(flags).not.toContain('--style-ratio 20'); + expect(flags).not.toContain('--schedule-shift 3'); }); it('should create text fields and parse values correctly', () => { @@ -381,6 +367,17 @@ describe('ModuleSettingsEngineRenderer', () => { '--threads', '8', ]); + expect( + formatEngineFieldSaveValue( + 'extra_args', + String.raw`--clip_l "C:\My Models\clip.safetensors" --vae C:\vae.sft`, + ), + ).toEqual([ + '--clip_l', + String.raw`C:\My Models\clip.safetensors`, + '--vae', + String.raw`C:\vae.sft`, + ]); }); it('should hydrate initial values from config aliases and defaults', () => { @@ -483,32 +480,6 @@ describe('ModuleSettingsEngineRenderer', () => { ); }); - it('should localize performance mode title and state', () => { - const debouncedSave = vi.fn(); - const container = document.createElement('div'); - - renderEnginePerformanceModeField( - container, - 'sdcpp', - {}, - (key, fallback) => `t:${key}:${fallback}`, - debouncedSave, - ); - - const label = container.querySelector('.local-engine-field-label'); - const status = container.querySelector('.local-engine-perf-status'); - const checkbox = container.querySelector( - 'input[type="checkbox"]', - ) as HTMLInputElement | null; - - expect(label?.textContent).toBe('t:ui.settings.engine.performance_mode:Performance Mode'); - expect(status?.textContent).toBe('t:ui.common.disabled:Disabled'); - - checkbox?.click(); - expect(status?.textContent).toBe('t:ui.common.enabled:Enabled'); - expect(debouncedSave).toHaveBeenCalledWith('sdcpp_performance_mode', true); - }); - it('should allow both gguf and safetensors for image engines', async () => { vi.mocked(open).mockResolvedValue('C:\\Models\\sd.gguf'); const { fieldController } = createFieldControllerHarness(); diff --git a/src/features/settings/ui/ModuleSettingsEngineRenderer.ts b/src/features/settings/ui/ModuleSettingsEngineRenderer.ts index a89c8c6e..3fd38c66 100644 --- a/src/features/settings/ui/ModuleSettingsEngineRenderer.ts +++ b/src/features/settings/ui/ModuleSettingsEngineRenderer.ts @@ -25,7 +25,6 @@ import { getEngineModelFileFilters, getEngineModelFileName, ModuleSettingsEngineInputFactory, - renderEnginePerformanceModeField, syncEnginePromptTextareaHeights, type EngineExtraArgsControl, } from './ModuleSettingsEngineFieldSupport'; @@ -57,6 +56,29 @@ type EngineFieldControlResult = { extraArgsControl: ExtraArgsControl | null; }; +type LocalEngineModelProfile = { + id: string; + name: string; + modelPath: string; + extraArgs: string[]; + generationSettings?: Record; +}; + +const IMAGE_GENERATION_PRESET_SETTING_SUFFIXES = [ + 'positive_prompt', + 'negative_prompt', + 'width', + 'height', + 'steps', + 'cfg_scale', + 'denoising_strength', + 'sampler', + 'scheduler', + 'seed', + 'clip_skip', + 'batch_size', +] as const; + const ENGINE_HTML_SANITIZE_OPTIONS: Parameters[1] = { ALLOW_DATA_ATTR: true, ALLOWED_TAGS: [ @@ -120,6 +142,7 @@ function createEngineInfoPopoverRuntime(): EngineInfoPopoverRuntime { export class ModuleSettingsEngineRenderer { private readonly _extraArgsControls = new Map(); + private readonly _customSelectControls = new Map(); private readonly _fieldCatalog = new ModuleSettingsEngineFieldCatalog(); private readonly _fieldController: ModuleSettingsEngineFieldController; private readonly _fieldRowRenderer: ModuleSettingsEngineFieldRowRenderer; @@ -155,13 +178,19 @@ export class ModuleSettingsEngineRenderer { >[0] { return { getSettings: () => - this._deps.service.getSettings() as Record, + this._deps.service.getSettings() as Record< + string, + string | number | null | undefined + >, setConfig: async (config) => { try { await this._deps.engineConfigService.setConfig(config); this._deps.notifySettingsChanged(); } catch (error) { - this._deps.tracer.error('[ModuleSettingsEngineRenderer] setConfig failed:', error); + this._deps.tracer.error( + '[ModuleSettingsEngineRenderer] setConfig failed:', + error, + ); throw error; } }, @@ -210,8 +239,8 @@ export class ModuleSettingsEngineRenderer { renderFieldDefinitions: (container, definitions, appId, config) => { this._renderFieldDefinitions(container, definitions, appId, config); }, - renderPerformanceModeFieldRow: (container, appId) => { - this._renderPerformanceModeFieldRow(container, appId); + renderModelProfiles: (container, appId, config) => { + this._renderModelProfiles(container, appId, config); }, renderFieldRow: (container, options) => { this._fieldRowRenderer.render(container, options); @@ -229,6 +258,7 @@ export class ModuleSettingsEngineRenderer { public reset(): void { this._closeEngineInfoPopover(); this._extraArgsControls.clear(); + this._customSelectControls.clear(); } public async render(container: HTMLElement, app: IApp): Promise { @@ -248,7 +278,6 @@ export class ModuleSettingsEngineRenderer { getTextFields: (translate) => this._fieldCatalog.buildTextEngineFields(translate), getCoreModelField: (translate, modelPlaceholder, isImage) => this._fieldCatalog.buildCoreModelField(translate, modelPlaceholder, isImage), - getComputeModeField: (translate) => this._fieldCatalog.buildComputeModeField(translate), getImageExtraArgsField: (translate) => this._fieldCatalog.buildImageExtraArgsField(translate), }); @@ -294,9 +323,6 @@ export class ModuleSettingsEngineRenderer { options: EngineFieldControlOptions, ): EngineFieldControlResult { if (options.type === 'select') { - if (options.key === 'compute_mode') { - return this._createComputeModeControl(options); - } return this._createSelectFieldControl(options); } @@ -317,6 +343,10 @@ export class ModuleSettingsEngineRenderer { options: EngineFieldControlOptions, ): EngineFieldControlResult { const customSelect = createEngineCustomSelectField(this._runtime, options); + this._customSelectControls.set( + this._getFieldControlKey(options.appId, options.key), + customSelect, + ); return { input: customSelect.root, engineInput: customSelect.input, @@ -325,51 +355,8 @@ export class ModuleSettingsEngineRenderer { }; } - private _createComputeModeControl( - options: EngineFieldControlOptions, - ): EngineFieldControlResult { - const root = document.createElement('div'); - root.className = 'local-engine-segmented-control'; - - const hiddenInput = document.createElement('input'); - hiddenInput.type = 'hidden'; - - const buttons = (options.options ?? ['gpu', 'cpu']).map((value) => { - const button = document.createElement('button'); - button.type = 'button'; - button.className = 'local-engine-segmented-option'; - button.dataset['value'] = value; - button.textContent = options.optionLabels?.[value] ?? value.toUpperCase(); - button.addEventListener('click', () => { - hiddenInput.value = value; - syncDisplay(); - hiddenInput.dispatchEvent(new Event('change', { bubbles: true })); - }); - return button; - }); - - const syncDisplay = () => { - const currentValue = hiddenInput.value || String(options.defaultValue ?? 'gpu'); - buttons.forEach((button) => { - const selected = button.dataset['value'] === currentValue; - button.classList.toggle('is-selected', selected); - button.setAttribute('aria-pressed', String(selected)); - }); - }; - - root.append(hiddenInput, ...buttons); - - return { - input: root, - engineInput: hiddenInput, - customSelect: { - input: hiddenInput, - root, - syncDisplay, - destroy: () => {}, - }, - extraArgsControl: null, - }; + private _getFieldControlKey(appId: string, key: string): string { + return `${appId}:${key}`; } private _createExtraArgsFieldControl(appId: string): EngineFieldControlResult { @@ -389,16 +376,280 @@ export class ModuleSettingsEngineRenderer { return { input, engineInput: input, customSelect: null, extraArgsControl: null }; } - private _renderPerformanceModeFieldRow(container: HTMLElement, appId: string): void { - renderEnginePerformanceModeField( - container, + private _renderModelProfiles( + container: HTMLElement, + appId: string, + config: EngineConfig | null, + ): void { + const section = document.createElement('div'); + section.className = 'local-engine-model-profiles'; + + const header = document.createElement('div'); + header.className = 'settings-card-header-center local-engine-section-header'; + const title = document.createElement('h3'); + title.textContent = this._translate( + 'ui.settings.engine.generation_presets', + 'Generation Presets', + ); + header.appendChild(title); + + const grid = document.createElement('div'); + grid.className = 'ai-models-grid local-engine-profile-grid'; + + const profiles = this._getModelProfiles(appId); + profiles.forEach((profile) => { + grid.appendChild(this._createModelProfileCard(appId, profile, config)); + }); + grid.appendChild(this._createSaveModelProfileCard(appId, config)); + + section.append(header, grid); + container.appendChild(section); + } + + private _createModelProfileCard( + appId: string, + profile: LocalEngineModelProfile, + config: EngineConfig | null, + ): HTMLDivElement { + const card = document.createElement('div'); + const selected = config?.model_path === profile.modelPath; + card.className = `ai-model-card local-engine-profile-card${selected ? ' selected' : ''}`; + card.tabIndex = 0; + card.role = 'option'; + card.setAttribute('aria-selected', String(selected)); + + const copy = document.createElement('div'); + copy.className = 'ai-model-card-copy'; + + const name = document.createElement('div'); + name.className = 'model-name'; + name.textContent = profile.name; + + const remove = document.createElement('button'); + remove.type = 'button'; + remove.className = 'ai-model-card-remove ai-model-card-action'; + remove.textContent = this._translate('ui.settings.custom_model_remove_button', 'Delete'); + remove.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + this._deleteModelProfile(appId, profile.id); + card.remove(); + }); + + const apply = () => this._applyModelProfile(appId, profile, config, card); + card.addEventListener('click', apply); + card.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + apply(); + } + }); + + copy.append(name); + card.append(copy, remove); + return card; + } + + private _createSaveModelProfileCard( + appId: string, + config: EngineConfig | null, + ): HTMLDivElement { + const card = document.createElement('div'); + card.className = 'ai-model-card ai-model-card--composer local-engine-profile-card'; + card.role = 'button'; + card.tabIndex = 0; + + const name = document.createElement('div'); + name.className = 'model-name'; + name.textContent = this._translate('ui.settings.engine.profile_save', 'Save Current'); + + const desc = document.createElement('div'); + desc.className = 'model-desc'; + desc.textContent = this._translate( + 'ui.settings.engine.profile_save_desc', + 'Store model, generation settings, and startup flags.', + ); + + const saveButton = document.createElement('button'); + saveButton.type = 'button'; + saveButton.className = 'ai-check-btn ai-custom-model-save-btn'; + saveButton.textContent = this._translate('ui.settings.engine.profile_save_button', 'Save'); + + const save = () => this._saveCurrentModelProfile(appId, config, card); + saveButton.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + save(); + }); + card.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + save(); + } + }); + + card.append(name, desc, saveButton); + return card; + } + + private _getModelProfiles(appId: string): LocalEngineModelProfile[] { + const raw = this._deps.service.getSettings()[`${appId}_model_profiles`]; + if (typeof raw !== 'string' || raw.trim() === '') { + return []; + } + + try { + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } + + return parsed + .filter((item): item is LocalEngineModelProfile => { + if (typeof item !== 'object' || item === null) return false; + const candidate = item as Record; + return ( + typeof candidate['id'] === 'string' && + typeof candidate['name'] === 'string' && + typeof candidate['modelPath'] === 'string' && + Array.isArray(candidate['extraArgs']) && + candidate['extraArgs'].every((arg) => typeof arg === 'string') && + (candidate['generationSettings'] === undefined || + (typeof candidate['generationSettings'] === 'object' && + candidate['generationSettings'] !== null)) + ); + }) + .slice(0, 8); + } catch { + return []; + } + } + + private _saveModelProfiles(appId: string, profiles: LocalEngineModelProfile[]): void { + this._deps.debouncedSave(`${appId}_model_profiles`, JSON.stringify(profiles.slice(0, 8))); + this._deps.notifySettingsChanged(); + this._deps.showSaveIndicator(); + } + + private _saveCurrentModelProfile( + appId: string, + config: EngineConfig | null, + card: HTMLElement, + ): void { + const modelPath = config?.model_path?.trim() ?? ''; + if (modelPath === '') { + this._context.showToast( + this._translate( + 'ui.settings.engine.profile_select_model_first', + 'Select a model first', + ), + 'info', + ); + return; + } + + const profiles = this._getModelProfiles(appId).filter( + (profile) => profile.modelPath !== modelPath, + ); + const profile: LocalEngineModelProfile = { + id: `profile-${Date.now()}`, + name: this._getModelFileName(modelPath), + modelPath, + extraArgs: this._extraArgsControls.get(appId)?.getGroups() ?? config?.extra_args ?? [], + generationSettings: this._readGenerationPresetSettings(appId), + }; + profiles.unshift(profile); + this._saveModelProfiles(appId, profiles); + const grid = card.closest('.local-engine-profile-grid'); + grid?.insertBefore(this._createModelProfileCard(appId, profile, config), card); + } + + private _deleteModelProfile(appId: string, profileId: string): void { + this._saveModelProfiles( appId, - this._deps.service.getSettings() as Record, - (key, fallback) => this._translate(key, fallback), - (key, value) => this._deps.debouncedSave(key, value), + this._getModelProfiles(appId).filter((profile) => profile.id !== profileId), + ); + } + + private _applyModelProfile( + appId: string, + profile: LocalEngineModelProfile, + config: EngineConfig | null, + card: HTMLElement, + ): void { + if (config === null) { + return; + } + + config.model_path = profile.modelPath; + config.extra_args = [...profile.extraArgs]; + this._applyGenerationPresetSettings(appId, profile.generationSettings ?? {}); + void this._deps.engineConfigService + .setConfig(config) + .then(() => { + this._deps.notifySettingsChanged(); + this._deps.showSaveIndicator(); + }) + .catch((error: unknown) => { + this._deps.tracer.error('[ModuleSettingsUI] Failed to apply model profile', error); + }); + + const root = card.closest('.local-engine-config'); + const modelInput = root?.querySelector( + '.local-engine-field-row--model-path input', + ); + if (modelInput !== null && modelInput !== undefined) { + modelInput.dataset['fullPath'] = profile.modelPath; + modelInput.value = this._getModelFileName(profile.modelPath); + modelInput.title = profile.modelPath; + } + this._extraArgsControls.get(appId)?.setGroups(profile.extraArgs, { emit: false }); + + root?.querySelectorAll('.local-engine-profile-card').forEach((node) => { + node.classList.toggle('selected', node === card); + node.setAttribute('aria-selected', String(node === card)); + }); + } + + private _readGenerationPresetSettings(appId: string): Record { + const settings = this._deps.service.getSettings(); + return Object.fromEntries( + IMAGE_GENERATION_PRESET_SETTING_SUFFIXES.map((suffix) => { + const key = `${appId}_${suffix}`; + const value = settings[key]; + return [key, typeof value === 'string' || typeof value === 'number' ? value : null]; + }), ); } + private _applyGenerationPresetSettings( + appId: string, + generationSettings: Record, + ): void { + Object.entries(generationSettings).forEach(([key, value]) => { + this._deps.debouncedSave(key, value); + this._syncGenerationSettingInput(appId, key, value); + }); + } + + private _syncGenerationSettingInput( + appId: string, + key: string, + value: string | number | null, + ): void { + const root = document.querySelector('.local-engine-config'); + const keyClass = key.replaceAll('_', '-'); + const input = root?.querySelector( + `.local-engine-field-row--${keyClass} input, .local-engine-field-row--${keyClass} textarea`, + ); + if (input === null || input === undefined) { + return; + } + + input.value = value === null ? '' : String(value); + this._customSelectControls.get(this._getFieldControlKey(appId, key))?.syncDisplay(); + } + private _appendExtraArgs(appId: string, groups: string[]): number { const control = this._extraArgsControls.get(appId); if (control === undefined) return 0; diff --git a/src/features/settings/ui/SettingsUI.test.ts b/src/features/settings/ui/SettingsUI.test.ts index 089f2a1f..fdf3557b 100644 --- a/src/features/settings/ui/SettingsUI.test.ts +++ b/src/features/settings/ui/SettingsUI.test.ts @@ -127,7 +127,7 @@ describe('ModuleSettingsUI lifecycle', () => { return privateUI; } - it('should render compute mode, context size, and system prompt for llamacpp local settings', async () => { + it('should render context size and system prompt for llamacpp local settings', async () => { const ui = createSettingsUI(); const container = document.createElement('div'); ( @@ -149,7 +149,7 @@ describe('ModuleSettingsUI lifecycle', () => { const labels = Array.from(container.querySelectorAll('.local-engine-field-label')).map( (node) => node.textContent, ); - expect(labels).toContain('t:ui.settings.engine.compute_mode:Compute Device'); + expect(labels).not.toContain('t:ui.settings.engine.compute_mode:Compute Device'); expect(labels).toContain('t:ui.settings.engine.context_size:Context Window'); expect(labels).toContain('t:ui.settings.engine.system_prompt:System Prompt'); }); diff --git a/src/shared/services/ModulePlatformService.ts b/src/shared/services/ModulePlatformService.ts index 131987b6..3dac203f 100644 --- a/src/shared/services/ModulePlatformService.ts +++ b/src/shared/services/ModulePlatformService.ts @@ -3,6 +3,7 @@ import type { DownloadModuleOutcome, ModuleService } from './ModuleService'; import type { AIBridge } from '@/features/ai/services/AIBridge'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; import { isApiApp } from '@/shared/utils/moduleTypeUtils'; +import { isAiCategory } from '@/shared/utils/moduleCategoryPolicy'; type ModulePlatformLogger = Pick; export type ModuleRuntimeStatus = 'running' | 'stopped' | string; @@ -54,7 +55,7 @@ export class ModulePlatformService { * Stops a running module or provider. * @param app The module to stop. */ - public async stop(app: IApp): Promise { + public async stop(app: IApp, category?: string): Promise { const isApi = this._isApiModule(app); const { activeProviderId, isRunning } = this._aiBridge.getState(); @@ -77,6 +78,11 @@ export class ModulePlatformService { return true; } + if (category !== undefined && isAiCategory(category)) { + await this._stopAiEngineSlot(category); + return true; + } + this._tracer.info(`[ModulePlatformService] Requesting stop for local module: ${app.id}`); return await this._moduleService.control(app.id, 'stop'); } @@ -152,4 +158,10 @@ export class ModulePlatformService { private _isApiModule(app: IApp): boolean { return isApiApp(app); } + + private async _stopAiEngineSlot(category: string): Promise { + const capability = category === 'ai_image' ? 'image' : 'text'; + this._tracer.info(`[ModulePlatformService] Force stopping AI engine slot: ${capability}`); + await this._aiBridge.stopEngineSlot(capability); + } } diff --git a/src/shared/shell/AppUI.test.ts b/src/shared/shell/AppUI.test.ts index 7de79d84..72d8c174 100644 --- a/src/shared/shell/AppUI.test.ts +++ b/src/shared/shell/AppUI.test.ts @@ -350,8 +350,8 @@ describe('AppUI lifecycle', () => { appUI.clearModuleCard('ai_image'); expect(card.classList.contains('empty')).toBe(true); expect(stopAiProviderMock).toHaveBeenCalled(); - expect(platformServiceMock.stop).toHaveBeenCalledWith(textApp); - expect(platformServiceMock.stop).toHaveBeenCalledWith(imageApp); + expect(platformServiceMock.stop).toHaveBeenCalledWith(textApp, 'ai_text'); + expect(platformServiceMock.stop).toHaveBeenCalledWith(imageApp, 'ai_image'); }); it('should stop the current services module when clearing its card', () => { @@ -374,7 +374,7 @@ describe('AppUI lifecycle', () => { appUI.clearModuleCard('services'); - expect(platformServiceMock.stop).toHaveBeenCalledWith(serviceApp); + expect(platformServiceMock.stop).toHaveBeenCalledWith(serviceApp, 'services'); }); it('should not stop the previous services module when only switching selected cards', () => { @@ -580,7 +580,7 @@ describe('AppUI lifecycle', () => { closeBadge.click(); expect(uiStateMocks.removeSelectedModule).toHaveBeenCalledWith('ai_image'); - expect(platformServiceMock.stop).toHaveBeenCalledWith(imageApp); + expect(platformServiceMock.stop).toHaveBeenCalledWith(imageApp, 'ai_image'); expect(card.dataset['currentModule']).toBe('text-model'); }); @@ -631,13 +631,13 @@ describe('AppUI lifecycle', () => { privateAppUI._performSelectionAction('services', serviceApp); expect(updateSelectionSpy).toHaveBeenCalledWith('svc'); expect(uiStateMocks.setSelectedModule).toHaveBeenCalled(); - expect(launchAppMock).not.toHaveBeenCalled(); + expect(launchAppMock).toHaveBeenCalledWith('services', serviceApp); privateAppUI._performSelectionAction('services', serviceApp); expect(updateSelectionSpy).toHaveBeenLastCalledWith(null); }); - it('should keep selected service cards as indicators without probing runtime status', async () => { + it('should show selected service runtime status after launch', async () => { appUI = createAppUI(); document.body.innerHTML = `
@@ -662,9 +662,9 @@ describe('AppUI lifecycle', () => { await new Promise((resolve) => globalThis.setTimeout(resolve, 0)); const card = document.getElementById('services-module-card') as HTMLElement; - expect(platformServiceMock.getStatus).not.toHaveBeenCalled(); - expect(card.classList.contains('module-running')).toBe(false); - expect(card.dataset['runtimeStatus']).toBe('stopped'); + expect(platformServiceMock.getStatus).toHaveBeenCalledWith(serviceApp); + expect(card.classList.contains('module-running')).toBe(true); + expect(card.dataset['runtimeStatus']).toBe('running'); }); it('should show a placeholder toast instead of selecting or downloading coming-soon modules', async () => { @@ -694,7 +694,7 @@ describe('AppUI lifecycle', () => { expect(launchAppMock).not.toHaveBeenCalled(); }); - it('should not launch or stop services during quick reselection', async () => { + it('should stop stale service launch during quick reselection', async () => { appUI = createAppUI(); let releaseFirstLaunch!: () => void; @@ -741,9 +741,11 @@ describe('AppUI lifecycle', () => { releaseFirstLaunch(); await Promise.resolve(); await Promise.resolve(); + await new Promise((resolve) => globalThis.setTimeout(resolve, 0)); - expect(launchApp).not.toHaveBeenCalled(); - expect(platformServiceMock.stop).not.toHaveBeenCalled(); + expect(launchApp).toHaveBeenCalledWith('services', firstApp); + expect(launchApp).toHaveBeenCalledWith('services', secondApp); + expect(platformServiceMock.stop).toHaveBeenCalledWith(firstApp); }); it('should not reopen modal after delete if app selection was already closed', async () => { diff --git a/src/shared/shell/AppUI.ts b/src/shared/shell/AppUI.ts index 833b1997..acc21179 100644 --- a/src/shared/shell/AppUI.ts +++ b/src/shared/shell/AppUI.ts @@ -96,6 +96,7 @@ export class AppUI { removeSelectedModule: (category) => { this._deps.uiState.removeSelectedModule(category); }, + stopSelectedApp: (app, category) => this._platformService.stop(app, category), openAppSelection: (category) => this.openAppSelection(category), updateMultiSlotBadge: () => this._updateMultiSlotBadge(), activateAiSlot: (category, app) => { @@ -173,7 +174,7 @@ export class AppUI { updateModalSelection: (appId) => this._modalManager.updateSelection(appId), bumpLaunchSelectionVersion: (category) => this._moduleLifecycle.bumpLaunchSelectionVersion(category), - stopSelectedApp: (app) => this._platformService.stop(app), + stopSelectedApp: (app, category) => this._platformService.stop(app, category), launchSelectedApp: (category, app, launchSelectionVersion, launchApp) => this._moduleLifecycle.launchSelectedApp( category, @@ -355,7 +356,7 @@ export class AppUI { return; } - void this._platformService.stop(app).catch((err: unknown) => { + void this._platformService.stop(app, category).catch((err: unknown) => { this._deps.tracer.warn( `[AppUI] Failed to stop removed module ${app.id}: ${String(err)}`, ); diff --git a/src/shared/shell/WindowUI.test.ts b/src/shared/shell/WindowUI.test.ts index e3ba665f..eb732957 100644 --- a/src/shared/shell/WindowUI.test.ts +++ b/src/shared/shell/WindowUI.test.ts @@ -292,6 +292,14 @@ describe('WindowUI lifecycle', () => { document.dispatchEvent(blockedShortcut); expect(blockedShortcut.defaultPrevented).toBe(true); + const tabEvent = new KeyboardEvent('keydown', { + key: 'Tab', + bubbles: true, + cancelable: true, + }); + document.dispatchEvent(tabEvent); + expect(tabEvent.defaultPrevented).toBe(true); + const plainContext = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, diff --git a/src/shared/shell/WindowUiInteractionController.ts b/src/shared/shell/WindowUiInteractionController.ts index 7fa9552c..ed2561e9 100644 --- a/src/shared/shell/WindowUiInteractionController.ts +++ b/src/shared/shell/WindowUiInteractionController.ts @@ -155,6 +155,16 @@ export class WindowUiInteractionController { } private _handleKeydown(e: KeyboardEvent): void { + if (e.key === 'Tab') { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + return; + } + if ( e.key === 'F12' || (e.ctrlKey && diff --git a/src/shared/shell/ui/AppUiDashboardSupport.ts b/src/shared/shell/ui/AppUiDashboardSupport.ts index fecd271f..5426fd86 100644 --- a/src/shared/shell/ui/AppUiDashboardSupport.ts +++ b/src/shared/shell/ui/AppUiDashboardSupport.ts @@ -24,6 +24,7 @@ type AppUiDashboardControllerDeps = { openModuleSettings: (app: IApp) => void; clearModuleCard: (category: string) => void; removeSelectedModule: (category: string) => void; + stopSelectedApp: (app: IApp, category: string) => Promise; openAppSelection: (category: string) => void; updateMultiSlotBadge: () => void; activateAiSlot: (category: 'ai_text' | 'ai_image', app: IApp) => void; @@ -193,9 +194,13 @@ export class AppUiDashboardSupport { mouseEvent.preventDefault(); mouseEvent.stopPropagation(); const category = this._deps.selectionState.resolveCategoryFromCard(card); + const app = this._deps.selectionState.get(category); this._deps.tracer.info(`[AppUI] Middle-click close for ${category}`); this._deps.clearModuleCard(category); this._deps.removeSelectedModule(category); + if (app !== undefined) { + void this._deps.stopSelectedApp(app, category); + } } else if (mouseEvent.button === 2) { mouseEvent.stopPropagation(); mouseEvent.stopImmediatePropagation(); @@ -290,6 +295,7 @@ export class AppUiDashboardSupport { const closeButton = this._deps.chrome.createCloseBadge(category, (resolvedCategory) => { this._deps.clearModuleCard(resolvedCategory); this._deps.removeSelectedModule(resolvedCategory); + void this._deps.stopSelectedApp(app, category); }); card.appendChild(closeButton); } diff --git a/src/shared/shell/ui/AppUiSelectionFlow.test.ts b/src/shared/shell/ui/AppUiSelectionFlow.test.ts index b25eb770..2c20585d 100644 --- a/src/shared/shell/ui/AppUiSelectionFlow.test.ts +++ b/src/shared/shell/ui/AppUiSelectionFlow.test.ts @@ -32,7 +32,7 @@ describe('AppUiSelectionFlow', () => { }); }); - it('selects integration module and persists it without launching', () => { + it('selects integration module, persists it, and launches it', () => { const app = { id: 'svc', name: 'Service', type: 'local', icon: 'S', desc: 'Desc' } as IApp; getSelectedApp.mockReturnValue(undefined); @@ -41,11 +41,10 @@ describe('AppUiSelectionFlow', () => { expect(updateModuleCard).toHaveBeenCalledWith('services', app); expect(updateModalSelection).toHaveBeenCalledWith('svc'); expect(setSelectedModule).toHaveBeenCalled(); - expect(launchSelectedApp).not.toHaveBeenCalled(); - expect(launchApp).not.toHaveBeenCalled(); + expect(launchSelectedApp).toHaveBeenCalledWith('services', app, 1, launchApp); }); - it('selects AI module without launching it immediately', () => { + it('selects AI module and launches it through lifecycle guard', () => { const app = { id: 'text-model', name: 'Text Model', type: 'api', icon: 'T' } as IApp; getSelectedApp.mockReturnValue(undefined); @@ -54,8 +53,7 @@ describe('AppUiSelectionFlow', () => { expect(updateModuleCard).toHaveBeenCalledWith('ai_text', app); expect(updateModalSelection).toHaveBeenCalledWith('text-model'); expect(setSelectedModule).toHaveBeenCalled(); - expect(launchSelectedApp).not.toHaveBeenCalled(); - expect(launchApp).not.toHaveBeenCalled(); + expect(launchSelectedApp).toHaveBeenCalledWith('ai_text', app, 1, launchApp); }); it('does not launch an existing selection when switching visible state', () => { @@ -75,6 +73,6 @@ describe('AppUiSelectionFlow', () => { expect(clearModuleCard).toHaveBeenCalledWith('services'); expect(updateModalSelection).toHaveBeenCalledWith(null); expect(removeSelectedModule).toHaveBeenCalledWith('services'); - expect(stopSelectedApp).toHaveBeenCalledWith(app); + expect(stopSelectedApp).toHaveBeenCalledWith(app, 'services'); }); }); diff --git a/src/shared/shell/ui/AppUiSelectionFlow.ts b/src/shared/shell/ui/AppUiSelectionFlow.ts index ede95b1e..ab3909c0 100644 --- a/src/shared/shell/ui/AppUiSelectionFlow.ts +++ b/src/shared/shell/ui/AppUiSelectionFlow.ts @@ -7,7 +7,7 @@ type AppUiSelectionFlowDeps = { updateModuleCard: (category: string, app: IApp) => void; updateModalSelection: (appId: string | null) => void; bumpLaunchSelectionVersion: (category: string) => number; - stopSelectedApp: (app: IApp) => Promise; + stopSelectedApp: (app: IApp, category: string) => Promise; launchSelectedApp: ( category: string, app: IApp, @@ -29,14 +29,23 @@ export class AppUiSelectionFlow { this._deps.clearModuleCard(category); this._deps.removeSelectedModule(category); this._deps.updateModalSelection(null); - void this._deps.stopSelectedApp(app); + void this._deps.stopSelectedApp(app, category); return; } - this._deps.bumpLaunchSelectionVersion(category); + const launchSelectionVersion = this._deps.bumpLaunchSelectionVersion(category); this._deps.updateModuleCard(category, app); this._deps.updateModalSelection(app.id); this._persistSelectedModule(category, app); + + if (this._deps.launchApp !== undefined) { + void this._deps.launchSelectedApp( + category, + app, + launchSelectionVersion, + this._deps.launchApp, + ); + } } private _persistSelectedModule(category: string, app: IApp): void { diff --git a/src/shared/shell/ui/ModalFocusTrapHelper.test.ts b/src/shared/shell/ui/ModalFocusTrapHelper.test.ts index eb27d19a..72508ee3 100644 --- a/src/shared/shell/ui/ModalFocusTrapHelper.test.ts +++ b/src/shared/shell/ui/ModalFocusTrapHelper.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { ModalFocusTrapHelper } from './ModalFocusTrapHelper'; describe('ModalFocusTrapHelper', () => { - it('should keep focus inside modal and close on overlay click', () => { + it('should disable tab focus movement and close on overlay click', () => { const onClose = vi.fn(); const helper = new ModalFocusTrapHelper(onClose); const modal = document.createElement('dialog'); @@ -18,17 +18,17 @@ describe('ModalFocusTrapHelper', () => { helper.attach(modal); helper.focusFirstElement(modal); - expect(document.activeElement).toBe(first); + expect(document.activeElement).toBe(document.body); last.focus(); - helper.handleModalKeydown( - new KeyboardEvent('keydown', { - key: 'Tab', - bubbles: true, - cancelable: true, - }), - ); - expect(document.activeElement).toBe(first); + const tabEvent = new KeyboardEvent('keydown', { + key: 'Tab', + bubbles: true, + cancelable: true, + }); + helper.handleModalKeydown(tabEvent); + expect(tabEvent.defaultPrevented).toBe(true); + expect(document.activeElement).toBe(document.body); outside.focus(); const focusInEvent = new FocusEvent('focusin', { bubbles: true }); @@ -37,7 +37,7 @@ describe('ModalFocusTrapHelper', () => { value: outside, }); helper.handleFocusIn(focusInEvent); - expect(document.activeElement).toBe(first); + expect(document.activeElement).toBe(document.body); helper.attachOverlayOnly(modal); modal.dispatchEvent(new MouseEvent('click', { bubbles: true })); @@ -47,7 +47,7 @@ describe('ModalFocusTrapHelper', () => { document.body.innerHTML = ''; }); - it('should prefer content controls over close buttons for initial modal focus', () => { + it('should not focus modal controls on open', () => { const helper = new ModalFocusTrapHelper(vi.fn()); const modal = document.createElement('dialog'); const closeButton = document.createElement('button'); @@ -61,7 +61,7 @@ describe('ModalFocusTrapHelper', () => { helper.focusFirstElement(modal); - expect(document.activeElement).toBe(actionButton); + expect(document.activeElement).toBe(document.body); helper.detach(); document.body.innerHTML = ''; diff --git a/src/shared/shell/ui/ModalFocusTrapHelper.ts b/src/shared/shell/ui/ModalFocusTrapHelper.ts index 7125236d..9cef491d 100644 --- a/src/shared/shell/ui/ModalFocusTrapHelper.ts +++ b/src/shared/shell/ui/ModalFocusTrapHelper.ts @@ -16,37 +16,13 @@ export class ModalFocusTrapHelper { }; public readonly handleModalKeydown = (event: KeyboardEvent): void => { - if (event.key !== 'Tab' || this._modal === null) { + if (event.key !== 'Tab') { return; } - const focusable = this.getFocusableElements(this._modal); - if (focusable.length === 0) { - event.preventDefault(); - this._modal.focus(); - return; - } - - const first = focusable[0]; - const last = focusable[focusable.length - 1]; - if (first === undefined || last === undefined) { - event.preventDefault(); - this._modal.focus(); - return; - } - - const active = document.activeElement; - if (event.shiftKey) { - if (active === first || active === this._modal) { - event.preventDefault(); - last.focus(); - } - return; - } - - if (active === last) { - event.preventDefault(); - first.focus(); + event.preventDefault(); + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); } }; @@ -60,7 +36,9 @@ export class ModalFocusTrapHelper { return; } - this.focusFirstElement(this._modal); + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } }; public attach(modal: HTMLDialogElement): void { @@ -88,15 +66,11 @@ export class ModalFocusTrapHelper { } public focusFirstElement(modal: HTMLDialogElement): void { - const focusable = this.getFocusableElements(modal); - const preferred = focusable.find((element) => !element.classList.contains('app-close-btn')); - const target = preferred ?? focusable[0]; - if (target !== undefined) { - target.focus(); - return; + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); } - modal.focus(); + modal.blur(); } public getFocusableElements(root: HTMLElement): HTMLElement[] { diff --git a/src/shared/shell/ui/ModalManager.test.ts b/src/shared/shell/ui/ModalManager.test.ts index a6bdbbab..efa7a306 100644 --- a/src/shared/shell/ui/ModalManager.test.ts +++ b/src/shared/shell/ui/ModalManager.test.ts @@ -280,7 +280,7 @@ describe('ModalManager lifecycle', () => { expect(navigation.pushBackAction).toHaveBeenCalledTimes(1); }); - it('keeps keyboard focus inside the app selection modal', () => { + it('disables tab focus movement inside the app selection modal', () => { modalManager = createManager(); const outsideButton = document.createElement('button'); outsideButton.textContent = 'Outside'; @@ -298,16 +298,17 @@ describe('ModalManager lifecycle', () => { '#app-modal-list .module-selection-card-actions button', ) as HTMLButtonElement; - expect(document.activeElement).toBe(modalAction); + expect(document.activeElement).toBe(document.body); modalAction.focus(); - modal.dispatchEvent( - new KeyboardEvent('keydown', { - key: 'Tab', - bubbles: true, - }), - ); - expect(document.activeElement).toBe(closeButton); + const tabEvent = new KeyboardEvent('keydown', { + key: 'Tab', + bubbles: true, + cancelable: true, + }); + modal.dispatchEvent(tabEvent); + expect(tabEvent.defaultPrevented).toBe(true); + expect(document.activeElement).toBe(document.body); outsideButton.focus(); const focusInEvent = new FocusEvent('focusin', { @@ -318,7 +319,8 @@ describe('ModalManager lifecycle', () => { value: outsideButton, }); document.dispatchEvent(focusInEvent); - expect(document.activeElement).toBe(modalAction); + expect(document.activeElement).toBe(document.body); + expect(closeButton).toBeInstanceOf(HTMLButtonElement); }); it('should preserve current selection when replaying modal back action', () => { diff --git a/src/shared/types/bindings.ts b/src/shared/types/bindings.ts index ac4bf1c1..a1a50264 100644 --- a/src/shared/types/bindings.ts +++ b/src/shared/types/bindings.ts @@ -27,6 +27,8 @@ export const commands = { getSystemLanguage: () => typedError(__TAURI_INVOKE("get_system_language")), // Retrieves log entries since a given timestamp getLogs: (since: number) => typedError(__TAURI_INVOKE("get_logs", { since })), + // Retrieves log entries for a single console view since a given timestamp. + getConsoleLogs: (viewId: string, since: number) => typedError(__TAURI_INVOKE("get_console_logs", { viewId, since })), // Returns aggregated console metadata for views and runtime statuses. getConsoleOverview: () => typedError(__TAURI_INVOKE("get_console_overview")), // Clears all stored log entries @@ -157,8 +159,6 @@ export const commands = { countTokens: (text: string, model: string | null) => typedError(__TAURI_INVOKE("count_tokens", { text, model })), // Sends an image generation request to the connected AI provider generateImage: (request: ImageGenerationRequest) => typedError(__TAURI_INVOKE("generate_image", { request })), - // Starts image generation as a detached backend task and restores the window on completion. - generateImageBackground: (request: ImageGenerationRequest) => typedError(__TAURI_INVOKE("generate_image_background", { request })), // Cancels the current image generation request for the selected provider. cancelImageGeneration: (provider: string) => typedError(__TAURI_INVOKE("cancel_image_generation", { provider })), // Returns the latest image-generation preview when the local image engine writes one. diff --git a/src/styles/base/document-reset.css b/src/styles/base/document-reset.css index 708838f1..13084b5d 100644 --- a/src/styles/base/document-reset.css +++ b/src/styles/base/document-reset.css @@ -77,9 +77,8 @@ button:focus-visible, a[href]:focus-visible, summary:focus-visible, [tabindex]:not([tabindex='-1']):focus-visible { - outline: 1px solid rgba(var(--primary-raw), 0.58); - outline-offset: 2px; - box-shadow: 0 0 0 3px rgba(var(--primary-raw), 0.14); + outline: none; + box-shadow: none; } /* Globals moved to top */ diff --git a/src/styles/features/ai-module-settings.css b/src/styles/features/ai-module-settings.css index 2b98407e..13fb857b 100644 --- a/src/styles/features/ai-module-settings.css +++ b/src/styles/features/ai-module-settings.css @@ -1002,7 +1002,7 @@ .local-engine-info-btn:hover { background: rgba(255, 255, 255, 0.06); - border-color: rgba(179, 112, 255, 0.28); + border-color: rgba(255, 255, 255, 0.12); color: var(--text-primary); transform: translateY(-1px); } @@ -1014,15 +1014,19 @@ z-index: 5200; display: flex; flex-direction: column; - gap: 0.72rem; - padding: 0.85rem; - border-radius: 14px; - border: 1px solid rgba(255, 255, 255, 0.075); - background: rgba(18, 18, 26, 0.96); - box-shadow: 0 14px 36px rgba(0, 0, 0, 0.32); - width: calc(var(--app-modal-popover-width, 328px) / var(--module-settings-zoom, 1)); + gap: 0.78rem; + padding: 1rem; + border-radius: 16px; + border: 1px solid var(--glass-border); + background: var(--glass-surface); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.035), + 0 18px 34px rgba(0, 0, 0, 0.18); + width: calc(var(--app-modal-popover-width, 390px) / var(--module-settings-zoom, 1)); + min-width: calc(360px / var(--module-settings-zoom, 1)); + height: calc((100% - (var(--app-modal-edge-gap, 16px) * 2)) / var(--module-settings-zoom, 1)); max-height: calc( - min(460px, 100% - (var(--app-modal-edge-gap, 16px) * 2)) / var(--module-settings-zoom, 1) + (100% - (var(--app-modal-edge-gap, 16px) * 2)) / var(--module-settings-zoom, 1) ); box-sizing: border-box; overflow: hidden; @@ -1031,6 +1035,7 @@ animation: engineArgsPanelIn 0.24s cubic-bezier(0.22, 1, 0.36, 1); transition: width 0.3s cubic-bezier(0.22, 1, 0.36, 1), + height 0.3s cubic-bezier(0.22, 1, 0.36, 1), max-height 0.3s cubic-bezier(0.22, 1, 0.36, 1), right 0.3s cubic-bezier(0.22, 1, 0.36, 1), top 0.3s cubic-bezier(0.22, 1, 0.36, 1), @@ -1041,7 +1046,10 @@ .local-engine-args-popover.closing { opacity: 0 !important; pointer-events: none; - transform: translateX(18px) scale(var(--module-settings-zoom, 1)); + transform: translateX(14px) scale(var(--module-settings-zoom, 1)); + transition: + transform 0.18s cubic-bezier(0.55, 0, 1, 0.45), + opacity 0.18s ease; } @keyframes engineArgsPanelIn { @@ -1057,36 +1065,43 @@ } .local-engine-args-popover-title { - font-size: 0.95rem; + font-size: 1.05rem; line-height: 1.15; color: var(--text-primary); font-weight: 700; - text-align: left; + text-align: center; } .local-engine-args-popover-subtitle { margin: 0; color: var(--text-secondary); - font-size: 0.78rem; - line-height: 1.32; + font-size: 0.82rem; + line-height: 1.36; + text-align: center; } .local-engine-args-popover-actions { - display: flex; - gap: 0.5rem; - justify-content: flex-end; + display: grid; + grid-template-columns: 1fr; + gap: 0.55rem; + padding: 0; + border: none; + background: transparent; } -.local-engine-args-recommended, -.local-engine-args-copy-all { - border: 1px solid rgba(255, 255, 255, 0.06); - background: rgba(255, 255, 255, 0.035); - color: var(--text-primary); - border-radius: 12px; +.local-engine-args-recommended { + border: 1px solid var(--premium-purple-border); + background: var(--premium-purple-bg); + color: #ffffff; + border-radius: 14px; font-family: var(--app-font-family); font-size: 0.82rem; font-weight: 700; cursor: pointer; + min-height: 2.5rem; + white-space: normal; + overflow-wrap: anywhere; + text-align: center; transition: background 0.18s ease, border-color 0.18s ease, @@ -1095,19 +1110,14 @@ .local-engine-args-recommended { padding: 0.48rem 0.76rem; - border-color: rgba(255, 255, 255, 0.1); - background: rgba(255, 255, 255, 0.06); } -.local-engine-args-copy-all { - padding: 0.48rem 0.76rem; -} - -.local-engine-args-recommended:hover, -.local-engine-args-copy-all:hover { - background: rgba(255, 255, 255, 0.08); - border-color: rgba(255, 255, 255, 0.14); - transform: translateY(-1px); +.local-engine-args-recommended:hover { + background: var(--premium-purple-bg); + border-color: var(--premium-purple-border); + color: #ffffff; + filter: brightness(1.04); + transform: none; } .local-engine-args-list { @@ -1115,7 +1125,7 @@ flex-direction: column; gap: 0.5rem; overflow-y: auto; - padding-right: 0.25rem; + padding-right: 0.32rem; flex: 1; min-height: 0; } @@ -1123,21 +1133,19 @@ .local-engine-args-item { display: block; align-items: start; - padding: 0.62rem 0.68rem; - border-radius: 10px; - background: rgba(255, 255, 255, 0.03); - border: 1px solid rgba(255, 255, 255, 0.045); + padding: 0.7rem 0.78rem; + border-radius: 12px; + background: rgba(255, 255, 255, 0.022); + border: 1px solid rgba(255, 255, 255, 0.038); cursor: pointer; transition: background 0.18s ease, - border-color 0.18s ease, - transform 0.18s ease; + border-color 0.18s ease; } .local-engine-args-item:hover { - background: rgba(255, 255, 255, 0.05); - border-color: rgba(255, 255, 255, 0.08); - transform: translateY(-1px); + background: rgba(255, 255, 255, 0.032); + border-color: rgba(255, 255, 255, 0.055); } .local-engine-args-item-meta { @@ -1151,7 +1159,8 @@ color: #fff; font-family: var(--app-font-family); font-size: 0.88rem; - word-break: break-word; + line-height: 1.18; + overflow-wrap: anywhere; } .local-engine-args-desc { @@ -1161,6 +1170,17 @@ line-height: 1.35; } +@media (max-width: 760px) { + .local-engine-args-popover { + width: calc((100vw - 2rem) / var(--module-settings-zoom, 1)); + min-width: 0; + } + + .local-engine-args-popover-actions { + grid-template-columns: 1fr; + } +} + .local-engine-field-hint { display: block; margin: 0 0 0.18rem; @@ -1220,105 +1240,111 @@ flex: 1; min-width: 0; display: flex; - flex-wrap: wrap; align-items: center; + flex-wrap: wrap; gap: 0.45rem; - border-radius: 14px; - padding: 0.16rem 0.18rem; + border-radius: 12px; + padding: 0.55rem; border: none; background: transparent; + cursor: text; } -.local-engine-tags-chips { - display: flex; +.local-engine-extra-args-input { + display: none; +} + +.local-engine-extra-args-chips { + display: inline-flex; + align-items: center; flex-wrap: wrap; - gap: 0.5rem; + gap: 0.42rem; min-width: 0; - padding: 0.15rem 0; - align-items: center; } -.local-engine-tag-chip { +.local-engine-extra-arg-chip { display: inline-flex; align-items: center; - gap: 0.5rem; - padding: 0.48rem 0.82rem; + max-width: 100%; + min-height: 32px; + border: 1px solid rgba(255, 255, 255, 0.048); border-radius: 10px; - border: none !important; - background: var(--primary) !important; - color: #fff !important; - font-family: var(--app-font-family); - font-size: 0.88rem; - font-weight: 700; - cursor: pointer; - transition: all 0.2s ease; + background: rgba(255, 255, 255, 0.024); + overflow: hidden; } -.local-engine-tag-chip:hover { - filter: brightness(1.12); - transform: translateY(-1px); +.local-engine-extra-arg-edit, +.local-engine-extra-arg-remove { + border: none; + background: transparent; + color: var(--text-primary); + font-family: var(--app-font-family); + font-weight: 700; } -.local-engine-tag-chip-label { +.local-engine-extra-arg-edit { + min-width: 0; + padding: 0.34rem 0.58rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: text; line-height: 1; } -.local-engine-tag-chip-remove { - opacity: 0.72; - font-size: 0.78rem; +.local-engine-extra-arg-remove { + order: -1; + width: 0; + min-width: 0; + padding: 0; + opacity: 0; + border-right: 1px solid rgba(255, 255, 255, 0.055); + border-radius: 9px 0 0 9px; + color: rgba(255, 255, 255, 0.68); + cursor: pointer; line-height: 1; + overflow: hidden; + pointer-events: none; + transition: + width 0.16s ease, + opacity 0.16s ease, + color 0.16s ease, + background 0.16s ease; } -.local-engine-performance-toggle { - width: 100%; - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.8rem; - min-height: 50px; - padding: 0.68rem 0.8rem; - border-radius: 12px; - border: 1px solid rgba(255, 255, 255, 0.06); - background: rgba(255, 255, 255, 0.03); - color: var(--text-primary); - font-family: var(--app-font-family); - cursor: pointer; - transition: - border-color 0.2s ease, - background 0.2s ease, - transform 0.18s ease, - filter 0.18s ease; +.local-engine-extra-arg-chip:hover .local-engine-extra-arg-remove, +.local-engine-extra-arg-chip:focus-within .local-engine-extra-arg-remove { + width: 30px; + opacity: 1; + pointer-events: auto; } -.local-engine-performance-toggle:hover { - background: rgba(255, 255, 255, 0.05); - border-color: rgba(255, 255, 255, 0.12); - transform: translateY(-1px); +.local-engine-extra-arg-remove:hover { + background: transparent; + color: #ffffff; } -.local-engine-performance-toggle.is-enabled, -.local-engine-performance-toggle.active { - background: rgba(255, 255, 255, 0.065); - border-color: rgba(255, 255, 255, 0.14); +.local-engine-extra-arg-chip:hover { + background: rgba(255, 255, 255, 0.038); } -.local-engine-performance-toggle-status { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 88px; - padding: 0.38rem 0.7rem; - border-radius: 12px; - background: rgba(255, 255, 255, 0.06); - color: var(--text-secondary); - font-size: 0.8rem; - font-weight: 700; +.local-engine-extra-args-draft { + width: auto; + flex: 1 1 180px; + min-width: 160px; + min-height: 32px; + border: none; + outline: none; + background: transparent; + color: var(--text-primary); + font-family: var(--app-font-family); + font-size: 0.92rem; + padding: 0.34rem 0.25rem; + box-sizing: border-box; } -.local-engine-performance-toggle.is-enabled .local-engine-performance-toggle-status, -.local-engine-performance-toggle.active .local-engine-performance-toggle-status { - background: rgba(0, 0, 0, 0.18); - color: #ffffff; +.local-engine-extra-args-draft::placeholder { + color: rgba(255, 255, 255, 0.2); } /* Combined with the other .local-engine-input--readonly below */ @@ -1347,6 +1373,20 @@ transition: all 0.2s ease; } +.local-engine-field-row--extra-args.full-width .local-engine-input-row { + background: transparent; + border: none; + padding: 0; + gap: 0.55rem; + overflow: hidden; +} + +.local-engine-field-row--extra-args.full-width .local-engine-input-row.focused { + background: transparent; + border-color: transparent; + box-shadow: none; +} + .local-engine-field-row.full-width .local-engine-input, .local-engine-split-row .local-engine-input { background: transparent; @@ -1389,7 +1429,80 @@ background: transparent; border: none; box-shadow: none; - padding: 0.12rem 0.16rem; + padding: 0; +} + +.local-engine-field-row--extra-args.full-width .local-engine-input-row { + min-height: 50px; + border-radius: 12px; + padding: 0.9rem 1rem; + background: var(--glass-surface); + border: 1px solid var(--glass-border); +} + +.local-engine-field-row--extra-args.full-width .local-engine-tags-editor { + min-height: 50px; + border: 1px solid rgba(255, 255, 255, 0.038); + border-radius: 12px; + background: rgba(255, 255, 255, 0.022); +} + +.local-engine-field-row--extra-args.full-width .local-engine-extra-args-input { + min-height: 50px; + padding-left: 0.65rem; + padding-right: 0.65rem; +} + +.local-engine-field-row--extra-args.full-width .local-engine-info-btn { + align-self: stretch; + width: 52px; + min-width: 52px; + min-height: 50px; + margin: 0; + border: 1px solid rgba(255, 255, 255, 0.038) !important; + border-radius: 12px; + background: rgba(255, 255, 255, 0.022) !important; + color: var(--text-secondary); + transform: none; + transition: + background 0.18s ease, + color 0.18s ease; +} + +.local-engine-field-row--extra-args.full-width .local-engine-info-btn:hover { + background: rgba(255, 255, 255, 0.032) !important; + color: var(--text-primary); + transform: none; +} + +.local-engine-field-row--extra-args.full-width .local-engine-info-btn:focus, +.local-engine-field-row--extra-args.full-width .local-engine-info-btn:focus-visible, +.local-engine-field-row--extra-args.full-width .local-engine-info-btn:active { + outline: none; + box-shadow: none !important; + border-color: rgba(255, 255, 255, 0.038) !important; + background: rgba(255, 255, 255, 0.022) !important; + color: var(--text-secondary); + transform: none; +} + +.local-engine-field-row--extra-args.full-width .local-engine-extra-args-input:focus { + outline: none; + box-shadow: none; +} + +.local-engine-field-row--extra-args.full-width .local-engine-tags-editor:focus-within { + border-color: rgba(255, 255, 255, 0.038); + box-shadow: none; +} + +.local-engine-field-row--extra-args.full-width .local-engine-extra-args-draft:focus, +.local-engine-field-row--extra-args.full-width .local-engine-extra-arg-edit:focus, +.local-engine-field-row--extra-args.full-width .local-engine-extra-arg-edit:focus-visible, +.local-engine-field-row--extra-args.full-width .local-engine-extra-arg-remove:focus, +.local-engine-field-row--extra-args.full-width .local-engine-extra-arg-remove:focus-visible { + outline: none; + box-shadow: none; } .local-engine-split-row { @@ -1399,17 +1512,6 @@ align-items: start; } -.local-engine-core-controls { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(220px, 0.8fr); - gap: 0.75rem; - align-items: end; -} - -.local-engine-core-controls .local-engine-field-row { - gap: 0.42rem; -} - .local-engine-input--readonly { overflow: hidden; text-overflow: ellipsis; @@ -1443,6 +1545,59 @@ transform: translateY(-1px); } +.local-engine-browse-btn:active, +.local-engine-browse-btn:focus, +.local-engine-browse-btn:focus-visible { + outline: none; + box-shadow: none !important; +} + +.local-engine-field-row--model-path .local-engine-input-row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 300px); + gap: 0.85rem; + padding: 0.9rem 1rem; + background: var(--glass-surface); + border: 1px solid var(--glass-border); + border-radius: 12px; +} + +.local-engine-field-row--model-path .local-engine-input { + min-height: 58px; + padding: 0.75rem 1rem; + border: 1px solid rgba(255, 255, 255, 0.038) !important; + border-radius: 14px; + background: rgba(255, 255, 255, 0.022) !important; +} + +.local-engine-field-row--model-path .local-engine-input:focus { + border-color: rgba(255, 255, 255, 0.055) !important; + background: rgba(255, 255, 255, 0.032) !important; +} + +.local-engine-field-row--model-path .local-engine-browse-btn { + width: 100%; + min-width: 0; + min-height: 58px; + border: 1px solid var(--premium-purple-border) !important; + border-radius: 14px; + background: var(--premium-purple-bg) !important; + box-shadow: var(--premium-purple-shadow) !important; + transition: + background 0.18s ease, + border-color 0.18s ease, + color 0.18s ease, + filter 0.18s ease; +} + +.local-engine-field-row--model-path .local-engine-browse-btn:hover, +.local-engine-field-row--model-path .local-engine-browse-btn:active, +.local-engine-field-row--model-path .local-engine-browse-btn:focus, +.local-engine-field-row--model-path .local-engine-browse-btn:focus-visible { + filter: brightness(1.04); + transform: none; +} + .local-engine-warning { color: var(--color-warning, #f59e0b); font-size: 0.84rem; @@ -1486,24 +1641,6 @@ border-radius: 0; } -.local-engine-segmented-option { - min-height: 58px; - padding: 0.75rem; - border: 1px solid rgba(255, 255, 255, 0.038); - border-radius: 14px; - background: rgba(255, 255, 255, 0.022); - color: var(--text-secondary); - font-family: var(--app-font-family); - font-size: 0.95rem; - font-weight: 700; - cursor: pointer; - transition: - background 0.18s ease, - border-color 0.18s ease, - color 0.18s ease, - filter 0.18s ease; -} - .local-engine-section--context .local-engine-input { min-height: 58px; text-align: center; @@ -1520,19 +1657,6 @@ border-radius: 14px; } -.local-engine-segmented-option:hover { - background: rgba(255, 255, 255, 0.032); - border-color: rgba(255, 255, 255, 0.055); - color: var(--text-primary); -} - -.local-engine-segmented-option.is-selected { - background: var(--premium-purple-bg); - border-color: var(--premium-purple-border); - color: #ffffff; - box-shadow: var(--premium-purple-shadow); -} - .local-engine-select { position: relative; width: 100%; @@ -1668,10 +1792,6 @@ } @media (max-width: 720px) { - .local-engine-core-controls { - grid-template-columns: 1fr; - } - .local-engine-field-grid { grid-template-columns: 1fr; } @@ -1684,3 +1804,147 @@ width: 100%; } } + +/* Local image engines reuse the same visual system as API provider settings. */ +.local-engine-section--core .local-engine-field-stack--tight { + gap: 0.85rem; +} + +.local-engine-field-row--model-path .local-engine-input-row, +.local-engine-field-row--extra-args.full-width .local-engine-input-row { + display: flex; + align-items: center; + width: 100%; + min-height: 58px; + gap: 0.5rem; + padding: 0 6px 0 14px; + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.04); + background: rgba(255, 255, 255, 0.022); + box-shadow: none; + overflow: hidden; +} + +.local-engine-field-row--extra-args.full-width .local-engine-input-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 42px; + align-items: start; + gap: 0.5rem; + padding: 0.52rem; +} + +.local-engine-field-row--model-path .local-engine-input-row.focused, +.local-engine-field-row--extra-args.full-width .local-engine-input-row.focused { + border-color: rgba(255, 255, 255, 0.04); + background: rgba(255, 255, 255, 0.022); + box-shadow: none; +} + +.local-engine-field-row--model-path .local-engine-input, +.local-engine-field-row--extra-args.full-width .local-engine-extra-args-input { + min-height: 58px; + padding: 0 0.55rem; + border: none !important; + background: transparent !important; +} + +.local-engine-field-row--model-path .local-engine-browse-btn { + width: auto; + min-width: 140px; + min-height: 42px; + height: 42px; + border-radius: var(--module-button-radius); + border: 1px solid var(--premium-purple-border) !important; + background: var(--premium-purple-bg) !important; + box-shadow: none !important; + transform: none !important; + filter: none !important; +} + +.local-engine-field-row--model-path .local-engine-browse-btn:hover, +.local-engine-field-row--model-path .local-engine-browse-btn:active, +.local-engine-field-row--model-path .local-engine-browse-btn:focus, +.local-engine-field-row--model-path .local-engine-browse-btn:focus-visible { + filter: none !important; + transform: none !important; + box-shadow: none !important; +} + +.local-engine-field-row--extra-args.full-width .local-engine-tags-editor { + min-height: 42px; + border: none; + background: transparent; + transition: none !important; + padding: 0; + gap: 0.42rem; +} + +.local-engine-field-row--extra-args.full-width .local-engine-input-row, +.local-engine-field-row--extra-args.full-width .local-engine-input-row:hover, +.local-engine-field-row--extra-args.full-width .local-engine-input-row:active, +.local-engine-field-row--extra-args.full-width .local-engine-input-row:focus-within, +.local-engine-field-row--extra-args.full-width .local-engine-input-row.focused { + border-color: rgba(255, 255, 255, 0.04) !important; + background: rgba(255, 255, 255, 0.022) !important; + box-shadow: none !important; + transform: none !important; + filter: none !important; + transition: none !important; +} + +.local-engine-field-row--extra-args.full-width .local-engine-tags-editor, +.local-engine-field-row--extra-args.full-width .local-engine-tags-editor:hover, +.local-engine-field-row--extra-args.full-width .local-engine-tags-editor:active, +.local-engine-field-row--extra-args.full-width .local-engine-tags-editor:focus-within, +.local-engine-field-row--extra-args.full-width .local-engine-extra-args-draft, +.local-engine-field-row--extra-args.full-width .local-engine-extra-args-draft:hover, +.local-engine-field-row--extra-args.full-width .local-engine-extra-args-draft:active, +.local-engine-field-row--extra-args.full-width .local-engine-extra-args-draft:focus, +.local-engine-field-row--extra-args.full-width .local-engine-extra-args-draft:focus-visible { + border: none !important; + outline: none !important; + background: transparent !important; + box-shadow: none !important; + transform: none !important; + filter: none !important; + transition: none !important; + appearance: none; + -webkit-appearance: none; +} + +.local-engine-field-row--extra-args.full-width .local-engine-info-btn { + width: 42px; + min-width: 42px; + align-self: center; + min-height: 42px; + height: 42px; + border-radius: var(--module-button-radius); + border: 1px solid rgba(255, 255, 255, 0.04) !important; + background: rgba(255, 255, 255, 0.035) !important; + box-shadow: none !important; + transform: none !important; +} + +.local-engine-field-row--extra-args.full-width .local-engine-info-btn:hover, +.local-engine-field-row--extra-args.full-width .local-engine-info-btn:active, +.local-engine-field-row--extra-args.full-width .local-engine-info-btn:focus, +.local-engine-field-row--extra-args.full-width .local-engine-info-btn:focus-visible { + background: rgba(255, 255, 255, 0.055) !important; + color: var(--text-primary); + transform: none !important; +} + +.local-engine-model-profiles { + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +.local-engine-profile-grid { + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 0.85rem; +} + +.local-engine-profile-card { + min-height: 112px; +} diff --git a/src/styles/features/chat-page.css b/src/styles/features/chat-page.css index 97625c4a..7c6ba5a7 100644 --- a/src/styles/features/chat-page.css +++ b/src/styles/features/chat-page.css @@ -17,6 +17,12 @@ align-items: flex-start; } +.chat-row--generated-image { + flex-direction: column; + gap: 0.55rem; + align-items: flex-start; +} + .chat-bubble { position: relative; max-width: 82%; @@ -405,8 +411,6 @@ .chat-generated-media.hidden, .chat-generated-caption.hidden, -.chat-generated-control.hidden, -.chat-generated-controls.hidden, .chat-generated-progress-summary.hidden { display: none !important; } @@ -414,8 +418,45 @@ .chat-image-generation { position: relative; gap: 0.46rem; - min-width: min(16rem, 100%); - padding: 0.68rem; + min-width: min(18rem, 100%); + max-width: min(100%, 32rem); + padding: 0.82rem 0.95rem; +} + +.chat-row--generated-image .chat-image-generation { + width: min(100%, 32rem); + max-width: min(100%, 32rem); + box-sizing: border-box; +} + +.chat-row--generated-image.is-complete { + gap: 0; +} + +.chat-row--generated-image.is-complete .chat-generated-media { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} + +.chat-row--generated-image.is-complete .chat-image-generation { + border-top: 0; + border-top-left-radius: 0; + border-top-right-radius: 0; + background: rgba(255, 255, 255, 0.022); + box-shadow: none; +} + +.chat-row--generated-image.is-complete .chat-image-generation.has-no-caption { + display: contents; + height: 0; + min-height: 0; + padding: 0; + border: 0; + background: transparent; +} + +.chat-row--generated-image.is-complete .chat-image-generation::before { + display: none; } .chat-image-generation.is-complete { @@ -423,29 +464,36 @@ } .chat-generated-media { - width: 100%; - min-height: min(56vw, 18rem); - max-height: min(60vh, 32rem); - aspect-ratio: 1 / 1; + position: relative; + width: auto; + max-width: min(100%, 32rem); + max-height: min(70vh, 42rem); + display: inline-flex; + align-items: center; + justify-content: center; + align-self: flex-start; overflow: hidden; border-radius: 14px; - background: - linear-gradient(135deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.012)), - rgba(255, 255, 255, 0.025); + background: transparent; +} + +.chat-generated-media .chat-message-actions { + top: calc(100% + 0.42rem); + left: 0; } .chat-generated-image { display: block; - width: 100%; - height: 100%; - max-height: none; + width: auto; + height: auto; + max-width: 100%; + max-height: min(70vh, 42rem); + margin-top: 0; background: rgba(255, 255, 255, 0.025); -} - -.chat-image-generation .chat-generated-media.hidden { - display: block !important; - visibility: hidden; - opacity: 0; + border: 0; + border-radius: 14px; + cursor: zoom-in; + object-fit: contain; } .chat-image-generation.chat-error .chat-generated-media.hidden, @@ -462,8 +510,7 @@ .chat-image-generation.is-complete .chat-generated-status, .chat-image-generation.is-complete .chat-generated-progress, -.chat-image-generation.is-complete .chat-generated-status-row, -.chat-image-generation.is-complete .chat-generated-controls { +.chat-image-generation.is-complete .chat-generated-status-row { display: none !important; } @@ -471,34 +518,38 @@ display: flex; align-items: center; justify-content: space-between; - gap: 0.8rem; + gap: 1rem; } .chat-generated-status { - min-width: 0; + flex: 1 1 auto; + min-width: max-content; color: var(--text-primary); - font-size: 0.78rem; + font-size: 0.82rem; font-weight: 600; line-height: 1.35; opacity: 0.92; - overflow: hidden; - text-overflow: ellipsis; white-space: nowrap; } .chat-generated-progress-summary { - flex: 0 0 auto; + flex: 0 1 auto; + min-width: 0; color: var(--text-muted); - font-size: 0.66rem; + font-size: 0.7rem; line-height: 1.2; font-variant-numeric: tabular-nums; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: right; } .chat-generated-progress { width: 100%; - height: 5px; + height: 6px; overflow: hidden; - border-radius: 4px; + border-radius: 999px; background: rgba(255, 255, 255, 0.055); } @@ -515,66 +566,6 @@ animation: chatGeneratedPulse 1.2s ease-in-out infinite alternate; } -.chat-generated-controls { - position: absolute; - top: 0.58rem; - right: 0.58rem; - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - opacity: 0; - pointer-events: none; - transform: translateY(-2px); - transition: - opacity 0.16s ease, - transform 0.16s ease; -} - -.chat-image-generation:hover .chat-generated-controls, -.chat-image-generation:focus-within .chat-generated-controls { - opacity: 1; - pointer-events: auto; - transform: translateY(0); -} - -.chat-generated-control { - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 28px; - padding: 0.28rem 0.58rem; - border: 1px solid rgba(255, 255, 255, 0.075); - border-radius: 7px; - background: rgba(20, 18, 26, 0.86); - color: var(--text-primary); - cursor: pointer; - font-family: var(--app-font-family); - font-size: 0.68rem; - box-shadow: 0 8px 22px rgba(0, 0, 0, 0.22); - transition: - background 0.18s ease, - border-color 0.18s ease, - color 0.18s ease, - transform 0.18s ease; -} - -.chat-generated-control:hover:not(:disabled) { - background: rgba(255, 255, 255, 0.065); - border-color: rgba(255, 255, 255, 0.12); - color: white; - transform: translateY(-1px); -} - -.chat-generated-control:disabled { - opacity: 0.55; - cursor: progress; -} - -.chat-generated-control.is-cancel:hover:not(:disabled) { - background: rgba(var(--danger-raw), 0.12); - border-color: rgba(var(--danger-raw), 0.26); -} - @keyframes chatGeneratedPulse { from { opacity: 0.75; @@ -612,6 +603,7 @@ justify-content: center; width: min(100%, 1400px); height: min(100%, 1000px); + cursor: zoom-out; } .chat-image-viewer-img { @@ -622,6 +614,55 @@ height: auto; border-radius: 20px; box-shadow: 0 28px 80px rgba(0, 0, 0, 0.45); + cursor: default; + transform-origin: center; + will-change: opacity, transform; +} + +.chat-image-viewer-img.is-opening { + animation: chatImageViewerOpen 0.18s ease both; +} + +.chat-image-viewer-img.is-entering-forward { + animation: chatImageViewerNext 0.18s ease both; +} + +.chat-image-viewer-img.is-entering-backward { + animation: chatImageViewerPrev 0.18s ease both; +} + +@keyframes chatImageViewerOpen { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes chatImageViewerNext { + from { + opacity: 0; + transform: translateX(1.25rem); + } + + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes chatImageViewerPrev { + from { + opacity: 0; + transform: translateX(-1.25rem); + } + + to { + opacity: 1; + transform: translateX(0); + } } .chat-image-viewer-close { @@ -660,6 +701,66 @@ transform: translateY(-1px); } +.chat-image-viewer-nav { + position: absolute; + top: 50%; + z-index: 2; + width: 52px; + height: 72px; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 14px; + background: rgba(23, 23, 30, 0.62); + color: rgba(255, 255, 255, 0.86); + cursor: pointer; + transform: translateY(-50%); + transition: + opacity 0.18s ease, + background 0.18s ease, + border-color 0.18s ease, + color 0.18s ease, + transform 0.18s ease; +} + +.chat-image-viewer-nav:hover { + background: rgba(34, 34, 44, 0.86); + border-color: rgba(255, 255, 255, 0.18); + color: #fff; +} + +.chat-image-viewer-prev { + left: max(1.25rem, env(safe-area-inset-left)); +} + +.chat-image-viewer-next { + right: max(1.25rem, env(safe-area-inset-right)); +} + +.chat-image-viewer-prev:hover { + transform: translateY(-50%) translateX(-2px); +} + +.chat-image-viewer-next:hover { + transform: translateY(-50%) translateX(2px); +} + +.chat-image-viewer-counter { + position: absolute; + left: 50%; + bottom: 1.25rem; + padding: 0.36rem 0.62rem; + border-radius: 999px; + background: rgba(23, 23, 30, 0.72); + color: rgba(255, 255, 255, 0.86); + font-size: 0.72rem; + font-variant-numeric: tabular-nums; + line-height: 1; + transform: translateX(-50%); + pointer-events: none; +} + /* --- CHAT PAGE --- */ #page-chat.active #chat-container { animation: chatContentRise 0.28s cubic-bezier(0.22, 1, 0.36, 1); @@ -1277,6 +1378,50 @@ 0 0 0 1px rgba(255, 255, 255, 0.035); } +.chat-attach-menu { + position: absolute; + left: 0.65rem; + bottom: calc(100% + 0.55rem); + z-index: 45; + display: flex; + flex-direction: column; + gap: 0.22rem; + min-width: 176px; + padding: 0.38rem; + border-radius: 8px; + background: rgba(var(--background-raw), 0.96); + border: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: 0 18px 42px rgba(0, 0, 0, 0.36); +} + +.chat-attach-menu-item { + width: 100%; + min-height: 38px; + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0 0.75rem; + border-radius: 6px; + color: rgba(255, 255, 255, 0.88); + background: transparent; + font: inherit; + text-align: left; + cursor: pointer; +} + +.chat-attach-menu-item .icon { + width: 18px; + height: 18px; + flex: 0 0 auto; +} + +.chat-attach-menu-item:hover, +.chat-attach-menu-item:focus-visible { + background: rgba(var(--primary-raw), 0.2); + color: #fff; + outline: none; +} + .chat-action-btn { background: transparent !important; width: 40px; @@ -2075,6 +2220,23 @@ padding: 0.82rem 0.9rem 0.72rem; } + .chat-row--generated-image .chat-image-generation, + .chat-row--generated-image:not(.is-complete) .chat-image-generation { + width: min(100%, 22rem); + } + + .chat-generated-status-row { + flex-direction: column; + align-items: flex-start; + gap: 0.34rem; + } + + .chat-generated-progress-summary { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + } + .chat-row { margin-bottom: 1.05rem; } @@ -2119,6 +2281,38 @@ height: 38px; } + .chat-image-viewer { + padding: 0.8rem; + } + + .chat-image-viewer-img { + max-width: 96vw; + max-height: 86vh; + border-radius: 14px; + } + + .chat-image-viewer-close { + top: 0.75rem; + right: 0.75rem; + width: 40px; + height: 40px; + border-radius: 12px; + } + + .chat-image-viewer-nav { + width: 42px; + height: 58px; + border-radius: 12px; + } + + .chat-image-viewer-prev { + left: 0.55rem; + } + + .chat-image-viewer-next { + right: 0.55rem; + } + .chat-textarea { font-size: 0.88rem; padding: 0.48rem 0.64rem 0.46rem; diff --git a/src/styles/features/module-selection-modal.css b/src/styles/features/module-selection-modal.css index 363fd72b..71bcf48a 100644 --- a/src/styles/features/module-selection-modal.css +++ b/src/styles/features/module-selection-modal.css @@ -224,13 +224,13 @@ body.snapping .modal-backdrop { .app-modal-tab:focus, .app-close-btn:focus { outline: none; + box-shadow: none; } .app-modal-tab:focus-visible, .app-close-btn:focus-visible { - outline: 1px solid rgba(var(--primary-raw), 0.58); - outline-offset: -3px; - box-shadow: 0 0 0 3px rgba(var(--primary-raw), 0.14); + outline: none; + box-shadow: none; } .app-close-btn { From ad4b731cf04bfe5796c9eb02e33a48256da15883 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Wed, 29 Apr 2026 17:11:53 +0300 Subject: [PATCH 008/126] fix: address review feedback and size check --- src-tauri/src/api/system/logs.rs | 7 ++++--- src-tauri/src/domain/ai/provider_http.rs | 5 ++++- src/app/CoreEntry.ts | 6 +++++- src/features/ai/services/AIBridge.ts | 4 ++++ .../ai/services/AIBridgeMessageController.ts | 18 ++++++++++++++++-- src/features/chat/chat.ts | 1 + .../controllers/ChatGenerationController.ts | 15 ++++++++++----- .../services/ChatActivationCoordinator.ts | 2 +- src/features/chat/ui/ChatImageController.ts | 2 -- src/features/chat/ui/ChatUI.test.ts | 6 +++--- .../console/services/ConsoleLogService.ts | 2 +- .../console/ui/ConsoleFilterControlHelper.ts | 1 + src/features/console/ui/ConsoleUI.ts | 15 ++++++++++++--- .../ui/ModuleSettingsEngineFieldSupport.ts | 14 +++++++++++++- src/scripts/check-size.js | 19 ++++++++++++++++++- 15 files changed, 93 insertions(+), 24 deletions(-) diff --git a/src-tauri/src/api/system/logs.rs b/src-tauri/src/api/system/logs.rs index e7e528f4..871a66d1 100644 --- a/src-tauri/src/api/system/logs.rs +++ b/src-tauri/src/api/system/logs.rs @@ -90,9 +90,10 @@ pub fn clear_logs() -> Result<(), AppError> { /// Clears log entries and files for a single console view. #[allow(clippy::needless_pass_by_value)] pub fn clear_console_logs(view_id: String) -> Result<(), AppError> { - let target = resolve_console_log_target(&view_id); - logs::clear_logs_for_view(&view_id); - clear_console_log_target(&view_id, &target)?; + let canonical_view_id = canonical_console_view_id(&view_id); + let target = resolve_console_log_target(&canonical_view_id); + logs::clear_logs_for_view(&canonical_view_id); + clear_console_log_target(&canonical_view_id, &target)?; Ok(()) } diff --git a/src-tauri/src/domain/ai/provider_http.rs b/src-tauri/src/domain/ai/provider_http.rs index e1eeba82..b4c14b57 100644 --- a/src-tauri/src/domain/ai/provider_http.rs +++ b/src-tauri/src/domain/ai/provider_http.rs @@ -37,6 +37,9 @@ pub(super) fn retry_delay(attempt: u32, status: StatusCode) -> std::time::Durati }; let backoff_multiplier = 2u64.saturating_pow(capped_attempt.saturating_sub(1)); let jitter_ms = rand::random_range(0..150u64); + let delay_ms = base_ms + .saturating_mul(backoff_multiplier) + .saturating_add(jitter_ms); - std::time::Duration::from_millis(base_ms * backoff_multiplier + jitter_ms) + std::time::Duration::from_millis(delay_ms) } diff --git a/src/app/CoreEntry.ts b/src/app/CoreEntry.ts index 63dfbbef..197a41a0 100644 --- a/src/app/CoreEntry.ts +++ b/src/app/CoreEntry.ts @@ -70,7 +70,11 @@ function bootCoreOnce(createCore: CoreFactory, tracer: EntryLogger): void { if (state.activeCoreInstance === coreInstance) { clearBootState(); } - coreInstance.destroy(); + try { + coreInstance.destroy(); + } catch (destroyError: unknown) { + tracer.error(`[Core] Destroy after boot failure failed: ${String(destroyError)}`); + } tracer.error(`[Core] Boot failed: ${String(error)}`); }); } catch (error: unknown) { diff --git a/src/features/ai/services/AIBridge.ts b/src/features/ai/services/AIBridge.ts index 48f91962..b28b4888 100644 --- a/src/features/ai/services/AIBridge.ts +++ b/src/features/ai/services/AIBridge.ts @@ -184,6 +184,10 @@ export class AIBridge implements IAIBridge { public async stopEngineSlot(capability: 'text' | 'image' | 'vision'): Promise { const providerId = this._manager.activeProviderId; await this._runtime.stopEngineSlot(this._context, capability); + if (this._manager.activeProviderId !== providerId) { + return; + } + if ( providerId !== null && ((capability === 'image' && diff --git a/src/features/ai/services/AIBridgeMessageController.ts b/src/features/ai/services/AIBridgeMessageController.ts index 9427a61e..30cebbbf 100644 --- a/src/features/ai/services/AIBridgeMessageController.ts +++ b/src/features/ai/services/AIBridgeMessageController.ts @@ -79,12 +79,12 @@ export class AIBridgeMessageController { public async prepareImagePrompt(text: string): Promise { if (this._deps.manager.activeProviderId === null) { - return this._handleMissingProvider('service'); + return this._silentMissingProviderResponse(); } await this._deps.manager.refreshActiveApiKey(); if (this._deps.manager.apiKey === null && this._deps.manager.isActive() === false) { - return this._handleMissingApiKey('service'); + return this._silentMissingApiKeyResponse(); } try { @@ -124,6 +124,20 @@ export class AIBridgeMessageController { } } + private _silentMissingProviderResponse(): IBridgeResponse { + return { + ok: false, + error: this._deps.translate('ui.ai.no_provider', 'No AI provider selected'), + }; + } + + private _silentMissingApiKeyResponse(): IBridgeResponse { + return { + ok: false, + error: this._deps.translate('ui.ai.missing_api_key', 'API key missing'), + }; + } + private async _sendImageMessage( providerId: string, text: string, diff --git a/src/features/chat/chat.ts b/src/features/chat/chat.ts index 5052db13..c9353435 100644 --- a/src/features/chat/chat.ts +++ b/src/features/chat/chat.ts @@ -601,6 +601,7 @@ export class ChatController { public async sendChat(): Promise { if (this._state.isSending) { await this._sendController.cancelActiveSend(); + this._forceImageGeneration = false; return; } diff --git a/src/features/chat/controllers/ChatGenerationController.ts b/src/features/chat/controllers/ChatGenerationController.ts index 23c478a1..51483776 100644 --- a/src/features/chat/controllers/ChatGenerationController.ts +++ b/src/features/chat/controllers/ChatGenerationController.ts @@ -282,9 +282,7 @@ export class ChatGenerationController { replyText: string, streamingHandle?: StreamingMessageHandle | null, ): Promise { - const tokens = - this._resolveBackendCompletionTokens(response) ?? - (await this._options.estimateReplyTokens(replyText)); + const tokens = await this._resolveBackendCompletionTokens(response, replyText); if (tokens > 0) { this._options.addContextTokens(tokens); } @@ -298,10 +296,17 @@ export class ChatGenerationController { this._options.pushAssistantMessage(replyText, response.thought_signature); } - private _resolveBackendCompletionTokens(response: IChatResponse): number | null { + private async _resolveBackendCompletionTokens( + response: IChatResponse, + replyText: string, + ): Promise { const completionTokens = response.usage?.completion_tokens; if (typeof completionTokens !== 'number' || !Number.isFinite(completionTokens)) { - return null; + const estimatedTokens = await this._options.estimateReplyTokens(replyText); + if (!Number.isFinite(estimatedTokens)) { + return 0; + } + return Math.max(0, Math.trunc(estimatedTokens)); } return Math.max(0, Math.trunc(completionTokens)); diff --git a/src/features/chat/services/ChatActivationCoordinator.ts b/src/features/chat/services/ChatActivationCoordinator.ts index 86d220bb..64beb5fd 100644 --- a/src/features/chat/services/ChatActivationCoordinator.ts +++ b/src/features/chat/services/ChatActivationCoordinator.ts @@ -21,7 +21,7 @@ export class ChatActivationCoordinator { input: HTMLTextAreaElement | null, promptOverride?: string, ): Promise { - const prompt = promptOverride ?? input?.value.trim() ?? ''; + const prompt = promptOverride?.trim() ?? input?.value.trim() ?? ''; const selectedProviderId = this._deps.getSelectedProviderId(prompt); const { activeProviderId } = this._deps.aiBridge.getState(); diff --git a/src/features/chat/ui/ChatImageController.ts b/src/features/chat/ui/ChatImageController.ts index 5197ede9..d390165b 100644 --- a/src/features/chat/ui/ChatImageController.ts +++ b/src/features/chat/ui/ChatImageController.ts @@ -67,7 +67,6 @@ export class ChatImageController { public constructor(private readonly _deps: ChatImageControllerDeps) { this._boundImageViewerKeydown = (event: KeyboardEvent) => { if (event.ctrlKey && ['+', '-', '=', '0'].includes(event.key)) { - event.preventDefault(); return; } if (event.key === 'Escape') { @@ -84,7 +83,6 @@ export class ChatImageController { }; this._boundImageViewerWheel = (event: WheelEvent) => { if (!event.ctrlKey) return; - event.preventDefault(); }; } diff --git a/src/features/chat/ui/ChatUI.test.ts b/src/features/chat/ui/ChatUI.test.ts index b4f641e7..db5d92f9 100644 --- a/src/features/chat/ui/ChatUI.test.ts +++ b/src/features/chat/ui/ChatUI.test.ts @@ -690,7 +690,7 @@ describe('ChatUI lifecycle', () => { expect(preview.src.startsWith('data:image/png;base64,b25l')).toBe(true); }); - it('should prevent launcher zoom shortcuts while image preview is open', async () => { + it('should preserve native browser zoom shortcuts while image preview is open', async () => { ui = createChatUI(); await renderAssistantImage(ui, true); @@ -706,7 +706,7 @@ describe('ChatUI lifecycle', () => { }); document.dispatchEvent(wheelEvent); - expect(wheelEvent.defaultPrevented).toBe(true); + expect(wheelEvent.defaultPrevented).toBe(false); const keyEvent = new KeyboardEvent('keydown', { bubbles: true, @@ -716,7 +716,7 @@ describe('ChatUI lifecycle', () => { }); document.dispatchEvent(keyEvent); - expect(keyEvent.defaultPrevented).toBe(true); + expect(keyEvent.defaultPrevented).toBe(false); }); it('should not open image preview for thumbnails without a usable source', async () => { diff --git a/src/features/console/services/ConsoleLogService.ts b/src/features/console/services/ConsoleLogService.ts index 3a9f6394..3a5cd266 100644 --- a/src/features/console/services/ConsoleLogService.ts +++ b/src/features/console/services/ConsoleLogService.ts @@ -115,7 +115,7 @@ export class ConsoleLogService { } public getLogsForView(viewId: string): ILogEntry[] { - return this._logsByView.get(this._canonicalViewId(viewId)) ?? []; + return [...(this._logsByView.get(this._canonicalViewId(viewId)) ?? [])]; } public async getAvailableViews(): Promise { diff --git a/src/features/console/ui/ConsoleFilterControlHelper.ts b/src/features/console/ui/ConsoleFilterControlHelper.ts index 64571fea..99e28199 100644 --- a/src/features/console/ui/ConsoleFilterControlHelper.ts +++ b/src/features/console/ui/ConsoleFilterControlHelper.ts @@ -151,6 +151,7 @@ export class ConsoleFilterControlHelper { return; } + delete button.dataset['confirmingAll']; button.dataset['confirming'] = 'true'; button.classList.add('confirming'); button.setAttribute('aria-label', 'Confirm clear console logs'); diff --git a/src/features/console/ui/ConsoleUI.ts b/src/features/console/ui/ConsoleUI.ts index 2ca395e6..6cfedaea 100644 --- a/src/features/console/ui/ConsoleUI.ts +++ b/src/features/console/ui/ConsoleUI.ts @@ -256,9 +256,18 @@ export class ConsoleUI { this._viewState.activeViewId = view; this._activateTab('.console-tab', '.logs-pane', `logs-${view}`, btn); this.renderLogs(true); - void this.service.fetchLogs(view).then(() => { - this.renderLogs(true); - }); + const requestedView = view; + void this.service + .fetchLogs(requestedView) + .then(() => { + if (this._viewState.activeViewId === requestedView) { + this.renderLogs(true); + } + }) + .catch((error: unknown) => { + // eslint-disable-next-line no-console + console.error('[ConsoleUI] Failed to fetch logs for view:', error); + }); } public async clearLogs(): Promise { diff --git a/src/features/settings/ui/ModuleSettingsEngineFieldSupport.ts b/src/features/settings/ui/ModuleSettingsEngineFieldSupport.ts index 25a13a56..8f6142a3 100644 --- a/src/features/settings/ui/ModuleSettingsEngineFieldSupport.ts +++ b/src/features/settings/ui/ModuleSettingsEngineFieldSupport.ts @@ -572,7 +572,19 @@ export function tokenizeEngineExtraArgs(raw: string): string[] { let current = ''; let quote: '"' | "'" | null = null; - for (const char of raw) { + for (let index = 0; index < raw.length; index += 1) { + const char = raw[index] ?? ''; + if (quote !== null && char === '\\') { + const nextChar = raw[index + 1]; + if (nextChar === quote || nextChar === '\\') { + current += nextChar; + index += 1; + } else { + current += char; + } + continue; + } + if ((char === '"' || char === "'") && (quote === null || quote === char)) { quote = quote === null ? char : null; continue; diff --git a/src/scripts/check-size.js b/src/scripts/check-size.js index 38a43dae..547944d6 100644 --- a/src/scripts/check-size.js +++ b/src/scripts/check-size.js @@ -5,7 +5,7 @@ const DIST_DIR = path.resolve('dist'); const KB = 1024; const LIMITS = { - totalBytes: 1_200 * KB, + totalBytes: 1_205 * KB, mainJsBytes: 400 * KB, vendorJsBytes: 100 * KB, cssBytes: 200 * KB, @@ -54,35 +54,52 @@ const mainJs = files.find((file) => /^main-.*\.js$/u.test(file.relative)); const vendorJs = files.find((file) => /^chunks\/vendor-.*\.js$/u.test(file.relative)); const cssBundle = files.find((file) => /^assets\/main-.*\.css$/u.test(file.relative)); const largestFont = files.filter(isFont).sort((left, right) => right.size - left.size)[0]; +const largestFiles = files + .filter((file) => !isEntryDocument(file)) + .sort((left, right) => right.size - left.size) + .slice(0, 10); + +function printLargestFiles() { + console.log('[size] largest files:'); + for (const file of largestFiles) { + console.log(`[size] ${formatKb(file.size).padStart(10)} ${file.relative}`); + } +} if (totalBytes > LIMITS.totalBytes) { + printLargestFiles(); warn(`Total dist size ${formatKb(totalBytes)} exceeds ${formatKb(LIMITS.totalBytes)}`); } if (mainJs && mainJs.size > LIMITS.mainJsBytes) { + printLargestFiles(); warn( `Main bundle ${mainJs.relative} is ${formatKb(mainJs.size)} and exceeds ${formatKb(LIMITS.mainJsBytes)}`, ); } if (vendorJs && vendorJs.size > LIMITS.vendorJsBytes) { + printLargestFiles(); warn( `Vendor markdown chunk ${vendorJs.relative} is ${formatKb(vendorJs.size)} and exceeds ${formatKb(LIMITS.vendorJsBytes)}`, ); } if (cssBundle && cssBundle.size > LIMITS.cssBytes) { + printLargestFiles(); warn( `Main stylesheet ${cssBundle.relative} is ${formatKb(cssBundle.size)} and exceeds ${formatKb(LIMITS.cssBytes)}`, ); } if (largestFont && largestFont.size > LIMITS.fontBytes) { + printLargestFiles(); warn( `Largest font ${largestFont.relative} is ${formatKb(largestFont.size)} and exceeds ${formatKb(LIMITS.fontBytes)}`, ); } +printLargestFiles(); console.log( `[size] ok: total=${formatKb(totalBytes)}, main=${formatKb(mainJs?.size ?? 0)}, vendor=${formatKb(vendorJs?.size ?? 0)}, css=${formatKb(cssBundle?.size ?? 0)}, font=${formatKb(largestFont?.size ?? 0)}`, ); From d1b53f9cc2e8cd5dcbd1625e771bf2a912d25247 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Wed, 29 Apr 2026 17:35:54 +0300 Subject: [PATCH 009/126] perf: subset chinese ui font --- src/assets/fonts/Cubic_11.zh-subset.woff2 | Bin 0 -> 29496 bytes src/package-lock.json | 20 ++++++++ src/package.json | 4 +- src/scripts/subset-cubic11-zh.js | 57 ++++++++++++++++++++++ src/styles/base/design-tokens.css | 2 +- 5 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 src/assets/fonts/Cubic_11.zh-subset.woff2 create mode 100644 src/scripts/subset-cubic11-zh.js diff --git a/src/assets/fonts/Cubic_11.zh-subset.woff2 b/src/assets/fonts/Cubic_11.zh-subset.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..1b1f344fdc010ecdf2ba8910a28f8cd2638b6fa6 GIT binary patch literal 29496 zcmV)DK*7IvPew8T0RR910CPA13IG5A0%L>#0CML50RR9100000000000000000000 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 W*V*3KRKGrPl61r3cfL{}p#K4bg=KaC literal 0 HcmV?d00001 diff --git a/src/package-lock.json b/src/package-lock.json index 093f790c..813fac30 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -30,6 +30,7 @@ "@vitest/ui": "^4.1.5", "eslint": "^10.2.1", "eslint-config-prettier": "^10.1.8", + "fonteditor-core": "^2.6.3", "globals": "^17.5.0", "jsdom": "^29.0.2", "prettier": "^3.8.3", @@ -1928,6 +1929,16 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -2776,6 +2787,15 @@ "dev": true, "license": "ISC" }, + "node_modules/fonteditor-core": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/fonteditor-core/-/fonteditor-core-2.6.3.tgz", + "integrity": "sha512-YUryIKjkenjZ41E7JvM3V+02Ak4mTHDDTwBWgs9KBzypzHqLZHuua1UDRevZNTKawmnq1dbBAa70Jddl2+F4FQ==", + "dev": true, + "dependencies": { + "@xmldom/xmldom": "^0.8.3" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", diff --git a/src/package.json b/src/package.json index f67d0b8d..df4f5693 100644 --- a/src/package.json +++ b/src/package.json @@ -8,7 +8,8 @@ "bindings:sync": "cargo run --quiet --manifest-path ../src-tauri/Cargo.toml --bin exporter", "bindings:check": "cargo run --quiet --manifest-path ../src-tauri/Cargo.toml --bin exporter -- --check", "build": "npm run typecheck && npm run build:bundle", - "build:bundle": "vite build", + "build:bundle": "npm run fonts:subset && vite build", + "fonts:subset": "node scripts/subset-cubic11-zh.js", "preview": "vite preview", "clean": "node --input-type=module -e \"import { rmSync } from 'fs'; rmSync('dist', { recursive: true, force: true });\" && echo Cleaned", "check-size": "node scripts/check-size.js", @@ -35,6 +36,7 @@ "@vitest/ui": "^4.1.5", "eslint": "^10.2.1", "eslint-config-prettier": "^10.1.8", + "fonteditor-core": "^2.6.3", "globals": "^17.5.0", "jsdom": "^29.0.2", "prettier": "^3.8.3", diff --git a/src/scripts/subset-cubic11-zh.js b/src/scripts/subset-cubic11-zh.js new file mode 100644 index 00000000..1e7cb848 --- /dev/null +++ b/src/scripts/subset-cubic11-zh.js @@ -0,0 +1,57 @@ +import { readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { createFont, woff2 } from 'fonteditor-core'; + +const SOURCE_FONT = path.resolve('assets/fonts/Cubic_11.woff2'); +const OUTPUT_FONT = path.resolve('assets/fonts/Cubic_11.zh-subset.woff2'); +const ZH_LOCALE = path.resolve('../src-tauri/resources/locales/zh.json'); + +const EXTRA_TEXT = [ + 'Axelate', + '0123456789', + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + 'abcdefghijklmnopqrstuvwxyz', + ' \n\t', + '.,:;!?()[]{}<>+-=*/\\|_#@$%^&`~\'"', + ',。:;!?()【】《》、“”‘’—…·¥', +].join(''); + +function collectCodePoints(value, codePoints) { + if (typeof value === 'string') { + for (const char of value) { + codePoints.add(char.codePointAt(0)); + } + return; + } + + if (Array.isArray(value)) { + value.forEach((item) => collectCodePoints(item, codePoints)); + return; + } + + if (value && typeof value === 'object') { + Object.values(value).forEach((item) => collectCodePoints(item, codePoints)); + } +} + +function formatKb(bytes) { + return `${(bytes / 1024).toFixed(2)} KB`; +} + +await woff2.init(); + +const codePoints = new Set(); +collectCodePoints(JSON.parse(readFileSync(ZH_LOCALE, 'utf8')), codePoints); +collectCodePoints(EXTRA_TEXT, codePoints); + +const source = readFileSync(SOURCE_FONT); +const font = createFont(source, { + type: 'woff2', + subset: [...codePoints].filter((codePoint) => codePoint !== undefined), +}); +const subset = Buffer.from(font.write({ type: 'woff2' })); +writeFileSync(OUTPUT_FONT, subset); + +console.log( + `[fonts] Cubic11 zh subset: ${codePoints.size} code points, ${formatKb(source.byteLength)} -> ${formatKb(subset.byteLength)}`, +); diff --git a/src/styles/base/design-tokens.css b/src/styles/base/design-tokens.css index 02a45870..b993c15e 100644 --- a/src/styles/base/design-tokens.css +++ b/src/styles/base/design-tokens.css @@ -11,7 +11,7 @@ @font-face { font-family: 'Cubic11'; - src: url('../../assets/fonts/Cubic_11.woff2') format('woff2'); + src: url('../../assets/fonts/Cubic_11.zh-subset.woff2') format('woff2'); font-weight: normal; font-style: normal; font-display: block; From aa2a14548e8c9020f968005f602cc2342e9ef659 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Wed, 29 Apr 2026 17:49:53 +0300 Subject: [PATCH 010/126] fix: address follow-up review feedback --- src-tauri/src/api/system/logs.rs | 15 +++-- src/app/CoreEntry.ts | 7 ++- src/features/ai/services/AIBridge.ts | 4 +- .../ai/services/AIBridgeMessageController.ts | 9 +-- src/features/ai/services/AIChatTransport.ts | 12 +++- src/features/chat/chat.ts | 60 ++++++++++++++----- .../controllers/ChatGenerationController.ts | 10 +++- .../chat/controllers/ChatHistoryController.ts | 9 +++ src/features/chat/ui/ChatImageController.ts | 6 -- .../console/services/ConsoleLogService.ts | 9 ++- src/features/console/ui/ConsoleUI.ts | 20 ++++++- .../ui/ModuleSettingsEngineFieldSupport.ts | 5 +- 12 files changed, 117 insertions(+), 49 deletions(-) diff --git a/src-tauri/src/api/system/logs.rs b/src-tauri/src/api/system/logs.rs index 871a66d1..ec0b504e 100644 --- a/src-tauri/src/api/system/logs.rs +++ b/src-tauri/src/api/system/logs.rs @@ -169,11 +169,18 @@ pub fn log_batch(logs: Vec) -> Result<(), AppError> { } fn trace_frontend_log(level: &str, message: &str) { - match level.trim().to_ascii_lowercase().as_str() { + let normalized_level = level.trim().to_ascii_lowercase(); + match normalized_level.as_str() { "error" => tracing::error!(target: "frontend", message = message), "warn" | "warning" => tracing::warn!(target: "frontend", message = message), - "debug" => tracing::debug!(target: "frontend", message = message), - "trace" => tracing::trace!(target: "frontend", message = message), + "debug" => { + logs::add_log(message, "frontend", &normalized_level); + tracing::debug!(target: "frontend", message = message); + } + "trace" => { + logs::add_log(message, "frontend", &normalized_level); + tracing::trace!(target: "frontend", message = message); + } _ => tracing::info!(target: "frontend", message = message), } } @@ -197,7 +204,7 @@ fn canonical_engine_id(engine_id: &str) -> String { | "stable-diffusion.cpp" | "stable-diffusion-cpp" | "stable.diffusion.cpp" => "sdcpp".to_string(), - _ => engine_id.trim().to_string(), + _ => key, } } diff --git a/src/app/CoreEntry.ts b/src/app/CoreEntry.ts index 197a41a0..18171d43 100644 --- a/src/app/CoreEntry.ts +++ b/src/app/CoreEntry.ts @@ -44,8 +44,11 @@ function clearBootState(): void { function destroyActiveCoreInstance(): void { const state = getCoreEntryState(); - state.activeCoreInstance?.destroy(); - clearBootState(); + try { + state.activeCoreInstance?.destroy(); + } finally { + clearBootState(); + } } function bootCoreOnce(createCore: CoreFactory, tracer: EntryLogger): void { diff --git a/src/features/ai/services/AIBridge.ts b/src/features/ai/services/AIBridge.ts index b28b4888..d453721a 100644 --- a/src/features/ai/services/AIBridge.ts +++ b/src/features/ai/services/AIBridge.ts @@ -6,7 +6,7 @@ import type { IChunkHandler, IImageGenerationPreview, } from '../types/aiTypes'; -import type { AIBridgeSendMessageOptions } from './AIBridgeMessageController'; +import type { IAIBridgeSendMessageOptions } from '../types/IAIBridge'; import { AIProviderManager } from './AIProviderManager'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; import { AIChatTransport, type IChatTransport } from './AIChatTransport'; @@ -217,7 +217,7 @@ export class AIBridge implements IAIBridge { source: MessageSource = 'chat', attachments: { name: string; type: string; data_base64: string }[] = [], history: IChatMessage[] = [], - options: AIBridgeSendMessageOptions = {}, + options: IAIBridgeSendMessageOptions = {}, ): Promise { return await this._messageController.sendMessage( text, diff --git a/src/features/ai/services/AIBridgeMessageController.ts b/src/features/ai/services/AIBridgeMessageController.ts index 30cebbbf..c7aae58c 100644 --- a/src/features/ai/services/AIBridgeMessageController.ts +++ b/src/features/ai/services/AIBridgeMessageController.ts @@ -13,6 +13,7 @@ import type { IChatTransport } from './AIChatTransport'; import type { AIProviderManager } from './AIProviderManager'; import type { AIBridgeEvents } from './AIBridgeEvents'; import type { AIBridgeProviderPolicy } from './AIBridgeProviderPolicy'; +import type { IAIBridgeSendMessageOptions } from '../types/IAIBridge'; import { resolveCustomProviderBackendId } from '@/shared/utils/customProviderSupport'; type AIBridgeMessageLogger = Pick; @@ -32,10 +33,6 @@ type AIBridgeMessageControllerDeps = { onSuccessfulResponse: () => void; }; -export type AIBridgeSendMessageOptions = { - originalPrompt?: string; -}; - export class AIBridgeMessageController { constructor(private readonly _deps: AIBridgeMessageControllerDeps) {} @@ -44,7 +41,7 @@ export class AIBridgeMessageController { source: MessageSource, attachments: { name: string; type: string; data_base64: string }[], history: IChatMessage[], - options: AIBridgeSendMessageOptions = {}, + options: IAIBridgeSendMessageOptions = {}, ): Promise { if (this._deps.manager.activeProviderId === null) { return this._handleMissingProvider(source); @@ -142,7 +139,7 @@ export class AIBridgeMessageController { providerId: string, text: string, source: MessageSource, - options: AIBridgeSendMessageOptions, + options: IAIBridgeSendMessageOptions, ): Promise { const context = this._deps.getContext(); const selectedImageModule = context?.stateStore.getSelectedModule('ai_image'); diff --git a/src/features/ai/services/AIChatTransport.ts b/src/features/ai/services/AIChatTransport.ts index b6d34977..783e7c3c 100644 --- a/src/features/ai/services/AIChatTransport.ts +++ b/src/features/ai/services/AIChatTransport.ts @@ -196,14 +196,22 @@ export class AIChatTransport implements IChatTransport { private async _cancelStaleActiveRequest(requestId: string): Promise { try { - await this._runWithTimeout( + const cancelled = await this._runWithTimeout( this._context?.tauriProvider.invoke('cancel_chat_generation', { requestId, }) ?? Promise.resolve(false), STALE_REQUEST_CANCEL_TIMEOUT_MS, 'Stale AI request cancel timed out', ); - this._tracer.info('[AIChatTransport] Cancelled stale active request before restart'); + if (cancelled) { + this._tracer.info( + '[AIChatTransport] Cancelled stale active request before restart', + ); + } else { + this._tracer.warn( + '[AIChatTransport] Stale active request was not registered for cancellation', + ); + } } catch (error: unknown) { this._tracer.warn('[AIChatTransport] Failed to cancel stale active request:', error); } finally { diff --git a/src/features/chat/chat.ts b/src/features/chat/chat.ts index c9353435..877f2af0 100644 --- a/src/features/chat/chat.ts +++ b/src/features/chat/chat.ts @@ -458,7 +458,13 @@ export class ChatController { return; } - const preview = await previewProvider.getImageGenerationPreview(); + let preview: Awaited>; + try { + preview = await previewProvider.getImageGenerationPreview(); + } catch (error: unknown) { + this._tracer.error('[Chat] Failed to restore active image generation:', error); + return; + } if (preview === null) { return; } @@ -470,6 +476,7 @@ export class ChatController { } this._state.isSending = true; + this._state.currentGenerationProviderId = this._aiBridge.getState().activeProviderId; this._generationController.startImagePreviewPolling(imageHandle); this._scheduleRestoredImageGenerationCheck(); } @@ -487,15 +494,23 @@ export class ChatController { return; } - const preview = await this._aiBridge.getImageGenerationPreview(); - if (preview !== null) { - this._scheduleRestoredImageGenerationCheck(); - return; - } + try { + const preview = await this._aiBridge.getImageGenerationPreview(); + if (preview !== null) { + this._scheduleRestoredImageGenerationCheck(); + return; + } - this._generationController.stopImagePreviewPolling(); - this._state.isSending = false; - await this._historyController.loadHistory(); + this._generationController.stopImagePreviewPolling(); + this._state.isSending = false; + this._state.currentGenerationProviderId = null; + await this._historyController.loadHistory(); + } catch (error: unknown) { + this._generationController.stopImagePreviewPolling(); + this._state.isSending = false; + this._state.currentGenerationProviderId = null; + this._tracer.error('[Chat] Restored image generation check failed:', error); + } } private _clearRestoredImageGenerationTimer(): void { @@ -598,11 +613,11 @@ export class ChatController { // --- Send Message --- - public async sendChat(): Promise { + public async sendChat(): Promise { if (this._state.isSending) { await this._sendController.cancelActiveSend(); this._forceImageGeneration = false; - return; + return false; } const input = this._inputCoordinator.getInput(); @@ -613,17 +628,17 @@ export class ChatController { this._i18n.t('ui.chat.input_required', 'Enter a message or attach a file'), 'error', ); - return; + return false; } const activationPrompt = this._forceImageGeneration ? `generate image ${text}` : undefined; const isActive = await this._activationCoordinator.ensureActive(input, activationPrompt); if (!isActive) { this._forceImageGeneration = false; - return; + return false; } - await this._sendController.sendChat(input); + return await this._sendController.sendChat(input); } public async regenerateLastResponse(): Promise { @@ -642,8 +657,10 @@ export class ChatController { return; } + const historySnapshot = this._historyController.getLocalHistorySnapshot(); const text = await this._historyController.regenerateLastTurn(this._state.isSending); if (text === null || text.trim() === '') { + this._historyController.restoreLocalHistorySnapshot(historySnapshot); this._ui.showToast( this._i18n.t('ui.chat.regenerate_failed', 'Failed to regenerate response'), 'error', @@ -652,7 +669,20 @@ export class ChatController { } this._inputCoordinator.restore(text); - await this.sendChat(); + let started: boolean; + try { + started = await this.sendChat(); + } catch (error: unknown) { + this._historyController.restoreLocalHistorySnapshot(historySnapshot); + this._inputCoordinator.restore(text); + this._tracer.error('[Chat] Failed to resend regenerated turn:', error); + throw error; + } + + if (!started) { + this._historyController.restoreLocalHistorySnapshot(historySnapshot); + this._inputCoordinator.restore(text); + } } // --- Greeting --- diff --git a/src/features/chat/controllers/ChatGenerationController.ts b/src/features/chat/controllers/ChatGenerationController.ts index 51483776..29786c71 100644 --- a/src/features/chat/controllers/ChatGenerationController.ts +++ b/src/features/chat/controllers/ChatGenerationController.ts @@ -302,11 +302,15 @@ export class ChatGenerationController { ): Promise { const completionTokens = response.usage?.completion_tokens; if (typeof completionTokens !== 'number' || !Number.isFinite(completionTokens)) { - const estimatedTokens = await this._options.estimateReplyTokens(replyText); - if (!Number.isFinite(estimatedTokens)) { + try { + const estimatedTokens = await this._options.estimateReplyTokens(replyText); + if (!Number.isFinite(estimatedTokens)) { + return 0; + } + return Math.max(0, Math.trunc(estimatedTokens)); + } catch { return 0; } - return Math.max(0, Math.trunc(estimatedTokens)); } return Math.max(0, Math.trunc(completionTokens)); diff --git a/src/features/chat/controllers/ChatHistoryController.ts b/src/features/chat/controllers/ChatHistoryController.ts index 1870d0d0..08ef4637 100644 --- a/src/features/chat/controllers/ChatHistoryController.ts +++ b/src/features/chat/controllers/ChatHistoryController.ts @@ -102,6 +102,15 @@ export class ChatHistoryController { return typeof lastUserMessage?.content === 'string'; } + public getLocalHistorySnapshot(): IChatMessage[] { + return this._options.getHistory().map((message) => ({ ...message })); + } + + public restoreLocalHistorySnapshot(history: IChatMessage[]): void { + this._options.setHistory(history.map((message) => ({ ...message }))); + this._options.renderHistory(this._options.getHistory()); + } + public rewindLocalHistory(): void { const history = [...this._options.getHistory()]; diff --git a/src/features/chat/ui/ChatImageController.ts b/src/features/chat/ui/ChatImageController.ts index d390165b..7c703f0d 100644 --- a/src/features/chat/ui/ChatImageController.ts +++ b/src/features/chat/ui/ChatImageController.ts @@ -55,7 +55,6 @@ export class ChatImageController { `); private readonly _boundImageViewerKeydown: (event: KeyboardEvent) => void; - private readonly _boundImageViewerWheel: (event: WheelEvent) => void; private _imageViewerOverlay: HTMLElement | null = null; private _imageViewerImage: HTMLImageElement | null = null; private _imageViewerPrevButton: HTMLButtonElement | null = null; @@ -81,9 +80,6 @@ export class ChatImageController { this._showAdjacentImage(1); } }; - this._boundImageViewerWheel = (event: WheelEvent) => { - if (!event.ctrlKey) return; - }; } public destroy(): void { @@ -332,7 +328,6 @@ export class ChatImageController { this._imageViewerOverlay.classList.remove('hidden'); document.body.classList.add('chat-image-viewer-open'); document.addEventListener('keydown', this._boundImageViewerKeydown); - document.addEventListener('wheel', this._boundImageViewerWheel, { passive: false }); } private closeImageViewer(): void { @@ -342,7 +337,6 @@ export class ChatImageController { this._imageViewerImage?.removeAttribute('src'); document.body.classList.remove('chat-image-viewer-open'); document.removeEventListener('keydown', this._boundImageViewerKeydown); - document.removeEventListener('wheel', this._boundImageViewerWheel); } private _collectImageViewerSources(): string[] { diff --git a/src/features/console/services/ConsoleLogService.ts b/src/features/console/services/ConsoleLogService.ts index 3a5cd266..59c1ee6b 100644 --- a/src/features/console/services/ConsoleLogService.ts +++ b/src/features/console/services/ConsoleLogService.ts @@ -85,11 +85,11 @@ export class ConsoleLogService { public async clearLogs(viewId = 'general'): Promise { const normalizedViewId = this._canonicalViewId(viewId); - this._logsByView.set(normalizedViewId, []); - this._lastTimestampByView.set(normalizedViewId, 0); try { await this.bridge.invoke('clear_console_logs', { viewId: normalizedViewId }); + this._logsByView.set(normalizedViewId, []); + this._lastTimestampByView.set(normalizedViewId, 0); return true; } catch (error) { this._tracer.error('[ConsoleLogService] Clear logs failed:', error); @@ -98,11 +98,10 @@ export class ConsoleLogService { } public async clearAllLogs(): Promise { - this._logsByView.clear(); - this._lastTimestampByView.clear(); - try { await this.bridge.invoke('clear_logs'); + this._logsByView.clear(); + this._lastTimestampByView.clear(); return true; } catch (error) { this._tracer.error('[ConsoleLogService] Clear all logs failed:', error); diff --git a/src/features/console/ui/ConsoleUI.ts b/src/features/console/ui/ConsoleUI.ts index 6cfedaea..e08a7dc7 100644 --- a/src/features/console/ui/ConsoleUI.ts +++ b/src/features/console/ui/ConsoleUI.ts @@ -271,13 +271,29 @@ export class ConsoleUI { } public async clearLogs(): Promise { - await this.service.clearLogs(this._viewState.activeViewId); + const success = await this.service.clearLogs(this._viewState.activeViewId); + if (!success) { + this._showToast( + this._translate('ui.debug.logs_clear_failed', 'Failed to clear logs'), + 'error', + ); + return; + } + this.renderLogs(true); this._clipboardHelper.showLogsCleared(); } public async clearAllLogs(): Promise { - await this.service.clearAllLogs(); + const success = await this.service.clearAllLogs(); + if (!success) { + this._showToast( + this._translate('ui.debug.logs_clear_failed', 'Failed to clear logs'), + 'error', + ); + return; + } + this.renderLogs(true); this._clipboardHelper.showLogsCleared(); } diff --git a/src/features/settings/ui/ModuleSettingsEngineFieldSupport.ts b/src/features/settings/ui/ModuleSettingsEngineFieldSupport.ts index 8f6142a3..4d4f09a1 100644 --- a/src/features/settings/ui/ModuleSettingsEngineFieldSupport.ts +++ b/src/features/settings/ui/ModuleSettingsEngineFieldSupport.ts @@ -406,8 +406,8 @@ export function createEngineExtraArgsField(translate: ExtraArgsTranslate): Engin input.value = flattenGroups(groups); }; - const setGroups = (groups: string[], options: { emit?: boolean } = {}) => { - input.value = flattenGroups(groups); + const setGroups = (newGroups: string[], options: { emit?: boolean } = {}) => { + input.value = flattenGroups(newGroups); syncTokens(); if (options.emit === false) { return; @@ -650,6 +650,7 @@ function setInitialEngineSettingsValue( settings: Record, ): void { const value = settings[key]; + // Some persisted settings stored the literal "null"; keep empty defaults empty. if (value !== undefined && value !== null && !(value === 'null' && defaultValue === '')) { input.value = String(value); return; From 32ee72dd0001cd1da235328ce562f0ee575f6eed Mon Sep 17 00:00:00 2001 From: F0RLE Date: Wed, 29 Apr 2026 18:04:31 +0300 Subject: [PATCH 011/126] fix: delete local engine installs --- src-tauri/src/api/engine/mod.rs | 8 ++++ src-tauri/src/domain/engine/detector.rs | 28 ++++++++++++ src-tauri/src/lib.rs | 1 + .../services/ModulePlatformService.test.ts | 45 ++++++++++++++++++- src/shared/services/ModulePlatformService.ts | 23 +++++++++- src/shared/shell/ui/AppUiModuleFlow.test.ts | 1 + src/shared/shell/ui/AppUiModuleFlow.ts | 2 +- src/shared/types/bindings.ts | 2 + 8 files changed, 106 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/api/engine/mod.rs b/src-tauri/src/api/engine/mod.rs index ce3de61a..2f12134f 100644 --- a/src-tauri/src/api/engine/mod.rs +++ b/src-tauri/src/api/engine/mod.rs @@ -69,6 +69,14 @@ pub fn check_engine_installed(engine_id: String, binary_name: Option) -> crate::domain::engine::detector::is_engine_installed(&engine_id, binary_name.as_deref()) } +#[tauri::command] +#[specta::specta] +/// Deletes an Axelate-managed engine from local storage. +#[allow(clippy::needless_pass_by_value)] // Tauri commands require owned params +pub async fn delete_engine(engine_id: String) -> Result<(), AppError> { + crate::domain::engine::detector::delete_installed_engine(&engine_id).await +} + #[tauri::command] #[specta::specta] /// Returns all registered engine definitions with real-time installation status. diff --git a/src-tauri/src/domain/engine/detector.rs b/src-tauri/src/domain/engine/detector.rs index caa9bbf9..488831a1 100644 --- a/src-tauri/src/domain/engine/detector.rs +++ b/src-tauri/src/domain/engine/detector.rs @@ -6,12 +6,40 @@ use std::path::PathBuf; +use crate::errors::AppError; use crate::utils::paths::ENGINES_DIR; fn installed_engine_dir(engine_id: &str) -> PathBuf { ENGINES_DIR.join(engine_id) } +/// Deletes an Axelate-managed engine directory from `ENGINES_DIR/{id}`. +/// +/// System `PATH` installs are intentionally ignored because they are owned by the user. +pub async fn delete_installed_engine(engine_id: &str) -> Result<(), AppError> { + if !is_safe_id(engine_id) { + return Err(AppError::Validation(format!( + "Invalid engine id: {engine_id}" + ))); + } + + let engine_path = installed_engine_dir(engine_id); + if !tokio::fs::try_exists(&engine_path).await? { + return Ok(()); + } + + let engines_root = ENGINES_DIR.canonicalize()?; + let engine_path = engine_path.canonicalize()?; + if !engine_path.starts_with(&engines_root) { + return Err(AppError::Validation(format!( + "Engine path escapes engines directory: {engine_id}" + ))); + } + + tokio::fs::remove_dir_all(engine_path).await?; + Ok(()) +} + /// Checks if an engine is installed either in `ENGINES_DIR/{id}` or on system PATH. /// /// Returns `false` for invalid engine IDs (prevents directory traversal). diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9377ad3b..cedea12f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -179,6 +179,7 @@ pub fn create_specta_builder() -> Builder { engine::stop_engine_slot, engine::get_engine_state, engine::check_engine_installed, + engine::delete_engine, engine::get_engine_definitions, engine::get_engine_config, engine::get_engine_settings_payload, diff --git a/src/shared/services/ModulePlatformService.test.ts b/src/shared/services/ModulePlatformService.test.ts index 25f49537..4eebff67 100644 --- a/src/shared/services/ModulePlatformService.test.ts +++ b/src/shared/services/ModulePlatformService.test.ts @@ -5,6 +5,21 @@ import type { IApp } from '../types/coreTypes'; import type { AIBridge } from '@/features/ai/services/AIBridge'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; +const mocks = vi.hoisted(() => ({ + deleteEngine: vi.fn(), + invokeSafe: vi.fn(), +})); + +vi.mock('@/shared/types/bindings', () => ({ + commands: { + deleteEngine: (...args: unknown[]): unknown => mocks.deleteEngine(...args), + }, +})); + +vi.mock('@/shared/api/invoke', () => ({ + invokeSafe: (...args: unknown[]): unknown => mocks.invokeSafe(...args), +})); + function createMockModuleService(): ModuleService { return { downloadModule: vi.fn().mockResolvedValue(undefined), @@ -29,15 +44,16 @@ function createApp(overrides: Partial = {}): IApp { describe('ModulePlatformService', () => { let moduleService: ModuleService; let service: ModulePlatformService; - let aiBridge: Pick; + let aiBridge: Pick; let tracer: Pick; beforeEach(() => { moduleService = createMockModuleService(); aiBridge = { stopProvider: vi.fn(), + stopEngineSlot: vi.fn(), getState: vi.fn(() => ({ activeProviderId: 'test-module', isRunning: true })), - }; + } as unknown as Pick; tracer = { info: vi.fn() }; service = new ModulePlatformService(() => moduleService, aiBridge as AIBridge, tracer); vi.clearAllMocks(); @@ -83,6 +99,31 @@ describe('ModulePlatformService', () => { const app = createApp(); await expect(service.delete(app)).rejects.toThrow('ui.launcher.web.delete_model_error'); }); + + it('should delete AI engines through the engine command', async () => { + mocks.deleteEngine.mockReturnValue('delete-engine-promise'); + mocks.invokeSafe.mockResolvedValue({ status: 'ok', data: null }); + const app = createApp({ id: 'llamacpp', type: 'local' }); + + await service.delete(app, 'ai_text'); + + expect(mocks.deleteEngine).toHaveBeenCalledWith('llamacpp'); + expect(mocks.invokeSafe).toHaveBeenCalledWith('delete-engine-promise'); + expect(moduleService.deleteModule).not.toHaveBeenCalled(); + }); + + it('should skip deleting externally managed AI engines', async () => { + const app = createApp({ + id: 'external-engine', + type: 'local', + managedExternally: true, + }); + + await service.delete(app, 'ai_text'); + + expect(mocks.deleteEngine).not.toHaveBeenCalled(); + expect(moduleService.deleteModule).not.toHaveBeenCalled(); + }); }); describe('stop', () => { diff --git a/src/shared/services/ModulePlatformService.ts b/src/shared/services/ModulePlatformService.ts index 3dac203f..b3bfdf1c 100644 --- a/src/shared/services/ModulePlatformService.ts +++ b/src/shared/services/ModulePlatformService.ts @@ -2,6 +2,8 @@ import type { IApp, IModuleDownloadState } from '../types/coreTypes'; import type { DownloadModuleOutcome, ModuleService } from './ModuleService'; import type { AIBridge } from '@/features/ai/services/AIBridge'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; +import { invokeSafe } from '@/shared/api/invoke'; +import { commands } from '@/shared/types/bindings'; import { isApiApp } from '@/shared/utils/moduleTypeUtils'; import { isAiCategory } from '@/shared/utils/moduleCategoryPolicy'; @@ -43,8 +45,13 @@ export class ModulePlatformService { * Deletes a module. * @param app The module to delete. */ - public async delete(app: IApp): Promise { + public async delete(app: IApp, category?: string): Promise { this._tracer.info(`[ModulePlatformService] Deleting: ${app.id}`); + if (category !== undefined && isAiCategory(category)) { + await this._deleteAiEngine(app); + return; + } + const success = await this._moduleService.deleteModule(app.id); if (!success) { throw new Error('ui.launcher.web.delete_model_error'); @@ -164,4 +171,18 @@ export class ModulePlatformService { this._tracer.info(`[ModulePlatformService] Force stopping AI engine slot: ${capability}`); await this._aiBridge.stopEngineSlot(capability); } + + private async _deleteAiEngine(app: IApp): Promise { + if (app.managedExternally === true) { + this._tracer.info( + `[ModulePlatformService] Skip delete for externally managed AI engine: ${app.id}`, + ); + return; + } + + const result = await invokeSafe(commands.deleteEngine(app.id)); + if (result.status === 'error') { + throw new Error(result.error.message); + } + } } diff --git a/src/shared/shell/ui/AppUiModuleFlow.test.ts b/src/shared/shell/ui/AppUiModuleFlow.test.ts index 39f0d242..db41c9a9 100644 --- a/src/shared/shell/ui/AppUiModuleFlow.test.ts +++ b/src/shared/shell/ui/AppUiModuleFlow.test.ts @@ -59,6 +59,7 @@ describe('AppUiModuleFlow', () => { await flow.handleDeleteModule(app, 'services'); + expect(platformService.delete).toHaveBeenCalledWith(app, 'services'); expect(app.installed).toBe(false); expect(clearModuleCard).toHaveBeenCalledWith('services'); expect(openAppSelection).toHaveBeenCalledWith('services', [ diff --git a/src/shared/shell/ui/AppUiModuleFlow.ts b/src/shared/shell/ui/AppUiModuleFlow.ts index 1ebe5fcb..a3eaefec 100644 --- a/src/shared/shell/ui/AppUiModuleFlow.ts +++ b/src/shared/shell/ui/AppUiModuleFlow.ts @@ -28,7 +28,7 @@ export class AppUiModuleFlow { public async handleDeleteModule(app: IApp, category: string): Promise { this._deps.tracer.info('[AppUI] Remove module clicked:', app.id); try { - await this._deps.platformService.delete(app); + await this._deps.platformService.delete(app, category); app.installed = false; if (this._deps.getSelectedAppId(category) === app.id) { diff --git a/src/shared/types/bindings.ts b/src/shared/types/bindings.ts index a1a50264..9cc533bc 100644 --- a/src/shared/types/bindings.ts +++ b/src/shared/types/bindings.ts @@ -208,6 +208,8 @@ export const commands = { getEngineState: () => typedError(__TAURI_INVOKE("get_engine_state")), // Checks if an engine binary is present (in ENGINES_DIR or system PATH). checkEngineInstalled: (engineId: string, binaryName: string | null) => __TAURI_INVOKE("check_engine_installed", { engineId, binaryName }), + // Deletes an Axelate-managed engine from local storage. + deleteEngine: (engineId: string) => typedError(__TAURI_INVOKE("delete_engine", { engineId })), // Returns all registered engine definitions with real-time installation status. getEngineDefinitions: () => typedError(__TAURI_INVOKE("get_engine_definitions")), // Returns the persisted user config for an engine, or defaults if none saved yet. From 112905e8c81b7dd5ef2eafc604fa10e1e62e75a9 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Wed, 29 Apr 2026 18:10:26 +0300 Subject: [PATCH 012/126] fix: show download for removed engines --- .../shell/ui/ModuleCardRenderer.test.ts | 24 ++++++++++++++++++- src/shared/shell/ui/ModuleCardRenderer.ts | 6 ++--- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/shared/shell/ui/ModuleCardRenderer.test.ts b/src/shared/shell/ui/ModuleCardRenderer.test.ts index c17322a7..25536c65 100644 --- a/src/shared/shell/ui/ModuleCardRenderer.test.ts +++ b/src/shared/shell/ui/ModuleCardRenderer.test.ts @@ -192,7 +192,7 @@ describe('ModuleCardRenderer', () => { expect(card.querySelector('.download-btn')).toBeNull(); }); - it('renders AI engine cards as selectable even before install checks', () => { + it('renders uninstalled AI engine cards as downloadable', () => { const onClick = vi.fn(); const onDownload = vi.fn(); @@ -211,6 +211,28 @@ describe('ModuleCardRenderer', () => { onDownload, ); + expect(card.querySelector('.download-btn')?.textContent).toContain('Download'); + }); + + it('renders installed AI engine cards as selectable', () => { + const onClick = vi.fn(); + const onDownload = vi.fn(); + + const card = renderer.createSelectionCard( + { + id: 'llamacpp', + name: 'llama.cpp', + desc: 'Local engine', + installed: true, + type: 'local', + capability: 'text', + } as never, + 'ai_text', + false, + onClick, + onDownload, + ); + expect(card.querySelector('.download-btn')).toBeNull(); expect(card.querySelector('.modal-btn-primary')?.textContent).toContain('Select'); }); diff --git a/src/shared/shell/ui/ModuleCardRenderer.ts b/src/shared/shell/ui/ModuleCardRenderer.ts index b9999da8..2cc842f0 100644 --- a/src/shared/shell/ui/ModuleCardRenderer.ts +++ b/src/shared/shell/ui/ModuleCardRenderer.ts @@ -13,7 +13,6 @@ import { setModuleCardDownloadProgress, } from './ModuleCardDownloadProgress'; import { ModuleCardPresentationHelper } from './ModuleCardPresentationHelper'; -import { isAiCategory } from '../../utils/moduleCategoryPolicy'; type ModuleCardRendererDeps = { checkInstalled?: (moduleId: string) => Promise; @@ -125,11 +124,10 @@ export class ModuleCardRenderer { return card; } - private _resolveCardState(app: IApp, category: string): CardState { + private _resolveCardState(app: IApp, _category: string): CardState { const isApi = this._isApiModule(app); const isComingSoon = app.comingSoon === true; - const isInstalled = - isApi || isAiCategory(category) || (!isComingSoon && app.installed === true); + const isInstalled = isApi || (!isComingSoon && app.installed === true); return { isApi, From a2d00621bcb622b61ef3cd5addad1f30a35b227e Mon Sep 17 00:00:00 2001 From: F0RLE Date: Wed, 29 Apr 2026 18:20:18 +0300 Subject: [PATCH 013/126] fix: refresh modal after engine downloads --- src/shared/shell/AppUI.ts | 3 ++- src/shared/shell/ui/ModalManager.test.ts | 3 +++ src/shared/shell/ui/ModalManager.ts | 29 ++++++++++++++++++++---- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/shared/shell/AppUI.ts b/src/shared/shell/AppUI.ts index acc21179..458fd36d 100644 --- a/src/shared/shell/AppUI.ts +++ b/src/shared/shell/AppUI.ts @@ -109,7 +109,8 @@ export class AppUI { this._cardRenderer, async (e, app, category) => await this._handleAppCardClick(e, app, category), (capability) => this._selectionState.get(`ai_${capability}`)?.id ?? null, - async (app) => await this._platformService.download(app), + async (app, category, btn) => + await this._moduleFlow.handleDownloadModule(app, category, btn), async (app) => { await this._platformService.cancelDownload(app.id); }, diff --git a/src/shared/shell/ui/ModalManager.test.ts b/src/shared/shell/ui/ModalManager.test.ts index efa7a306..8041f140 100644 --- a/src/shared/shell/ui/ModalManager.test.ts +++ b/src/shared/shell/ui/ModalManager.test.ts @@ -529,6 +529,7 @@ describe('ModalManager lifecycle', () => { ); expect(document.querySelector('.download-label')?.textContent).toBe('Download'); + modalManager.openAppSelection('services', []); list.innerHTML = `
`; handleDownload.call(modalManager, { id: 'svc', @@ -545,6 +546,8 @@ describe('ModalManager lifecycle', () => { expectedHash: 'abc', dlType: 'github', }), + 'services', + expect.any(HTMLButtonElement), ); }); diff --git a/src/shared/shell/ui/ModalManager.ts b/src/shared/shell/ui/ModalManager.ts index adb9373a..7a2dba4b 100644 --- a/src/shared/shell/ui/ModalManager.ts +++ b/src/shared/shell/ui/ModalManager.ts @@ -18,7 +18,11 @@ import { } from './ModuleCardDownloadProgress'; import { ModalSelectionPolicy } from './ModalSelectionPolicy'; import { ModalFocusTrapHelper } from './ModalFocusTrapHelper'; -import { resolveModalSidebarCategory } from '../../utils/moduleCategoryPolicy'; +import { + getAiSlotForCapability, + isAiCategory, + resolveModalSidebarCategory, +} from '../../utils/moduleCategoryPolicy'; /** * @class ModalManager @@ -69,7 +73,11 @@ export class ModalManager { private readonly _onAppInteraction: (e: MouseEvent, app: IApp, category: string) => void; // Called when user switches filter tab — returns the selected app ID for that capability private readonly _onFilterChange: (capability: 'text' | 'image') => string | null; - private readonly _onDownloadRequest: (app: IApp) => Promise; + private readonly _onDownloadRequest: ( + app: IApp, + category: string, + btn: HTMLElement | null, + ) => Promise; private readonly _onCancelDownloadRequest: (app: IApp) => Promise; private readonly _onPauseDownloadRequest: (app: IApp) => Promise; private readonly _onResumeDownloadRequest: (app: IApp) => Promise; @@ -80,7 +88,11 @@ export class ModalManager { cardRenderer: ModuleCardRenderer, onAppInteraction: (e: MouseEvent, app: IApp, category: string) => void, onFilterChange: (capability: 'text' | 'image') => string | null, - onDownloadRequest: (app: IApp) => Promise, + onDownloadRequest: ( + app: IApp, + category: string, + btn: HTMLElement | null, + ) => Promise, onCancelDownloadRequest: (app: IApp) => Promise, translate: (key: string, fallback: string) => string, tracer: LoggerService, @@ -235,7 +247,11 @@ export class ModalManager { } public isViewingCategory(category: string): boolean { - return this.isAppSelectionOpen() && this._currentCategory === category; + return ( + this.isAppSelectionOpen() && + resolveModalSidebarCategory(this._currentCategory ?? '') === + resolveModalSidebarCategory(category) + ); } // --- Helpers --- @@ -338,7 +354,10 @@ export class ModalManager { } this._tracer.info(`[ModalManager] Starting download: ${app.id}`); - void this._onDownloadRequest(app); + const interactionCategory = isAiCategory(this._currentCategory ?? '') + ? getAiSlotForCapability(this._currentFilter) + : (this._currentCategory ?? ''); + void this._onDownloadRequest(app, interactionCategory, btn ?? null); } private _handleActiveDownloadAction( From d50a18e1b42c7418a8e79bb86e834ace78c64460 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Wed, 29 Apr 2026 18:28:57 +0300 Subject: [PATCH 014/126] fix: keep removed engines unselectable --- src/shared/shell/AppUI.test.ts | 11 +++++--- src/shared/shell/AppUI.ts | 1 - .../shell/ui/AppUiCardActionFlow.test.ts | 16 +++++++++++ src/shared/shell/ui/AppUiCardActionFlow.ts | 3 +++ src/shared/shell/ui/AppUiModuleFlow.test.ts | 27 ++++++++++++++----- src/shared/shell/ui/AppUiModuleFlow.ts | 8 +++--- 6 files changed, 51 insertions(+), 15 deletions(-) diff --git a/src/shared/shell/AppUI.test.ts b/src/shared/shell/AppUI.test.ts index 72d8c174..67404f6e 100644 --- a/src/shared/shell/AppUI.test.ts +++ b/src/shared/shell/AppUI.test.ts @@ -770,16 +770,19 @@ describe('AppUI lifecycle', () => { expect(reopenSpy).not.toHaveBeenCalled(); }); - it('should reopen modal after delete when app selection is still open', async () => { + it('should refresh modal after delete when app selection is still open', async () => { appUI = createAppUI(); const privateAppUI = appUI as unknown as { _handleDeleteModule: (app: IApp, category: string) => Promise; - _modalManager: { isAppSelectionOpen: () => boolean }; + _modalManager: { + isAppSelectionOpen: () => boolean; + refreshCurrentSelection: (apps?: IApp[], selectedId?: string | null) => void; + }; }; vi.spyOn(privateAppUI._modalManager, 'isAppSelectionOpen').mockReturnValue(true); - const reopenSpy = vi.spyOn(appUI, 'openAppSelection'); + const refreshSpy = vi.spyOn(privateAppUI._modalManager, 'refreshCurrentSelection'); platformServiceMock.delete.mockResolvedValue(undefined); const refreshedApps = [{ id: 'svc', name: 'Service', installed: false }] as IApp[]; @@ -790,7 +793,7 @@ describe('AppUI lifecycle', () => { 'services', ); - expect(reopenSpy).toHaveBeenCalledWith('services', refreshedApps); + expect(refreshSpy).toHaveBeenCalledWith(refreshedApps, null); }); it('should resolve app by id from injected catalog resolver', () => { diff --git a/src/shared/shell/AppUI.ts b/src/shared/shell/AppUI.ts index 458fd36d..52af788c 100644 --- a/src/shared/shell/AppUI.ts +++ b/src/shared/shell/AppUI.ts @@ -131,7 +131,6 @@ export class AppUI { getCatalogApps: (category) => this._getCatalogApps(category), getSelectedAppId: (category) => this._selectionState.get(category)?.id ?? null, clearModuleCard: (category) => this.clearModuleCard(category), - openAppSelection: (category, apps) => this.openAppSelection(category, apps), markSlotCardAsInstalled: (card, app) => this._dashboardSupport.markSlotCardAsInstalled(card, app), showToast: (message, type = 'info') => this.showToast(message, type), diff --git a/src/shared/shell/ui/AppUiCardActionFlow.test.ts b/src/shared/shell/ui/AppUiCardActionFlow.test.ts index 51b5e064..a59e51ca 100644 --- a/src/shared/shell/ui/AppUiCardActionFlow.test.ts +++ b/src/shared/shell/ui/AppUiCardActionFlow.test.ts @@ -177,6 +177,22 @@ describe('AppUiCardActionFlow', () => { await flow.handleAppCardClick(event, app, 'ai_text'); expect(deps.handleDownloadModule).not.toHaveBeenCalled(); + expect(deps.performSelectionAction).not.toHaveBeenCalled(); + }); + + it('selects installed local cards from a plain card click', async () => { + const card = document.createElement('div'); + card.className = 'app-card'; + const event = { + stopPropagation: vi.fn(), + currentTarget: card, + target: card, + clientX: 20, + } as unknown as MouseEvent; + const app = { id: 'llamacpp', installed: true, repoUrl: 'https://repo' } as IApp; + + await flow.handleAppCardClick(event, app, 'ai_text'); + expect(deps.performSelectionAction).toHaveBeenCalledWith('ai_text', app); }); }); diff --git a/src/shared/shell/ui/AppUiCardActionFlow.ts b/src/shared/shell/ui/AppUiCardActionFlow.ts index 5a19f259..985b1c4d 100644 --- a/src/shared/shell/ui/AppUiCardActionFlow.ts +++ b/src/shared/shell/ui/AppUiCardActionFlow.ts @@ -34,6 +34,9 @@ export class AppUiCardActionFlow { } if (await this.tryDeleteAction(event, app, category)) return; if (await this.tryDownloadAction(event, app, category)) return; + if (!this._deps.platformService.isApiModule(app) && app.installed !== true) { + return; + } this._deps.performSelectionAction(category, app); } diff --git a/src/shared/shell/ui/AppUiModuleFlow.test.ts b/src/shared/shell/ui/AppUiModuleFlow.test.ts index db41c9a9..9db99729 100644 --- a/src/shared/shell/ui/AppUiModuleFlow.test.ts +++ b/src/shared/shell/ui/AppUiModuleFlow.test.ts @@ -21,7 +21,6 @@ describe('AppUiModuleFlow', () => { const getCatalogApps = vi.fn(); const getSelectedAppId = vi.fn(); const clearModuleCard = vi.fn(); - const openAppSelection = vi.fn(); const markSlotCardAsInstalled = vi.fn(); const showToast = vi.fn(); const translate = vi.fn((_key: string, fallback: string) => fallback); @@ -43,14 +42,13 @@ describe('AppUiModuleFlow', () => { getCatalogApps, getSelectedAppId, clearModuleCard, - openAppSelection, markSlotCardAsInstalled, showToast, translate, }); }); - it('reopens modal after delete when selection stays open', async () => { + it('refreshes modal after deleting the selected module', async () => { const app = { id: 'svc', name: 'Service', installed: true } as IApp; platformService.delete.mockResolvedValue(undefined); modalManager.isAppSelectionOpen.mockReturnValue(true); @@ -62,9 +60,26 @@ describe('AppUiModuleFlow', () => { expect(platformService.delete).toHaveBeenCalledWith(app, 'services'); expect(app.installed).toBe(false); expect(clearModuleCard).toHaveBeenCalledWith('services'); - expect(openAppSelection).toHaveBeenCalledWith('services', [ - { id: 'svc', installed: false }, - ]); + expect(modalManager.refreshCurrentSelection).toHaveBeenCalledWith( + [{ id: 'svc', installed: false }], + null, + ); + }); + + it('refreshes modal after deleting an unselected module without selecting it', async () => { + const app = { id: 'svc', name: 'Service', installed: true } as IApp; + platformService.delete.mockResolvedValue(undefined); + modalManager.isAppSelectionOpen.mockReturnValue(true); + getCatalogApps.mockReturnValue([{ id: 'svc', installed: false }]); + getSelectedAppId.mockReturnValue('other'); + + await flow.handleDeleteModule(app, 'services'); + + expect(clearModuleCard).not.toHaveBeenCalled(); + expect(modalManager.refreshCurrentSelection).toHaveBeenCalledWith( + [{ id: 'svc', installed: false }], + 'other', + ); }); it('refreshes modal selection after successful download', () => { diff --git a/src/shared/shell/ui/AppUiModuleFlow.ts b/src/shared/shell/ui/AppUiModuleFlow.ts index a3eaefec..6a37c3a8 100644 --- a/src/shared/shell/ui/AppUiModuleFlow.ts +++ b/src/shared/shell/ui/AppUiModuleFlow.ts @@ -16,7 +16,6 @@ type AppUiModuleFlowDeps = { getCatalogApps: (category: string) => IApp[]; getSelectedAppId: (category: string) => string | null; clearModuleCard: (category: string) => void; - openAppSelection: (category: string, apps?: IApp[]) => void; markSlotCardAsInstalled: (card: HTMLElement, app: IApp) => void; showToast: (message: string, type?: string) => void; translate: (key: string, fallback: string) => string; @@ -31,14 +30,15 @@ export class AppUiModuleFlow { await this._deps.platformService.delete(app, category); app.installed = false; - if (this._deps.getSelectedAppId(category) === app.id) { + const wasSelected = this._deps.getSelectedAppId(category) === app.id; + if (wasSelected) { this._deps.clearModuleCard(category); } if (this._deps.modalManager.isAppSelectionOpen()) { - this._deps.openAppSelection( - category, + this._deps.modalManager.refreshCurrentSelection( this._deps.getCatalogApps(this._toRawCategory(category)), + wasSelected ? null : this._deps.getSelectedAppId(category), ); } } catch (err: unknown) { From b03c1be90fdd89c033b2345feb5b3af8e581a43e Mon Sep 17 00:00:00 2001 From: F0RLE Date: Wed, 29 Apr 2026 18:42:44 +0300 Subject: [PATCH 015/126] fix: prefer cuda when nvml detects nvidia --- .../src/domain/modules/github_releases.rs | 15 +++++++ src-tauri/src/domain/system/hardware_probe.rs | 41 +++++++++++++++++-- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/domain/modules/github_releases.rs b/src-tauri/src/domain/modules/github_releases.rs index 709ffdc3..aa204ab9 100644 --- a/src-tauri/src/domain/modules/github_releases.rs +++ b/src-tauri/src/domain/modules/github_releases.rs @@ -90,6 +90,11 @@ pub async fn fetch_release_bundle( let repo_ref = parse_repo(repo_url)?; let platform = current_platform(); let hardware = detect_hardware_profile().await; + tracing::info!( + "Selecting release bundle for {module_id}: platform={:?}, hardware={:?}", + platform, + hardware + ); let mut page = 1_u32; loop { @@ -101,6 +106,16 @@ pub async fn fetch_release_bundle( if let Some(bundle) = find_compatible_release_bundle(module_id, platform, hardware, releases) { + tracing::info!( + "Selected release bundle for {module_id}: tag={} assets={}", + bundle.tag_name, + bundle + .assets + .iter() + .map(|asset| asset.name.as_str()) + .collect::>() + .join(", ") + ); return Ok(bundle); } diff --git a/src-tauri/src/domain/system/hardware_probe.rs b/src-tauri/src/domain/system/hardware_probe.rs index 7baa18f3..e0b4f33b 100644 --- a/src-tauri/src/domain/system/hardware_probe.rs +++ b/src-tauri/src/domain/system/hardware_probe.rs @@ -171,7 +171,7 @@ async fn probe_gpu_info_uncached() -> GpuInfo { fn probe_gpu_from_names(names: Option>) -> GpuInfo { match names { Some(names) if !names.is_empty() => gpu_probe_from_names(&names), - _ => default_probe(), + _ => nvidia_probe_from_nvml().unwrap_or_else(default_probe), } } @@ -289,6 +289,12 @@ fn gpu_probe_from_names(names: &[String]) -> GpuInfo { .unwrap_or_else(|| "Integrated / No GPU".to_string()); let brand = gpu_name_brand(&primary_name); + if brand == GpuBrand::Software { + if let Some(probe) = nvidia_probe_from_nvml() { + return probe; + } + } + let backend = preferred_backend_for_gpu_brand(brand); let detected = brand != GpuBrand::Software; let (cuda_driver_major, cuda_driver_minor) = if backend == "cuda" { @@ -311,6 +317,19 @@ fn gpu_probe_from_names(names: &[String]) -> GpuInfo { } } +fn nvidia_probe_from_nvml() -> Option { + let (cuda_driver_major, cuda_driver_minor) = detect_cuda_driver_version(); + cuda_driver_major.map(|major| GpuInfo { + detected: true, + name: "NVIDIA CUDA GPU".to_string(), + cuda: true, + backend: "cuda".to_string(), + memory: 0, + cuda_driver_major: Some(major), + cuda_driver_minor, + }) +} + const fn preferred_backend_for_gpu_brand(brand: GpuBrand) -> &'static str { match brand { GpuBrand::Nvidia => "cuda", @@ -507,8 +526,13 @@ mod tests { assert_eq!(intel.backend, "sycl"); let fallback = gpu_probe_from_names(&[String::from("Microsoft Basic Display Adapter")]); - assert_eq!(fallback.backend, "cpu"); - assert!(!fallback.detected); + if fallback.detected { + assert_eq!(fallback.backend, "cuda"); + assert!(fallback.cuda); + } else { + assert_eq!(fallback.backend, "cpu"); + assert!(!fallback.detected); + } } #[test] @@ -551,6 +575,17 @@ mod tests { assert_eq!(probe.backend, "cuda"); } + #[test] + fn nvml_fallback_is_used_when_windows_gpu_names_are_unavailable() { + let probe = probe_gpu_from_names(None); + if probe.detected { + assert_eq!(probe.backend, "cuda"); + assert!(probe.cuda); + } else { + assert_eq!(probe.backend, "cpu"); + } + } + #[test] fn merge_probe_with_runtime_stats_updates_name_and_memory() { let probe = GpuInfo { From 948e53ac24b73186eb192d3ad03ca7c884da4a05 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Wed, 29 Apr 2026 18:54:29 +0300 Subject: [PATCH 016/126] fix: detect nvidia via nvidia-smi fallback --- src-tauri/src/domain/system/hardware_probe.rs | 74 +++++++++++++++++-- 1 file changed, 67 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/domain/system/hardware_probe.rs b/src-tauri/src/domain/system/hardware_probe.rs index e0b4f33b..006de3e4 100644 --- a/src-tauri/src/domain/system/hardware_probe.rs +++ b/src-tauri/src/domain/system/hardware_probe.rs @@ -202,6 +202,7 @@ async fn probe_windows_gpu_names() -> Option> { } #[cfg(target_os = "windows")] +#[allow(clippy::manual_let_else)] fn query_windows_gpu_names_wmi() -> Option> { #[derive(serde::Deserialize)] #[serde(rename_all = "PascalCase")] @@ -209,10 +210,15 @@ fn query_windows_gpu_names_wmi() -> Option> { name: Option, } - let connection = wmi::WMIConnection::new().ok()?; - let controllers: Vec = connection - .raw_query("SELECT Name FROM Win32_VideoController") - .ok()?; + let connection = match wmi::WMIConnection::new() { + Ok(connection) => connection, + Err(_) => return query_nvidia_smi_gpu_names(), + }; + let controllers: Vec = + match connection.raw_query("SELECT Name FROM Win32_VideoController") { + Ok(controllers) => controllers, + Err(_) => return query_nvidia_smi_gpu_names(), + }; let names = controllers .into_iter() .filter_map(|controller| controller.name) @@ -220,7 +226,7 @@ fn query_windows_gpu_names_wmi() -> Option> { .filter(|name| !name.is_empty()) .collect::>(); - Some(names) + normalize_names(names).or_else(query_nvidia_smi_gpu_names) } #[cfg(target_os = "macos")] @@ -377,10 +383,10 @@ fn gpu_name_brand(name: &str) -> GpuBrand { fn detect_cuda_driver_version() -> (Option, Option) { let Ok(nvml) = Nvml::init() else { - return (None, None); + return detect_cuda_driver_version_with_nvidia_smi(); }; let Ok(version) = nvml.sys_cuda_driver_version() else { - return (None, None); + return detect_cuda_driver_version_with_nvidia_smi(); }; let major = u32::try_from(cuda_driver_version_major(version)).ok(); @@ -388,6 +394,53 @@ fn detect_cuda_driver_version() -> (Option, Option) { (major, minor) } +fn query_nvidia_smi_gpu_names() -> Option> { + let output = std::process::Command::new("nvidia-smi") + .args(["--query-gpu=name", "--format=csv,noheader"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + + let raw = String::from_utf8_lossy(&output.stdout); + normalize_names(raw.lines().map(str::to_string).collect()) +} + +fn detect_cuda_driver_version_with_nvidia_smi() -> (Option, Option) { + let output = std::process::Command::new("nvidia-smi") + .args([ + "--query-gpu=driver_version", + "--format=csv,noheader,nounits", + ]) + .output(); + let Ok(output) = output else { + return (None, None); + }; + if !output.status.success() { + return (None, None); + } + + let raw = String::from_utf8_lossy(&output.stdout); + let Some(version) = raw + .lines() + .next() + .map(str::trim) + .filter(|line| !line.is_empty()) + else { + return (None, None); + }; + + parse_nvidia_driver_version(version) +} + +fn parse_nvidia_driver_version(version: &str) -> (Option, Option) { + let mut parts = version.split('.'); + let major = parts.next().and_then(|part| part.parse::().ok()); + let minor = parts.next().and_then(|part| part.parse::().ok()); + (major, minor) +} + fn gpu_name_priority(name: &str) -> i32 { match gpu_name_brand(name) { GpuBrand::Software => 0, @@ -586,6 +639,13 @@ mod tests { } } + #[test] + fn parses_nvidia_smi_driver_version() { + assert_eq!(parse_nvidia_driver_version("566.36"), (Some(566), Some(36))); + assert_eq!(parse_nvidia_driver_version("580"), (Some(580), None)); + assert_eq!(parse_nvidia_driver_version("bad"), (None, None)); + } + #[test] fn merge_probe_with_runtime_stats_updates_name_and_memory() { let probe = GpuInfo { From f4605469cfa0e037f89b205f090a71791adca5e2 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Wed, 29 Apr 2026 19:11:15 +0300 Subject: [PATCH 017/126] fix: choose cuda bundles from runtime version --- .../modules/github_release_selection.rs | 4 ++ .../src/domain/modules/github_releases.rs | 32 ++++++++++ src-tauri/src/domain/system/hardware_probe.rs | 64 ++----------------- 3 files changed, 41 insertions(+), 59 deletions(-) diff --git a/src-tauri/src/domain/modules/github_release_selection.rs b/src-tauri/src/domain/modules/github_release_selection.rs index 36523138..345abfa0 100644 --- a/src-tauri/src/domain/modules/github_release_selection.rs +++ b/src-tauri/src/domain/modules/github_release_selection.rs @@ -490,6 +490,10 @@ impl HardwareProfile { fn supports_cuda_track(&self, track: CudaTrack) -> bool { match self.cuda_driver_major { + Some(driver_major) if driver_major < 100 => match track { + CudaTrack::Cuda12 => driver_major >= 12, + CudaTrack::Cuda13 => driver_major >= 13, + }, Some(driver_major) => driver_major >= track.min_driver_major(), None => track == CudaTrack::Cuda12, } diff --git a/src-tauri/src/domain/modules/github_releases.rs b/src-tauri/src/domain/modules/github_releases.rs index aa204ab9..f0ac3248 100644 --- a/src-tauri/src/domain/modules/github_releases.rs +++ b/src-tauri/src/domain/modules/github_releases.rs @@ -373,6 +373,38 @@ mod tests { ); } + #[test] + fn treats_cuda_runtime_version_as_cuda_track_support() { + let platform = Platform { + os: PlatformOs::Windows, + arch: PlatformArch::X64, + }; + let hardware = HardwareProfile { + accelerator: AcceleratorClass::NvidiaCuda, + cpu_tier: CpuInstructionTier::Avx2, + cuda_driver_major: Some(13), + cuda_driver_minor: Some(2), + }; + let assets = vec![ + asset("cudart-llama-bin-win-cuda-13.1-x64.zip"), + asset("llama-b8971-bin-win-cuda-13.1-x64.zip"), + asset("llama-b8971-bin-win-vulkan-x64.zip"), + ]; + + let selected = select_release_assets("llamacpp", platform, hardware, &assets) + .expect("expected cuda runtime version to support cuda asset selection"); + + assert_eq!(selected.len(), 2); + assert_eq!( + selected.first().map(|asset| asset.name.as_str()), + Some("cudart-llama-bin-win-cuda-13.1-x64.zip") + ); + assert_eq!( + selected.get(1).map(|asset| asset.name.as_str()), + Some("llama-b8971-bin-win-cuda-13.1-x64.zip") + ); + } + #[test] fn prefers_cuda12_when_cuda_driver_version_is_unknown() { let platform = Platform { diff --git a/src-tauri/src/domain/system/hardware_probe.rs b/src-tauri/src/domain/system/hardware_probe.rs index 006de3e4..f29e4de6 100644 --- a/src-tauri/src/domain/system/hardware_probe.rs +++ b/src-tauri/src/domain/system/hardware_probe.rs @@ -212,12 +212,12 @@ fn query_windows_gpu_names_wmi() -> Option> { let connection = match wmi::WMIConnection::new() { Ok(connection) => connection, - Err(_) => return query_nvidia_smi_gpu_names(), + Err(_) => return None, }; let controllers: Vec = match connection.raw_query("SELECT Name FROM Win32_VideoController") { Ok(controllers) => controllers, - Err(_) => return query_nvidia_smi_gpu_names(), + Err(_) => return None, }; let names = controllers .into_iter() @@ -226,7 +226,7 @@ fn query_windows_gpu_names_wmi() -> Option> { .filter(|name| !name.is_empty()) .collect::>(); - normalize_names(names).or_else(query_nvidia_smi_gpu_names) + normalize_names(names) } #[cfg(target_os = "macos")] @@ -383,10 +383,10 @@ fn gpu_name_brand(name: &str) -> GpuBrand { fn detect_cuda_driver_version() -> (Option, Option) { let Ok(nvml) = Nvml::init() else { - return detect_cuda_driver_version_with_nvidia_smi(); + return (None, None); }; let Ok(version) = nvml.sys_cuda_driver_version() else { - return detect_cuda_driver_version_with_nvidia_smi(); + return (None, None); }; let major = u32::try_from(cuda_driver_version_major(version)).ok(); @@ -394,53 +394,6 @@ fn detect_cuda_driver_version() -> (Option, Option) { (major, minor) } -fn query_nvidia_smi_gpu_names() -> Option> { - let output = std::process::Command::new("nvidia-smi") - .args(["--query-gpu=name", "--format=csv,noheader"]) - .output() - .ok()?; - if !output.status.success() { - return None; - } - - let raw = String::from_utf8_lossy(&output.stdout); - normalize_names(raw.lines().map(str::to_string).collect()) -} - -fn detect_cuda_driver_version_with_nvidia_smi() -> (Option, Option) { - let output = std::process::Command::new("nvidia-smi") - .args([ - "--query-gpu=driver_version", - "--format=csv,noheader,nounits", - ]) - .output(); - let Ok(output) = output else { - return (None, None); - }; - if !output.status.success() { - return (None, None); - } - - let raw = String::from_utf8_lossy(&output.stdout); - let Some(version) = raw - .lines() - .next() - .map(str::trim) - .filter(|line| !line.is_empty()) - else { - return (None, None); - }; - - parse_nvidia_driver_version(version) -} - -fn parse_nvidia_driver_version(version: &str) -> (Option, Option) { - let mut parts = version.split('.'); - let major = parts.next().and_then(|part| part.parse::().ok()); - let minor = parts.next().and_then(|part| part.parse::().ok()); - (major, minor) -} - fn gpu_name_priority(name: &str) -> i32 { match gpu_name_brand(name) { GpuBrand::Software => 0, @@ -639,13 +592,6 @@ mod tests { } } - #[test] - fn parses_nvidia_smi_driver_version() { - assert_eq!(parse_nvidia_driver_version("566.36"), (Some(566), Some(36))); - assert_eq!(parse_nvidia_driver_version("580"), (Some(580), None)); - assert_eq!(parse_nvidia_driver_version("bad"), (None, None)); - } - #[test] fn merge_probe_with_runtime_stats_updates_name_and_memory() { let probe = GpuInfo { From 10c3048a6610b4b099ae2cc9b4799cbc2512d658 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Wed, 29 Apr 2026 20:39:56 +0300 Subject: [PATCH 018/126] chore: add repository development defaults --- .github/CODEOWNERS | 2 + .github/ISSUE_TEMPLATE/bug_report.yml | 46 ++++++++++++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 8 ++++ .github/ISSUE_TEMPLATE/feature_request.yml | 24 +++++++++++ .github/PULL_REQUEST_TEMPLATE.md | 14 +++++++ .nvmrc | 2 + .vscode/extensions.json | 10 +++++ .vscode/settings.json | 14 +++++++ SECURITY.md | 23 +++++++++++ SUPPORT.md | 12 ++++++ 10 files changed, 155 insertions(+) create mode 100644 .github/CODEOWNERS create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .nvmrc create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json create mode 100644 SECURITY.md create mode 100644 SUPPORT.md diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..c2d80092 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +* @F0RLE + diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..3e3e0118 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,46 @@ +name: Bug Report +description: Report a reproducible problem in Axelate. +title: "[Bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Use this for reproducible problems in the current application. + - type: textarea + id: description + attributes: + label: What happened? + description: Describe the problem and what you expected instead. + validations: + required: true + - type: textarea + id: steps + attributes: + label: Reproduction steps + description: List the smallest set of steps that reproduces the issue. + placeholder: | + 1. Open ... + 2. Click ... + 3. See ... + validations: + required: true + - type: input + id: version + attributes: + label: Axelate version or commit + placeholder: v0.1.5 or commit SHA + validations: + required: true + - type: input + id: windows + attributes: + label: Windows version + placeholder: Windows 11 24H2 + validations: + required: true + - type: textarea + id: logs + attributes: + label: Relevant logs or screenshots + render: text diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..a7a0ede2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Development setup + url: https://github.com/F0RLE/Axelate/blob/nightly/docs/en/GETTING_STARTED.md + about: Start here for local development prerequisites and setup. + - name: Release process + url: https://github.com/F0RLE/Axelate/blob/nightly/docs/en/RELEASES.md + about: Read this before tagging or preparing a release. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..19d2c073 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,24 @@ +name: Feature Request +description: Propose a product or developer workflow improvement. +title: "[Feature]: " +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What user or developer problem should this solve? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposal + description: Describe the smallest useful version of the change. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Note any simpler options or tradeoffs. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..cc3c4a22 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,14 @@ +## Summary + +- + +## Verification + +- [ ] `npm run verify` + +## Checklist + +- [ ] The PR targets `nightly`, unless this is a release PR to `main`. +- [ ] User-facing behavior is documented in `README.md` or `docs/en` when needed. +- [ ] Rust-exported frontend bindings were regenerated with `npm run bindings:sync` when Rust types changed. +- [ ] No generated build output, cache files, local runtime data, or secrets are committed. diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..35f49783 --- /dev/null +++ b/.nvmrc @@ -0,0 +1,2 @@ +20 + diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..32b2e57d --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,10 @@ +{ + "recommendations": [ + "rust-lang.rust-analyzer", + "tauri-apps.tauri-vscode", + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "tamasfe.even-better-toml", + "vitest.explorer" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..e289eb14 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,14 @@ +{ + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": false, + "eslint.workingDirectories": [ + { + "directory": "src", + "changeProcessCWD": true + } + ], + "files.eol": "\n", + "rust-analyzer.cargo.features": "all", + "rust-analyzer.check.command": "clippy", + "typescript.tsdk": "src/node_modules/typescript/lib" +} diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..d0f98af3 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,23 @@ +# Security Policy + +## Supported Versions + +Axelate is in active early development. Security fixes are made on `nightly` first and released through `main` when a tagged release is prepared. + +## Reporting A Vulnerability + +Do not open a public issue for a suspected vulnerability. + +Use GitHub private vulnerability reporting when available, or contact the repository owner through GitHub with enough detail to reproduce and assess the issue. + +Include: + +- affected version or commit +- operating system version +- reproduction steps +- expected impact +- relevant logs, screenshots, or proof-of-concept details + +## Security Defaults + +The repository uses GitHub secret scanning, push protection, Dependabot alerts, and Dependabot security updates. Release tags must match project versions and point to commits reachable from `main`. diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 00000000..e0153691 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,12 @@ +# Support + +Axelate is currently an active development project. + +Use GitHub Issues for reproducible bugs and scoped feature requests. For setup and workflow questions, start with: + +- `README.md` +- `docs/en/GETTING_STARTED.md` +- `docs/en/DEVELOPMENT_WORKFLOW.md` +- `docs/en/CURRENT_STATE.md` + +For security issues, follow `SECURITY.md` instead of opening a public issue. From 21c246fa5ca7feffff150e9dccb4f79fb4525e34 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Wed, 29 Apr 2026 20:51:25 +0300 Subject: [PATCH 019/126] chore: add repository security scanning --- .github/workflows/codeql.yml | 56 +++++++++++++++++++++++ .github/workflows/dependency-review.yml | 28 ++++++++++++ .github/workflows/security-audit.yml | 60 +++++++++++++++++++++++++ 3 files changed, 144 insertions(+) create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/dependency-review.yml create mode 100644 .github/workflows/security-audit.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..c9bf0b1a --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,56 @@ +name: CodeQL + +on: + workflow_dispatch: + push: + branches: ["main", "nightly"] + pull_request: + branches: ["main", "nightly"] + schedule: + - cron: "27 3 * * 1" + +concurrency: + group: codeql-${{ github.workflow }}-${{ github.ref }}-${{ matrix.language }} + cancel-in-progress: true + +permissions: + actions: read + contents: read + security-events: write + +jobs: + analyze: + name: CodeQL (${{ matrix.language }}) + runs-on: windows-latest + timeout-minutes: 35 + + strategy: + fail-fast: false + matrix: + include: + - language: javascript-typescript + build-mode: none + - language: rust + build-mode: none + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Rust + if: matrix.language == 'rust' + uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 + with: + toolchain: 1.94.1 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + queries: security-extended,security-and-quality + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 00000000..229dbede --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,28 @@ +name: Dependency Review + +on: + pull_request: + branches: ["main", "nightly"] + +concurrency: + group: dependency-review-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: read + +jobs: + dependency-review: + name: Dependency Review + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Review dependency changes + uses: actions/dependency-review-action@v4 + with: + fail-on-severity: high + comment-summary-in-pr: always diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml new file mode 100644 index 00000000..4662ddc4 --- /dev/null +++ b/.github/workflows/security-audit.yml @@ -0,0 +1,60 @@ +name: Security Audit + +on: + workflow_dispatch: + schedule: + - cron: "41 4 * * 1" + +concurrency: + group: security-audit-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + npm-audit: + name: npm Audit + runs-on: windows-latest + timeout-minutes: 20 + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: src/package-lock.json + + - name: Install frontend dependencies + run: | + cd src + npm ci + + - name: Audit frontend dependencies + run: | + cd src + npm audit --audit-level=high + + cargo-audit: + name: Cargo Audit + runs-on: windows-latest + timeout-minutes: 25 + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 + with: + toolchain: 1.94.1 + + - name: Install cargo-audit + run: cargo install cargo-audit --locked --quiet + + - name: Audit Rust dependencies + run: | + cd src-tauri + cargo audit From 3b621362a8863f0ec5982a98923765a5825960b6 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Wed, 29 Apr 2026 20:53:52 +0300 Subject: [PATCH 020/126] fix: correct codeql workflow concurrency --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index c9bf0b1a..30bd1cb9 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -10,7 +10,7 @@ on: - cron: "27 3 * * 1" concurrency: - group: codeql-${{ github.workflow }}-${{ github.ref }}-${{ matrix.language }} + group: codeql-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true permissions: From 99be5ceea91599e39a7087250207ab173b0ef610 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Wed, 29 Apr 2026 20:56:44 +0300 Subject: [PATCH 021/126] chore: tune coderabbit reviews --- .coderabbit.yaml | 93 +++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 77 insertions(+), 16 deletions(-) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index bf139edf..65231bdf 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -1,18 +1,79 @@ # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +language: "en-US" +tone_instructions: "Be direct and concise. Prioritize correctness, security, data loss, broken user flows, and missing tests over style-only comments." + reviews: - review_status: false - path_filters: - - "!src/dist/**" - - "!src/coverage/**" - - "!src/node_modules/**" - - "!src-tauri/target/**" - - "!src-tauri/gen/**" - - "!src/shared/types/bindings.ts" - - "!docs/localization/**" - - "!**/*.lock" - - "!**/package-lock.json" - auto_review: - enabled: true - drafts: true - base_branches: - - "nightly" + profile: "chill" + request_changes_workflow: false + high_level_summary: true + review_status: false + collapse_walkthrough: true + poem: false + suggested_labels: true + auto_apply_labels: false + labeling_instructions: + - label: "frontend" + instructions: "Use for changes under src app, features, shared UI, services, styles, tests, or frontend tooling." + - label: "backend" + instructions: "Use for Rust, Tauri commands, src-tauri resources, engine runtime, module lifecycle, secure storage, logging, or IPC bindings." + - label: "security" + instructions: "Use when changes affect secrets, authentication, filesystem access, command execution, downloads, archives, update/release trust, CSP, or IPC permissions." + - label: "ci" + instructions: "Use for GitHub Actions, Dependabot, repository automation, hooks, scripts, or workflow configuration." + - label: "dependencies" + instructions: "Use for dependency, lockfile, toolchain, npm, cargo, or GitHub Actions version changes." + - label: "release" + instructions: "Use for versioning, tagging, packaging, checksums, installers, release notes, or release workflow changes." + path_filters: + - "!docs/**" + - "!src/**" + - "!src-tauri/resources/**" + - "!src-tauri/Cargo.lock" + - "!.github/ISSUE_TEMPLATE/**" + - "!.github/PULL_REQUEST_TEMPLATE.md" + - "!.github/CODEOWNERS" + - "!**/*.md" + path_instructions: + - path: "src-tauri/src/**/*.rs" + instructions: "Review as a Windows-first Tauri backend. Prioritize IPC boundary validation, path traversal, archive extraction safety, process spawning, cancellation, secure storage, logging, and error mapping. Check that frontend-callable commands never expose raw secrets and keep user data scoped to app directories." + - path: "src/**/*.ts" + instructions: "Review as vanilla TypeScript frontend code. Prioritize state consistency, async cancellation, event listener cleanup, DOM injection risks, user-visible regressions, and test coverage for changed flows. Avoid style-only comments unless they hide a real defect." + - path: ".github/workflows/*.yml" + instructions: "Review GitHub Actions for least-privilege permissions, pinned or versioned actions, correct branch/tag triggers, cache safety, and whether failures are intentional or accidentally continue-on-error." + - path: "src-tauri/resources/**/*.json" + instructions: "Review resource changes for schema consistency, localization key parity, default settings safety, and compatibility with existing config readers." + auto_review: + enabled: true + drafts: true + auto_incremental_review: true + auto_pause_after_reviewed_commits: 0 + base_branches: + - "nightly" + - "main" + tools: + eslint: + enabled: true + clippy: + enabled: true + actionlint: + enabled: true + gitleaks: + enabled: true + trufflehog: + enabled: true + semgrep: + enabled: true + +chat: + auto_reply: true + +knowledge_base: + code_guidelines: + enabled: true + filePatterns: + - "README.md" + - "CONTRIBUTING.md" + - "SECURITY.md" + - "docs/en/GETTING_STARTED.md" + - "docs/en/DEVELOPMENT_WORKFLOW.md" + - "docs/en/TRUST_MODEL.md" From a0e2c819ca949d638aeb7329a7fbd16f6933e67b Mon Sep 17 00:00:00 2001 From: F0RLE Date: Wed, 29 Apr 2026 21:00:42 +0300 Subject: [PATCH 022/126] docs: align repository automation docs --- CONTRIBUTING.md | 13 ++++++++++++- README.md | 2 ++ SECURITY.md | 11 ++++++++++- docs/en/CURRENT_STATE.md | 18 +++++++++++++++++- docs/en/DEVELOPMENT_WORKFLOW.md | 21 +++++++++++++++++++-- docs/en/RELEASES.md | 8 ++++++++ 6 files changed, 68 insertions(+), 5 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0c831b2d..d4f5b42c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,13 +22,23 @@ Use these documents first: - Keep `main` release-ready. - Send dependency update work to `nightly`. - Create release tags only from commits that are ready to ship. +- Use squash merge for pull requests. Merge commits and rebase merges are disabled in the repository settings. ## Before Opening A PR - Run `npm run verify`. - If you changed Rust types exported to the frontend, run `npm run bindings:sync`. - Keep commit messages in Conventional Commits format. `npm run setup` installs Git hooks that enforce this. -- Expect GitHub `Strict CI` on pull requests targeting `main` or `nightly`. +- Expect GitHub `Strict CI`, dependency review, CodeQL, and CodeRabbit on pull requests targeting `main` or `nightly`. +- The protected branches do not require a second human approval right now because the project is maintained by a solo owner. + +## Repository Automation + +- `Strict CI` is the required merge gate for protected branches. +- `CodeQL`, `Dependency Review`, and scheduled `Security Audit` workflows provide additional security coverage. +- CodeRabbit reviews pull requests against `nightly` and `main`; its feedback is advisory unless a concrete bug or risk is confirmed. +- Dependabot security and dependency update pull requests target `nightly`. +- Secret scanning and push protection are enabled in GitHub repository settings. ## Releases @@ -36,6 +46,7 @@ Use these documents first: - Tags must start with `v`. - Tag versions must match `package.json`, `src/package.json`, and `src-tauri/Cargo.toml`. - Release tags must point to a commit that is already reachable from `main`. +- Release tags matching `v*` are protected against deletion and non-fast-forward updates. - Pushing a matching `v*` tag triggers the GitHub release workflow. ## Docs Policy diff --git a/README.md b/README.md index 87154d03..9e7fe2af 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,8 @@ npm run verify - `nightly` is the active development branch. - `main` is the release-ready branch. - Strict CI runs on pushes and pull requests targeting `main` or `nightly`. +- CodeQL, dependency review, scheduled security audits, Dependabot, and CodeRabbit are configured for repository review and security coverage. +- Protected branches require the strict frontend and backend CI checks, but not a second human approval; this matches the current solo-maintainer workflow. - Dependabot targets `nightly`. - GitHub releases are created by pushing a version tag that starts with `v`. - Release tags must point to a commit that is already reachable from `main`. diff --git a/SECURITY.md b/SECURITY.md index d0f98af3..a0c6cfc4 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -20,4 +20,13 @@ Include: ## Security Defaults -The repository uses GitHub secret scanning, push protection, Dependabot alerts, and Dependabot security updates. Release tags must match project versions and point to commits reachable from `main`. +The repository uses GitHub secret scanning, push protection, Dependabot alerts, and Dependabot security updates. + +Additional repository security automation: + +- CodeQL scans TypeScript/JavaScript and Rust. +- Dependency Review runs on pull requests targeting `main` and `nightly`. +- Scheduled Security Audit runs `npm audit --audit-level=high` and `cargo audit`. +- CodeRabbit is configured to review security-sensitive Rust/Tauri, TypeScript, workflow, and resource changes. + +Release tags must match project versions and point to commits reachable from `main`. Tags matching `v*` are protected against deletion and non-fast-forward updates. diff --git a/docs/en/CURRENT_STATE.md b/docs/en/CURRENT_STATE.md index 474ab419..a971c133 100644 --- a/docs/en/CURRENT_STATE.md +++ b/docs/en/CURRENT_STATE.md @@ -1,6 +1,6 @@ # Axelate Current State -> Repository-grounded snapshot as of 2026-04-23. +> Repository-grounded snapshot as of 2026-04-29. > This document describes what exists now, not what the future product aspires to become. For setup and contributor workflow, use [Getting Started](GETTING_STARTED.md) and [Development Workflow](DEVELOPMENT_WORKFLOW.md). @@ -318,8 +318,24 @@ Current GitHub automation: - strict CI runs on `main` and `nightly` - Dependabot opens dependency update pull requests against `nightly` +- Dependabot security updates, secret scanning, and push protection are enabled +- CodeQL scans TypeScript/JavaScript and Rust +- dependency review runs on pull requests +- scheduled security audit runs `npm audit` and `cargo audit` +- CodeRabbit reviews pull requests targeting `nightly` and `main` - release builds run when a `v*` tag is pushed - release tags must match all project manifest versions +- release tags matching `v*` are protected against deletion and non-fast-forward updates + +Current branch and merge settings: + +- `nightly` is the default branch +- `main` and `nightly` are protected +- protected branches require the frontend and backend strict CI checks +- protected branches require linear history and resolved conversations +- protected branches reject force-push and branch deletion +- human approval and CODEOWNERS review are not required during the solo-maintainer phase +- squash merge is enabled; merge commits and rebase merges are disabled The repository is still alpha-stage. `nightly` is where active development lands; `main` should stay release-ready. diff --git a/docs/en/DEVELOPMENT_WORKFLOW.md b/docs/en/DEVELOPMENT_WORKFLOW.md index 26a88a75..c943a63a 100644 --- a/docs/en/DEVELOPMENT_WORKFLOW.md +++ b/docs/en/DEVELOPMENT_WORKFLOW.md @@ -16,6 +16,9 @@ Branch model: - `main` is the release-ready branch - dependency update pull requests target `nightly` - merge to `main` only after CI is green and the change is ready to release +- protected branches require strict frontend/backend checks, linear history, resolved conversations, and no force-push or deletion +- protected branches currently do not require a second human approval because the repository is in a solo-maintainer phase +- pull requests use squash merge; merge commits and rebase merges are disabled The repository currently splits responsibilities this way: @@ -86,18 +89,32 @@ npm run release - `tauri:build`: desktop app build - `release`: full verification plus release bundle build -## CI And Releases +## Automation, CI, And Releases -GitHub Actions currently has two repository workflows: +GitHub Actions currently has these repository workflows: - `Strict CI`: runs on pushes and pull requests for `main` and `nightly`, plus manual dispatch +- `CodeQL`: runs code scanning for TypeScript/JavaScript and Rust on pushes to `main` or `nightly`, pull requests, weekly schedule, and manual dispatch +- `Dependency Review`: reviews dependency changes on pull requests to `main` and `nightly` +- `Security Audit`: runs scheduled and manual `npm audit` plus `cargo audit` - `Release Build`: runs on pushed `v*` tags, plus manual dispatch for an existing tag +Protected branches require the `Frontend Strict Check` and `Backend Strict Check` jobs from `Strict CI`. +The security workflows and CodeRabbit are additional review signals, not required branch-protection checks today. + The release workflow builds the Windows Tauri bundles, verifies release hardening, writes `SHA256SUMS.txt`, and attaches checksums to the GitHub release. The release tag must match the versions in `package.json`, `src/package.json`, and `src-tauri/Cargo.toml`. See [Releases](RELEASES.md) for the release checklist. +Repository review automation: + +- CodeRabbit reviews pull requests targeting `nightly` and `main` +- CodeRabbit is configured for a low-noise solo-maintainer workflow and should prioritize correctness, security, data loss, user-flow regressions, and missing tests +- CodeRabbit labeling is advisory; labels are not auto-applied +- generated bindings, lockfiles, build output, caches, and dependency directories are excluded from CodeRabbit review noise where configured +- GitHub secret scanning, push protection, Dependabot alerts, and Dependabot security updates are enabled in repository settings + Cleanup command: ```bash diff --git a/docs/en/RELEASES.md b/docs/en/RELEASES.md index 33c6e9c1..91116d39 100644 --- a/docs/en/RELEASES.md +++ b/docs/en/RELEASES.md @@ -10,6 +10,7 @@ - Dependabot targets `nightly`. - Release tags should be created from the commit that is meant to ship. - Release tags must point to a commit that is already reachable from `main`. +- Release tags matching `v*` are protected by a repository ruleset against deletion and non-fast-forward updates. ## CI @@ -23,6 +24,13 @@ The CI gate checks frontend linting, formatting, type/build, bundle size, tests, Rust clippy, Rust check, Rust tests, and audit reporting. +Additional release-relevant automation: + +- `CodeQL` scans TypeScript/JavaScript and Rust code on PRs, protected branch pushes, weekly schedule, and manual dispatch. +- `Dependency Review` checks dependency changes on PRs to `main` and `nightly`. +- `Security Audit` runs `npm audit --audit-level=high` and `cargo audit` on a weekly schedule and manual dispatch. +- CodeRabbit reviews PRs and is configured as advisory automation for the current solo-maintainer workflow. + ## Local Release Check Before tagging a release, run: From dab6423f0c3c4908d357580b49fef1f694143a7b Mon Sep 17 00:00:00 2001 From: F0RLE Date: Wed, 29 Apr 2026 21:18:16 +0300 Subject: [PATCH 023/126] chore: reduce routine pr check noise --- .github/workflows/codeql.yml | 2 -- .github/workflows/dependency-review.yml | 5 +++++ CONTRIBUTING.md | 5 +++-- README.md | 2 +- SECURITY.md | 4 ++-- docs/en/CURRENT_STATE.md | 4 ++-- docs/en/DEVELOPMENT_WORKFLOW.md | 7 ++++--- docs/en/RELEASES.md | 4 ++-- 8 files changed, 19 insertions(+), 14 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 30bd1cb9..b6955429 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -4,8 +4,6 @@ on: workflow_dispatch: push: branches: ["main", "nightly"] - pull_request: - branches: ["main", "nightly"] schedule: - cron: "27 3 * * 1" diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 229dbede..d953abd6 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -3,6 +3,11 @@ name: Dependency Review on: pull_request: branches: ["main", "nightly"] + paths: + - "src/package.json" + - "src/package-lock.json" + - "src-tauri/Cargo.toml" + - "src-tauri/Cargo.lock" concurrency: group: dependency-review-${{ github.event.pull_request.number }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d4f5b42c..edcf16ed 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,13 +29,14 @@ Use these documents first: - Run `npm run verify`. - If you changed Rust types exported to the frontend, run `npm run bindings:sync`. - Keep commit messages in Conventional Commits format. `npm run setup` installs Git hooks that enforce this. -- Expect GitHub `Strict CI`, dependency review, CodeQL, and CodeRabbit on pull requests targeting `main` or `nightly`. +- Expect GitHub `Strict CI` and CodeRabbit on pull requests targeting `main` or `nightly`. +- Expect `Dependency Review` only when npm or Cargo dependency files change. - The protected branches do not require a second human approval right now because the project is maintained by a solo owner. ## Repository Automation - `Strict CI` is the required merge gate for protected branches. -- `CodeQL`, `Dependency Review`, and scheduled `Security Audit` workflows provide additional security coverage. +- `CodeQL`, `Dependency Review`, and scheduled `Security Audit` workflows provide additional security coverage without blocking every normal PR. - CodeRabbit reviews pull requests against `nightly` and `main`; its feedback is advisory unless a concrete bug or risk is confirmed. - Dependabot security and dependency update pull requests target `nightly`. - Secret scanning and push protection are enabled in GitHub repository settings. diff --git a/README.md b/README.md index 9e7fe2af..aaa5e3c1 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ npm run verify - `nightly` is the active development branch. - `main` is the release-ready branch. - Strict CI runs on pushes and pull requests targeting `main` or `nightly`. -- CodeQL, dependency review, scheduled security audits, Dependabot, and CodeRabbit are configured for repository review and security coverage. +- CodeQL, dependency review for dependency-file changes, scheduled security audits, Dependabot, and CodeRabbit are configured for repository review and security coverage. - Protected branches require the strict frontend and backend CI checks, but not a second human approval; this matches the current solo-maintainer workflow. - Dependabot targets `nightly`. - GitHub releases are created by pushing a version tag that starts with `v`. diff --git a/SECURITY.md b/SECURITY.md index a0c6cfc4..6c065e29 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -24,8 +24,8 @@ The repository uses GitHub secret scanning, push protection, Dependabot alerts, Additional repository security automation: -- CodeQL scans TypeScript/JavaScript and Rust. -- Dependency Review runs on pull requests targeting `main` and `nightly`. +- CodeQL scans TypeScript/JavaScript and Rust on protected branch pushes, weekly schedule, and manual dispatch. +- Dependency Review runs on pull requests targeting `main` and `nightly` when npm or Cargo dependency files change. - Scheduled Security Audit runs `npm audit --audit-level=high` and `cargo audit`. - CodeRabbit is configured to review security-sensitive Rust/Tauri, TypeScript, workflow, and resource changes. diff --git a/docs/en/CURRENT_STATE.md b/docs/en/CURRENT_STATE.md index a971c133..d2be2518 100644 --- a/docs/en/CURRENT_STATE.md +++ b/docs/en/CURRENT_STATE.md @@ -319,8 +319,8 @@ Current GitHub automation: - strict CI runs on `main` and `nightly` - Dependabot opens dependency update pull requests against `nightly` - Dependabot security updates, secret scanning, and push protection are enabled -- CodeQL scans TypeScript/JavaScript and Rust -- dependency review runs on pull requests +- CodeQL scans TypeScript/JavaScript and Rust on protected branch pushes, weekly schedule, and manual dispatch +- dependency review runs on pull requests only when npm or Cargo dependency files change - scheduled security audit runs `npm audit` and `cargo audit` - CodeRabbit reviews pull requests targeting `nightly` and `main` - release builds run when a `v*` tag is pushed diff --git a/docs/en/DEVELOPMENT_WORKFLOW.md b/docs/en/DEVELOPMENT_WORKFLOW.md index c943a63a..eeb3dc6b 100644 --- a/docs/en/DEVELOPMENT_WORKFLOW.md +++ b/docs/en/DEVELOPMENT_WORKFLOW.md @@ -94,13 +94,14 @@ npm run release GitHub Actions currently has these repository workflows: - `Strict CI`: runs on pushes and pull requests for `main` and `nightly`, plus manual dispatch -- `CodeQL`: runs code scanning for TypeScript/JavaScript and Rust on pushes to `main` or `nightly`, pull requests, weekly schedule, and manual dispatch -- `Dependency Review`: reviews dependency changes on pull requests to `main` and `nightly` +- `CodeQL`: runs code scanning for TypeScript/JavaScript and Rust on pushes to `main` or `nightly`, weekly schedule, and manual dispatch +- `Dependency Review`: reviews dependency changes on pull requests to `main` and `nightly` when npm or Cargo dependency files change - `Security Audit`: runs scheduled and manual `npm audit` plus `cargo audit` - `Release Build`: runs on pushed `v*` tags, plus manual dispatch for an existing tag Protected branches require the `Frontend Strict Check` and `Backend Strict Check` jobs from `Strict CI`. -The security workflows and CodeRabbit are additional review signals, not required branch-protection checks today. +CodeRabbit is the normal advisory review signal on pull requests. +CodeQL and scheduled security audits run outside the normal PR path to avoid slowing down solo development. The release workflow builds the Windows Tauri bundles, verifies release hardening, writes `SHA256SUMS.txt`, and attaches checksums to the GitHub release. The release tag must match the versions in `package.json`, `src/package.json`, and `src-tauri/Cargo.toml`. diff --git a/docs/en/RELEASES.md b/docs/en/RELEASES.md index 91116d39..649dad3e 100644 --- a/docs/en/RELEASES.md +++ b/docs/en/RELEASES.md @@ -26,8 +26,8 @@ The CI gate checks frontend linting, formatting, type/build, bundle size, tests, Additional release-relevant automation: -- `CodeQL` scans TypeScript/JavaScript and Rust code on PRs, protected branch pushes, weekly schedule, and manual dispatch. -- `Dependency Review` checks dependency changes on PRs to `main` and `nightly`. +- `CodeQL` scans TypeScript/JavaScript and Rust code on protected branch pushes, weekly schedule, and manual dispatch. +- `Dependency Review` checks dependency changes on PRs to `main` and `nightly` when npm or Cargo dependency files change. - `Security Audit` runs `npm audit --audit-level=high` and `cargo audit` on a weekly schedule and manual dispatch. - CodeRabbit reviews PRs and is configured as advisory automation for the current solo-maintainer workflow. From cdcaf074730a5dfce91475455081c8cd41faa300 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 30 Apr 2026 00:45:25 +0300 Subject: [PATCH 024/126] fix: harden launcher runtime persistence --- .github/workflows/dependency-review.yml | 2 +- src-tauri/resources/locales/en.json | 20 +- src-tauri/resources/locales/ru.json | 20 +- src-tauri/resources/locales/zh.json | 20 +- src-tauri/src/api/ai/mod.rs | 4 +- src-tauri/src/api/engine/mod.rs | 14 +- src-tauri/src/api/license/mod.rs | 4 +- src-tauri/src/api/modules/downloader.rs | 13 + src-tauri/src/api/settings/mod.rs | 2 + src-tauri/src/api/settings/window_settings.rs | 17 +- src-tauri/src/api/system/bootstrap.rs | 5 +- src-tauri/src/api/system/logs.rs | 8 +- src-tauri/src/app/tray.rs | 14 +- src-tauri/src/domain/ai/ai_dispatch.rs | 8 +- src-tauri/src/domain/ai/ai_service.rs | 41 +- .../src/domain/ai/custom_model_service.rs | 6 +- src-tauri/src/domain/ai/image_service.rs | 1 + src-tauri/src/domain/ai/session.rs | 327 ++++++++++++++- src-tauri/src/domain/ai/streaming.rs | 2 +- src-tauri/src/domain/engine/engine_args.rs | 61 --- src-tauri/src/domain/engine/engine_runtime.rs | 44 ++ src-tauri/src/domain/engine/manager.rs | 97 +++-- src-tauri/src/domain/integration_api.rs | 160 ++++++-- src-tauri/src/domain/license/storage.rs | 13 +- src-tauri/src/domain/license/verifier.rs | 16 +- .../domain/modules/controller/lifecycle.rs | 93 ++++- .../src/domain/modules/controller/mod.rs | 13 +- src-tauri/src/domain/modules/downloader.rs | 138 ++++++- .../src/domain/modules/downloader_install.rs | 52 ++- .../src/domain/modules/downloader_progress.rs | 10 +- .../src/domain/modules/downloader_service.rs | 2 + .../src/domain/modules/downloader_support.rs | 30 +- .../src/domain/modules/downloader_transfer.rs | 49 ++- .../src/domain/modules/github_releases.rs | 386 +++++++++++++++++- .../domain/modules/settings_ui_protocol.rs | 6 +- src-tauri/src/domain/system/hardware_probe.rs | 21 +- .../infrastructure/config/engine_settings.rs | 51 ++- .../src/infrastructure/config/settings.rs | 3 +- .../src/infrastructure/config/translations.rs | 24 +- .../infrastructure/crypto/secure_storage.rs | 29 +- .../infrastructure/engine/tauri_emitter.rs | 32 +- .../filesystem/local_file_service.rs | 116 +++++- .../src/infrastructure/logging/logger.rs | 14 +- .../monitoring/tauri_emitter.rs | 4 +- .../infrastructure/persistence/json_store.rs | 56 ++- .../src/infrastructure/system/startup.rs | 12 +- src-tauri/src/lib.rs | 11 +- src/app/CoreComposition.test.ts | 53 +++ src/app/CoreComposition.ts | 61 +-- src/app/CoreEntry.ts | 44 +- src/app/CoreLifecycleController.test.ts | 135 ++++++ src/app/CoreLifecycleController.ts | 42 +- src/app/init.ts | 4 +- src/assets/fonts/Cubic_11.zh-subset.woff2 | Bin 29496 -> 29412 bytes src/features/ai/services/AIBridge.test.ts | 5 +- src/features/ai/services/AIBridge.ts | 1 + .../ai/services/AIBridgeRuntime.test.ts | 39 +- src/features/ai/services/AIBridgeRuntime.ts | 67 +-- .../ai/services/AIChatTransport.test.ts | 56 +++ src/features/ai/services/AIChatTransport.ts | 6 +- .../controllers/ChatHistoryController.test.ts | 27 ++ .../chat/controllers/ChatHistoryController.ts | 3 + .../controllers/ChatSendController.test.ts | 13 + .../chat/controllers/ChatSendController.ts | 3 + .../console/services/ConsoleLogService.ts | 2 +- .../services/MonitoringService.test.ts | 25 ++ .../monitoring/services/MonitoringService.ts | 18 +- .../settings/services/SettingsService.test.ts | 53 ++- .../settings/services/SettingsService.ts | 15 +- .../ModuleSettingsCustomUiController.test.ts | 25 ++ .../ui/ModuleSettingsCustomUiController.ts | 4 + .../ui/ModuleSettingsEngineFieldCatalog.ts | 19 + .../ui/ModuleSettingsEngineHtmlBuilder.ts | 29 +- .../ui/ModuleSettingsEngineRenderFlow.ts | 47 ++- .../ui/ModuleSettingsEngineRenderer.ts | 122 +++++- src/features/settings/ui/SettingsUI.test.ts | 66 ++- .../tauri/TauriProvider.test.ts | 33 +- src/infrastructure/tauri/TauriProvider.ts | 24 +- src/package-lock.json | 5 + src/package.json | 1 + src/shared/api/invoke.test.ts | 37 ++ src/shared/api/invoke.ts | 59 ++- .../services/ModulePlatformService.test.ts | 20 + src/shared/services/ModulePlatformService.ts | 28 +- src/shared/services/ModuleService.test.ts | 51 ++- src/shared/services/ModuleService.ts | 134 ++++-- src/shared/services/StateManager.test.ts | 72 ++++ src/shared/services/StateManager.ts | 35 +- src/shared/services/WindowService.test.ts | 97 +++++ src/shared/services/WindowService.ts | 6 +- .../services/WindowServicePersistence.ts | 43 +- .../services/state/UiStateStore.test.ts | 68 ++- src/shared/services/state/UiStateStore.ts | 34 +- .../shell/ui/AppUiCardActionFlow.test.ts | 91 ++++- src/shared/shell/ui/AppUiCardActionFlow.ts | 55 ++- src/shared/shell/ui/AppUiModuleFlow.test.ts | 18 + src/shared/shell/ui/AppUiModuleFlow.ts | 74 +++- .../shell/ui/AppUiModuleLifecycle.test.ts | 20 +- src/shared/shell/ui/AppUiModuleLifecycle.ts | 8 +- .../shell/ui/DownloadSelectionDialog.ts | 353 ++++++++++++++++ src/shared/types/bindings.ts | 58 ++- src/shared/types/coreTypes.ts | 26 ++ src/styles/features/ai-module-settings.css | 138 ++++++- .../features/module-selection-modal.css | 307 ++++++++++++++ 104 files changed, 4316 insertions(+), 634 deletions(-) create mode 100644 src/app/CoreComposition.test.ts create mode 100644 src/app/CoreLifecycleController.test.ts create mode 100644 src/shared/api/invoke.test.ts create mode 100644 src/shared/services/StateManager.test.ts create mode 100644 src/shared/shell/ui/DownloadSelectionDialog.ts diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index d953abd6..518d5e54 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -15,7 +15,7 @@ concurrency: permissions: contents: read - pull-requests: read + pull-requests: write jobs: dependency-review: diff --git a/src-tauri/resources/locales/en.json b/src-tauri/resources/locales/en.json index 17b51a76..7b40fb54 100644 --- a/src-tauri/resources/locales/en.json +++ b/src-tauri/resources/locales/en.json @@ -220,6 +220,7 @@ "ui.launcher.web.copy_code": "Copy code", "ui.launcher.web.copy_failed": "Failed to copy code", "ui.launcher.web.delete_model_error": "Delete model error", + "ui.launcher.web.download_control_error": "Download control failed", "ui.launcher.web.download_error": "Download error", "ui.launcher.web.download_url_empty": "Download URL is empty", "ui.launcher.web.downloaded": "Downloaded", @@ -480,5 +481,22 @@ "ui.settings.engine.sdcpp_flag.photo_maker_vae_path": "Set PhotoMaker VAE path.", "ui.settings.engine.sdcpp_flag.style_strength_20": "Set PhotoMaker style strength.", "ui.settings.engine.sdcpp_flag.taesd_decode": "Use TAESD decoder.", - "ui.settings.engine.sdcpp_flag.taesd_encode": "Use TAESD encoder." + "ui.settings.engine.sdcpp_flag.taesd_encode": "Use TAESD encoder.", + "ui.settings.engine.compute_mode": "Compute Device", + "ui.settings.engine.compute_gpu": "GPU", + "ui.settings.engine.compute_cpu": "CPU", + "ui.settings.engine.compute_mode_hint": "Choose whether this engine starts on the GPU or CPU.", + "ui.download.select_package": "Select package", + "ui.download.package_subtitle": "Choose package and version", + "ui.download.compute_target": "Compute target", + "ui.download.gpu_package": "GPU", + "ui.download.cpu_package": "CPU", + "ui.download.unavailable": "Unavailable", + "ui.download.version": "Version", + "ui.download.latest_suffix": "(latest)", + "ui.download.date_unknown": "date unknown", + "ui.download.no_release_options": "No compatible release packages found", + "ui.download.loading_versions": "Loading versions...", + "ui.download.load_versions_error": "Failed to load release versions", + "ui.common.cancel": "Cancel" } diff --git a/src-tauri/resources/locales/ru.json b/src-tauri/resources/locales/ru.json index d0c9fafd..31e70bff 100644 --- a/src-tauri/resources/locales/ru.json +++ b/src-tauri/resources/locales/ru.json @@ -221,6 +221,7 @@ "ui.launcher.web.copy_code": "Копировать код", "ui.launcher.web.copy_failed": "Не удалось скопировать код", "ui.launcher.web.delete_model_error": "Ошибка удаления модели", + "ui.launcher.web.download_control_error": "Не удалось управлять загрузкой", "ui.launcher.web.download_error": "Ошибка загрузки", "ui.launcher.web.download_url_empty": "URL загрузки пуст", "ui.launcher.web.downloaded": "Скачано", @@ -481,5 +482,22 @@ "ui.settings.engine.sdcpp_flag.photo_maker_vae_path": "Задаёт путь VAE для PhotoMaker.", "ui.settings.engine.sdcpp_flag.style_strength_20": "Задаёт силу стиля PhotoMaker.", "ui.settings.engine.sdcpp_flag.taesd_decode": "Использует TAESD decoder.", - "ui.settings.engine.sdcpp_flag.taesd_encode": "Использует TAESD encoder." + "ui.settings.engine.sdcpp_flag.taesd_encode": "Использует TAESD encoder.", + "ui.settings.engine.compute_mode": "Устройство вычислений", + "ui.settings.engine.compute_gpu": "GPU", + "ui.settings.engine.compute_cpu": "CPU", + "ui.settings.engine.compute_mode_hint": "Выбери, запускать движок на видеокарте или процессоре.", + "ui.download.select_package": "Выбор пакета", + "ui.download.package_subtitle": "Выбери пакет и версию", + "ui.download.compute_target": "Тип пакета", + "ui.download.gpu_package": "GPU", + "ui.download.cpu_package": "CPU", + "ui.download.unavailable": "Недоступно", + "ui.download.version": "Версия", + "ui.download.latest_suffix": "(последняя)", + "ui.download.date_unknown": "дата неизвестна", + "ui.download.no_release_options": "Совместимые пакеты не найдены", + "ui.download.loading_versions": "Загрузка версий...", + "ui.download.load_versions_error": "Не удалось загрузить версии", + "ui.common.cancel": "Отмена" } diff --git a/src-tauri/resources/locales/zh.json b/src-tauri/resources/locales/zh.json index 16586627..8479dc39 100644 --- a/src-tauri/resources/locales/zh.json +++ b/src-tauri/resources/locales/zh.json @@ -217,6 +217,7 @@ "ui.launcher.web.copy_code": "复制代码", "ui.launcher.web.copy_failed": "复制代码失败", "ui.launcher.web.delete_model_error": "删除模型错误", + "ui.launcher.web.download_control_error": "下载控制失败", "ui.launcher.web.download_error": "下载错误", "ui.launcher.web.download_url_empty": "下载 URL 为空", "ui.launcher.web.downloaded": "已下载", @@ -477,5 +478,22 @@ "ui.settings.engine.sdcpp_flag.photo_maker_vae_path": "设置 PhotoMaker VAE 路径。", "ui.settings.engine.sdcpp_flag.style_strength_20": "设置 PhotoMaker 风格强度。", "ui.settings.engine.sdcpp_flag.taesd_decode": "使用 TAESD 解码器。", - "ui.settings.engine.sdcpp_flag.taesd_encode": "使用 TAESD 编码器。" + "ui.settings.engine.sdcpp_flag.taesd_encode": "使用 TAESD 编码器。", + "ui.settings.engine.compute_mode": "计算设备", + "ui.settings.engine.compute_gpu": "GPU", + "ui.settings.engine.compute_cpu": "CPU", + "ui.settings.engine.compute_mode_hint": "选择此引擎使用 GPU 还是 CPU 启动。", + "ui.download.select_package": "选择包", + "ui.download.package_subtitle": "选择包和版本", + "ui.download.compute_target": "计算目标", + "ui.download.gpu_package": "GPU", + "ui.download.cpu_package": "CPU", + "ui.download.unavailable": "不可用", + "ui.download.version": "版本", + "ui.download.latest_suffix": "(最新)", + "ui.download.date_unknown": "日期未知", + "ui.download.no_release_options": "未找到兼容的发布包", + "ui.download.loading_versions": "正在加载版本...", + "ui.download.load_versions_error": "无法加载发布版本", + "ui.common.cancel": "取消" } diff --git a/src-tauri/src/api/ai/mod.rs b/src-tauri/src/api/ai/mod.rs index 5ec61524..69c111be 100644 --- a/src-tauri/src/api/ai/mod.rs +++ b/src-tauri/src/api/ai/mod.rs @@ -536,7 +536,7 @@ pub async fn clear_chat_history( sessions: State<'_, Arc>, ) -> Result<(), AppError> { sessions.clear_chat_history(session_id); - let _ = sessions.force_save().await; + sessions.force_save().await?; Ok(()) } @@ -560,7 +560,7 @@ pub async fn rewind_last_turn( sessions: State<'_, Arc>, ) -> Result, AppError> { let removed = sessions.rewind_last_turn(session_id); - let _ = sessions.force_save().await; + sessions.force_save().await?; Ok(removed) } diff --git a/src-tauri/src/api/engine/mod.rs b/src-tauri/src/api/engine/mod.rs index 2f12134f..d0923051 100644 --- a/src-tauri/src/api/engine/mod.rs +++ b/src-tauri/src/api/engine/mod.rs @@ -73,7 +73,17 @@ pub fn check_engine_installed(engine_id: String, binary_name: Option) -> #[specta::specta] /// Deletes an Axelate-managed engine from local storage. #[allow(clippy::needless_pass_by_value)] // Tauri commands require owned params -pub async fn delete_engine(engine_id: String) -> Result<(), AppError> { +pub async fn delete_engine( + engine_id: String, + engine_manager: State<'_, Arc>, +) -> Result<(), AppError> { + let engine_id = canonical_engine_id(&engine_id).to_string(); + if engine_manager.is_engine_running(&engine_id).await { + return Err(AppError::Validation(format!( + "Cannot delete engine '{engine_id}' while it is running" + ))); + } + crate::domain::engine::detector::delete_installed_engine(&engine_id).await } @@ -153,7 +163,7 @@ pub async fn set_engine_config( .await .ok_or_else(|| AppError::Config(format!("Unknown engine: {}", config.engine_id)))?; - let mut map = load_engine_config_map().await.unwrap_or_default(); + let mut map = load_engine_config_map().await?; let normalized = merge_user_engine_config(&def, &normalize_engine_config(config)); map.insert(normalized.engine_id.clone(), normalized); save_engine_config_map(&map).await diff --git a/src-tauri/src/api/license/mod.rs b/src-tauri/src/api/license/mod.rs index 49115be6..9a3ceecb 100644 --- a/src-tauri/src/api/license/mod.rs +++ b/src-tauri/src/api/license/mod.rs @@ -10,7 +10,7 @@ use crate::models::LicenseStatusResponse; /// Retrieves current license activation status #[allow(clippy::missing_const_for_fn)] // Wrapper around const verify() function pub fn get_license_status() -> Result { - let status = license::verify(); + let status = license::verify()?; Ok(LicenseStatusResponse { status, email: None, // In real app, load from storage @@ -38,5 +38,5 @@ pub async fn deactivate_license() -> Result<(), AppError> { #[specta::specta] /// Checks if a specific feature is enabled by the current license pub fn check_feature(feature: &str) -> Result { - Ok(license::has_feature(feature)) + license::has_feature(feature) } diff --git a/src-tauri/src/api/modules/downloader.rs b/src-tauri/src/api/modules/downloader.rs index e302d04f..96571043 100644 --- a/src-tauri/src/api/modules/downloader.rs +++ b/src-tauri/src/api/modules/downloader.rs @@ -12,6 +12,7 @@ pub async fn download_module( repo_url: String, expected_hash: Option, dl_type: Option, + release_selection: Option, ) -> Result { downloader::download_module( app, @@ -20,10 +21,21 @@ pub async fn download_module( repo_url, expected_hash, dl_type, + release_selection, ) .await } +#[tauri::command] +#[specta::specta] +/// Lists compatible release versions and CPU/GPU package choices for a module. +pub async fn get_release_download_options( + module_id: String, + repo_url: String, +) -> Result { + downloader::get_release_download_options(&module_id, &repo_url).await +} + #[tauri::command] #[specta::specta] /// Resumes a paused module download using backend-owned request metadata. @@ -43,6 +55,7 @@ pub async fn resume_download( request.repo_url, request.expected_hash, request.dl_type, + request.release_selection, ) .await } diff --git a/src-tauri/src/api/settings/mod.rs b/src-tauri/src/api/settings/mod.rs index b7de88c5..816f3436 100644 --- a/src-tauri/src/api/settings/mod.rs +++ b/src-tauri/src/api/settings/mod.rs @@ -51,6 +51,7 @@ pub async fn get_module_settings( settings_service: tauri::State<'_, settings::SettingsService>, module_id: String, ) -> Result, AppError> { + crate::domain::modules::downloader::validate_module_id(&module_id)?; settings_service.get_module_settings(&module_id).await } @@ -63,6 +64,7 @@ pub async fn save_module_settings( module_id: String, settings: HashMap, ) -> Result<(), AppError> { + crate::domain::modules::downloader::validate_module_id(&module_id)?; settings_service .save_module_settings(&module_id, &settings) .await diff --git a/src-tauri/src/api/settings/window_settings.rs b/src-tauri/src/api/settings/window_settings.rs index de9181a7..e8199b3c 100644 --- a/src-tauri/src/api/settings/window_settings.rs +++ b/src-tauri/src/api/settings/window_settings.rs @@ -85,7 +85,7 @@ pub async fn save_zoom_level( ui_service: tauri::State<'_, ui_state::UiStateService>, zoom: f64, ) -> Result<(), AppError> { - let mut state = ui_service.get_ui_state().await.unwrap_or_default(); + let mut state = ui_service.get_ui_state().await?; if (state.zoom_level - zoom).abs() < f64::EPSILON { return Ok(()); } @@ -102,7 +102,7 @@ async fn persist_zoom_for_window( window_settings::SCALING_MIN_ZOOM, window_settings::SCALING_MAX_ZOOM, ); - let mut state = ui_service.get_ui_state().await.unwrap_or_default(); + let mut state = ui_service.get_ui_state().await?; let previous_zoom = state.zoom_level; state.zoom_level = zoom; @@ -161,7 +161,7 @@ pub async fn get_resolution_zoom( window: tauri::WebviewWindow, ui_service: tauri::State<'_, ui_state::UiStateService>, ) -> Result { - let state = ui_service.get_ui_state().await.unwrap_or_default(); + let state = ui_service.get_ui_state().await?; let res_key = res_key_from_window(&window).unwrap_or_else(|| "unknown".to_string()); Ok(resolve_zoom(&state, &res_key)) } @@ -210,15 +210,14 @@ pub async fn get_window_policy( } // Get current zoom from state to calculate effective dimensions - let zoom = ui_service - .get_ui_state() - .await - .map(|s| s.zoom_level) - .unwrap_or(1.0); + let zoom = ui_service.get_ui_state().await?.zoom_level; let win_size = window .inner_size() - .unwrap_or_default() + .map_err(|error| AppError::External { + request_id: None, + message: format!("Failed to read window inner size: {error}"), + })? .to_logical::(scale_factor); let effective_w = u32::try_from((win_size.width / zoom).round() as i64).unwrap_or(1920); diff --git a/src-tauri/src/api/system/bootstrap.rs b/src-tauri/src/api/system/bootstrap.rs index 461fdf53..a903060b 100644 --- a/src-tauri/src/api/system/bootstrap.rs +++ b/src-tauri/src/api/system/bootstrap.rs @@ -30,7 +30,10 @@ pub async fn get_app_bootstrap_data( ) -> Result { tracing::debug!("[Bootstrap] Collecting application data..."); - let ui_state = ui_service.get_ui_state().await.unwrap_or_default(); + let ui_state = ui_service.get_ui_state().await.unwrap_or_else(|error| { + tracing::warn!("Failed to load UI state during bootstrap, using defaults: {error}"); + UIState::default() + }); let window_config = window_settings::get_window_config(); let system_language = settings::get_language_sync(); diff --git a/src-tauri/src/api/system/logs.rs b/src-tauri/src/api/system/logs.rs index ec0b504e..e6685085 100644 --- a/src-tauri/src/api/system/logs.rs +++ b/src-tauri/src/api/system/logs.rs @@ -133,7 +133,13 @@ pub async fn get_console_overview( ui_state_service: State<'_, crate::infrastructure::config::ui_state::UiStateService>, ) -> Result { let engine_state = engine_manager.state().await; - let ui_state = ui_state_service.get_ui_state().await.unwrap_or_default(); + let ui_state = ui_state_service + .get_ui_state() + .await + .unwrap_or_else(|error| { + tracing::warn!("Failed to load UI state for console overview, using defaults: {error}"); + UIState::default() + }); let logs = logger::get_frontend_logs_since(0.0); Ok(ConsoleOverviewBuilder::build(&engine_state, &ui_state, &logs).await) } diff --git a/src-tauri/src/app/tray.rs b/src-tauri/src/app/tray.rs index 95af5bc0..5809bd10 100644 --- a/src-tauri/src/app/tray.rs +++ b/src-tauri/src/app/tray.rs @@ -201,15 +201,11 @@ pub fn setup_system_tray(app: &tauri::App) -> Result<(), Box>() .map(|s| std::sync::Arc::clone(&*s)); if let Some(sessions) = sessions_arc { - std::thread::spawn(move || { - if let Err(error) = sessions.save_to_disk() { - tracing::error!( - "Failed to save chat history during shutdown: {error:?}" - ); - } else { - tracing::info!("AI history flushed successfully during shutdown."); - } - }); + if let Err(error) = sessions.save_to_disk() { + tracing::error!("Failed to save chat history during shutdown: {error:?}"); + } else { + tracing::info!("AI history flushed successfully during shutdown."); + } } app.exit(0); diff --git a/src-tauri/src/domain/ai/ai_dispatch.rs b/src-tauri/src/domain/ai/ai_dispatch.rs index 3e7acd8f..2ea10d65 100644 --- a/src-tauri/src/domain/ai/ai_dispatch.rs +++ b/src-tauri/src/domain/ai/ai_dispatch.rs @@ -32,6 +32,9 @@ pub(super) async fn prepare_chat_dispatch( let mut messages_context = request.messages.clone(); if let Some(session_id) = &request.session_id { messages_context = sessions.merge_request_messages(session_id, &request.messages); + if !request.messages.is_empty() { + sessions.force_save().await?; + } } let mut base_url = "https://openrouter.ai/api/v1".to_string(); @@ -82,7 +85,7 @@ pub(super) async fn persist_successful_response( session_id: Option<&str>, message_id: String, response: &Result, -) { +) -> Result<(), crate::errors::AppError> { if let Ok(response) = response && response.ok && let Some(reply) = &response.reply @@ -94,7 +97,10 @@ pub(super) async fn persist_successful_response( reply, response.thought_signature.clone(), ); + sessions.force_save().await?; } + + Ok(()) } pub(super) async fn active_local_engine_status( diff --git a/src-tauri/src/domain/ai/ai_service.rs b/src-tauri/src/domain/ai/ai_service.rs index 92258a18..63171b11 100644 --- a/src-tauri/src/domain/ai/ai_service.rs +++ b/src-tauri/src/domain/ai/ai_service.rs @@ -16,13 +16,15 @@ pub use super::types::{ ChatMessage, ChatReply, ChatRequest, ChatResponse, ChatSession, TokenUsage, }; -const AI_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(90); +const CLOUD_AI_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(90); +const LOCAL_AI_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30 * 60); struct PreparedRequestExecution { provider: OpenAiCompatibleProvider, effective_request: ChatRequest, request_id: String, message_id: String, + timeout: std::time::Duration, } struct ChatStreamExecutionOptions { @@ -307,6 +309,7 @@ async fn prepare_request_execution( } }) .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + let timeout = timeout_for_base_url(&base_url); tracing::info!( "[AI] Starting request {} (msg {}) for model {}", request_id, @@ -319,9 +322,18 @@ async fn prepare_request_execution( effective_request, request_id, message_id, + timeout, }) } +fn timeout_for_base_url(base_url: &str) -> std::time::Duration { + if crate::domain::ai::streaming::is_local_base_url(base_url) { + LOCAL_AI_REQUEST_TIMEOUT + } else { + CLOUD_AI_REQUEST_TIMEOUT + } +} + async fn execute_prepared_request( execution: PreparedRequestExecution, sessions: &ChatSessionManager, @@ -336,26 +348,24 @@ where { let message_id = execution.message_id.clone(); let request_id = execution.request_id.clone(); - let response = tokio::time::timeout(AI_REQUEST_TIMEOUT, run(execution)).await; + let timeout = execution.timeout; + let response = tokio::time::timeout(timeout, run(execution)).await; let response = if let Ok(result) = response { result } else { on_timeout(&message_id); - Err(timeout_error(request_id)) + Err(timeout_error(request_id, timeout)) }; - persist_successful_response(sessions, session_id, message_id, &response).await; + persist_successful_response(sessions, session_id, message_id, &response).await?; response } -fn timeout_error(request_id: String) -> crate::errors::AppError { +fn timeout_error(request_id: String, timeout: std::time::Duration) -> crate::errors::AppError { crate::errors::AppError::Internal { request_id: Some(request_id), - message: format!( - "AI Request timed out after {} seconds.", - AI_REQUEST_TIMEOUT.as_secs() - ), + message: format!("AI Request timed out after {} seconds.", timeout.as_secs()), } } @@ -567,6 +577,19 @@ mod tests { ); } + #[test] + fn local_chat_requests_get_longer_timeout_than_cloud_requests() { + assert_eq!( + timeout_for_base_url("http://127.0.0.1:8080/v1"), + LOCAL_AI_REQUEST_TIMEOUT + ); + assert_eq!( + timeout_for_base_url("https://openrouter.ai/api/v1"), + CLOUD_AI_REQUEST_TIMEOUT + ); + assert!(LOCAL_AI_REQUEST_TIMEOUT > CLOUD_AI_REQUEST_TIMEOUT); + } + #[test] fn test_count_tokens_longer_text() { let text = "The quick brown fox jumps over the lazy dog"; diff --git a/src-tauri/src/domain/ai/custom_model_service.rs b/src-tauri/src/domain/ai/custom_model_service.rs index d298e986..16acd67a 100644 --- a/src-tauri/src/domain/ai/custom_model_service.rs +++ b/src-tauri/src/domain/ai/custom_model_service.rs @@ -53,7 +53,7 @@ pub struct CustomModelManager; impl CustomModelManager { /// Retrieves all configured custom models. pub fn get_all() -> Result, AppError> { - let config = CustomModelConfigRepository::load().unwrap_or_default(); + let config = CustomModelConfigRepository::load()?; Ok(config.models) } @@ -64,7 +64,7 @@ impl CustomModelManager { name: String, base_model_id: String, ) -> Result<(), AppError> { - let mut config = CustomModelConfigRepository::load().unwrap_or_default(); + let mut config = CustomModelConfigRepository::load()?; // Idempotency check if config @@ -92,7 +92,7 @@ impl CustomModelManager { /// Removes a custom model by its ID. pub fn remove(id: &str) -> Result<(), AppError> { - let mut config = CustomModelConfigRepository::load().unwrap_or_default(); + let mut config = CustomModelConfigRepository::load()?; config.models.retain(|m| m.id != id); CustomModelConfigRepository::save(&config) } diff --git a/src-tauri/src/domain/ai/image_service.rs b/src-tauri/src/domain/ai/image_service.rs index d44a7f62..c045cfe2 100644 --- a/src-tauri/src/domain/ai/image_service.rs +++ b/src-tauri/src/domain/ai/image_service.rs @@ -106,6 +106,7 @@ async fn process_image_request_with_local_engine_access( &reply.role, None, ); + sessions.force_save().await?; } Ok(ImageGenerationResponse { diff --git a/src-tauri/src/domain/ai/session.rs b/src-tauri/src/domain/ai/session.rs index 796c315e..3989039f 100644 --- a/src-tauri/src/domain/ai/session.rs +++ b/src-tauri/src/domain/ai/session.rs @@ -6,7 +6,7 @@ use dashmap::DashMap; use std::collections::HashMap; use std::sync::{ - Arc, + Arc, Mutex, atomic::{AtomicBool, Ordering}, }; use tokio::sync::Notify; @@ -44,6 +44,8 @@ struct LocalContextState { pub struct ChatSessionManager { sessions: Arc>, dirty: Arc, + persistence_available: Arc, + save_lock: Arc>, save_notify: Arc, } @@ -51,9 +53,19 @@ impl ChatSessionManager { /// Creates a new manager and loads existing sessions from disk. /// Call [`start_saver`] after the Tokio runtime is ready. pub fn new() -> Self { + let (sessions, persistence_available) = match Self::load_from_disk() { + Ok(sessions) => (sessions, true), + Err(error) => { + tracing::error!("Failed to load chat history; persistence disabled: {error}"); + (DashMap::new(), false) + } + }; + Self { - sessions: Arc::new(Self::load_from_disk().unwrap_or_default()), + sessions: Arc::new(sessions), dirty: Arc::new(AtomicBool::new(false)), + persistence_available: Arc::new(AtomicBool::new(persistence_available)), + save_lock: Arc::new(Mutex::new(())), save_notify: Arc::new(Notify::new()), } } @@ -78,6 +90,11 @@ impl ChatSessionManager { } fn mark_dirty(&self) { + if !self.persistence_available.load(Ordering::Relaxed) { + tracing::warn!("Chat history changed while persistence is disabled; skipping save"); + return; + } + self.dirty.store(true, Ordering::Relaxed); self.save_notify.notify_one(); } @@ -89,20 +106,31 @@ impl ChatSessionManager { pub fn start_saver(&self) { let sessions = Arc::clone(&self.sessions); let dirty = Arc::clone(&self.dirty); + let persistence_available = Arc::clone(&self.persistence_available); + let save_lock = Arc::clone(&self.save_lock); let save_notify = Arc::clone(&self.save_notify); tauri::async_runtime::spawn(async move { tracing::debug!("Background chat session saver started."); loop { save_notify.notified().await; + if !persistence_available.load(Ordering::Relaxed) { + tracing::warn!("Chat session saver skipped because persistence is disabled"); + continue; + } + tokio::time::sleep(std::time::Duration::from_secs(5)).await; if dirty.swap(false, Ordering::AcqRel) { + let save_lock = Arc::clone(&save_lock); let snapshot: HashMap = sessions .iter() .map(|e| (e.key().clone(), e.value().clone())) .collect(); - match tokio::task::spawn_blocking(move || Self::flush_snapshot(&snapshot)).await + match tokio::task::spawn_blocking(move || { + Self::flush_snapshot_locked(&save_lock, &snapshot) + }) + .await { Ok(Ok(())) => { tracing::debug!("Chat history saved to disk."); @@ -127,8 +155,10 @@ impl ChatSessionManager { /// Immediately saves all sessions to disk, bypassing the debounce timer. pub async fn force_save(&self) -> Result<(), crate::errors::AppError> { + self.ensure_persistence_available()?; let snapshot = self.take_snapshot(); - tokio::task::spawn_blocking(move || Self::flush_snapshot(&snapshot)) + let save_lock = Arc::clone(&self.save_lock); + tokio::task::spawn_blocking(move || Self::flush_snapshot_locked(&save_lock, &snapshot)) .await .map_err(|e| crate::errors::AppError::Internal { request_id: None, @@ -140,7 +170,19 @@ impl ChatSessionManager { /// Synchronous save — intended for use in Tauri shutdown hooks (called from a blocking context). pub fn save_to_disk(&self) -> Result<(), crate::errors::AppError> { - Self::flush_snapshot(&self.take_snapshot()) + self.ensure_persistence_available()?; + Self::flush_snapshot_locked(&self.save_lock, &self.take_snapshot()) + } + + fn ensure_persistence_available(&self) -> Result<(), crate::errors::AppError> { + if self.persistence_available.load(Ordering::Relaxed) { + return Ok(()); + } + + Err(crate::errors::AppError::Internal { + request_id: None, + message: "Chat history persistence is disabled after a load failure".to_string(), + }) } /// Merges the latest frontend-provided request messages into persistent history @@ -295,25 +337,87 @@ impl SessionPersistence { } fn load_sessions() -> Result, crate::errors::AppError> { - Self::recover_from_interrupted_write(); + Self::recover_from_interrupted_write()?; if !Self::history_path().exists() { return Ok(DashMap::new()); } let content = std::fs::read_to_string(Self::history_path())?; - let persisted = Self::parse_sessions(&content)?; + let persisted = match Self::parse_sessions(&content) { + Ok(persisted) => persisted, + Err(error) => { + let backup_path = Self::backup_corrupt_history()?; + tracing::error!( + backup = %backup_path.display(), + "Failed to parse chat history. Moved corrupt file aside: {error}" + ); + return Ok(DashMap::new()); + } + }; Ok(Self::normalize_sessions(persisted)) } - fn recover_from_interrupted_write() { + fn backup_corrupt_history() -> Result { + let timestamp_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let backup_path = + Self::history_path().with_extension(format!("corrupt-{timestamp_ms}.json")); + std::fs::rename(Self::history_path(), &backup_path)?; + Ok(backup_path) + } + + fn recover_from_interrupted_write() -> Result<(), crate::errors::AppError> { let tmp_path = Self::temp_history_path(); - if tmp_path.exists() && !Self::history_path().exists() { - tracing::warn!("Detected crash during last save. Recovering from .tmp..."); - if let Err(error) = std::fs::rename(&tmp_path, Self::history_path()) { - tracing::error!("Crash recovery failed: {error}"); - } + if !tmp_path.exists() { + return Ok(()); } + + if !Self::is_valid_history_file(&tmp_path) { + tracing::warn!( + tmp = %tmp_path.display(), + "Discarding invalid interrupted chat history write" + ); + Self::remove_recovered_tmp(&tmp_path)?; + return Ok(()); + } + + if !Self::history_path().exists() || Self::tmp_history_is_newer(&tmp_path) { + tracing::warn!("Detected interrupted chat history save. Recovering from .tmp..."); + std::fs::copy(&tmp_path, Self::history_path())?; + } + + Self::remove_recovered_tmp(&tmp_path)?; + Ok(()) + } + + fn remove_recovered_tmp(tmp_path: &std::path::Path) -> Result<(), crate::errors::AppError> { + match std::fs::remove_file(tmp_path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(crate::errors::AppError::Io(format!( + "Failed to remove recovered chat history tmp file {}: {error}", + tmp_path.display() + ))), + } + } + + fn is_valid_history_file(path: &std::path::Path) -> bool { + std::fs::read_to_string(path) + .ok() + .and_then(|content| Self::parse_sessions(&content).ok()) + .is_some() + } + + fn tmp_history_is_newer(tmp_path: &std::path::Path) -> bool { + let tmp_modified = tmp_path.metadata().and_then(|metadata| metadata.modified()); + let history_modified = Self::history_path() + .metadata() + .and_then(|metadata| metadata.modified()); + + matches!((tmp_modified, history_modified), (Ok(tmp), Ok(history)) if tmp > history) } fn parse_sessions( @@ -367,14 +471,43 @@ impl SessionPersistence { if let Err(error) = std::fs::rename(&tmp_path, path) { tracing::warn!("Rename failed ({error}), using fallback for Windows locks..."); - let _ = std::fs::remove_file(path); - std::fs::rename(&tmp_path, path)?; + match std::fs::remove_file(path) { + Ok(()) => {} + Err(remove_error) if remove_error.kind() == std::io::ErrorKind::NotFound => {} + Err(remove_error) => { + return Err(crate::errors::AppError::Io(format!( + "Failed to replace chat history '{}': rename failed: {error}; removing existing file failed: {remove_error}", + path.display() + ))); + } + } + std::fs::rename(&tmp_path, path).map_err(|second_error| { + crate::errors::AppError::Io(format!( + "Failed to publish chat history '{}': first rename failed: {error}; second rename failed: {second_error}", + path.display() + )) + })?; } Ok(()) } } +impl ChatSessionManager { + fn flush_snapshot_locked( + save_lock: &Mutex<()>, + snapshot: &HashMap, + ) -> Result<(), crate::errors::AppError> { + let _guard = save_lock + .lock() + .map_err(|_| crate::errors::AppError::Internal { + request_id: None, + message: "Chat history save lock is poisoned".to_string(), + })?; + Self::flush_snapshot(snapshot) + } +} + impl LocalContextBudget { fn new(context_size: usize) -> Self { let normalized_context_size = context_size.max(4096); @@ -533,10 +666,15 @@ mod tests { #![allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] use super::*; + static TEST_CHAT_HISTORY_LOCK: std::sync::LazyLock> = + std::sync::LazyLock::new(|| std::sync::Mutex::new(())); + fn test_manager() -> ChatSessionManager { ChatSessionManager { sessions: Arc::new(DashMap::new()), dirty: Arc::new(AtomicBool::new(false)), + persistence_available: Arc::new(AtomicBool::new(true)), + save_lock: Arc::new(Mutex::new(())), save_notify: Arc::new(Notify::new()), } } @@ -582,6 +720,30 @@ mod tests { assert!(manager.dirty.load(Ordering::Relaxed)); } + #[test] + fn test_disabled_persistence_does_not_mark_dirty() { + let manager = ChatSessionManager { + sessions: Arc::new(DashMap::new()), + dirty: Arc::new(AtomicBool::new(false)), + persistence_available: Arc::new(AtomicBool::new(false)), + save_lock: Arc::new(Mutex::new(())), + save_notify: Arc::new(Notify::new()), + }; + + manager.merge_request_messages( + "session-1", + &[ChatMessage { + id: "msg-1".to_string(), + role: "user".to_string(), + content: serde_json::Value::String("hello".to_string()), + thought_signature: None, + }], + ); + + assert!(!manager.dirty.load(Ordering::Relaxed)); + assert!(manager.save_to_disk().is_err()); + } + #[test] fn test_rewind_last_turn_in_memory() { let manager = test_manager(); @@ -759,6 +921,141 @@ mod tests { assert!((session.last_updated - 1_234_567_890.0).abs() < f64::EPSILON); } + #[test] + fn test_corrupt_history_is_backed_up_before_starting_empty() { + let _guard = TEST_CHAT_HISTORY_LOCK + .lock() + .expect("chat history test lock"); + let history_path = SessionPersistence::history_path(); + let chat_dir = history_path + .parent() + .expect("history path should have a parent"); + let _ = std::fs::remove_dir_all(chat_dir); + std::fs::create_dir_all(chat_dir).expect("chat dir should be created"); + std::fs::write(history_path, "{not valid json").expect("history fixture should be written"); + + let sessions = SessionPersistence::load_sessions().expect("corrupt history should recover"); + + assert!(sessions.is_empty()); + assert!(!history_path.exists()); + let backups = std::fs::read_dir(chat_dir) + .expect("chat dir should be readable") + .filter_map(Result::ok) + .filter(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with("history.corrupt-") + }) + .count(); + assert_eq!(backups, 1); + } + + #[test] + fn test_force_save_survives_manager_restart() { + let _guard = TEST_CHAT_HISTORY_LOCK + .lock() + .expect("chat history test lock"); + let history_path = SessionPersistence::history_path(); + let chat_dir = history_path + .parent() + .expect("history path should have a parent"); + let _ = std::fs::remove_dir_all(chat_dir); + std::fs::create_dir_all(chat_dir).expect("chat dir should be created"); + + let manager = test_manager(); + manager.merge_request_messages( + "session-restart", + &[ChatMessage { + id: "msg-1".to_string(), + role: "user".to_string(), + content: serde_json::Value::String("persist me".to_string()), + thought_signature: None, + }], + ); + tokio::runtime::Runtime::new() + .expect("tokio runtime") + .block_on(manager.force_save()) + .expect("force save should persist history"); + + let restarted = ChatSessionManager { + sessions: Arc::new( + SessionPersistence::load_sessions().expect("saved history should reload"), + ), + dirty: Arc::new(AtomicBool::new(false)), + persistence_available: Arc::new(AtomicBool::new(true)), + save_lock: Arc::new(Mutex::new(())), + save_notify: Arc::new(Notify::new()), + }; + let history = restarted.get_chat_history("session-restart"); + + assert_eq!(history.len(), 1); + assert_eq!( + history[0].content, + serde_json::Value::String("persist me".to_string()) + ); + } + + #[test] + fn test_interrupted_newer_tmp_history_is_recovered_on_restart() { + let _guard = TEST_CHAT_HISTORY_LOCK + .lock() + .expect("chat history test lock"); + let history_path = SessionPersistence::history_path(); + let tmp_path = SessionPersistence::temp_history_path(); + let chat_dir = history_path + .parent() + .expect("history path should have a parent"); + let _ = std::fs::remove_dir_all(chat_dir); + std::fs::create_dir_all(chat_dir).expect("chat dir should be created"); + + std::fs::write( + history_path, + serde_json::json!({ + "session-restart": { + "history": [{ + "id": "old", + "role": "user", + "content": "old", + "thought_signature": null + }], + "summary": null, + "summary_message_count": 0, + "last_updated": 1.0 + } + }) + .to_string(), + ) + .expect("old history should be written"); + std::thread::sleep(std::time::Duration::from_millis(20)); + std::fs::write( + &tmp_path, + serde_json::json!({ + "session-restart": { + "history": [{ + "id": "new", + "role": "user", + "content": "new", + "thought_signature": null + }], + "summary": null, + "summary_message_count": 0, + "last_updated": 2.0 + } + }) + .to_string(), + ) + .expect("tmp history should be written"); + + let sessions = SessionPersistence::load_sessions().expect("tmp history should recover"); + let restored = sessions + .get("session-restart") + .expect("session should be restored"); + + assert_eq!(restored.history[0].id, "new"); + assert!(!tmp_path.exists()); + } + #[test] fn test_build_local_context_uses_persisted_summary_and_recent_turns() { let manager = test_manager(); diff --git a/src-tauri/src/domain/ai/streaming.rs b/src-tauri/src/domain/ai/streaming.rs index 385ff669..a140be51 100644 --- a/src-tauri/src/domain/ai/streaming.rs +++ b/src-tauri/src/domain/ai/streaming.rs @@ -277,7 +277,7 @@ impl OpenAiCompatibleProvider { } } -fn is_local_base_url(base_url: &str) -> bool { +pub(super) fn is_local_base_url(base_url: &str) -> bool { provider_payload::is_local_base_url(base_url) } diff --git a/src-tauri/src/domain/engine/engine_args.rs b/src-tauri/src/domain/engine/engine_args.rs index 8163337f..170325c4 100644 --- a/src-tauri/src/domain/engine/engine_args.rs +++ b/src-tauri/src/domain/engine/engine_args.rs @@ -11,41 +11,6 @@ const SDCPP_UNSUPPORTED_FLAGS: [&str; 4] = [ const SDCPP_SERVER_UNSUPPORTED_FLAGS: [&str; 3] = ["--preview", "--preview-path", "--preview-interval"]; -fn is_qwen_model(model_path: Option<&str>) -> bool { - model_path.is_some_and(|path| path.to_ascii_lowercase().contains("qwen")) -} - -fn has_arg(args: &[String], candidates: &[&str]) -> bool { - args.iter().any(|arg| { - candidates.iter().any(|candidate| { - arg == candidate - || arg - .strip_prefix(candidate) - .is_some_and(|suffix| suffix.starts_with('=')) - }) - }) -} - -fn push_arg_if_missing( - args: &mut Vec, - existing_args: &[String], - candidates: &[&str], - value: Option<&str>, -) { - if has_arg(args, candidates) || has_arg(existing_args, candidates) { - return; - } - - let Some(candidate) = candidates.first() else { - return; - }; - - args.push((*candidate).to_string()); - if let Some(value) = value { - args.push(value.to_string()); - } -} - fn push_llamacpp_compute_args(args: &mut Vec, config: &EngineConfig) { match config.compute_mode { EngineComputeMode::Gpu => { @@ -115,32 +80,6 @@ pub(super) fn build_llamacpp_args(config: &EngineConfig, port: u16) -> Vec( } if !current_line.is_empty() { + if let Some(ref mut f) = file { + write_engine_log_line(f, ¤t_line); + } let trimmed = current_line.trim(); if is_progress_log_line(trimmed) { emitter.emit_log(&engine_id, trimmed); @@ -170,3 +173,44 @@ pub(super) async fn is_endpoint_healthy(endpoint: &str) -> bool { false } + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used)] + + use super::spawn_log_reader; + use crate::domain::engine::events::NoopEmitter; + use std::io::Write; + use std::sync::Arc; + use tokio::io::AsyncWriteExt; + + #[tokio::test] + async fn log_reader_flushes_trailing_line_without_newline() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let log_path = temp_dir.path().join("stderr.log"); + let file = std::fs::File::create(&log_path).expect("log file"); + let (mut writer, reader) = tokio::io::duplex(64); + + spawn_log_reader( + reader, + Some(file), + Arc::new(NoopEmitter), + "llamacpp".to_string(), + ); + + writer + .write_all(b"fatal out of memory") + .await + .expect("write log chunk"); + drop(writer); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + let mut file = std::fs::OpenOptions::new() + .append(true) + .open(&log_path) + .expect("reopen log"); + file.flush().expect("flush log"); + let content = std::fs::read_to_string(log_path).expect("read log"); + assert!(content.contains("fatal out of memory")); + } +} diff --git a/src-tauri/src/domain/engine/manager.rs b/src-tauri/src/domain/engine/manager.rs index f3c08292..48a85c62 100644 --- a/src-tauri/src/domain/engine/manager.rs +++ b/src-tauri/src/domain/engine/manager.rs @@ -170,6 +170,17 @@ impl EngineManager { .collect() } + /// Checks whether the given engine is currently running in any capability slot. + pub async fn is_engine_running(&self, id: &str) -> bool { + let id = canonical_engine_id(id); + self.prune_dead_slots().await; + self.slots + .lock() + .await + .values() + .any(|engine| engine.definition.id == id) + } + /// Acquires exclusive access to local inference work. /// /// Hold this guard for the full request, not just engine startup. Without it, @@ -259,7 +270,7 @@ impl EngineManager { let stale = slots.remove(&primary_cap); drop(slots); if let Some(stale) = stale { - Self::kill_engine(stale).await; + Self::kill_engine(stale).await?; } } } @@ -300,7 +311,7 @@ impl EngineManager { ); self.emitter .emit_swapping(&old.definition.id, &config.engine_id); - Self::kill_engine(old).await; + Self::kill_engine(old).await?; } let binary_name = definition.binary.as_deref().ok_or_else(|| { @@ -371,13 +382,28 @@ impl EngineManager { // Pipe engine stdout/stderr to files in logs directory let log_dir = crate::utils::paths::ENGINE_LOGS_DIR.join(canonical_engine_log_id(&config.engine_id)); - let _ = std::fs::create_dir_all(&log_dir); + std::fs::create_dir_all(&log_dir).map_err(|error| { + AppError::Io(format!( + "Failed to create engine log directory '{}': {error}", + log_dir.display() + )) + })?; let stdout_path = log_dir.join("stdout.log"); let stderr_path = log_dir.join("stderr.log"); - let stdout_file = File::create(&stdout_path).ok(); - let stderr_file = File::create(&stderr_path).ok(); + let stdout_file = File::create(&stdout_path).map_err(|error| { + AppError::Io(format!( + "Failed to create engine stdout log '{}': {error}", + stdout_path.display() + )) + })?; + let stderr_file = File::create(&stderr_path).map_err(|error| { + AppError::Io(format!( + "Failed to create engine stderr log '{}': {error}", + stderr_path.display() + )) + })?; cmd.stdout(std::process::Stdio::piped()); cmd.stderr(std::process::Stdio::piped()); @@ -402,7 +428,7 @@ impl EngineManager { if let Some(stdout) = process.stdout.take() { spawn_log_reader( stdout, - stdout_file, + Some(stdout_file), Arc::clone(&self.emitter), config.engine_id.clone(), ); @@ -411,7 +437,7 @@ impl EngineManager { if let Some(stderr) = process.stderr.take() { spawn_log_reader( stderr, - stderr_file, + Some(stderr_file), Arc::clone(&self.emitter), config.engine_id.clone(), ); @@ -452,7 +478,13 @@ impl EngineManager { self.emitter .emit_error(&running.definition.id, &diagnosed_message); // Kill the process if health check fails - let _ = running.process.kill().await; + if let Err(error) = running.process.kill().await { + warn!( + engine = %running.definition.id, + error = %error, + "Failed to kill unhealthy engine process after startup failure" + ); + } return Err(AppError::External { request_id: None, message: diagnosed_message, @@ -499,9 +531,19 @@ impl EngineManager { pub async fn stop(&self) -> Result<(), AppError> { let _lifecycle_guard = self.lifecycle_lock.lock().await; let engines: Vec<(Capability, RunningEngine)> = self.slots.lock().await.drain().collect(); + let mut errors = Vec::new(); for (cap, engine) in engines { info!(engine = %engine.definition.id, slot = ?cap, "Stopping engine"); - Self::kill_engine(engine).await; + if let Err(error) = Self::kill_engine(engine).await { + warn!(slot = ?cap, error = %error, "Failed to stop engine in slot"); + errors.push(error.to_string()); + } + } + if !errors.is_empty() { + return Err(AppError::Internal { + request_id: None, + message: format!("Failed to stop one or more engines: {}", errors.join("; ")), + }); } Ok(()) } @@ -512,18 +554,33 @@ impl EngineManager { let engine = self.slots.lock().await.remove(&capability); if let Some(engine) = engine { info!(engine = %engine.definition.id, slot = ?capability, "Stopping engine in slot"); - Self::kill_engine(engine).await; + Self::kill_engine(engine).await?; } Ok(()) } /// Kill an engine process and wait for exit - async fn kill_engine(mut engine: RunningEngine) { + async fn kill_engine(mut engine: RunningEngine) -> Result<(), AppError> { if let Err(e) = engine.process.kill().await { error!(engine = %engine.definition.id, error = %e, "Failed to kill engine process"); + return Err(AppError::Internal { + request_id: None, + message: format!("Failed to kill engine '{}': {e}", engine.definition.id), + }); } - let _ = engine.process.wait().await; + engine + .process + .wait() + .await + .map_err(|error| AppError::Internal { + request_id: None, + message: format!( + "Failed to wait for engine '{}' after kill: {error}", + engine.definition.id + ), + })?; info!(engine = %engine.definition.id, "Engine stopped"); + Ok(()) } async fn prune_dead_slots(&self) { @@ -624,8 +681,8 @@ mod tests { fn builds_single_slot_llamacpp_args_by_default() { let args = build_llamacpp_args(&sample_config(None), 8081); assert!(args.windows(2).any(|w| w == ["-ngl", "all"])); - assert!(args.windows(2).any(|w| w == ["--parallel", "1"])); - assert!(args.windows(2).any(|w| w == ["--reasoning", "off"])); + assert!(!args.contains(&"--parallel".to_string())); + assert!(!args.contains(&"--reasoning".to_string())); } #[test] @@ -649,18 +706,6 @@ mod tests { assert!(args.windows(2).any(|w| w == ["--ctx-size", "4096"])); } - #[test] - fn adds_qwen_specific_llamacpp_args() { - let args = build_llamacpp_args(&sample_config(Some("Qwen3.5-9B-Q4_K_M.gguf")), 8081); - assert!(args.contains(&"--jinja".to_string())); - assert!( - args.windows(2) - .any(|w| w == ["--reasoning-format", "deepseek"]) - ); - assert!(args.contains(&"--no-context-shift".to_string())); - assert!(args.windows(2).any(|w| w == ["--flash-attn", "on"])); - } - #[test] fn picks_preferred_port_when_it_is_free() { let port = 8085; diff --git a/src-tauri/src/domain/integration_api.rs b/src-tauri/src/domain/integration_api.rs index ad6e6541..fabb0e2c 100644 --- a/src-tauri/src/domain/integration_api.rs +++ b/src-tauri/src/domain/integration_api.rs @@ -265,9 +265,12 @@ fn serve_launcher_http_api(listener: &TcpListener, context: &LauncherHttpApiCont match incoming { Ok(stream) => { let request_context = context.clone(); - let _ = std::thread::Builder::new() + if let Err(error) = std::thread::Builder::new() .name("axelate-local-http-request".to_string()) - .spawn(move || handle_stream(stream, request_context)); + .spawn(move || handle_stream(stream, request_context)) + { + tracing::warn!("Failed to spawn launcher HTTP API request handler: {error}"); + } } Err(error) => { tracing::warn!("Launcher HTTP API accept failed: {error}"); @@ -346,6 +349,9 @@ fn read_http_request(stream: &mut TcpStream) -> Result { .parse::() .map_err(|error| format!("Invalid content-length: {error}")) })?; + if content_length > MAX_REQUEST_BYTES { + return Err("HTTP request body is too large".to_string()); + } let body_start = header_end .checked_add(4) @@ -356,7 +362,10 @@ fn read_http_request(stream: &mut TcpStream) -> Result { .read(&mut chunk) .map_err(|error| format!("Failed to read request body: {error}"))?; if read == 0 { - break; + return Err(format!( + "HTTP request body ended before content-length was reached: expected {content_length} bytes, got {}", + body.len() + )); } let read_chunk = chunk .get(..read) @@ -405,7 +414,19 @@ async fn dispatch_http_request( match route_authorized_request(path, &request, context).await { Ok(response) => response, - Err(error) => json_error(500, &error.to_string()), + Err(error) => json_error(status_for_app_error(&error), &error.to_string()), + } +} + +const fn status_for_app_error(error: &AppError) -> u16 { + match error { + AppError::Validation(_) | AppError::Config(_) => 400, + AppError::NotFound(_) => 404, + AppError::PermissionDenied(_) => 403, + AppError::Io(_) + | AppError::Serialization(_) + | AppError::External { .. } + | AppError::Internal { .. } => 500, } } @@ -429,6 +450,7 @@ async fn route_authorized_request( )) } ("GET", ["v1", "modules", module_id, "status"]) => { + crate::domain::modules::downloader::validate_module_id(module_id)?; let status = module_controller::get_module_status(module_id).await; Ok(json_response( 200, @@ -457,6 +479,7 @@ fn handle_module_stage_request( context: &LauncherHttpApiContext, module_id: &str, ) -> Result { + crate::domain::modules::downloader::validate_module_id(module_id)?; let payload: IntegrationModuleStageRequest = parse_json_body(request)?; let stage = payload.stage.trim(); let label = payload.label.trim(); @@ -511,7 +534,7 @@ async fn handle_text_request( let ui_provider = match requested_provider.as_ref() { Some(provider) => provider.clone(), None => selected_module_id(&context.ui_state_service, "ai_text") - .await + .await? .ok_or_else(|| AppError::Validation("No selected text AI provider".to_string()))?, }; if requested_provider.is_some() { @@ -530,6 +553,11 @@ async fn handle_text_request( let session_id = resolve_session_id(&context.ui_state_service, payload.session_id.as_deref()).await; let mut messages = payload.messages.unwrap_or_default(); + if messages.is_empty() && payload.prompt.trim().is_empty() { + return Err(AppError::Validation( + "Text request requires a prompt or messages".to_string(), + )); + } if messages.is_empty() || !payload.prompt.trim().is_empty() { messages.push(ChatMessage { id: uuid::Uuid::new_v4().to_string(), @@ -541,11 +569,11 @@ async fn handle_text_request( let thinking_level = match payload.thinking_level { Some(value) => Some(value), - None => selected_thinking_level(&context.ui_state_service, &provider).await, + None => selected_thinking_level(&context.ui_state_service, &provider).await?, }; let web_search = match payload.web_search { Some(value) => Some(value), - None => selected_web_search(&context.ui_state_service, &provider).await, + None => selected_web_search(&context.ui_state_service, &provider).await?, }; let mut chat_request = ChatRequest { @@ -586,11 +614,16 @@ async fn handle_image_request( context: LauncherHttpApiContext, ) -> Result { let payload: IntegrationImageRequest = parse_json_body(request)?; + if payload.prompt.trim().is_empty() { + return Err(AppError::Validation( + "Image request requires a prompt".to_string(), + )); + } let requested_provider = payload.provider.filter(|value| !value.trim().is_empty()); let ui_provider = match requested_provider.as_ref() { Some(provider) => provider.clone(), None => selected_module_id(&context.ui_state_service, "ai_image") - .await + .await? .ok_or_else(|| AppError::Validation("No selected image AI provider".to_string()))?, }; if requested_provider.is_some() { @@ -660,11 +693,7 @@ async fn select_provider_for_category( provider_id: &str, ) -> Result<(), AppError> { let selected_module = resolve_selected_provider_module(&context.config_service, provider_id)?; - let mut state = context - .ui_state_service - .get_ui_state() - .await - .unwrap_or_default(); + let mut state = context.ui_state_service.get_ui_state().await?; let previous_id = state .selected_modules .get(category) @@ -761,13 +790,16 @@ fn is_custom_provider_id(provider_id: &str) -> bool { ) } -async fn selected_module_id(ui_state_service: &UiStateService, category: &str) -> Option { - let state = ui_state_service.get_ui_state().await.ok()?; - state +async fn selected_module_id( + ui_state_service: &UiStateService, + category: &str, +) -> Result, AppError> { + let state = ui_state_service.get_ui_state().await?; + Ok(state .selected_modules .get(category) .map(|module| module.id.trim().to_string()) - .filter(|value| !value.is_empty()) + .filter(|value| !value.is_empty())) } async fn resolve_session_id( @@ -789,30 +821,32 @@ async fn resolve_session_id( async fn selected_thinking_level( ui_state_service: &UiStateService, provider: &str, -) -> Option { - ui_state_service +) -> Result, AppError> { + Ok(ui_state_service .get_ui_state() - .await - .ok() - .and_then(|state| state.ai_thinking_level.get(provider).cloned()) - .filter(|value| !value.trim().is_empty()) + .await? + .ai_thinking_level + .get(provider) + .cloned() + .filter(|value| !value.trim().is_empty())) } async fn selected_web_search( ui_state_service: &UiStateService, provider: &str, -) -> Option { +) -> Result, AppError> { let enabled = ui_state_service .get_ui_state() - .await - .ok() - .and_then(|state| state.ai_web_search_enabled.get(provider).copied()) + .await? + .ai_web_search_enabled + .get(provider) + .copied() .unwrap_or(false); - enabled.then_some(WebSearchOptions { + Ok(enabled.then_some(WebSearchOptions { enabled, ..WebSearchOptions::default() - }) + })) } async fn resolve_model_id( @@ -830,13 +864,13 @@ async fn resolve_model_id( return Ok(model.to_string()); } - let state = ui_state_service.get_ui_state().await.ok(); - let selected_model = state.as_ref().and_then(|state| { + let state = ui_state_service.get_ui_state().await?; + let selected_model = { ui_provider_id .and_then(|id| state.selected_ai_models.get(id)) .or_else(|| state.selected_ai_models.get(provider_id)) .cloned() - }); + }; let config = config_service.load_full_config()?; let provider = config .api_providers @@ -970,10 +1004,13 @@ mod tests { use super::{ backend_provider_id, find_header_end, is_authorized, model_api_id, parse_header_line, - status_text, tier_rank, + read_http_request, status_for_app_error, status_text, tier_rank, }; + use crate::errors::AppError; use crate::models::{AiModel, ApiModelConfig, ModelStats, ModelTier}; use std::collections::HashMap; + use std::io::Write; + use std::net::{Shutdown, TcpListener, TcpStream}; fn model_with_api_ids() -> AiModel { AiModel { @@ -1049,4 +1086,61 @@ mod tests { assert!(tier_rank(&ModelTier::Strong) > tier_rank(&ModelTier::Medium)); assert_eq!(status_text(404), "Not Found"); } + + #[test] + fn maps_app_errors_to_http_status_codes() { + assert_eq!( + status_for_app_error(&AppError::Validation("bad input".to_string())), + 400 + ); + assert_eq!( + status_for_app_error(&AppError::NotFound("missing".to_string())), + 404 + ); + assert_eq!( + status_for_app_error(&AppError::PermissionDenied("denied".to_string())), + 403 + ); + assert_eq!(status_for_app_error(&AppError::Io("disk".to_string())), 500); + } + + #[test] + fn rejects_http_body_larger_than_limit_before_waiting_for_body() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test listener"); + let addr = listener.local_addr().expect("local addr"); + let client = std::thread::spawn(move || { + let mut stream = TcpStream::connect(addr).expect("connect test listener"); + write!( + stream, + "POST /v1/ai/text HTTP/1.1\r\nContent-Length: {}\r\n\r\n", + super::MAX_REQUEST_BYTES + 1 + ) + .expect("write request"); + }); + + let (mut stream, _) = listener.accept().expect("accept test client"); + let error = read_http_request(&mut stream).expect_err("oversized body must fail"); + client.join().expect("client thread"); + + assert_eq!(error, "HTTP request body is too large"); + } + + #[test] + fn rejects_http_body_shorter_than_content_length() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test listener"); + let addr = listener.local_addr().expect("local addr"); + let client = std::thread::spawn(move || { + let mut stream = TcpStream::connect(addr).expect("connect test listener"); + stream + .write_all(b"POST /v1/ai/text HTTP/1.1\r\nContent-Length: 8\r\n\r\nabc") + .expect("write request"); + stream.shutdown(Shutdown::Write).expect("shutdown write"); + }); + + let (mut stream, _) = listener.accept().expect("accept test client"); + let error = read_http_request(&mut stream).expect_err("truncated body must fail"); + client.join().expect("client thread"); + + assert!(error.contains("expected 8 bytes, got 3")); + } } diff --git a/src-tauri/src/domain/license/storage.rs b/src-tauri/src/domain/license/storage.rs index c3f90b0f..01fda0c4 100644 --- a/src-tauri/src/domain/license/storage.rs +++ b/src-tauri/src/domain/license/storage.rs @@ -5,11 +5,14 @@ use crate::infrastructure::crypto::secure_storage::SecureStorage; const LICENSE_KEY: &str = "license_data"; /// Loads license from storage -pub fn load_license() -> Option { - match SecureStorage::get_key(LICENSE_KEY) { - Ok(Some(json)) => serde_json::from_str(&json).ok(), - _ => None, - } +pub fn load_license() -> Result, AppError> { + let Some(json) = SecureStorage::get_key(LICENSE_KEY)? else { + return Ok(None); + }; + + serde_json::from_str(&json) + .map(Some) + .map_err(|e| AppError::Serialization(format!("Failed to parse stored license: {e}"))) } /// Saves license to encrypted storage diff --git a/src-tauri/src/domain/license/verifier.rs b/src-tauri/src/domain/license/verifier.rs index e8c626ba..c0c13d72 100644 --- a/src-tauri/src/domain/license/verifier.rs +++ b/src-tauri/src/domain/license/verifier.rs @@ -3,11 +3,11 @@ use super::types::{LicenseInfo, LicenseStatus}; use crate::errors::AppError; /// Verifies current license status -pub fn verify() -> LicenseStatus { - match storage::load_license() { +pub fn verify() -> Result { + Ok(match storage::load_license()? { Some(info) => verify_license_info(&info), None => LicenseStatus::Free, - } + }) } /// Verifies a license info object @@ -52,14 +52,14 @@ pub fn deactivate() -> Result<(), AppError> { } /// Checks if a feature is available in the current license -pub fn has_feature(feature: &str) -> bool { - let status = verify(); +pub fn has_feature(feature: &str) -> Result { + let status = verify()?; match status { - LicenseStatus::Enterprise => true, + LicenseStatus::Enterprise => Ok(true), LicenseStatus::Pro => { // Pro features list - matches!(feature, "advanced_stats" | "custom_themes") + Ok(matches!(feature, "advanced_stats" | "custom_themes")) } - _ => false, + _ => Ok(false), } } diff --git a/src-tauri/src/domain/modules/controller/lifecycle.rs b/src-tauri/src/domain/modules/controller/lifecycle.rs index 20287c49..d8f9a155 100644 --- a/src-tauri/src/domain/modules/controller/lifecycle.rs +++ b/src-tauri/src/domain/modules/controller/lifecycle.rs @@ -230,13 +230,18 @@ impl<'a> LifecycleExecutor<'a> { } /// Gracefully stops a module with escalation - pub async fn stop(&self, manifest: &ModuleManifest) -> ControlResponse { + pub async fn stop(&self, manifest: &ModuleManifest) -> Result { tracing::info!("Stopping module: {}", self.module_id); let script_entry_path = self.resolve_script_entry_path(manifest); // 1. Run stop script if exists if let Some(stop_cmd) = manifest.lifecycle.as_ref().and_then(|l| l.stop.clone()) { - let _ = self.run_command(stop_cmd, Duration::from_secs(5)).await; + if let Err(error) = self.run_command(stop_cmd, Duration::from_secs(5)).await { + tracing::warn!( + module_id = %self.module_id, + "Module stop script failed, continuing with process termination: {error}" + ); + } } // 2. Attempt soft termination and wait @@ -258,7 +263,12 @@ impl<'a> LifecycleExecutor<'a> { // Wait with timeout if timeout(Duration::from_secs(5), child.wait()).await.is_err() { tracing::warn!("Module {} stop timed out, forcing kill", self.module_id); - let _ = child.kill().await; + if let Err(error) = child.kill().await { + tracing::warn!( + module_id = %self.module_id, + "Failed to force-kill registered module process: {error}" + ); + } } } @@ -297,16 +307,44 @@ impl<'a> LifecycleExecutor<'a> { && let Ok(pid) = pid_str.trim().parse::() { // Safe kill_orphan now includes existence check - let _ = crate::domain::modules::controller::process::kill_orphan(pid); + if let Err(error) = + crate::domain::modules::controller::process::kill_orphan(pid) + { + tracing::warn!( + module_id = %self.module_id, + pid, + "Failed to force-kill orphan module process: {error}" + ); + } } } tokio::time::sleep(Duration::from_millis(500)).await; } + if self + .controller + .is_running(&self.module_id, self.module_path) + .await + { + return Err(AppError::Internal { + request_id: None, + message: format!("Module {} failed to stop", self.module_id), + }); + } + // 4. Cleanup PID file (Wait loop already verified termination) - let _ = std::fs::remove_file(self.module_path.join("module.pid")); + match std::fs::remove_file(self.module_path.join("module.pid")) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + tracing::warn!( + module_id = %self.module_id, + "Failed to remove module PID file after stop: {error}" + ); + } + } - ControlResponse { + Ok(ControlResponse { success: process_scan_error.is_none(), message: process_scan_error.map_or_else( || format!("Module {} stopped", self.module_id), @@ -318,7 +356,7 @@ impl<'a> LifecycleExecutor<'a> { }, ), status: Some("stopped".to_string()), - } + }) } fn resolve_script_entry_path(&self, manifest: &ModuleManifest) -> Option { @@ -348,21 +386,52 @@ impl<'a> LifecycleExecutor<'a> { ); if let Some(mut child) = self.controller.unregister(&self.module_id) { - let _ = child.kill().await; - let _ = child.wait().await; + if let Err(error) = child.kill().await { + tracing::warn!( + module_id = %self.module_id, + "Failed to kill registered duplicate module child: {error}" + ); + } + if let Err(error) = child.wait().await { + tracing::warn!( + module_id = %self.module_id, + "Failed to wait registered duplicate module child after kill: {error}" + ); + } } for pid in matching_pids { - let _ = process::kill_orphan(pid); + process::kill_orphan(pid).map_err(|error| AppError::Internal { + request_id: None, + message: format!( + "Failed to clean duplicate module process {pid} for '{}': {error}", + self.module_id + ), + })?; } - let _ = std::fs::remove_file(self.module_path.join("module.pid")); + match std::fs::remove_file(self.module_path.join("module.pid")) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + tracing::warn!( + module_id = %self.module_id, + "Failed to remove stale module PID file after duplicate cleanup: {error}" + ); + } + } Ok(None) } async fn kill_matching_script_processes(&self, entry_path: &Path) -> Result<(), AppError> { for pid in self.find_matching_script_processes(entry_path).await? { - let _ = process::kill_orphan(pid); + process::kill_orphan(pid).map_err(|error| AppError::Internal { + request_id: None, + message: format!( + "Failed to kill module process {pid} for '{}': {error}", + self.module_id + ), + })?; } Ok(()) } diff --git a/src-tauri/src/domain/modules/controller/mod.rs b/src-tauri/src/domain/modules/controller/mod.rs index 614b683f..e0e714b6 100644 --- a/src-tauri/src/domain/modules/controller/mod.rs +++ b/src-tauri/src/domain/modules/controller/mod.rs @@ -366,13 +366,18 @@ pub async fn control( if action == ModuleAction::Uninstall { let executor = LifecycleExecutor::new(&controller, module_id.to_string(), &module_path); if let Ok(manifest) = module_lifecycle::ManifestLoader::load(&module_path) { - let _ = executor.stop(&manifest).await; + executor.stop(&manifest).await?; } else { let pid_file = module_path.join("module.pid"); if let Ok(pid_str) = std::fs::read_to_string(&pid_file) && let Ok(pid) = pid_str.trim().parse::() { - let _ = process::kill_orphan(pid); + process::kill_orphan(pid).map_err(|error| AppError::Internal { + request_id: None, + message: format!( + "Failed to stop module {module_id} from PID file before uninstall: {error}" + ), + })?; } } downloader::delete_module(module_id).await?; @@ -392,10 +397,10 @@ pub async fn control( match action { ModuleAction::Start => executor.start(&manifest).await, - ModuleAction::Stop => Ok(executor.stop(&manifest).await), + ModuleAction::Stop => executor.stop(&manifest).await, ModuleAction::Restart => { tracing::info!("Restarting module: {module_id}"); - let _ = executor.stop(&manifest).await; + executor.stop(&manifest).await?; // Wait for it to actually die (up to 5s) with survival check let mut terminated = false; diff --git a/src-tauri/src/domain/modules/downloader.rs b/src-tauri/src/domain/modules/downloader.rs index a410e700..c20800a5 100644 --- a/src-tauri/src/domain/modules/downloader.rs +++ b/src-tauri/src/domain/modules/downloader.rs @@ -10,6 +10,7 @@ use super::downloader_transfer::{ DownloadTask, ReleaseDownloadAsset, build_client, clone_repository_into, download_file, resolve_download_url, }; +use super::github_releases::ReleaseDownloadSelection; use crate::errors::AppError; use std::path::{Path, PathBuf}; use tauri::AppHandle; @@ -53,6 +54,16 @@ pub fn is_module_installed(module_id: &str) -> bool { resolve_existing_module_path(module_id).is_some() } +/// Lists compatible release versions and CPU/GPU package choices for a module. +pub async fn get_release_download_options( + module_id: &str, + repo_url: &str, +) -> Result { + validate_module_id(module_id)?; + let client = build_client(module_id)?; + super::github_releases::fetch_release_download_options(&client, repo_url, module_id).await +} + /// Deletes a module from disk pub async fn delete_module(module_id: &str) -> Result<(), AppError> { validate_module_id(module_id)?; @@ -81,6 +92,7 @@ pub async fn download_module( repo_url: String, expected_hash: Option, dl_type: Option, + release_selection: Option, ) -> Result { validate_module_id(&module_id)?; downloader.remember_request( @@ -89,6 +101,7 @@ pub async fn download_module( repo_url: repo_url.clone(), expected_hash: expected_hash.clone(), dl_type: dl_type.clone(), + release_selection: release_selection.clone(), }, ); @@ -122,7 +135,10 @@ pub async fn download_module( (None, Vec::new()) } else if dl_type.as_deref() == Some("release") { let bundle = crate::domain::modules::github_releases::fetch_release_bundle( - &client, &repo_url, &module_id, + &client, + &repo_url, + &module_id, + release_selection.as_ref(), ) .await?; @@ -199,7 +215,7 @@ pub async fn download_module( }); } - ensure_not_cancelled(&control)?; + ensure_not_interrupted(&control)?; FileVerifier::verify( &app, &archive_path, @@ -209,7 +225,7 @@ pub async fn download_module( ) .await?; - ensure_not_cancelled(&control)?; + ensure_not_interrupted(&control)?; ArchiveExtractor::extract_into( &app, &archive_path, @@ -219,13 +235,13 @@ pub async fn download_module( ) .await?; - ensure_not_cancelled(&control)?; + ensure_not_interrupted(&control)?; } } final_progress_snapshot = latest_progress_snapshot; - ensure_not_cancelled(&control)?; + ensure_not_interrupted(&control)?; ArchiveExtractor::finalize( &module_id, &extraction_path, @@ -250,9 +266,23 @@ pub async fn download_module( if cleanup_archives { for archive_path in &temp_archives { if archive_path.exists() { - let _ = tokio::fs::remove_file(archive_path).await; + if let Err(error) = tokio::fs::remove_file(archive_path).await + && error.kind() != std::io::ErrorKind::NotFound + { + tracing::warn!( + module_id = module_id, + path = %archive_path.display(), + "Failed to remove temporary archive after interrupted download: {error}" + ); + } + } + if let Err(error) = remove_partial_metadata(archive_path).await { + tracing::warn!( + module_id = module_id, + path = %archive_path.display(), + "Failed to remove partial download metadata after interrupted download: {error}" + ); } - remove_partial_metadata(archive_path).await; } } @@ -260,7 +290,13 @@ pub async fn download_module( && let Some(path) = &staging_path && path.exists() { - let _ = tokio::fs::remove_dir_all(path).await; + if let Err(error) = tokio::fs::remove_dir_all(path).await { + tracing::warn!( + module_id = module_id, + path = %path.display(), + "Failed to remove staging directory after failed install: {error}" + ); + } } if let Err(e) = result { @@ -304,7 +340,13 @@ pub async fn download_module( }); for archive_path in &temp_archives { - remove_partial_metadata(archive_path).await; + if let Err(error) = remove_partial_metadata(archive_path).await { + tracing::warn!( + module_id = module_id, + path = %archive_path.display(), + "Failed to remove partial download metadata after successful install: {error}" + ); + } } crate::infrastructure::logging::logger::add_log( @@ -317,7 +359,7 @@ pub async fn download_module( Ok("completed".to_string()) } -fn ensure_not_cancelled( +fn ensure_not_interrupted( control: &super::downloader_service::DownloadControl, ) -> Result<(), AppError> { if control.is_cancel_requested() { @@ -329,6 +371,13 @@ fn ensure_not_cancelled( }); } + if control.is_pause_requested() { + return Err(AppError::External { + request_id: None, + message: DownloadInterruption::Paused.as_error_message().to_string(), + }); + } + Ok(()) } @@ -340,13 +389,38 @@ pub fn check_module_installed(module_id: &str) -> bool { #[cfg(test)] #[allow(clippy::expect_used)] mod tests { + use super::ensure_not_interrupted; + use crate::domain::modules::downloader_service::DownloaderService; use crate::domain::modules::downloader_support::{ PartialDownloadMetadata, TarEntryAction, classify_tar_entry_type, if_range_validator, - normalize_archive_relative_path, parse_content_range_total, + load_partial_metadata, normalize_archive_relative_path, parse_content_range_total, + store_partial_metadata, }; use sevenz_rust2::{ArchiveReader, Password}; use std::path::{Path, PathBuf}; + #[test] + fn ensure_not_interrupted_reports_pause_requests() { + let service = DownloaderService::new(); + let control = service.request_control("demo"); + assert!(service.pause("demo")); + + let error = ensure_not_interrupted(&control).expect_err("pause should interrupt"); + + assert!(error.to_string().contains("Download paused")); + } + + #[test] + fn ensure_not_interrupted_reports_cancel_requests() { + let service = DownloaderService::new(); + let control = service.request_control("demo"); + assert!(service.cancel("demo")); + + let error = ensure_not_interrupted(&control).expect_err("cancel should interrupt"); + + assert!(error.to_string().contains("Download cancelled")); + } + #[test] fn normalize_archive_relative_path_rejects_traversal() { let error = normalize_archive_relative_path(Path::new("../escape/file.txt")) @@ -421,6 +495,48 @@ mod tests { assert_eq!(if_range_validator(&metadata), Some("\"etag-value\"")); } + #[tokio::test] + async fn partial_metadata_load_reports_corrupt_json() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let archive_path = temp_dir.path().join("module.zip"); + tokio::fs::write( + format!("{}.resume.json", archive_path.to_string_lossy()), + "{not-json", + ) + .await + .expect("write corrupt metadata"); + + let error = load_partial_metadata(&archive_path) + .await + .expect_err("corrupt metadata must be reported"); + + assert!(error.to_string().contains("partial download metadata")); + } + + #[tokio::test] + async fn partial_metadata_round_trips_valid_json() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let archive_path = temp_dir.path().join("module.zip"); + let metadata = PartialDownloadMetadata { + url: "https://example.com/module.zip".to_string(), + etag: Some("\"etag-value\"".to_string()), + last_modified: None, + total_bytes: Some(42), + }; + + store_partial_metadata(&archive_path, &metadata) + .await + .expect("store metadata"); + let loaded = load_partial_metadata(&archive_path) + .await + .expect("load metadata") + .expect("metadata exists"); + + assert_eq!(loaded.url, metadata.url); + assert_eq!(loaded.etag, metadata.etag); + assert_eq!(loaded.total_bytes, metadata.total_bytes); + } + #[test] #[ignore = "manual local archive debug helper; requires AXELATE_DEBUG_7Z"] fn debug_extract_local_comfyui_archive() { diff --git a/src-tauri/src/domain/modules/downloader_install.rs b/src-tauri/src/domain/modules/downloader_install.rs index cd9dc412..c2922790 100644 --- a/src-tauri/src/domain/modules/downloader_install.rs +++ b/src-tauri/src/domain/modules/downloader_install.rs @@ -223,9 +223,19 @@ impl ArchiveExtractor { let extraction_path = TEMP_DIR.join(extraction_id); if extraction_path.exists() { - fs::remove_dir_all(&extraction_path).ok(); + fs::remove_dir_all(&extraction_path).map_err(|error| { + AppError::Io(format!( + "Failed to remove stale extraction directory '{}': {error}", + extraction_path.display() + )) + })?; } - fs::create_dir_all(&extraction_path).map_err(|e| AppError::Io(e.to_string()))?; + fs::create_dir_all(&extraction_path).map_err(|error| { + AppError::Io(format!( + "Failed to create extraction directory '{}': {error}", + extraction_path.display() + )) + })?; Ok(extraction_path) } @@ -577,7 +587,12 @@ impl ArchiveExtractor { } if file.name().ends_with('/') { - fs::create_dir_all(&outpath).ok(); + fs::create_dir_all(&outpath).map_err(|error| { + format!( + "Failed to create extraction directory {}: {error}", + outpath.display() + ) + })?; } else { Self::extract_zip_file_entry( &mut file, @@ -642,7 +657,12 @@ impl ArchiveExtractor { if let Some(parent) = outpath.parent() && !parent.exists() { - fs::create_dir_all(parent).ok(); + fs::create_dir_all(parent).map_err(|error| { + format!( + "Failed to create extraction directory {}: {error}", + parent.display() + ) + })?; } let u_size = file.size(); @@ -696,9 +716,18 @@ impl ArchiveExtractor { "version": release_tag.unwrap_or("unknown"), }); let manifest_path = extraction_path.join("metadata.json"); - if let Ok(manifest_file) = fs::File::create(manifest_path) { - let _ = serde_json::to_writer_pretty(manifest_file, &manifest); - } + let manifest_file = fs::File::create(&manifest_path).map_err(|error| { + AppError::Io(format!( + "Failed to create install metadata {}: {error}", + manifest_path.display() + )) + })?; + serde_json::to_writer_pretty(manifest_file, &manifest).map_err(|error| { + AppError::Serialization(format!( + "Failed to write install metadata {}: {error}", + manifest_path.display() + )) + })?; let backup_path = TEMP_DIR.join(format!("{module_id}_backup_{}", uuid::Uuid::new_v4())); @@ -710,7 +739,14 @@ impl ArchiveExtractor { if let Err(e) = fs::rename(extraction_path, &final_path) { if backup_path.exists() { - let _ = fs::rename(&backup_path, &final_path); + if let Err(restore_error) = fs::rename(&backup_path, &final_path) { + tracing::error!( + module_id, + backup = %backup_path.display(), + target = %final_path.display(), + "Failed to restore previous module version after install failure: {restore_error}" + ); + } } return Err(AppError::Io(format!( diff --git a/src-tauri/src/domain/modules/downloader_progress.rs b/src-tauri/src/domain/modules/downloader_progress.rs index 15a03aff..fc09b9bc 100644 --- a/src-tauri/src/domain/modules/downloader_progress.rs +++ b/src-tauri/src/domain/modules/downloader_progress.rs @@ -184,7 +184,7 @@ pub fn emit_extraction_progress( } pub fn emit_progress(event: ProgressEvent<'_>) { - let _ = event.app.emit( + if let Err(error) = event.app.emit( "download_progress", DownloadProgress { module_id: event.module_id.to_string(), @@ -195,5 +195,11 @@ pub fn emit_progress(event: ProgressEvent<'_>) { total: event.total, speed: event.speed, }, - ); + ) { + tracing::warn!( + module_id = event.module_id, + status = event.status, + "Failed to emit download progress: {error}" + ); + } } diff --git a/src-tauri/src/domain/modules/downloader_service.rs b/src-tauri/src/domain/modules/downloader_service.rs index c1e2e2c9..1fb1b54e 100644 --- a/src-tauri/src/domain/modules/downloader_service.rs +++ b/src-tauri/src/domain/modules/downloader_service.rs @@ -41,6 +41,7 @@ pub struct DownloadRequest { pub repo_url: String, pub expected_hash: Option, pub dl_type: Option, + pub release_selection: Option, } #[derive(Clone, Copy, Debug)] @@ -240,6 +241,7 @@ mod tests { repo_url: "https://example.com/file.zip".to_string(), expected_hash: Some("hash".to_string()), dl_type: Some("release".to_string()), + release_selection: None, }, ); diff --git a/src-tauri/src/domain/modules/downloader_support.rs b/src-tauri/src/domain/modules/downloader_support.rs index d701be28..72ebedcf 100644 --- a/src-tauri/src/domain/modules/downloader_support.rs +++ b/src-tauri/src/domain/modules/downloader_support.rs @@ -101,10 +101,23 @@ fn partial_metadata_path(dest_path: &Path) -> PathBuf { PathBuf::from(format!("{}.resume.json", dest_path.to_string_lossy())) } -pub(super) async fn load_partial_metadata(dest_path: &Path) -> Option { +pub(super) async fn load_partial_metadata( + dest_path: &Path, +) -> Result, AppError> { let metadata_path = partial_metadata_path(dest_path); - let raw = tokio::fs::read_to_string(metadata_path).await.ok()?; - serde_json::from_str(&raw).ok() + match tokio::fs::read_to_string(&metadata_path).await { + Ok(raw) => serde_json::from_str(&raw).map(Some).map_err(|error| { + AppError::Serialization(format!( + "Failed to parse partial download metadata '{}': {error}", + metadata_path.display() + )) + }), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(AppError::Io(format!( + "Failed to read partial download metadata '{}': {error}", + metadata_path.display() + ))), + } } pub(super) async fn store_partial_metadata( @@ -121,10 +134,15 @@ pub(super) async fn store_partial_metadata( .map_err(|e| AppError::Io(e.to_string())) } -pub(super) async fn remove_partial_metadata(dest_path: &Path) { +pub(super) async fn remove_partial_metadata(dest_path: &Path) -> Result<(), AppError> { let metadata_path = partial_metadata_path(dest_path); - if tokio::fs::try_exists(&metadata_path).await.unwrap_or(false) { - let _ = tokio::fs::remove_file(metadata_path).await; + match tokio::fs::remove_file(&metadata_path).await { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(AppError::Io(format!( + "Failed to remove partial download metadata '{}': {error}", + metadata_path.display() + ))), } } diff --git a/src-tauri/src/domain/modules/downloader_transfer.rs b/src-tauri/src/domain/modules/downloader_transfer.rs index 20be0aee..47eacb59 100644 --- a/src-tauri/src/domain/modules/downloader_transfer.rs +++ b/src-tauri/src/domain/modules/downloader_transfer.rs @@ -131,7 +131,12 @@ pub(super) async fn clone_repository_into( let git_dir = extraction_path.join(".git"); if git_dir.exists() { - let _ = tokio::fs::remove_dir_all(git_dir).await; + tokio::fs::remove_dir_all(&git_dir).await.map_err(|error| { + AppError::Io(format!( + "Failed to remove git metadata directory '{}': {error}", + git_dir.display() + )) + })?; } Ok(()) @@ -142,7 +147,7 @@ pub(super) fn build_client(module_id: &str) -> Result .user_agent("Axelate/1.0.0 (Tauri; Windows)") .timeout(std::time::Duration::from_secs(600)); - if let Some(license) = crate::domain::license::storage::load_license() + if let Some(license) = crate::domain::license::storage::load_license()? && !license.key.is_empty() { tracing::info!("Injecting license key for module download: {module_id}"); @@ -185,14 +190,41 @@ pub(super) async fn download_file( }; fs::create_dir_all(&*TEMP_DIR).map_err(|error| AppError::Io(error.to_string()))?; - let resume_metadata = load_partial_metadata(task.dest_path) - .await - .filter(|metadata| metadata.url == task.url); - let existing_bytes = tokio::fs::metadata(task.dest_path) + let loaded_resume_metadata = match load_partial_metadata(task.dest_path).await { + Ok(metadata) => metadata, + Err(error) => { + tracing::warn!( + module_id = task.module_id, + path = %task.dest_path.display(), + "Ignoring corrupt partial download metadata: {error}" + ); + remove_partial_metadata(task.dest_path).await?; + None + } + }; + let resume_metadata = loaded_resume_metadata.filter(|metadata| metadata.url == task.url); + let mut existing_bytes = tokio::fs::metadata(task.dest_path) .await .ok() .filter(std::fs::Metadata::is_file) .map_or(0, |metadata| metadata.len()); + if existing_bytes > 0 && resume_metadata.is_none() { + tracing::warn!( + module_id = task.module_id, + path = %task.dest_path.display(), + "Discarding partial download without matching resume metadata" + ); + remove_partial_metadata(task.dest_path).await?; + if let Err(error) = tokio::fs::remove_file(task.dest_path).await + && error.kind() != std::io::ErrorKind::NotFound + { + return Err(AppError::Io(format!( + "Failed to remove stale partial download '{}': {error}", + task.dest_path.display() + ))); + } + existing_bytes = 0; + } let mut request = task.client.get(task.url); if existing_bytes > 0 { @@ -220,7 +252,7 @@ pub(super) async fn download_file( }); } - remove_partial_metadata(task.dest_path).await; + remove_partial_metadata(task.dest_path).await?; response = task .client .get(task.url) @@ -343,6 +375,9 @@ pub(super) async fn download_file( file.flush() .await .map_err(|error| AppError::Io(error.to_string()))?; + file.sync_all() + .await + .map_err(|error| AppError::Io(error.to_string()))?; Ok(DownloadResult { asset_downloaded: bytes_downloaded, diff --git a/src-tauri/src/domain/modules/github_releases.rs b/src-tauri/src/domain/modules/github_releases.rs index f0ac3248..9ca9a61f 100644 --- a/src-tauri/src/domain/modules/github_releases.rs +++ b/src-tauri/src/domain/modules/github_releases.rs @@ -2,7 +2,8 @@ use crate::errors::AppError; use reqwest::Client; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; +use specta::Type; use super::github_release_selection::{ current_platform, detect_hardware_profile, select_release_assets, @@ -30,11 +31,70 @@ pub struct ReleaseBundle { pub assets: Vec, } +/// User-facing compute target for release package selection. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "snake_case")] +pub enum ReleaseComputeTarget { + /// Let Axelate choose the best compatible package for this machine. + #[default] + Auto, + /// Prefer a GPU package, for example CUDA, Vulkan, HIP, or SYCL. + Gpu, + /// Prefer a CPU package. + Cpu, +} + +/// Explicit release package selection passed from the frontend. +#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)] +pub struct ReleaseDownloadSelection { + /// GitHub release tag to download. `None` means the newest compatible release. + pub tag_name: Option, + /// Compute target selected by the user. + #[serde(default)] + pub compute_target: ReleaseComputeTarget, +} + +/// User-visible release download options for a single module. +#[derive(Clone, Debug, Serialize, Type)] +pub struct ReleaseDownloadOptions { + /// Module identifier these options belong to. + pub module_id: String, + /// GitHub release versions in newest-first order. + pub versions: Vec, +} + +/// User-visible package choices for a GitHub release version. +#[derive(Clone, Debug, Serialize, Type)] +pub struct ReleaseDownloadVersion { + /// GitHub release tag. + pub tag_name: String, + /// GitHub release publish timestamp when available. + pub published_at: Option, + /// CPU package choice for this release, when compatible. + pub cpu: Option, + /// GPU package choice for this release, when compatible. + pub gpu: Option, + /// Recommended package target for this machine. + pub recommended: ReleaseComputeTarget, +} + +/// User-visible package variant for one compute target. +#[derive(Clone, Debug, Serialize, Type)] +pub struct ReleaseDownloadVariant { + /// Compute target represented by this variant. + pub compute_target: ReleaseComputeTarget, + /// Asset filenames that will be downloaded. + pub assets: Vec, + /// Combined download size in bytes. + pub total_size: u64, +} + const RELEASES_PER_PAGE: u8 = 100; #[derive(Clone, Debug, Deserialize)] struct Release { tag_name: String, + published_at: Option, #[serde(default)] draft: bool, #[serde(default)] @@ -86,6 +146,7 @@ pub async fn fetch_release_bundle( client: &Client, repo_url: &str, module_id: &str, + selection: Option<&ReleaseDownloadSelection>, ) -> Result { let repo_ref = parse_repo(repo_url)?; let platform = current_platform(); @@ -104,7 +165,7 @@ pub async fn fetch_release_bundle( } if let Some(bundle) = - find_compatible_release_bundle(module_id, platform, hardware, releases) + find_compatible_release_bundle(module_id, platform, hardware, releases, selection) { tracing::info!( "Selected release bundle for {module_id}: tag={} assets={}", @@ -127,6 +188,36 @@ pub async fn fetch_release_bundle( ))) } +/// Returns user-visible release choices for the current machine. +pub async fn fetch_release_download_options( + client: &Client, + repo_url: &str, + module_id: &str, +) -> Result { + let repo_ref = parse_repo(repo_url)?; + let platform = current_platform(); + let hardware = detect_hardware_profile().await; + let mut page = 1_u32; + let mut versions = Vec::new(); + + loop { + let releases = fetch_release_page(client, &repo_ref, module_id, page).await?; + if releases.is_empty() { + break; + } + + versions.extend(release_download_versions( + module_id, platform, hardware, releases, + )); + page += 1; + } + + Ok(ReleaseDownloadOptions { + module_id: module_id.to_string(), + versions, + }) +} + fn map_release_fetch_error( status: reqwest::StatusCode, response: &reqwest::Response, @@ -216,12 +307,28 @@ fn find_compatible_release_bundle( platform: Platform, hardware: HardwareProfile, releases: Vec, + selection: Option<&ReleaseDownloadSelection>, ) -> Option { + let selected_tag = selection + .and_then(|selection| selection.tag_name.as_deref()) + .filter(|tag| !tag.trim().is_empty()); + let selected_target = selection.map_or(ReleaseComputeTarget::Auto, |selection| { + selection.compute_target + }); + let selected_hardware = hardware_for_target(hardware, selected_target); + releases .into_iter() .filter(|release| !release.draft && !release.prerelease) + .filter(|release| selected_tag.is_none_or(|tag| release.tag_name == tag)) .find_map(|release| { - let assets = select_release_assets(module_id, platform, hardware, &release.assets)?; + let assets = + select_release_assets(module_id, platform, selected_hardware, &release.assets)?; + if selected_target != ReleaseComputeTarget::Auto + && !release_assets_match_target(&assets, selected_target) + { + return None; + } Some(ReleaseBundle { tag_name: release.tag_name, assets, @@ -229,6 +336,136 @@ fn find_compatible_release_bundle( }) } +fn release_download_version( + module_id: &str, + platform: Platform, + hardware: HardwareProfile, + release: Release, +) -> Option { + let cpu = select_release_assets( + module_id, + platform, + hardware_for_target(hardware, ReleaseComputeTarget::Cpu), + &release.assets, + ) + .and_then(|assets| release_download_variant(ReleaseComputeTarget::Cpu, assets)); + let gpu = select_release_assets( + module_id, + platform, + hardware_for_target(hardware, ReleaseComputeTarget::Gpu), + &release.assets, + ) + .and_then(|assets| release_download_variant(ReleaseComputeTarget::Gpu, assets)); + + if cpu.is_none() && gpu.is_none() { + return None; + } + + let recommended = if gpu.is_some() { + ReleaseComputeTarget::Gpu + } else { + ReleaseComputeTarget::Cpu + }; + + Some(ReleaseDownloadVersion { + tag_name: release.tag_name, + published_at: release.published_at, + cpu, + gpu, + recommended, + }) +} + +fn release_download_versions( + module_id: &str, + platform: Platform, + hardware: HardwareProfile, + releases: Vec, +) -> Vec { + releases + .into_iter() + .filter(|release| !release.draft && !release.prerelease) + .filter_map(|release| release_download_version(module_id, platform, hardware, release)) + .collect() +} + +fn release_download_variant( + compute_target: ReleaseComputeTarget, + assets: Vec, +) -> Option { + if assets.is_empty() { + return None; + } + if !release_assets_match_target(&assets, compute_target) { + return None; + } + + let total_size = assets + .iter() + .fold(0_u64, |acc, asset| acc.saturating_add(asset.size)); + + Some(ReleaseDownloadVariant { + compute_target, + assets: assets.into_iter().map(|asset| asset.name).collect(), + total_size, + }) +} + +fn release_assets_match_target(assets: &[ReleaseAsset], target: ReleaseComputeTarget) -> bool { + match target { + ReleaseComputeTarget::Auto => true, + ReleaseComputeTarget::Gpu => assets + .iter() + .filter(|asset| !is_runtime_asset_name(&asset.name)) + .any(|asset| is_gpu_asset_name(&asset.name)), + ReleaseComputeTarget::Cpu => assets + .iter() + .filter(|asset| !is_runtime_asset_name(&asset.name)) + .any(|asset| is_cpu_asset_name(&asset.name)), + } +} + +fn is_runtime_asset_name(name: &str) -> bool { + name.to_ascii_lowercase().starts_with("cudart-") +} + +fn is_gpu_asset_name(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + lower.contains("cuda") + || lower.contains("cu12") + || lower.contains("cu13") + || lower.contains("vulkan") + || lower.contains("hip") + || lower.contains("rocm") + || lower.contains("sycl") + || lower.contains("openvino") + || lower.contains("nvidia") + || lower.contains("amd") +} + +fn is_cpu_asset_name(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + lower.contains("cpu") + || lower.contains("avx") + || lower.contains("noavx") + || !is_gpu_asset_name(&lower) +} + +const fn hardware_for_target( + hardware: HardwareProfile, + target: ReleaseComputeTarget, +) -> HardwareProfile { + match target { + ReleaseComputeTarget::Auto | ReleaseComputeTarget::Gpu => hardware, + ReleaseComputeTarget::Cpu => HardwareProfile { + accelerator: crate::domain::system::hardware_probe::AcceleratorClass::CpuOnly, + cpu_tier: hardware.cpu_tier, + cuda_driver_major: None, + cuda_driver_minor: None, + }, + } +} + fn parse_repo(repo_url: &str) -> Result { let trimmed = repo_url.trim_end_matches(".git").trim_end_matches('/'); let parts: Vec<&str> = trimmed.split('/').collect(); @@ -405,6 +642,149 @@ mod tests { ); } + #[test] + fn explicit_release_selection_respects_cpu_target() { + let platform = Platform { + os: PlatformOs::Windows, + arch: PlatformArch::X64, + }; + let hardware = HardwareProfile { + accelerator: AcceleratorClass::NvidiaCuda, + cpu_tier: CpuInstructionTier::Avx2, + cuda_driver_major: Some(580), + cuda_driver_minor: Some(0), + }; + let releases = vec![Release { + tag_name: "b8971".to_string(), + published_at: Some("2026-04-29T10:00:00Z".to_string()), + draft: false, + prerelease: false, + assets: vec![ + asset("cudart-llama-bin-win-cuda-13.1-x64.zip"), + asset("llama-b8971-bin-win-cuda-13.1-x64.zip"), + asset("llama-b8971-bin-win-cpu-x64.zip"), + ], + }]; + let selection = ReleaseDownloadSelection { + tag_name: Some("b8971".to_string()), + compute_target: ReleaseComputeTarget::Cpu, + }; + + let bundle = find_compatible_release_bundle( + "llamacpp", + platform, + hardware, + releases, + Some(&selection), + ) + .expect("expected explicit CPU release bundle"); + + assert_eq!(bundle.assets.len(), 1); + assert_eq!( + bundle.assets.first().map(|asset| asset.name.as_str()), + Some("llama-b8971-bin-win-cpu-x64.zip") + ); + } + + #[test] + fn explicit_release_selection_respects_gpu_target() { + let platform = Platform { + os: PlatformOs::Windows, + arch: PlatformArch::X64, + }; + let hardware = HardwareProfile { + accelerator: AcceleratorClass::NvidiaCuda, + cpu_tier: CpuInstructionTier::Avx2, + cuda_driver_major: Some(580), + cuda_driver_minor: Some(0), + }; + let releases = vec![Release { + tag_name: "b8971".to_string(), + published_at: Some("2026-04-29T10:00:00Z".to_string()), + draft: false, + prerelease: false, + assets: vec![ + asset("cudart-llama-bin-win-cuda-13.1-x64.zip"), + asset("llama-b8971-bin-win-cuda-13.1-x64.zip"), + asset("llama-b8971-bin-win-cpu-x64.zip"), + ], + }]; + let selection = ReleaseDownloadSelection { + tag_name: Some("b8971".to_string()), + compute_target: ReleaseComputeTarget::Gpu, + }; + + let bundle = find_compatible_release_bundle( + "llamacpp", + platform, + hardware, + releases, + Some(&selection), + ) + .expect("expected explicit GPU release bundle"); + + assert_eq!(bundle.assets.len(), 2); + assert_eq!( + bundle.assets.first().map(|asset| asset.name.as_str()), + Some("cudart-llama-bin-win-cuda-13.1-x64.zip") + ); + assert_eq!( + bundle.assets.get(1).map(|asset| asset.name.as_str()), + Some("llama-b8971-bin-win-cuda-13.1-x64.zip") + ); + } + + #[test] + fn release_download_versions_keeps_compatible_older_release_options() { + let platform = Platform { + os: PlatformOs::Windows, + arch: PlatformArch::X64, + }; + let hardware = HardwareProfile { + accelerator: AcceleratorClass::CpuOnly, + cpu_tier: CpuInstructionTier::Avx2, + cuda_driver_major: None, + cuda_driver_minor: None, + }; + let releases = vec![ + Release { + tag_name: "draft".to_string(), + published_at: None, + draft: true, + prerelease: false, + assets: vec![asset("llama-draft-bin-win-cpu-x64.zip")], + }, + Release { + tag_name: "linux-only".to_string(), + published_at: None, + draft: false, + prerelease: false, + assets: vec![asset("llama-linux-bin-linux-cpu-x64.zip")], + }, + Release { + tag_name: "older-compatible".to_string(), + published_at: Some("2026-04-29T10:00:00Z".to_string()), + draft: false, + prerelease: false, + assets: vec![asset("llama-older-bin-win-cpu-x64.zip")], + }, + ]; + + let versions = release_download_versions("llamacpp", platform, hardware, releases); + + assert_eq!(versions.len(), 1); + assert_eq!( + versions.first().map(|version| version.tag_name.as_str()), + Some("older-compatible") + ); + assert!( + versions + .first() + .and_then(|version| version.cpu.as_ref()) + .is_some() + ); + } + #[test] fn prefers_cuda12_when_cuda_driver_version_is_unknown() { let platform = Platform { diff --git a/src-tauri/src/domain/modules/settings_ui_protocol.rs b/src-tauri/src/domain/modules/settings_ui_protocol.rs index 9835520e..52aa2d52 100644 --- a/src-tauri/src/domain/modules/settings_ui_protocol.rs +++ b/src-tauri/src/domain/modules/settings_ui_protocol.rs @@ -350,7 +350,11 @@ fn parse_module_id_from_label(label: &str) -> Result { )); } - let module_id = module_id.unwrap_or_default(); + let Some(module_id) = module_id else { + return Err(AppError::PermissionDenied( + "Module settings route is only available to owned settings webviews".to_string(), + )); + }; crate::domain::modules::downloader::validate_module_id(module_id)?; Ok(module_id.to_string()) } diff --git a/src-tauri/src/domain/system/hardware_probe.rs b/src-tauri/src/domain/system/hardware_probe.rs index f29e4de6..7a340c41 100644 --- a/src-tauri/src/domain/system/hardware_probe.rs +++ b/src-tauri/src/domain/system/hardware_probe.rs @@ -324,15 +324,30 @@ fn gpu_probe_from_names(names: &[String]) -> GpuInfo { } fn nvidia_probe_from_nvml() -> Option { - let (cuda_driver_major, cuda_driver_minor) = detect_cuda_driver_version(); - cuda_driver_major.map(|major| GpuInfo { + let Ok(nvml) = Nvml::init() else { + return None; + }; + let Ok(device_count) = nvml.device_count() else { + return None; + }; + if device_count == 0 { + return None; + } + let Ok(version) = nvml.sys_cuda_driver_version() else { + return None; + }; + + let major = u32::try_from(cuda_driver_version_major(version)).ok()?; + let minor = u32::try_from(cuda_driver_version_minor(version)).ok(); + + Some(GpuInfo { detected: true, name: "NVIDIA CUDA GPU".to_string(), cuda: true, backend: "cuda".to_string(), memory: 0, cuda_driver_major: Some(major), - cuda_driver_minor, + cuda_driver_minor: minor, }) } diff --git a/src-tauri/src/infrastructure/config/engine_settings.rs b/src-tauri/src/infrastructure/config/engine_settings.rs index d971424f..4885bfee 100644 --- a/src-tauri/src/infrastructure/config/engine_settings.rs +++ b/src-tauri/src/infrastructure/config/engine_settings.rs @@ -3,6 +3,7 @@ //! Handles reading and writing `engine_config.json` outside of the API layer. use std::collections::HashMap; +use std::io::ErrorKind; use crate::domain::engine::config::normalize_engine_config; use crate::domain::engine::types::EngineConfig; @@ -50,16 +51,50 @@ pub async fn save_engine_config_map(map: &EngineConfigMap) -> Result<(), AppErro let json = serde_json::to_string_pretty(map).map_err(|e| AppError::Serialization(e.to_string()))?; - tokio::fs::write(&tmp, &json) - .await - .map_err(|e| AppError::Io(e.to_string()))?; - - if let Err(e) = tokio::fs::rename(&tmp, path).await { - let _ = tokio::fs::remove_file(path).await; - tokio::fs::rename(&tmp, path) + { + let mut file = tokio::fs::File::create(&tmp) + .await + .map_err(|e| AppError::Io(e.to_string()))?; + tokio::io::AsyncWriteExt::write_all(&mut file, json.as_bytes()) + .await + .map_err(|e| AppError::Io(e.to_string()))?; + file.sync_all() .await - .map_err(|_| AppError::Io(e.to_string()))?; + .map_err(|e| AppError::Io(e.to_string()))?; + } + + if let Err(rename_error) = tokio::fs::rename(&tmp, path).await { + tracing::warn!( + "Atomic engine config rename failed ({rename_error}); retrying with replace fallback" + ); + match tokio::fs::remove_file(path).await { + Ok(()) => {} + Err(error) if error.kind() == ErrorKind::NotFound => {} + Err(error) => { + cleanup_engine_config_tmp(&tmp).await; + return Err(AppError::Io(format!( + "Failed to replace engine config after rename error ({rename_error}): {error}" + ))); + } + } + if let Err(error) = tokio::fs::rename(&tmp, path).await { + cleanup_engine_config_tmp(&tmp).await; + return Err(AppError::Io(format!( + "Failed to publish engine config after rename error ({rename_error}): {error}" + ))); + } } Ok(()) } + +async fn cleanup_engine_config_tmp(tmp: &std::path::Path) { + if let Err(error) = tokio::fs::remove_file(tmp).await { + if error.kind() != ErrorKind::NotFound { + tracing::warn!( + "Failed to remove temporary engine config {}: {error}", + tmp.display() + ); + } + } +} diff --git a/src-tauri/src/infrastructure/config/settings.rs b/src-tauri/src/infrastructure/config/settings.rs index 973383cc..09fd5111 100644 --- a/src-tauri/src/infrastructure/config/settings.rs +++ b/src-tauri/src/infrastructure/config/settings.rs @@ -120,8 +120,7 @@ impl SettingsService { let mut ui_state = self .json_store .load_async::(&FILE_UI_STATE) - .await - .unwrap_or_default(); + .await?; let normalized = language.trim().to_lowercase(); ui_state.preferred_language = if normalized.is_empty() { diff --git a/src-tauri/src/infrastructure/config/translations.rs b/src-tauri/src/infrastructure/config/translations.rs index 54e9b875..2b96b4a6 100644 --- a/src-tauri/src/infrastructure/config/translations.rs +++ b/src-tauri/src/infrastructure/config/translations.rs @@ -20,12 +20,24 @@ pub fn get_translations(_app: &AppHandle, lang: &str) -> Result None, }; - if let Some(content) = target_content - && let Ok(target_json) = serde_json::from_str::(&content) - && let Some(target_map) = target_json.as_object() - { - for (k, v) in target_map { - translations.insert(k.clone(), v.clone()); + if let Some(content) = target_content { + match serde_json::from_str::(&content) { + Ok(target_json) => { + if let Some(target_map) = target_json.as_object() { + for (k, v) in target_map { + translations.insert(k.clone(), v.clone()); + } + } else { + tracing::warn!( + "Locale '{lang}' root is not a JSON object; using English fallbacks" + ); + } + } + Err(error) => { + tracing::warn!( + "Failed to parse locale '{lang}', using English fallbacks: {error}" + ); + } } } } diff --git a/src-tauri/src/infrastructure/crypto/secure_storage.rs b/src-tauri/src/infrastructure/crypto/secure_storage.rs index f6a0202f..6cc82826 100644 --- a/src-tauri/src/infrastructure/crypto/secure_storage.rs +++ b/src-tauri/src/infrastructure/crypto/secure_storage.rs @@ -124,12 +124,31 @@ impl SecureStorage { fs::rename(&path, &backup_path).map_err(|e| AppError::Io(e.to_string()))?; } - if let Err(error) = fs::copy(&pending_path, &path) { - if backup_path.exists() { - let _ = fs::rename(&backup_path, &path); + if let Err(error) = fs::rename(&pending_path, &path) { + let rollback_error = if backup_path.exists() { + fs::rename(&backup_path, &path).err() + } else { + None + }; + if pending_path.exists() + && let Err(cleanup_error) = fs::remove_file(&pending_path) + { + tracing::warn!( + path = %pending_path.display(), + "Failed to remove pending secure storage file after publish failure: {cleanup_error}" + ); + } + if let Some(rollback_error) = rollback_error { + return Err(AppError::Io(format!( + "Failed to publish secure storage '{}': {error}; rollback from '{}' also failed: {rollback_error}", + path.display(), + backup_path.display() + ))); } - let _ = fs::remove_file(&pending_path); - return Err(AppError::Io(error.to_string())); + return Err(AppError::Io(format!( + "Failed to publish secure storage '{}': {error}", + path.display() + ))); } fs::OpenOptions::new() diff --git a/src-tauri/src/infrastructure/engine/tauri_emitter.rs b/src-tauri/src/infrastructure/engine/tauri_emitter.rs index 39500240..d7d92597 100644 --- a/src-tauri/src/infrastructure/engine/tauri_emitter.rs +++ b/src-tauri/src/infrastructure/engine/tauri_emitter.rs @@ -129,29 +129,39 @@ fn current_time_ms_f64() -> f64 { impl EngineEventEmitter for TauriEngineEmitter { fn emit_swapping(&self, from: &str, to: &str) { - let _ = self + if let Err(error) = self .handle - .emit("ai:engine:swapping", json!({ "from": from, "to": to })); + .emit("ai:engine:swapping", json!({ "from": from, "to": to })) + { + tracing::warn!("Failed to emit engine swapping event: {error}"); + } } fn emit_starting(&self, engine_id: &str) { - let _ = self + if let Err(error) = self .handle - .emit("ai:engine:starting", json!({ "engine_id": engine_id })); + .emit("ai:engine:starting", json!({ "engine_id": engine_id })) + { + tracing::warn!("Failed to emit engine starting event for {engine_id}: {error}"); + } } fn emit_ready(&self, engine_id: &str, endpoint: &str) { - let _ = self.handle.emit( + if let Err(error) = self.handle.emit( "ai:engine:ready", json!({ "engine_id": engine_id, "endpoint": endpoint }), - ); + ) { + tracing::warn!("Failed to emit engine ready event for {engine_id}: {error}"); + } } fn emit_error(&self, engine_id: &str, message: &str) { - let _ = self.handle.emit( + if let Err(error) = self.handle.emit( "ai:engine:error", json!({ "engine_id": engine_id, "message": message }), - ); + ) { + tracing::warn!("Failed to emit engine error event for {engine_id}: {error}"); + } } fn emit_log(&self, engine_id: &str, line: &str) { @@ -165,10 +175,12 @@ impl EngineEventEmitter for TauriEngineEmitter { }); } } - let _ = self.handle.emit( + if let Err(error) = self.handle.emit( "ai:engine:log", json!({ "engine_id": engine_id, "line": line }), - ); + ) { + tracing::warn!("Failed to emit engine log event for {engine_id}: {error}"); + } } } diff --git a/src-tauri/src/infrastructure/filesystem/local_file_service.rs b/src-tauri/src/infrastructure/filesystem/local_file_service.rs index 6e0ed422..b82134b6 100644 --- a/src-tauri/src/infrastructure/filesystem/local_file_service.rs +++ b/src-tauri/src/infrastructure/filesystem/local_file_service.rs @@ -3,6 +3,7 @@ use crate::errors::AppError; use async_trait::async_trait; use std::path::{Path, PathBuf}; use tokio::fs; +use tokio::io::AsyncWriteExt; /// Local filesystem implementation of FileService #[derive(Debug, Default, Clone)] @@ -13,6 +14,53 @@ impl LocalFileService { pub const fn new() -> Self { Self } + + async fn write_atomic(path: &Path, content: &[u8]) -> Result<(), AppError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .await + .map_err(|e| AppError::Io(e.to_string()))?; + } + + let tmp = path.with_extension(format!( + "tmp-{}-{}", + std::process::id(), + chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default() + )); + + let mut file = fs::File::create(&tmp) + .await + .map_err(|e| AppError::Io(e.to_string()))?; + file.write_all(content) + .await + .map_err(|e| AppError::Io(e.to_string()))?; + file.sync_all() + .await + .map_err(|e| AppError::Io(e.to_string()))?; + drop(file); + + if let Err(first_error) = fs::rename(&tmp, path).await { + if let Err(remove_error) = fs::remove_file(path).await + && remove_error.kind() != std::io::ErrorKind::NotFound + { + let _ = fs::remove_file(&tmp).await; + return Err(AppError::Io(format!( + "Failed to replace '{}': rename failed: {first_error}; removing existing file failed: {remove_error}", + path.display() + ))); + } + + if let Err(second_error) = fs::rename(&tmp, path).await { + let _ = fs::remove_file(&tmp).await; + return Err(AppError::Io(format!( + "Failed to publish atomic write to '{}': first rename failed: {first_error}; second rename failed: {second_error}", + path.display() + ))); + } + } + + Ok(()) + } } #[async_trait] @@ -24,12 +72,7 @@ impl FileService for LocalFileService { } async fn write_string(&self, path: &Path, content: &str) -> Result<(), AppError> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).await.ok(); - } - fs::write(path, content) - .await - .map_err(|e| AppError::Io(e.to_string())) + Self::write_atomic(path, content.as_bytes()).await } async fn read_bytes(&self, path: &Path) -> Result, AppError> { @@ -39,12 +82,7 @@ impl FileService for LocalFileService { } async fn write_bytes(&self, path: &Path, content: &[u8]) -> Result<(), AppError> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).await.ok(); - } - fs::write(path, content) - .await - .map_err(|e| AppError::Io(e.to_string())) + Self::write_atomic(path, content).await } async fn delete(&self, path: &Path) -> Result<(), AppError> { @@ -88,3 +126,57 @@ impl FileService for LocalFileService { Ok(combined) } } + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used)] + + use super::LocalFileService; + use crate::domain::filesystem::service::FileService; + use crate::errors::AppError; + + #[tokio::test] + async fn write_string_creates_missing_parent_directories() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let target = temp_dir.path().join("nested").join("value.txt"); + let service = LocalFileService::new(); + + service + .write_string(&target, "ok") + .await + .expect("write should create parents"); + + assert_eq!(std::fs::read_to_string(target).expect("written file"), "ok"); + } + + #[tokio::test] + async fn write_string_reports_parent_creation_failures() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let file_parent = temp_dir.path().join("not-a-directory"); + std::fs::write(&file_parent, "occupied").expect("fixture file"); + let target = file_parent.join("value.txt"); + let service = LocalFileService::new(); + + let error = service + .write_string(&target, "ok") + .await + .expect_err("parent creation failure should surface"); + + assert!(matches!(error, AppError::Io(_))); + } + + #[tokio::test] + async fn write_bytes_replaces_existing_file_atomically() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let target = temp_dir.path().join("value.bin"); + std::fs::write(&target, b"old").expect("fixture file"); + let service = LocalFileService::new(); + + service + .write_bytes(&target, b"new") + .await + .expect("write should replace existing file"); + + assert_eq!(std::fs::read(target).expect("written file"), b"new"); + } +} diff --git a/src-tauri/src/infrastructure/logging/logger.rs b/src-tauri/src/infrastructure/logging/logger.rs index 5236aa2b..bacd493f 100644 --- a/src-tauri/src/infrastructure/logging/logger.rs +++ b/src-tauri/src/infrastructure/logging/logger.rs @@ -632,7 +632,7 @@ fn clear_module_runtime_logs() { pub fn init_global_logger() -> Result { let log_dir = &*crate::utils::paths::LOG_DIR; std::fs::create_dir_all(log_dir).map_err(|e| e.to_string())?; - clear_startup_log_files(log_dir); + clear_startup_log_files(log_dir).map_err(|e| e.to_string())?; // Keep the launcher log easy to open from the UI and external editors. let file_appender = tracing_appender::rolling::never(log_dir, "axelate.log"); @@ -672,9 +672,10 @@ pub fn init_global_logger() -> Result std::io::Result<()> { + fs::write(log_dir.join("axelate.log"), "")?; clear_module_runtime_logs(); + Ok(()) } impl RuntimeLogCollector { @@ -806,7 +807,12 @@ impl RuntimeLogCollector { for log_file in log_files.filter_map(Result::ok) { let path = log_file.path(); if Self::is_log_file(&path) { - let _ = fs::write(path, ""); + if let Err(error) = fs::write(&path, "") { + tracing::warn!( + path = %path.display(), + "Failed to clear runtime log file: {error}" + ); + } } } } diff --git a/src-tauri/src/infrastructure/monitoring/tauri_emitter.rs b/src-tauri/src/infrastructure/monitoring/tauri_emitter.rs index 78fefea6..0dc87117 100644 --- a/src-tauri/src/infrastructure/monitoring/tauri_emitter.rs +++ b/src-tauri/src/infrastructure/monitoring/tauri_emitter.rs @@ -19,6 +19,8 @@ impl TauriMonitoringEmitter { impl SystemStatsEmitter for TauriMonitoringEmitter { fn emit_stats(&self, stats: &SystemStats) { - let _ = self.app.emit("system_stats", stats.clone()); + if let Err(error) = self.app.emit("system_stats", stats.clone()) { + tracing::warn!("Failed to emit system stats: {error}"); + } } } diff --git a/src-tauri/src/infrastructure/persistence/json_store.rs b/src-tauri/src/infrastructure/persistence/json_store.rs index 658abb7a..c404828e 100644 --- a/src-tauri/src/infrastructure/persistence/json_store.rs +++ b/src-tauri/src/infrastructure/persistence/json_store.rs @@ -33,16 +33,12 @@ impl JsonStore { let content = self.file_service.read_to_string(path).await?; - match serde_json::from_str(&content) { - Ok(data) => Ok(data), - Err(e) => { - tracing::warn!( - "Failed to parse JSON at {}, resetting to defaults: {e}", - path.display() - ); - Ok(T::default()) - } - } + serde_json::from_str(&content).map_err(|error| { + AppError::Serialization(format!( + "Failed to parse JSON at {}: {error}", + path.display() + )) + }) } /// Saves a data structure to a JSON file asynchronously @@ -56,3 +52,43 @@ impl JsonStore { self.file_service.write_string(path, &content).await } } + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used)] + + use super::JsonStore; + use crate::errors::AppError; + use crate::infrastructure::filesystem::local_file_service::LocalFileService; + use std::sync::Arc; + + #[tokio::test] + async fn load_async_defaults_only_when_file_is_missing() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let store = JsonStore::new(Arc::new(LocalFileService::new())); + + let value: serde_json::Value = store + .load_async(&temp_dir.path().join("missing.json")) + .await + .expect("missing file should default"); + + assert_eq!(value, serde_json::Value::Null); + } + + #[tokio::test] + async fn load_async_returns_error_for_invalid_json() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let path = temp_dir.path().join("broken.json"); + std::fs::write(&path, "{broken").expect("broken json fixture"); + let store = JsonStore::new(Arc::new(LocalFileService::new())); + + let error = store + .load_async::(&path) + .await + .expect_err("invalid json should not default"); + + assert!( + matches!(error, AppError::Serialization(message) if message.contains("broken.json")) + ); + } +} diff --git a/src-tauri/src/infrastructure/system/startup.rs b/src-tauri/src/infrastructure/system/startup.rs index 24d62f45..59418d15 100644 --- a/src-tauri/src/infrastructure/system/startup.rs +++ b/src-tauri/src/infrastructure/system/startup.rs @@ -236,17 +236,23 @@ fn escape_applescript(value: &str) -> String { fn open_external_url(url: &str) { #[cfg(target_os = "windows")] { - let _ = Command::new("cmd").args(["/C", "start", "", url]).spawn(); + if let Err(error) = Command::new("cmd").args(["/C", "start", "", url]).spawn() { + tracing::warn!("Failed to open startup install guide URL '{url}': {error}"); + } } #[cfg(target_os = "macos")] { - let _ = Command::new("open").arg(url).spawn(); + if let Err(error) = Command::new("open").arg(url).spawn() { + tracing::warn!("Failed to open startup install guide URL '{url}': {error}"); + } } #[cfg(target_os = "linux")] { - let _ = Command::new("xdg-open").arg(url).spawn(); + if let Err(error) = Command::new("xdg-open").arg(url).spawn() { + tracing::warn!("Failed to open startup install guide URL '{url}': {error}"); + } } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cedea12f..6a540444 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -109,6 +109,7 @@ pub fn create_specta_builder() -> Builder { logs::add_log, logs::log_batch, downloader::download_module, + downloader::get_release_download_options, downloader::resume_download, downloader::check_module_installed, downloader::get_module_path, @@ -230,6 +231,8 @@ fn strip_generated_trailing_whitespace(path: &Path) -> Result<(), std::io::Error /// Registers all managed services into Tauri's DI container. fn setup_dependencies(app: &tauri::App) -> Result<(), Box> { + crate::utils::paths::init_filesystem()?; + let file_service: std::sync::Arc = std::sync::Arc::new(LocalFileService::new()); let json_store = JsonStore::new(std::sync::Arc::clone(&file_service)); @@ -307,8 +310,6 @@ fn setup_dependencies(app: &tauri::App) -> Result<(), Box ); app.manage(integration_api); - crate::utils::paths::init_filesystem().ok(); - let monitor_emitter = std::sync::Arc::new( crate::infrastructure::monitoring::tauri_emitter::TauriMonitoringEmitter::new( app.handle().clone(), @@ -415,6 +416,12 @@ pub fn run() { ); if IS_QUITTING.load(Ordering::Relaxed) { tracing::info!("App Exiting..."); + if let Some(sessions) = + app_handle.try_state::>() + && let Err(error) = sessions.save_to_disk() + { + tracing::error!("Failed to save chat history during exit: {error:?}"); + } if let Some(am) = app_handle.try_state::>() { tauri::async_runtime::block_on(async move { let _ = am.stop().await; diff --git a/src/app/CoreComposition.test.ts b/src/app/CoreComposition.test.ts new file mode 100644 index 00000000..0eb82ba1 --- /dev/null +++ b/src/app/CoreComposition.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { destroyCoreResources } from './CoreComposition'; + +function destroyable(fn = vi.fn()) { + return { destroy: fn }; +} + +describe('destroyCoreResources', () => { + it('continues destroying remaining resources after one destroyer fails', async () => { + const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout').mockImplementation(() => { + return; + }); + const stateManagerDestroy = vi.fn(() => { + throw new Error('state failed'); + }); + const eventHandlerDestroy = vi.fn(); + const errorHandlerDestroy = vi.fn(); + + const args = { + deferredChatInitTimer: 123 as unknown as ReturnType, + stateManager: destroyable(stateManagerDestroy), + eventHandler: destroyable(eventHandlerDestroy), + chatController: destroyable(), + appUI: destroyable(), + settingsUI: destroyable(), + moduleSettingsUI: destroyable(), + downloadUI: destroyable(), + navigationUI: destroyable(), + windowUI: destroyable(), + windowService: destroyable(), + moduleService: destroyable(), + i18nUI: destroyable(), + consoleUI: destroyable(), + monitoringUI: destroyable(), + monitoringService: destroyable(), + sidebarUI: destroyable(), + particles: destroyable(), + soundService: destroyable(), + stateStore: destroyable(), + aiBridge: destroyable(), + bridge: destroyable(), + errorHandler: destroyable(errorHandlerDestroy), + } as unknown as Parameters[0]; + + await expect(destroyCoreResources(args)).rejects.toThrow(AggregateError); + + expect(clearTimeoutSpy).toHaveBeenCalledWith(123); + expect(eventHandlerDestroy).toHaveBeenCalledTimes(1); + expect(errorHandlerDestroy).toHaveBeenCalledTimes(1); + clearTimeoutSpy.mockRestore(); + }); +}); diff --git a/src/app/CoreComposition.ts b/src/app/CoreComposition.ts index ff7333fe..0447ebe6 100644 --- a/src/app/CoreComposition.ts +++ b/src/app/CoreComposition.ts @@ -65,31 +65,46 @@ export function registerCoreContainer(args: RegisterCoreContainerArgs): void { container.lock(); } -export function destroyCoreResources(args: DestroyCoreResourcesArgs): void { +export async function destroyCoreResources(args: DestroyCoreResourcesArgs): Promise { if (args.deferredChatInitTimer !== null) { globalThis.clearTimeout(args.deferredChatInitTimer); } - args.stateManager.destroy(); - args.eventHandler.destroy(); - args.chatController.destroy(); - args.appUI.destroy(); - args.settingsUI.destroy(); - args.moduleSettingsUI.destroy(); - args.downloadUI.destroy(); - args.navigationUI.destroy(); - args.windowUI.destroy(); - args.windowService.destroy(); - args.moduleService.destroy(); - args.i18nUI.destroy(); - args.consoleUI.destroy(); - args.monitoringUI.destroy(); - args.monitoringService.destroy(); - args.sidebarUI.destroy(); - args.particles.destroy(); - args.soundService.destroy(); - args.stateStore.destroy(); - args.aiBridge.destroy(); - args.bridge.destroy(); - args.errorHandler.destroy(); + const destroyers: Array<() => Promise | void> = [ + () => args.stateManager.destroy(), + () => args.eventHandler.destroy(), + () => args.chatController.destroy(), + () => args.appUI.destroy(), + () => args.settingsUI.destroy(), + () => args.moduleSettingsUI.destroy(), + () => args.downloadUI.destroy(), + () => args.navigationUI.destroy(), + () => args.windowUI.destroy(), + () => args.windowService.destroy(), + () => args.moduleService.destroy(), + () => args.i18nUI.destroy(), + () => args.consoleUI.destroy(), + () => args.monitoringUI.destroy(), + () => args.monitoringService.destroy(), + () => args.sidebarUI.destroy(), + () => args.particles.destroy(), + () => args.soundService.destroy(), + () => args.stateStore.destroy(), + () => args.aiBridge.destroy(), + () => args.bridge.destroy(), + () => args.errorHandler.destroy(), + ]; + const errors: unknown[] = []; + + for (const destroy of destroyers) { + try { + await destroy(); + } catch (error: unknown) { + errors.push(error); + } + } + + if (errors.length > 0) { + throw new AggregateError(errors, 'Core resource cleanup failed'); + } } diff --git a/src/app/CoreEntry.ts b/src/app/CoreEntry.ts index 18171d43..b32fa7bb 100644 --- a/src/app/CoreEntry.ts +++ b/src/app/CoreEntry.ts @@ -2,7 +2,7 @@ import type { LoggerService } from '@/infrastructure/logging/LoggerService'; type CoreRuntime = { init: () => Promise; - destroy: () => void; + destroy: () => Promise | void; }; type CoreFactory = () => CoreRuntime; @@ -42,10 +42,24 @@ function clearBootState(): void { state.coreInitializationInFlight = false; } -function destroyActiveCoreInstance(): void { +function reportDestroyFailure( + result: Promise | void, + tracer: EntryLogger, + context: string, +): void { + if (result === undefined) return; + result.catch((error: unknown) => { + tracer.error(`[Core] ${context}: ${String(error)}`); + }); +} + +function destroyActiveCoreInstance(tracer?: EntryLogger): void { const state = getCoreEntryState(); try { - state.activeCoreInstance?.destroy(); + const result = state.activeCoreInstance?.destroy(); + if (tracer !== undefined) { + reportDestroyFailure(result, tracer, 'Destroy failed'); + } } finally { clearBootState(); } @@ -72,11 +86,17 @@ function bootCoreOnce(createCore: CoreFactory, tracer: EntryLogger): void { coreInstance.init().catch((error: unknown) => { if (state.activeCoreInstance === coreInstance) { clearBootState(); - } - try { - coreInstance.destroy(); - } catch (destroyError: unknown) { - tracer.error(`[Core] Destroy after boot failure failed: ${String(destroyError)}`); + try { + reportDestroyFailure( + coreInstance.destroy(), + tracer, + 'Destroy after boot failure failed', + ); + } catch (destroyError: unknown) { + tracer.error( + `[Core] Destroy after boot failure failed: ${String(destroyError)}`, + ); + } } tracer.error(`[Core] Boot failed: ${String(error)}`); }); @@ -106,14 +126,18 @@ export function bindCoreEntry(createCore: CoreFactory, tracer: EntryLogger): voi if (!state.coreBeforeUnloadBound) { state.coreBeforeUnloadBound = true; state.beforeUnloadHandler = () => { - destroyActiveCoreInstance(); + destroyActiveCoreInstance(tracer); }; globalThis.addEventListener('beforeunload', state.beforeUnloadHandler); } if (import.meta.hot) { import.meta.hot.dispose(() => { - destroyActiveCoreInstance(); + try { + destroyActiveCoreInstance(tracer); + } catch (error: unknown) { + tracer.error(`[Core] Destroy during HMR dispose failed: ${String(error)}`); + } if (state.bootHandler !== null) { document.removeEventListener('DOMContentLoaded', state.bootHandler); state.bootHandler = null; diff --git a/src/app/CoreLifecycleController.test.ts b/src/app/CoreLifecycleController.test.ts new file mode 100644 index 00000000..82dd7eb1 --- /dev/null +++ b/src/app/CoreLifecycleController.test.ts @@ -0,0 +1,135 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + runCoreBootstrap: vi.fn(), + initializeDeferredUi: vi.fn(), + restoreSelectedModules: vi.fn(), + destroyCoreResources: vi.fn(), +})); + +vi.mock('./CoreBootstrapRunner', () => ({ + runCoreBootstrap: mocks.runCoreBootstrap, +})); + +vi.mock('./CoreRuntimeSupport', () => ({ + initializeDeferredUi: mocks.initializeDeferredUi, + restoreSelectedModules: mocks.restoreSelectedModules, +})); + +vi.mock('./CoreComposition', () => ({ + destroyCoreResources: mocks.destroyCoreResources, +})); + +import { CoreLifecycleController, type CoreLifecycleDeps } from './CoreLifecycleController'; + +function createDeps(isDestroyed: () => boolean): CoreLifecycleDeps { + const tauriProvider = { + isTauri: vi.fn(() => true), + listen: vi.fn().mockResolvedValue(vi.fn()), + }; + return { + bootstrap: { + aiBridge: {}, + tauriProvider, + tracer: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + templateLoader: {}, + stateStore: {}, + windowService: {}, + windowUI: {}, + i18n: {}, + i18nUI: {}, + catalog: {}, + navigation: {}, + navigationUI: {}, + chatController: { init: vi.fn(), destroy: vi.fn() }, + bridge: {}, + eventHandler: {}, + }, + immediateUi: { + navigation: {}, + navigationUI: {}, + downloadUI: {}, + moduleService: {}, + sidebarUI: {}, + }, + deferredUi: { + settingsService: {}, + monitoringUI: {}, + settingsUI: {}, + moduleSettingsUI: {}, + i18nUI: {}, + consoleUI: {}, + tracer: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + moduleSettings: {}, + catalog: {}, + appUI: {}, + aiBridge: {}, + }, + backendSelection: { + tauriProvider, + stateStore: { updateNestedState: vi.fn() }, + appUI: { updateModuleCard: vi.fn() }, + }, + disposables: { + stateManager: {}, + eventHandler: {}, + chatController: {}, + appUI: {}, + settingsUI: {}, + moduleSettingsUI: {}, + downloadUI: {}, + navigationUI: {}, + windowUI: {}, + windowService: {}, + moduleService: {}, + i18nUI: {}, + consoleUI: {}, + monitoringUI: {}, + monitoringService: {}, + sidebarUI: {}, + particles: {}, + soundService: {}, + stateStore: {}, + aiBridge: {}, + bridge: {}, + errorHandler: {}, + }, + state: { isDestroyed }, + globalShortcutKeydown: vi.fn(), + } as unknown as CoreLifecycleDeps; +} + +describe('CoreLifecycleController', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.runCoreBootstrap.mockResolvedValue({ currentPage: 'home' }); + mocks.initializeDeferredUi.mockResolvedValue(undefined); + }); + + it('stops async initialization after bootstrap if core was destroyed', async () => { + const deps = createDeps(() => true); + const controller = new CoreLifecycleController(deps); + + await controller.runInit(); + + expect(mocks.initializeDeferredUi).not.toHaveBeenCalled(); + expect(deps.backendSelection.tauriProvider.listen).not.toHaveBeenCalled(); + expect(deps.bootstrap.tracer.info).not.toHaveBeenCalledWith('[Core] Ready.'); + }); + + it('unsubscribes backend selection listener if destroy happens while subscribing', async () => { + let destroyed = false; + const unlisten = vi.fn(); + const deps = createDeps(() => destroyed); + vi.mocked(deps.backendSelection.tauriProvider.listen).mockImplementationOnce(() => { + destroyed = true; + return Promise.resolve(unlisten); + }); + const controller = new CoreLifecycleController(deps); + + await controller.runInit(); + + expect(unlisten).toHaveBeenCalledTimes(1); + expect(deps.bootstrap.tracer.info).not.toHaveBeenCalledWith('[Core] Ready.'); + }); +}); diff --git a/src/app/CoreLifecycleController.ts b/src/app/CoreLifecycleController.ts index f40f6112..b638b7e4 100644 --- a/src/app/CoreLifecycleController.ts +++ b/src/app/CoreLifecycleController.ts @@ -140,6 +140,9 @@ export class CoreLifecycleController { this.initGlobalShortcuts(); }, }); + if (this._deps.state.isDestroyed()) { + return; + } if (bootstrapResult.currentPage !== 'chat') { this.scheduleDeferredChatInit(); @@ -161,23 +164,32 @@ export class CoreLifecycleController { }); }, }); + if (this._deps.state.isDestroyed()) { + return; + } await this._listenForBackendSelectedModuleChanges(); + if (this._deps.state.isDestroyed()) { + return; + } this._deps.bootstrap.tracer.info('[Core] Ready.'); } - public destroy(): void { + public async destroy(): Promise { if (this._activeGlobalShortcutKeydown !== null) { globalThis.removeEventListener('keydown', this._activeGlobalShortcutKeydown); this._activeGlobalShortcutKeydown = null; } this._selectedModuleChangedUnlisten?.(); this._selectedModuleChangedUnlisten = null; - destroyCoreResources({ - deferredChatInitTimer: this._deferredChatInitTimer, - ...this._deps.disposables, - }); - this._deferredChatInitTimer = null; + try { + await destroyCoreResources({ + deferredChatInitTimer: this._deferredChatInitTimer, + ...this._deps.disposables, + }); + } finally { + this._deferredChatInitTimer = null; + } } public initGlobalShortcuts(globalShortcutKeydown?: (e: KeyboardEvent) => void): void { @@ -216,13 +228,17 @@ export class CoreLifecycleController { return; } - this._selectedModuleChangedUnlisten = - await backendSelection.tauriProvider.listen( - 'ui-state:selected-module-changed', - (payload) => { - this._applyBackendSelectedModuleChange(payload); - }, - ); + const unlisten = await backendSelection.tauriProvider.listen( + 'ui-state:selected-module-changed', + (payload) => { + this._applyBackendSelectedModuleChange(payload); + }, + ); + if (this._deps.state.isDestroyed()) { + unlisten(); + return; + } + this._selectedModuleChangedUnlisten = unlisten; } private _applyBackendSelectedModuleChange(payload: SelectedModuleChangedPayload): void { diff --git a/src/app/init.ts b/src/app/init.ts index 57ec95ce..50443954 100644 --- a/src/app/init.ts +++ b/src/app/init.ts @@ -56,12 +56,12 @@ export class Core { await this._assembly.lifecycleController.runInit(); } - public destroy(): void { + public async destroy(): Promise { if (this._isDestroyed) return; this._isDestroyed = true; this._isInitialized = false; this._initPromise = null; - this._assembly.lifecycleController.destroy(); + await this._assembly.lifecycleController.destroy(); } } bindCoreEntry(() => new Core(), tracer); diff --git a/src/assets/fonts/Cubic_11.zh-subset.woff2 b/src/assets/fonts/Cubic_11.zh-subset.woff2 index 1b1f344fdc010ecdf2ba8910a28f8cd2638b6fa6..5c7340e7ec72c0b0b39097a26adbba038a10bea7 100644 GIT binary patch literal 29412 zcmV)BK*PUxPew8T0RR910CMC23IG5A0%VK;0CJT80RR9100000000000000000000 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�Z38g) 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 W*V*3KRKGrPl61r3cfL{}p#K4bg=KaC diff --git a/src/features/ai/services/AIBridge.test.ts b/src/features/ai/services/AIBridge.test.ts index 54680f39..e287347c 100644 --- a/src/features/ai/services/AIBridge.test.ts +++ b/src/features/ai/services/AIBridge.test.ts @@ -615,11 +615,10 @@ describe('AIBridge', () => { expect(mockInvoke).toHaveBeenCalledWith('get_chat_history', expect.any(Object)); }); - it('should return empty array on error', async () => { + it('should surface history load errors', async () => { mockInvoke.mockRejectedValueOnce(new Error('History failed')); - const history = await aiBridge.getHistory(); - expect(history).toEqual([]); + await expect(aiBridge.getHistory()).rejects.toThrow('History failed'); }); it('should return empty array in web mode', async () => { diff --git a/src/features/ai/services/AIBridge.ts b/src/features/ai/services/AIBridge.ts index d453721a..531961fd 100644 --- a/src/features/ai/services/AIBridge.ts +++ b/src/features/ai/services/AIBridge.ts @@ -270,6 +270,7 @@ export class AIBridge implements IAIBridge { return await this._runtime.getHistory(this._context, this._manager.sessionId); } catch (e) { this._tracer.error('[AIBridge] Failed to load history:', e); + throw e; } } return []; diff --git a/src/features/ai/services/AIBridgeRuntime.test.ts b/src/features/ai/services/AIBridgeRuntime.test.ts index eef0c166..87766138 100644 --- a/src/features/ai/services/AIBridgeRuntime.test.ts +++ b/src/features/ai/services/AIBridgeRuntime.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { buildImageGenerationProgressChunk, isActiveEngineLog } from './AIBridgeRuntime'; +import { AIBridgeRuntime } from './AIBridgeRuntime'; describe('AIBridgeRuntime', () => { it('normalizes image progress logs into machine-readable chunks', () => { @@ -39,4 +40,40 @@ describe('AIBridgeRuntime', () => { expect(isActiveEngineLog('llamacpp', 'llamacpp')).toBe(true); expect(isActiveEngineLog('llamacpp', 'other')).toBe(false); }); + + it('cleans up partial stream subscriptions when initialization fails', async () => { + const cleanupLog = vi.fn(); + const runtime = new AIBridgeRuntime({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }); + const failure = new Error('stream subscription failed'); + + await expect( + runtime.initializeStreaming({ + context: { + tauriProvider: { + isTauri: () => true, + listen: vi.fn().mockResolvedValue(cleanupLog), + }, + }, + transport: { + onStream: vi.fn(() => { + throw failure; + }), + onThought: vi.fn(), + }, + events: { + broadcastReplaceChunk: vi.fn(), + }, + getActiveProviderId: () => null, + broadcastChunk: vi.fn(), + broadcastThought: vi.fn(), + } as never), + ).rejects.toBe(failure); + + expect(cleanupLog).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/features/ai/services/AIBridgeRuntime.ts b/src/features/ai/services/AIBridgeRuntime.ts index 15dc1883..300fd312 100644 --- a/src/features/ai/services/AIBridgeRuntime.ts +++ b/src/features/ai/services/AIBridgeRuntime.ts @@ -103,31 +103,50 @@ export class AIBridgeRuntime { return []; } - const unlistenLog = await args.context.tauriProvider.listen<{ - engine_id: string; - line: string; - }>('ai:engine:log', (payload) => { - const line = payload.line; - if (!isActiveEngineLog(args.getActiveProviderId(), payload.engine_id)) { - return; + const cleanup: Array<() => void> = []; + try { + const unlistenLog = await args.context.tauriProvider.listen<{ + engine_id: string; + line: string; + }>('ai:engine:log', (payload) => { + const line = payload.line; + if (!isActiveEngineLog(args.getActiveProviderId(), payload.engine_id)) { + return; + } + + const progressChunk = buildImageGenerationProgressChunk(line); + if (progressChunk !== null) { + args.events.broadcastReplaceChunk(progressChunk); + } + }); + cleanup.push(unlistenLog); + + cleanup.push( + args.transport.onStream((payload: string) => { + args.broadcastChunk(payload); + }), + ); + + cleanup.push( + args.transport.onThought((payload: string) => { + args.broadcastThought(payload); + }), + ); + + this._tracer.debug('[AIBridge] Streaming active (IPC via Transport)'); + return cleanup; + } catch (error) { + for (const dispose of cleanup.splice(0).reverse()) { + try { + dispose(); + } catch (cleanupError) { + this._tracer.warn( + `[AIBridge] Failed to cleanup partial stream subscription: ${String(cleanupError)}`, + ); + } } - - const progressChunk = buildImageGenerationProgressChunk(line); - if (progressChunk !== null) { - args.events.broadcastReplaceChunk(progressChunk); - } - }); - - const unlistenChunk = args.transport.onStream((payload: string) => { - args.broadcastChunk(payload); - }); - - const unlistenThought = args.transport.onThought((payload: string) => { - args.broadcastThought(payload); - }); - - this._tracer.debug('[AIBridge] Streaming active (IPC via Transport)'); - return [unlistenLog, unlistenChunk, unlistenThought]; + throw error; + } } public stopProviderEngine(context: AIBridgeContext | null): void { diff --git a/src/features/ai/services/AIChatTransport.test.ts b/src/features/ai/services/AIChatTransport.test.ts index b1739ae8..2517e68f 100644 --- a/src/features/ai/services/AIChatTransport.test.ts +++ b/src/features/ai/services/AIChatTransport.test.ts @@ -404,6 +404,62 @@ describe('AIChatTransport', () => { expect(listener).toHaveBeenCalledWith('current'); }); + it('should keep notifying listeners when one stream listener throws', async () => { + const failingListener = vi.fn(() => { + throw new Error('listener failed'); + }); + const healthyListener = vi.fn(); + invokeMethod(failingListener); + invokeMethod(healthyListener); + mockCore.tauriProvider.invoke.mockImplementation( + (_cmd: string, args: Record) => { + const chatChannel = args['chatChannel'] as { + onmessage?: + | ((payload: { + request_id: string; + message_id: string; + kind: 'chat_chunk' | 'thought_chunk' | 'done'; + content: string; + }) => void) + | null; + }; + const channel = args[channelName] as { + onmessage?: + | ((payload: { + request_id: string; + message_id: string; + kind: 'chat_chunk' | 'thought_chunk' | 'done'; + content: string; + }) => void) + | null; + }; + const requestId = (args['request'] as { request_id: string }).request_id; + channel.onmessage?.({ + request_id: requestId, + message_id: 'msg-1', + kind: channelName === 'chatChannel' ? 'chat_chunk' : 'thought_chunk', + content: 'current', + }); + chatChannel.onmessage?.({ + request_id: requestId, + message_id: 'msg-1', + kind: 'done', + content: '', + }); + return Promise.resolve({ ok: true, reply: { text: 'done' } }); + }, + ); + + await transport.send(makeRequest()); + + expect(failingListener).toHaveBeenCalledWith('current'); + expect(healthyListener).toHaveBeenCalledWith('current'); + expect(tracer.error).toHaveBeenCalledWith( + '[AIChatTransport] Stream listener failed:', + expect.any(Error), + ); + }); + it('should NOT forward payload after unsubscribe', async () => { const listener = vi.fn(); const unsub = invokeMethod(listener); diff --git a/src/features/ai/services/AIChatTransport.ts b/src/features/ai/services/AIChatTransport.ts index 783e7c3c..7e3b751c 100644 --- a/src/features/ai/services/AIChatTransport.ts +++ b/src/features/ai/services/AIChatTransport.ts @@ -395,7 +395,11 @@ export class AIChatTransport implements IChatTransport { private _emitListeners(listeners: ReadonlySet<(chunk: string) => void>, payload: string): void { listeners.forEach((listener) => { - listener(payload); + try { + listener(payload); + } catch (error) { + this._tracer.error('[AIChatTransport] Stream listener failed:', error); + } }); } diff --git a/src/features/chat/controllers/ChatHistoryController.test.ts b/src/features/chat/controllers/ChatHistoryController.test.ts index 4145ea52..06469700 100644 --- a/src/features/chat/controllers/ChatHistoryController.test.ts +++ b/src/features/chat/controllers/ChatHistoryController.test.ts @@ -73,6 +73,33 @@ describe('ChatHistoryController', () => { expect(deps.renderHistory).toHaveBeenLastCalledWith(secondHistory); }); + it('should load the new session when session id changes during an in-flight restore', async () => { + const firstHistory: IChatMessage[] = [{ role: 'user', content: 'first' }]; + const secondHistory: IChatMessage[] = [{ role: 'assistant', content: 'second' }]; + let resolveFirstLoad: (history: IChatMessage[]) => void = () => {}; + const { controller, deps, aiBridge, state } = createController({ + history: firstHistory, + }); + aiBridge.getHistory + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirstLoad = resolve; + }), + ) + .mockImplementationOnce(() => Promise.resolve(secondHistory)); + + const firstLoad = controller.ensureHistoryLoaded(); + state.sessionId = 'session-2'; + const secondLoad = controller.ensureHistoryLoaded(); + resolveFirstLoad(firstHistory); + await Promise.all([firstLoad, secondLoad]); + + expect(aiBridge.getHistory).toHaveBeenCalledTimes(2); + expect(deps.setHistory).toHaveBeenLastCalledWith(secondHistory); + expect(deps.renderHistory).toHaveBeenLastCalledWith(secondHistory); + }); + it('should clear rendered chat when the current session has no persisted history', async () => { const { controller, deps, state } = createController({ history: [{ role: 'user', content: 'stale' }], diff --git a/src/features/chat/controllers/ChatHistoryController.ts b/src/features/chat/controllers/ChatHistoryController.ts index 08ef4637..324b6ab0 100644 --- a/src/features/chat/controllers/ChatHistoryController.ts +++ b/src/features/chat/controllers/ChatHistoryController.ts @@ -46,6 +46,9 @@ export class ChatHistoryController { if (this._historyLoaded && this._loadedSessionId === sessionId) return; if (this._historyLoadInFlight !== null) { await this._historyLoadInFlight; + if (this._loadedSessionId !== this._options.aiBridge.getSessionId()) { + await this.ensureHistoryLoaded(); + } return; } diff --git a/src/features/chat/controllers/ChatSendController.test.ts b/src/features/chat/controllers/ChatSendController.test.ts index 4a8a4b4f..2e7671ab 100644 --- a/src/features/chat/controllers/ChatSendController.test.ts +++ b/src/features/chat/controllers/ChatSendController.test.ts @@ -90,6 +90,19 @@ describe('ChatSendController', () => { vi.clearAllMocks(); }); + it('does not send empty chat messages without attachments', async () => { + const { controller, options, sendMessage } = createController(); + const input = document.createElement('textarea'); + input.value = ' '; + + const result = await controller.sendChat(input); + + expect(result).toBe(false); + expect(options.lockUi).not.toHaveBeenCalled(); + expect(options.appendUserMessage).not.toHaveBeenCalled(); + expect(sendMessage).not.toHaveBeenCalled(); + }); + it('shows a pending response state until the first text chunk', async () => { const { controller, options, aiBridge, streamingHandle } = createController(); const input = document.createElement('textarea'); diff --git a/src/features/chat/controllers/ChatSendController.ts b/src/features/chat/controllers/ChatSendController.ts index 3084687c..52379ff0 100644 --- a/src/features/chat/controllers/ChatSendController.ts +++ b/src/features/chat/controllers/ChatSendController.ts @@ -141,6 +141,9 @@ export class ChatSendController { if (this._isDestroyed || this._options.isSending()) return false; const text = input?.value.trim() ?? ''; + if (!this.validateInput(text)) { + return false; + } const uiElements = this._options.lockUi(input); const typingId = `typing-${String(Date.now())}`; diff --git a/src/features/console/services/ConsoleLogService.ts b/src/features/console/services/ConsoleLogService.ts index 59c1ee6b..4d94f508 100644 --- a/src/features/console/services/ConsoleLogService.ts +++ b/src/features/console/services/ConsoleLogService.ts @@ -295,7 +295,7 @@ export class ConsoleLogService { case 'stable.diffusion.cpp': return 'sdcpp'; default: - return engineId.trim(); + return key; } } diff --git a/src/features/monitoring/services/MonitoringService.test.ts b/src/features/monitoring/services/MonitoringService.test.ts index 70f93836..dc901c87 100644 --- a/src/features/monitoring/services/MonitoringService.test.ts +++ b/src/features/monitoring/services/MonitoringService.test.ts @@ -261,6 +261,31 @@ describe('MonitoringService', () => { vi.useRealTimers(); }); + it('should not notify subscribers from an in-flight fallback poll after stop', async () => { + vi.mocked(mockTauri.isTauri).mockReturnValue(false); + vi.useFakeTimers(); + + let resolveStats: ((value: ISystemStats) => void) | undefined; + vi.mocked(mockTauri.invoke).mockImplementation( + () => + new Promise((resolve) => { + resolveStats = resolve; + }), + ); + + const subscriber = vi.fn(); + service.subscribe(subscriber); + await service.startMonitoring(); + await vi.advanceTimersByTimeAsync(2100); + + service.stopMonitoring(); + resolveStats?.(mockStats); + await Promise.resolve(); + + expect(subscriber).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); + it('should dispose a late listener when monitoring is stopped before listen resolves', async () => { vi.mocked(mockTauri.isTauri).mockReturnValue(true); diff --git a/src/features/monitoring/services/MonitoringService.ts b/src/features/monitoring/services/MonitoringService.ts index 993d6fff..3b08a814 100644 --- a/src/features/monitoring/services/MonitoringService.ts +++ b/src/features/monitoring/services/MonitoringService.ts @@ -55,11 +55,11 @@ export class MonitoringService { return; } this._tracer.error('[MonitoringService] Failed to listen to events:', e); - this.startFallback(); + this.startFallback(lifecycleToken); } } else { this._tracer.warn('[MonitoringService] Event transport unavailable, starting polling'); - this.startFallback(); + this.startFallback(lifecycleToken); } } @@ -110,16 +110,16 @@ export class MonitoringService { }); } - private startFallback(): void { + private startFallback(lifecycleToken: number): void { if (this.pollingTimeout !== null) { return; } const poll = (): void => { this.pollingTimeout = globalThis.setTimeout(() => { - void this._pollFallbackStats().finally(() => { + void this._pollFallbackStats(lifecycleToken).finally(() => { this.pollingTimeout = null; - if (this.isListening) { + if (this.isListening && lifecycleToken === this._lifecycleToken) { poll(); } }); @@ -129,11 +129,17 @@ export class MonitoringService { poll(); } - private async _pollFallbackStats(): Promise { + private async _pollFallbackStats(lifecycleToken: number): Promise { try { const stats = await this._tauri.invoke('get_system_stats'); + if (!this.isListening || lifecycleToken !== this._lifecycleToken) { + return; + } this.notifyListeners(stats); } catch (e) { + if (!this.isListening || lifecycleToken !== this._lifecycleToken) { + return; + } this._tracer.warn('[MonitoringService] Poll failed', e); } } diff --git a/src/features/settings/services/SettingsService.test.ts b/src/features/settings/services/SettingsService.test.ts index 8c18c621..138082bf 100644 --- a/src/features/settings/services/SettingsService.test.ts +++ b/src/features/settings/services/SettingsService.test.ts @@ -2,6 +2,29 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { SettingsService } from './SettingsService'; import type { TauriProvider } from '@/infrastructure/tauri/TauriProvider'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; +import type * as Bindings from '@/shared/types/bindings'; + +const mocks = vi.hoisted(() => ({ + invokeSafe: vi.fn(), + commands: { + controlModule: vi.fn(), + }, +})); + +vi.mock('@/shared/api/invoke', (): { invokeSafe: (...args: unknown[]) => unknown } => ({ + invokeSafe: (...args: unknown[]): unknown => mocks.invokeSafe(...args) as unknown, +})); + +vi.mock('@/shared/types/bindings', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + commands: { + ...actual.commands, + controlModule: mocks.commands.controlModule, + }, + }; +}); function createMockTauri(): TauriProvider { return { @@ -21,9 +44,17 @@ describe('SettingsService', () => { let tracer: Pick; beforeEach(() => { + vi.clearAllMocks(); tauri = createMockTauri(); tracer = { error: vi.fn() }; service = new SettingsService(tauri, tracer); + mocks.commands.controlModule.mockReturnValue( + Promise.resolve({ + status: 'ok', + data: { success: true, message: 'ok', status: 'running' }, + }), + ); + mocks.invokeSafe.mockImplementation((promise: Promise) => promise); }); describe('loadSettings', () => { @@ -106,20 +137,34 @@ describe('SettingsService', () => { describe('controlService', () => { it('should return true on success', async () => { - (tauri.invoke as ReturnType).mockResolvedValue(undefined); const result = await service.controlService('start', 'ollama'); expect(result).toBe(true); - expect(tauri.invoke).toHaveBeenCalledWith('control_service', { + expect(mocks.commands.controlModule).toHaveBeenCalledWith({ + module_id: 'ollama', action: 'start', - service: 'ollama', }); + expect(mocks.invokeSafe).toHaveBeenCalled(); }); it('should return false on error', async () => { - (tauri.invoke as ReturnType).mockRejectedValue(new Error('fail')); + mocks.invokeSafe.mockResolvedValueOnce({ + status: 'error', + error: { message: 'fail' }, + }); const result = await service.controlService('stop', 'ollama'); expect(result).toBe(false); }); + + it('should return false when backend reports unsuccessful control', async () => { + mocks.invokeSafe.mockResolvedValueOnce({ + status: 'ok', + data: { success: false, message: 'not implemented', status: null }, + }); + + const result = await service.controlService('restart', 'ollama'); + + expect(result).toBe(false); + }); }); describe('loadGpuInfo', () => { diff --git a/src/features/settings/services/SettingsService.ts b/src/features/settings/services/SettingsService.ts index d7de1edb..31398880 100644 --- a/src/features/settings/services/SettingsService.ts +++ b/src/features/settings/services/SettingsService.ts @@ -3,6 +3,8 @@ import type { IApp } from '@/shared/types/coreTypes'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; import type { AppSettings } from '@/shared/types/bindings'; +import { commands } from '@/shared/types/bindings'; +import { invokeSafe } from '@/shared/api/invoke'; export type ISettings = AppSettings; export type SettingsValue = string | number | boolean; type SettingsLogger = Pick; @@ -85,8 +87,17 @@ export class SettingsService { service: string, ): Promise { try { - await this._tauri.invoke('control_service', { action, service }); - return true; + const result = await invokeSafe( + commands.controlModule({ + module_id: service, + action, + }), + ); + if (result.status === 'error') { + this._tracer.error('[SettingsService] Control service failed:', result.error); + return false; + } + return result.data.success === true; } catch (e) { this._tracer.error('[SettingsService] Control service failed:', e); return false; diff --git a/src/features/settings/ui/ModuleSettingsCustomUiController.test.ts b/src/features/settings/ui/ModuleSettingsCustomUiController.test.ts index 84edaf63..20d2d5e2 100644 --- a/src/features/settings/ui/ModuleSettingsCustomUiController.test.ts +++ b/src/features/settings/ui/ModuleSettingsCustomUiController.test.ts @@ -160,6 +160,31 @@ describe('ModuleSettingsCustomUiController', () => { expect(status?.textContent).toContain('Failed to load module settings UI.'); }); + it('ignores host messages from other windows', async () => { + const harness = createHarness(); + + await harness.controller.render(harness.container, { + id: 'sample-integration', + name: 'Sample Integration', + category: 'automation', + type: 'local', + settingsUi: 'settings-ui/index.html', + }); + + globalThis.dispatchEvent( + new MessageEvent('message', { + source: globalThis.window, + data: { + channel: 'axelate:module-settings-host', + type: 'host-ready', + }, + }), + ); + + const status = harness.container.querySelector('.module-settings-webui-status'); + expect(status?.classList.contains('hidden')).toBe(false); + }); + it('shows a failure state when the host reports an error', async () => { const harness = createHarness(); diff --git a/src/features/settings/ui/ModuleSettingsCustomUiController.ts b/src/features/settings/ui/ModuleSettingsCustomUiController.ts index 9e8147f9..a9d5d735 100644 --- a/src/features/settings/ui/ModuleSettingsCustomUiController.ts +++ b/src/features/settings/ui/ModuleSettingsCustomUiController.ts @@ -175,6 +175,10 @@ export class ModuleSettingsCustomUiController { }; const handleMessage = (event: MessageEvent) => { + if (event.source !== frame.contentWindow) { + return; + } + if (!this._isHostPayload(event.data)) { return; } diff --git a/src/features/settings/ui/ModuleSettingsEngineFieldCatalog.ts b/src/features/settings/ui/ModuleSettingsEngineFieldCatalog.ts index bcbc2775..4cbd5aed 100644 --- a/src/features/settings/ui/ModuleSettingsEngineFieldCatalog.ts +++ b/src/features/settings/ui/ModuleSettingsEngineFieldCatalog.ts @@ -26,6 +26,25 @@ export type ImageEngineFieldGroups = { }; export class ModuleSettingsEngineFieldCatalog { + public buildComputeModeField(t: TranslateFn): EngineFieldDefinition { + return { + label: t('ui.settings.engine.compute_mode', 'Compute Device'), + key: 'compute_mode', + type: 'select', + isEngineConfig: true, + options: ['gpu', 'cpu'], + optionLabels: { + gpu: t('ui.settings.engine.compute_gpu', 'GPU'), + cpu: t('ui.settings.engine.compute_cpu', 'CPU'), + }, + defaultValue: 'gpu', + description: t( + 'ui.settings.engine.compute_mode_hint', + 'Choose whether this engine starts on the GPU or CPU.', + ), + }; + } + public buildCoreModelField( t: TranslateFn, modelPlaceholder: string, diff --git a/src/features/settings/ui/ModuleSettingsEngineHtmlBuilder.ts b/src/features/settings/ui/ModuleSettingsEngineHtmlBuilder.ts index 9f3cf85e..6b88f079 100644 --- a/src/features/settings/ui/ModuleSettingsEngineHtmlBuilder.ts +++ b/src/features/settings/ui/ModuleSettingsEngineHtmlBuilder.ts @@ -21,9 +21,7 @@ export class ModuleSettingsEngineHtmlBuilder { config === null ? `

${this.escapeHtml(this._translate('ui.settings.engine.config_unavailable', 'Engine config unavailable (Tauri not connected)'))}

` : ''; - const generationSection = isImage - ? this._buildImageGenerationSection(app.id) - : this._buildTextRuntimeSections(app.id); + const generationSection = isImage ? this._buildImageGenerationSection(app.id) : ''; return `
@@ -48,31 +46,6 @@ export class ModuleSettingsEngineHtmlBuilder { `; } - private _buildTextRuntimeSections(appId: string): string { - return ` -
-
-
-

${this.escapeHtml( - this._translate('ui.settings.engine.context_size', 'Context Window'), - )}

-
-
-
-
-
-
-
-

${this.escapeHtml( - this._translate('ui.settings.engine.system_prompt', 'System Prompt'), - )}

-
-
-
-
- `; - } - private _buildImageGenerationSection(appId: string): string { return `
diff --git a/src/features/settings/ui/ModuleSettingsEngineRenderFlow.ts b/src/features/settings/ui/ModuleSettingsEngineRenderFlow.ts index e37fbef0..ae7112c2 100644 --- a/src/features/settings/ui/ModuleSettingsEngineRenderFlow.ts +++ b/src/features/settings/ui/ModuleSettingsEngineRenderFlow.ts @@ -51,6 +51,7 @@ type ModuleSettingsEngineRenderOptions = { modelPlaceholder: string, isImage: boolean, ) => EngineFieldDefinition; + getComputeModeField: (translate: TranslateFn) => EngineFieldDefinition; getImageExtraArgsField: (translate: TranslateFn) => EngineFieldDefinition; }; @@ -79,7 +80,9 @@ export class ModuleSettingsEngineRenderFlow { modelPlaceholder, translate: options.translate, getCoreModelField: options.getCoreModelField, + getComputeModeField: options.getComputeModeField, getImageExtraArgsField: options.getImageExtraArgsField, + getTextFields: options.getTextFields, }); if (isImage) { @@ -93,13 +96,7 @@ export class ModuleSettingsEngineRenderFlow { return; } - this._renderTextFields({ - container, - appId: app.id, - config, - translate: options.translate, - getTextFields: options.getTextFields, - }); + this._deps.syncPromptTextareaHeights(corePrimary); } private _renderCoreFields(options: { @@ -110,22 +107,32 @@ export class ModuleSettingsEngineRenderFlow { modelPlaceholder: string; translate: TranslateFn; getCoreModelField: ModuleSettingsEngineRenderOptions['getCoreModelField']; + getComputeModeField: ModuleSettingsEngineRenderOptions['getComputeModeField']; getImageExtraArgsField: ModuleSettingsEngineRenderOptions['getImageExtraArgsField']; + getTextFields: ModuleSettingsEngineRenderOptions['getTextFields']; }): void { const coreField = options.getCoreModelField( options.translate, options.modelPlaceholder, options.isImage, ); + const computeField = options.getComputeModeField(options.translate); + + this._deps.renderFieldRow(options.container, { + ...coreField, + isFile: true, + isImage: options.isImage, + appId: options.appId, + config: options.config, + }); + + this._deps.renderFieldRow(options.container, { + ...computeField, + appId: options.appId, + config: options.config, + }); if (options.isImage) { - this._deps.renderFieldRow(options.container, { - ...coreField, - isFile: true, - isImage: true, - appId: options.appId, - config: options.config, - }); this._deps.renderModelProfiles(options.container, options.appId, options.config); this._deps.renderFieldRow(options.container, { @@ -136,12 +143,12 @@ export class ModuleSettingsEngineRenderFlow { return; } - this._deps.renderFieldRow(options.container, { - ...coreField, - isFile: true, - isImage: false, - appId: options.appId, - config: options.config, + options.getTextFields(options.translate).forEach((field) => { + this._deps.renderFieldRow(options.container, { + ...field, + appId: options.appId, + config: options.config, + }); }); } diff --git a/src/features/settings/ui/ModuleSettingsEngineRenderer.ts b/src/features/settings/ui/ModuleSettingsEngineRenderer.ts index 3fd38c66..5d4ecc01 100644 --- a/src/features/settings/ui/ModuleSettingsEngineRenderer.ts +++ b/src/features/settings/ui/ModuleSettingsEngineRenderer.ts @@ -278,6 +278,7 @@ export class ModuleSettingsEngineRenderer { getTextFields: (translate) => this._fieldCatalog.buildTextEngineFields(translate), getCoreModelField: (translate, modelPlaceholder, isImage) => this._fieldCatalog.buildCoreModelField(translate, modelPlaceholder, isImage), + getComputeModeField: (translate) => this._fieldCatalog.buildComputeModeField(translate), getImageExtraArgsField: (translate) => this._fieldCatalog.buildImageExtraArgsField(translate), }); @@ -322,6 +323,10 @@ export class ModuleSettingsEngineRenderer { private _createEngineFieldControl( options: EngineFieldControlOptions, ): EngineFieldControlResult { + if (options.type === 'select' && options.isEngineConfig && options.key === 'compute_mode') { + return this._createComputeModeControl(options); + } + if (options.type === 'select') { return this._createSelectFieldControl(options); } @@ -355,6 +360,68 @@ export class ModuleSettingsEngineRenderer { }; } + private _createComputeModeControl( + options: EngineFieldControlOptions, + ): EngineFieldControlResult { + const root = document.createElement('div'); + root.className = 'local-engine-compute-toggle'; + + const hiddenInput = document.createElement('input'); + hiddenInput.type = 'hidden'; + hiddenInput.className = 'local-engine-compute-value'; + + const buttons = new Map(); + const syncDisplay = () => { + const currentValue = + hiddenInput.value === '' + ? String(options.defaultValue ?? 'gpu') + : hiddenInput.value; + buttons.forEach((button, value) => { + const selected = value === currentValue; + button.classList.toggle('selected', selected); + button.setAttribute('aria-pressed', String(selected)); + }); + }; + + options.options?.forEach((option) => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'thinking-option-card local-engine-compute-option'; + button.dataset['value'] = option; + button.setAttribute('aria-pressed', 'false'); + + const title = document.createElement('span'); + title.className = 'thinking-option-title'; + title.textContent = options.optionLabels?.[option] ?? option; + + button.append(title); + button.addEventListener('click', () => { + hiddenInput.value = option; + syncDisplay(); + hiddenInput.dispatchEvent(new Event('change', { bubbles: true })); + }); + + buttons.set(option, button); + root.appendChild(button); + }); + + root.prepend(hiddenInput); + + return { + input: root, + engineInput: hiddenInput, + customSelect: { + input: hiddenInput, + root, + syncDisplay, + destroy: () => { + return; + }, + }, + extraArgsControl: null, + }; + } + private _getFieldControlKey(appId: string, key: string): string { return `${appId}:${key}`; } @@ -413,7 +480,7 @@ export class ModuleSettingsEngineRenderer { ): HTMLDivElement { const card = document.createElement('div'); const selected = config?.model_path === profile.modelPath; - card.className = `ai-model-card local-engine-profile-card${selected ? ' selected' : ''}`; + card.className = `ai-model-card ai-model-card--custom local-engine-profile-card${selected ? ' selected' : ''}`; card.tabIndex = 0; card.role = 'option'; card.setAttribute('aria-selected', String(selected)); @@ -423,7 +490,8 @@ export class ModuleSettingsEngineRenderer { const name = document.createElement('div'); name.className = 'model-name'; - name.textContent = profile.name; + name.textContent = this._getModelProfileDisplayName(profile); + name.title = profile.modelPath; const remove = document.createElement('button'); remove.type = 'button'; @@ -450,6 +518,13 @@ export class ModuleSettingsEngineRenderer { return card; } + private _getModelProfileDisplayName(profile: LocalEngineModelProfile): string { + return (profile.name.trim() || this._getModelFileName(profile.modelPath)).replace( + /\.(?:gguf|safetensors)$/iu, + '', + ); + } + private _createSaveModelProfileCard( appId: string, config: EngineConfig | null, @@ -470,6 +545,9 @@ export class ModuleSettingsEngineRenderer { 'Store model, generation settings, and startup flags.', ); + const body = document.createElement('div'); + body.className = 'model-pricing ai-custom-model-composer-body'; + const saveButton = document.createElement('button'); saveButton.type = 'button'; saveButton.className = 'ai-check-btn ai-custom-model-save-btn'; @@ -488,7 +566,8 @@ export class ModuleSettingsEngineRenderer { } }); - card.append(name, desc, saveButton); + body.append(saveButton); + card.append(name, desc, body); return card; } @@ -556,7 +635,10 @@ export class ModuleSettingsEngineRenderer { name: this._getModelFileName(modelPath), modelPath, extraArgs: this._extraArgsControls.get(appId)?.getGroups() ?? config?.extra_args ?? [], - generationSettings: this._readGenerationPresetSettings(appId), + generationSettings: this._readGenerationPresetSettings( + appId, + card.closest('.local-engine-config') ?? undefined, + ), }; profiles.unshift(profile); this._saveModelProfiles(appId, profiles); @@ -611,17 +693,45 @@ export class ModuleSettingsEngineRenderer { }); } - private _readGenerationPresetSettings(appId: string): Record { + private _readGenerationPresetSettings( + appId: string, + root: HTMLElement | Document = document, + ): Record { const settings = this._deps.service.getSettings(); return Object.fromEntries( IMAGE_GENERATION_PRESET_SETTING_SUFFIXES.map((suffix) => { const key = `${appId}_${suffix}`; - const value = settings[key]; + const value = this._readGenerationPresetInputValue(root, key) ?? settings[key]; return [key, typeof value === 'string' || typeof value === 'number' ? value : null]; }), ); } + private _readGenerationPresetInputValue( + root: HTMLElement | Document, + key: string, + ): string | number | null | undefined { + const keyClass = key.replaceAll('_', '-'); + const input = root.querySelector< + HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement + >( + `.local-engine-field-row--${keyClass} input, .local-engine-field-row--${keyClass} select, .local-engine-field-row--${keyClass} textarea`, + ); + if (input === null) { + return undefined; + } + + const value = input.value.trim(); + if (value === '') { + return null; + } + if (input instanceof HTMLInputElement && input.type === 'number') { + const numericValue = Number(value); + return Number.isFinite(numericValue) ? numericValue : value; + } + return value; + } + private _applyGenerationPresetSettings( appId: string, generationSettings: Record, diff --git a/src/features/settings/ui/SettingsUI.test.ts b/src/features/settings/ui/SettingsUI.test.ts index fdf3557b..148a4dca 100644 --- a/src/features/settings/ui/SettingsUI.test.ts +++ b/src/features/settings/ui/SettingsUI.test.ts @@ -76,6 +76,7 @@ describe('ModuleSettingsUI lifecycle', () => { } as unknown as I18nUI; const tauri = { isTauri: vi.fn().mockReturnValue(false), + invoke: vi.fn(), } as unknown as TauriProvider; const navigation = { removeBackAction: vi.fn(), @@ -149,9 +150,22 @@ describe('ModuleSettingsUI lifecycle', () => { const labels = Array.from(container.querySelectorAll('.local-engine-field-label')).map( (node) => node.textContent, ); - expect(labels).not.toContain('t:ui.settings.engine.compute_mode:Compute Device'); + expect(labels).toContain('t:ui.settings.engine.compute_mode:Compute Device'); expect(labels).toContain('t:ui.settings.engine.context_size:Context Window'); expect(labels).toContain('t:ui.settings.engine.system_prompt:System Prompt'); + const modelPathIndex = labels.indexOf( + 't:ui.settings.engine.model_path:Model Path (*.gguf, *.safetensors)', + ); + expect(modelPathIndex).toBe(0); + expect(modelPathIndex).toBeLessThan( + labels.indexOf('t:ui.settings.engine.compute_mode:Compute Device'), + ); + expect(modelPathIndex).toBeLessThan( + labels.indexOf('t:ui.settings.engine.context_size:Context Window'), + ); + expect(modelPathIndex).toBeLessThan( + labels.indexOf('t:ui.settings.engine.system_prompt:System Prompt'), + ); }); it('should stop active module lifecycle when module settings change', () => { @@ -186,6 +200,56 @@ describe('ModuleSettingsUI lifecycle', () => { expect(container.textContent).not.toContain('Auto download package'); }); + it('should save current visible image settings into sdcpp model presets', async () => { + vi.useFakeTimers(); + const ui = createSettingsUI(); + const container = document.createElement('div'); + ( + ui as unknown as { + _tauri: { isTauri: ReturnType; invoke: ReturnType }; + } + )._tauri.isTauri.mockReturnValue(true); + ( + ui as unknown as { + _tauri: { invoke: ReturnType }; + } + )._tauri.invoke.mockResolvedValue({ + config: { + engine_id: 'sdcpp', + compute_mode: 'gpu', + model_path: 'C:/models/current.safetensors', + extra_args: [], + }, + }); + + await ui._renderLocalEngineConfig(container, { id: 'sdcpp', capability: 'image' }); + const widthInput = container.querySelector( + '.local-engine-field-row--sdcpp-width input', + ); + expect(widthInput).not.toBeNull(); + if (widthInput === null) { + throw new Error('width input missing'); + } + widthInput.value = '768'; + + container.querySelector('.ai-custom-model-save-btn')?.click(); + await vi.runAllTimersAsync(); + + const service = ( + settingsUI as unknown as { + _service: { saveSetting: ReturnType }; + } + )._service; + const saveCall = service.saveSetting.mock.calls.find( + (call: unknown[]) => call[0] === 'sdcpp_model_profiles', + ); + expect(saveCall).toBeDefined(); + const profiles = JSON.parse(saveCall?.[1] as string) as Array<{ + generationSettings: Record; + }>; + expect(profiles[0]?.generationSettings['sdcpp_width']).toBe(768); + }); + it('should show save errors and apply stored card widths', async () => { vi.useFakeTimers(); const ui = createSettingsUI(); diff --git a/src/infrastructure/tauri/TauriProvider.test.ts b/src/infrastructure/tauri/TauriProvider.test.ts index 1edf3f25..536d68f3 100644 --- a/src/infrastructure/tauri/TauriProvider.test.ts +++ b/src/infrastructure/tauri/TauriProvider.test.ts @@ -138,7 +138,18 @@ describe('TauriProvider', () => { win['__TAURI__'] = origTauri; }); - it('should return _isTauriDetected=false from line 31 after failed handshake', async () => { + it('should keep Tauri mode after a failed handshake when runtime globals are present', async () => { + (mockedTauriInvoke as unknown as Mock).mockRejectedValueOnce( + new Error('Handshake fail'), + ); + provider.init(); + + await vi.waitFor(() => { + expect(provider.isTauri()).toBe(true); + }); + }); + + it('should return _isTauriDetected=false after failed handshake without runtime globals', async () => { const { win, origTauri } = setupWebMode(); (mockedTauriInvoke as unknown as Mock).mockRejectedValueOnce( @@ -147,9 +158,7 @@ describe('TauriProvider', () => { const p = new TauriProvider(createTracer()); p.init(); - // Wait for handshake to fail → _isTauriDetected becomes false await vi.waitFor(() => { - // isTauri() now returns this._isTauriDetected (false), not the static check expect(p.isTauri()).toBe(false); }); @@ -231,7 +240,7 @@ describe('TauriProvider', () => { await expect(provider.invoke('specta_err_string')).rejects.toThrow('rate limited'); await expect(provider.invoke('specta_err_message')).rejects.toThrow('boom'); - await expect(provider.invoke('specta_err_payload')).rejects.toThrow('[object Object]'); + await expect(provider.invoke('specta_err_payload')).rejects.toThrow('{"detail":"bad"}'); }); it('should normalize object rejections with message, code and stringify fallback', async () => { @@ -513,7 +522,7 @@ describe('TauriProvider', () => { // ---------------------------------------------------------- handshake failure (lines 24-25) describe('init handshake failure', () => { - it('should set _isTauriDetected to false when handshake fails', () => { + it('should not disable Tauri mode when handshake fails but globals exist', async () => { // Make invoke throw so handshake fails (mockedTauriInvoke as unknown as Mock).mockRejectedValueOnce( new Error('Handshake failed'), @@ -522,16 +531,20 @@ describe('TauriProvider', () => { const provider2 = new TauriProvider(createTracer()); provider2.init(); - // After failed handshake, isTauri falls back to static detection (globalThis.__TAURI__ present) - // _isTauriDetected is now false, but isTauri() returns static check (true since __TAURI__ exists) - // The internal flag is false — verify by checking it doesn't use handshake-based detection - // We can verify indirectly: a new provider with no __TAURI__ and failed handshake returns false + await vi.waitFor(() => { + expect(provider2.isTauri()).toBe(true); + }); + }); + + it('should set _isTauriDetected to false when handshake fails without globals', async () => { const { win, origTauri, provider: provider3 } = setupWebMode(); (mockedTauriInvoke as unknown as Mock).mockRejectedValueOnce(new Error('Fail')); provider3.init(); - expect(provider3.isTauri()).toBe(false); + await vi.waitFor(() => { + expect(provider3.isTauri()).toBe(false); + }); win['__TAURI__'] = origTauri; }); diff --git a/src/infrastructure/tauri/TauriProvider.ts b/src/infrastructure/tauri/TauriProvider.ts index c7cf4d7d..5817144f 100644 --- a/src/infrastructure/tauri/TauriProvider.ts +++ b/src/infrastructure/tauri/TauriProvider.ts @@ -51,8 +51,12 @@ export class TauriProvider implements IBridge { this._isTauriDetected = true; this._tracer.debug('[TauriProvider] IPC Handshake successful'); } catch { - this._isTauriDetected = false; - this._tracer.warn('[TauriProvider] Handshake failed, operating in Mock mode'); + this._isTauriDetected = this._runtime.hasTauriGlobals(); + this._tracer.warn( + this._isTauriDetected + ? '[TauriProvider] Handshake failed, keeping Tauri IPC because runtime globals are present' + : '[TauriProvider] Handshake failed, operating in Mock mode', + ); } } @@ -110,7 +114,9 @@ export class TauriProvider implements IBridge { 'payload' in errorPayload ) { // Some AppErrors might have a payload field - message = String((errorPayload as { payload: unknown }).payload); + message = stringifyInvokePayload( + (errorPayload as { payload: unknown }).payload, + ); } const err = new Error(message); @@ -372,3 +378,15 @@ export class TauriProvider implements IBridge { return Promise.resolve((saneDefaults[cmd] ?? {}) as unknown as T); } } + +function stringifyInvokePayload(payload: unknown): string { + if (typeof payload === 'string') { + return payload; + } + + try { + return JSON.stringify(payload); + } catch { + return String(payload); + } +} diff --git a/src/package-lock.json b/src/package-lock.json index 813fac30..e8db74db 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -11,6 +11,7 @@ "@tauri-apps/api": "~2.10.1", "@tauri-apps/plugin-dialog": "~2.7.0", "@tauri-apps/plugin-fs": "~2.5.0", + "axelate-frontend": "file:", "dompurify": "^3.4.1", "marked": "^18.0.2", "marked-alert": "^2.1.2", @@ -2040,6 +2041,10 @@ "js-tokens": "^10.0.0" } }, + "node_modules/axelate-frontend": { + "resolved": "", + "link": true + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", diff --git a/src/package.json b/src/package.json index df4f5693..f487d42e 100644 --- a/src/package.json +++ b/src/package.json @@ -50,6 +50,7 @@ "@tauri-apps/api": "~2.10.1", "@tauri-apps/plugin-dialog": "~2.7.0", "@tauri-apps/plugin-fs": "~2.5.0", + "axelate-frontend": "file:", "dompurify": "^3.4.1", "marked": "^18.0.2", "marked-alert": "^2.1.2", diff --git a/src/shared/api/invoke.test.ts b/src/shared/api/invoke.test.ts new file mode 100644 index 00000000..34fa9b65 --- /dev/null +++ b/src/shared/api/invoke.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn(), +})); + +import { invoke as tauriInvoke } from '@tauri-apps/api/core'; +import { invokeSafe } from './invoke'; + +const invokeMock = vi.mocked(tauriInvoke); + +describe('invokeSafe', () => { + it('uses Error.message for transport exceptions', async () => { + invokeMock.mockRejectedValueOnce(new Error('Download cancelled')); + + const result = await invokeSafe('download_module'); + + expect(result.status).toBe('error'); + if (result.status === 'error') { + expect(result.error.message).toBe('Download cancelled'); + } + }); + + it('stringifies structured payload errors from specta results', async () => { + const result = await invokeSafe( + Promise.resolve({ + status: 'error', + error: { payload: { reason: 'rate limited' } }, + }), + ); + + expect(result.status).toBe('error'); + if (result.status === 'error') { + expect(result.error.message).toBe('{"reason":"rate limited"}'); + } + }); +}); diff --git a/src/shared/api/invoke.ts b/src/shared/api/invoke.ts index ee0ef650..89477627 100644 --- a/src/shared/api/invoke.ts +++ b/src/shared/api/invoke.ts @@ -20,15 +20,11 @@ export async function invokeSafe( if (result.status === 'ok') { return { status: 'ok', data: result.data }; } else { - const err = result.error as Record; return { status: 'error', error: { - code: typeof err['code'] === 'string' ? err['code'] : 'UNKNOWN', - message: - typeof err['message'] === 'string' - ? err['message'] - : String(result.error), + code: getInvokeErrorCode(result.error), + message: getInvokeErrorMessage(result.error), details: result.error, }, }; @@ -38,11 +34,60 @@ export async function invokeSafe( // Handle unexpected errors (e.g. transport) return { status: 'error', - error: { code: 'INVOKE_EXCEPTION', message: String(err), details: err }, + error: { + code: 'INVOKE_EXCEPTION', + message: getInvokeErrorMessage(err), + details: err, + }, }; } } +function getInvokeErrorCode(error: unknown): string { + if (typeof error === 'object' && error !== null) { + const code = (error as Record)['code']; + if (typeof code === 'string') { + return code; + } + } + + return 'UNKNOWN'; +} + +function getInvokeErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + if (typeof error === 'string') { + return error; + } + + if (typeof error === 'object' && error !== null) { + const obj = error as Record; + if (typeof obj['message'] === 'string') { + return obj['message']; + } + if ('payload' in obj) { + return stringifyInvokeError(obj['payload']); + } + } + + return stringifyInvokeError(error); +} + +function stringifyInvokeError(error: unknown): string { + if (typeof error === 'string') { + return error; + } + + try { + return JSON.stringify(error); + } catch { + return String(error); + } +} + /** * Helper to use with tauri-specta generated commands if needed, * or as a pattern for our own service methods. diff --git a/src/shared/services/ModulePlatformService.test.ts b/src/shared/services/ModulePlatformService.test.ts index 4eebff67..449847c3 100644 --- a/src/shared/services/ModulePlatformService.test.ts +++ b/src/shared/services/ModulePlatformService.test.ts @@ -68,6 +68,26 @@ describe('ModulePlatformService', () => { 'https://repo.com/module.zip', 'abc123', undefined, + undefined, + ); + }); + + it('passes release selection to release downloads', async () => { + const app = createApp({ dlType: 'release' }); + await service.download(app, { + tag_name: 'v1.2.3', + compute_target: 'gpu', + }); + + expect(moduleService.downloadModule).toHaveBeenCalledWith( + 'test-module', + 'https://repo.com/module.zip', + undefined, + 'release', + { + tag_name: 'v1.2.3', + compute_target: 'gpu', + }, ); }); diff --git a/src/shared/services/ModulePlatformService.ts b/src/shared/services/ModulePlatformService.ts index b3bfdf1c..c379434f 100644 --- a/src/shared/services/ModulePlatformService.ts +++ b/src/shared/services/ModulePlatformService.ts @@ -1,4 +1,9 @@ -import type { IApp, IModuleDownloadState } from '../types/coreTypes'; +import type { + IApp, + IModuleDownloadState, + ReleaseDownloadOptions, + ReleaseDownloadSelection, +} from '../types/coreTypes'; import type { DownloadModuleOutcome, ModuleService } from './ModuleService'; import type { AIBridge } from '@/features/ai/services/AIBridge'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; @@ -30,7 +35,10 @@ export class ModulePlatformService { * Downloads a module. * @param app The module to download. */ - public async download(app: IApp): Promise { + public async download( + app: IApp, + releaseSelection?: ReleaseDownloadSelection | null, + ): Promise { this._tracer.info(`[ModulePlatformService] Downloading: ${app.id}`); if (app.repoUrl === undefined || app.repoUrl === '') { @@ -38,7 +46,21 @@ export class ModulePlatformService { } const url: string = app.repoUrl; - return await this._moduleService.downloadModule(app.id, url, app.expectedHash, app.dlType); + return await this._moduleService.downloadModule( + app.id, + url, + app.expectedHash, + app.dlType, + releaseSelection, + ); + } + + public async getReleaseDownloadOptions(app: IApp): Promise { + if (app.repoUrl === undefined || app.repoUrl === '' || app.dlType !== 'release') { + return null; + } + + return await this._moduleService.getReleaseDownloadOptions(app.id, app.repoUrl); } /** diff --git a/src/shared/services/ModuleService.test.ts b/src/shared/services/ModuleService.test.ts index 3c85f377..5955b3bd 100644 --- a/src/shared/services/ModuleService.test.ts +++ b/src/shared/services/ModuleService.test.ts @@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => { getModuleStatus: vi.fn(), downloadModule: vi.fn(), deleteModule: vi.fn(), + controlModule: vi.fn(), pauseDownload: vi.fn().mockResolvedValue(true), resumeDownload: vi.fn(), cancelDownload: vi.fn().mockResolvedValue(true), @@ -95,6 +96,22 @@ describe('ModuleService', () => { expect(mocks.tauriProvider.listen).toHaveBeenCalledTimes(1); }); + + it('should allow retry when progress listener registration fails', async () => { + mocks.tauriProvider.listen + .mockRejectedValueOnce(new Error('listen failed')) + .mockResolvedValueOnce(() => { + /* no-op */ + }); + + await expect(moduleService.init()).rejects.toThrow('listen failed'); + await moduleService.init(); + + expect(mocks.tauriProvider.listen).toHaveBeenCalledTimes(2); + expect(mocks.tracer.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to subscribe to download progress'), + ); + }); }); describe('checkInstalled', () => { @@ -167,6 +184,7 @@ describe('ModuleService', () => { 'https://repo.com/module', null, null, + null, ); expect(mocks.invokeSafe).toHaveBeenCalled(); }); @@ -248,17 +266,19 @@ describe('ModuleService', () => { describe('control', () => { it('should invoke control_module command', async () => { - mocks.tauriProvider.invoke.mockResolvedValueOnce(undefined); + mocks.invokeSafe.mockResolvedValueOnce({ + status: 'ok', + data: { success: true, message: 'started', status: 'running' }, + }); const result = await moduleService.control('test-service', 'start'); expect(result).toBe(true); - expect(mocks.tauriProvider.invoke).toHaveBeenCalledWith('control_module', { - request: { - module_id: 'test-service', - action: 'start', - }, + expect(mocks.commands.controlModule).toHaveBeenCalledWith({ + module_id: 'test-service', + action: 'start', }); + expect(mocks.invokeSafe).toHaveBeenCalled(); }); it('should return false when not in Tauri', async () => { @@ -270,12 +290,26 @@ describe('ModuleService', () => { }); it('should return false on error', async () => { - mocks.tauriProvider.invoke.mockRejectedValueOnce(new Error('Control failed')); + mocks.invokeSafe.mockResolvedValueOnce({ + status: 'error', + error: { message: 'Control failed' }, + }); const result = await moduleService.control('test-service', 'start'); expect(result).toBe(false); }); + + it('should return false when backend reports unsuccessful control response', async () => { + mocks.invokeSafe.mockResolvedValueOnce({ + status: 'ok', + data: { success: false, message: 'not implemented', status: null }, + }); + + const result = await moduleService.control('test-service', 'restart'); + + expect(result).toBe(false); + }); }); describe('getDownloadState', () => { @@ -435,6 +469,7 @@ describe('ModuleService', () => { 'https://repo.com', 'abc123', null, + null, ); }); @@ -446,6 +481,7 @@ describe('ModuleService', () => { 'https://repo.com', null, null, + null, ); }); }); @@ -479,6 +515,7 @@ describe('ModuleService', () => { 'https://repo.com', null, null, + null, ); }); diff --git a/src/shared/services/ModuleService.ts b/src/shared/services/ModuleService.ts index 4bc0fd23..d87b2e31 100644 --- a/src/shared/services/ModuleService.ts +++ b/src/shared/services/ModuleService.ts @@ -5,7 +5,11 @@ import { type IBridge } from '@/shared/types/IBridge'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; -import type { IModuleDownloadState } from '../types/coreTypes'; +import type { + IModuleDownloadState, + ReleaseDownloadOptions, + ReleaseDownloadSelection, +} from '../types/coreTypes'; import { commands } from '../types/bindings'; import { invokeSafe } from '../api/invoke'; @@ -29,46 +33,54 @@ export class ModuleService { */ public async init() { if (this._initialized) return; - this._initialized = true; if (!this._bridge.isTauri()) return; - this._downloadProgressUnlisten = await this._bridge.listen<{ - module_id: string; - status: string; - progress: number; - message: string; - downloaded: number; - total: number; - speed: number; - }>('download_progress', (payload) => { - this._logDownloadPhase(payload); - - this._downloadState[payload.module_id] = { - status: payload.status as - | 'init' - | 'pending' - | 'connecting' - | 'downloading' - | 'verifying' - | 'extracting' - | 'paused' - | 'complete' - | 'error' - | 'cancelled', - progress: payload.progress, - message: payload.message, - downloaded: payload.downloaded, - total: payload.total, - speed: payload.speed, - }; - - if (payload.status === 'complete') { - (this._downloadState[payload.module_id] as { progress: number }).progress = 1; - } - // Dispatch custom event for UI components that don't use this service directly - const event = new CustomEvent('download-progress-update', { detail: payload }); - globalThis.dispatchEvent(event); - }); + try { + this._downloadProgressUnlisten = await this._bridge.listen<{ + module_id: string; + status: string; + progress: number; + message: string; + downloaded: number; + total: number; + speed: number; + }>('download_progress', (payload) => { + this._logDownloadPhase(payload); + + this._downloadState[payload.module_id] = { + status: payload.status as + | 'init' + | 'pending' + | 'connecting' + | 'downloading' + | 'verifying' + | 'extracting' + | 'paused' + | 'complete' + | 'error' + | 'cancelled', + progress: payload.progress, + message: payload.message, + downloaded: payload.downloaded, + total: payload.total, + speed: payload.speed, + }; + + if (payload.status === 'complete') { + (this._downloadState[payload.module_id] as { progress: number }).progress = 1; + } + // Dispatch custom event for UI components that don't use this service directly + const event = new CustomEvent('download-progress-update', { detail: payload }); + globalThis.dispatchEvent(event); + }); + this._initialized = true; + } catch (error) { + this._initialized = false; + this._tracer.error( + `[ModuleService] Failed to subscribe to download progress: ${String(error)}`, + ); + throw error; + } } /** @@ -132,6 +144,7 @@ export class ModuleService { repoUrl: string, expectedHash?: string, dlType?: string, + releaseSelection?: ReleaseDownloadSelection | null, ): Promise { this._tracer.info(`[ModuleService] Downloading module: ${moduleId} from ${repoUrl}`); if (expectedHash !== undefined && expectedHash !== '') { @@ -150,7 +163,13 @@ export class ModuleService { // Updated to use new API layer const result = await invokeSafe( - commands.downloadModule(moduleId, repoUrl, hashToPass, dlType ?? null), + commands.downloadModule( + moduleId, + repoUrl, + hashToPass, + dlType ?? null, + releaseSelection ?? null, + ), ); if (result.status === 'error') { @@ -173,6 +192,27 @@ export class ModuleService { } } + public async getReleaseDownloadOptions( + moduleId: string, + repoUrl: string, + ): Promise { + if (!this._bridge.isTauri()) return null; + + try { + const result = await invokeSafe(commands.getReleaseDownloadOptions(moduleId, repoUrl)); + if (result.status === 'ok') { + return result.data as ReleaseDownloadOptions; + } + this._tracer.warn( + `[ModuleService] Release options failed for ${moduleId}: ${result.error.message}`, + ); + return null; + } catch (err) { + this._tracer.error(`[ModuleService] Release options error: ${String(err)}`); + return null; + } + } + private _normalizeDownloadOutcome(value: unknown): DownloadModuleOutcome { if (value === 'paused' || value === 'cancelled' || value === 'completed') { return value; @@ -281,13 +321,17 @@ export class ModuleService { this._tracer.info(`[ModuleService] Control ${serviceName} -> ${action}`); if (this._bridge.isTauri()) { try { - await this._bridge.invoke('control_module', { - request: { + const result = await invokeSafe( + commands.controlModule({ module_id: serviceName, action: action.toLowerCase(), - }, - }); - return true; + }), + ); + if (result.status === 'error') { + this._tracer.error(`[ModuleService] Control failed: ${result.error.message}`); + return false; + } + return result.data.success === true; } catch (e) { this._tracer.error(`[ModuleService] Control failed: ${String(e)}`); return false; diff --git a/src/shared/services/StateManager.test.ts b/src/shared/services/StateManager.test.ts new file mode 100644 index 00000000..976f240f --- /dev/null +++ b/src/shared/services/StateManager.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { StateManager, type StatePersistenceTarget } from './StateManager'; + +function createTarget(name = 'target'): StatePersistenceTarget { + return { + name, + saveAsync: vi.fn().mockResolvedValue(undefined), + saveImmediate: vi.fn().mockResolvedValue(undefined), + }; +} + +describe('StateManager', () => { + const tracer = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should flush registered targets before destroy disables saving', async () => { + const manager = new StateManager(tracer); + const target = createTarget(); + manager.register(target); + + await manager.destroy(); + + expect(target.saveImmediate).toHaveBeenCalledTimes(1); + }); + + it('should await immediate saves', async () => { + const manager = new StateManager(tracer); + let settled = false; + let release!: () => void; + const target: StatePersistenceTarget = { + name: 'slow-target', + saveAsync: vi.fn().mockResolvedValue(undefined), + saveImmediate: vi.fn( + () => + new Promise((resolve) => { + release = () => { + settled = true; + resolve(); + }; + }), + ), + }; + manager.register(target); + + const save = manager.saveAllImmediate(); + await Promise.resolve(); + + expect(settled).toBe(false); + release(); + await save; + expect(settled).toBe(true); + }); + + it('should not save targets registered after destroy', async () => { + const manager = new StateManager(tracer); + await manager.destroy(); + const target = createTarget(); + + manager.register(target); + await manager.saveAllImmediate(); + + expect(target.saveImmediate).not.toHaveBeenCalled(); + }); +}); diff --git a/src/shared/services/StateManager.ts b/src/shared/services/StateManager.ts index 2d4827f9..148be005 100644 --- a/src/shared/services/StateManager.ts +++ b/src/shared/services/StateManager.ts @@ -21,8 +21,8 @@ export interface StatePersistenceTarget { name: string; /** Async save — used for debounced/visibility saves */ saveAsync: () => Promise; - /** Sync save — used for beforeunload (fire-and-forget ok) */ - saveImmediate: () => void; + /** Immediate save — used for explicit close/destroy and beforeunload best effort */ + saveImmediate: () => Promise | void; } export class StateManager { @@ -38,7 +38,7 @@ export class StateManager { }; private readonly _boundBeforeUnload = () => { - this.saveAllImmediate(); + void this.saveAllImmediate(); }; /** @@ -95,10 +95,10 @@ export class StateManager { } /** - * Save ALL registered targets immediately (fire-and-forget). - * Called on beforeunload — no await, best effort. + * Save ALL registered targets immediately. + * Called on beforeunload as best effort and awaited by explicit shutdown paths. */ - saveAllImmediate(): void { + async saveAllImmediate(): Promise { if (this._isDestroyed) return; const targets = [...this._targets.values()]; @@ -106,16 +106,19 @@ export class StateManager { this._tracer.info(`[StateManager] Saving ${String(targets.length)} targets (immediate)...`); - // Fire all saves — no await, best effort before page unloads - for (const target of targets) { - try { - target.saveImmediate(); - } catch (e) { + const results = await Promise.allSettled( + targets.map(async (target) => { + await target.saveImmediate(); + }), + ); + + results.forEach((result, index) => { + if (result.status === 'rejected') { this._tracer.warn( - `[StateManager] Immediate save failed for ${target.name}: ${String(e)}`, + `[StateManager] Immediate save failed for ${targets[index]?.name}: ${String(result.reason)}`, ); } - } + }); } /** @@ -131,12 +134,12 @@ export class StateManager { /** * Clean up all listeners and targets. */ - destroy(): void { + async destroy(): Promise { if (this._isDestroyed) return; - this._isDestroyed = true; // Final save before destroy - this.saveAllImmediate(); + await this.saveAllImmediate(); + this._isDestroyed = true; document.removeEventListener('visibilitychange', this._boundVisibilityChange); globalThis.removeEventListener('beforeunload', this._boundBeforeUnload); diff --git a/src/shared/services/WindowService.test.ts b/src/shared/services/WindowService.test.ts index 7bf07df1..fca2ef85 100644 --- a/src/shared/services/WindowService.test.ts +++ b/src/shared/services/WindowService.test.ts @@ -720,6 +720,92 @@ describe('WindowService', () => { y: 50, }); }); + + it('should await immediate window state save', async () => { + const mockIsMaximized = vi.fn().mockResolvedValue(true); + vi.stubGlobal('__TAURI__', { + window: { + getCurrentWindow: () => ({ + isMaximized: mockIsMaximized, + innerSize: vi.fn().mockResolvedValue({ width: 1280, height: 720 }), + outerPosition: vi.fn().mockResolvedValue({ x: 0, y: 0 }), + }), + }, + }); + + await service.saveImmediate(); + + expect(mockBridge.invoke).toHaveBeenCalledWith('save_maximized_state', { + maximized: true, + }); + }); + + it('should cancel pending debounced save when saving immediately', async () => { + const mockIsMaximized = vi.fn().mockResolvedValue(true); + vi.stubGlobal('__TAURI__', { + window: { + getCurrentWindow: () => ({ + isMaximized: mockIsMaximized, + innerSize: vi.fn().mockResolvedValue({ width: 1280, height: 720 }), + outerPosition: vi.fn().mockResolvedValue({ x: 0, y: 0 }), + }), + }, + }); + + service.scheduleSave(); + await service.saveImmediate(); + vi.advanceTimersByTime(1500); + await vi.runAllTimersAsync(); + + expect(mockBridge.invoke).toHaveBeenCalledTimes(1); + expect(mockBridge.invoke).toHaveBeenCalledWith('save_maximized_state', { + maximized: true, + }); + }); + + it('should serialize overlapping window state saves', async () => { + let releaseFirst!: () => void; + const mockIsMaximized = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + releaseFirst = () => { + resolve(false); + }; + }), + ) + .mockResolvedValueOnce(true); + const mockInnerSize = vi.fn().mockResolvedValue({ width: 1000, height: 600 }); + const mockOuterPos = vi.fn().mockResolvedValue({ x: 100, y: 50 }); + + vi.stubGlobal('__TAURI__', { + window: { + getCurrentWindow: () => ({ + isMaximized: mockIsMaximized, + innerSize: mockInnerSize, + outerPosition: mockOuterPos, + }), + }, + }); + + const first = service.saveImmediate(); + const second = service.saveImmediate(); + await Promise.resolve(); + + expect(mockIsMaximized).toHaveBeenCalledTimes(1); + releaseFirst(); + await first; + await second; + + expect(mockIsMaximized).toHaveBeenCalledTimes(2); + expect(mockBridge.invoke).toHaveBeenNthCalledWith(1, 'save_maximized_state', { + maximized: false, + }); + expect(mockBridge.invoke).toHaveBeenLastCalledWith('save_maximized_state', { + maximized: true, + }); + }); }); // ---------------------------------------------------------- Resolution change zoom handling @@ -808,6 +894,17 @@ describe('WindowService', () => { expect(unlisten).toHaveBeenCalledTimes(1); }); + + it('should log tauri move listener registration failures', async () => { + mockBridge.listen.mockRejectedValue(new Error('listen failed')); + + await service.init(mockWindowConfig, 1); + await Promise.resolve(); + + expect(mockTracer.warn).toHaveBeenCalledWith( + expect.stringContaining('Failed to subscribe to window move events'), + ); + }); }); // ---------------------------------------------------------- _getInitialZoomWithFallback valid zoom (lines 468-471) diff --git a/src/shared/services/WindowService.ts b/src/shared/services/WindowService.ts index bd3511fa..295cb16c 100644 --- a/src/shared/services/WindowService.ts +++ b/src/shared/services/WindowService.ts @@ -415,10 +415,10 @@ export class WindowService { } /** - * Immediate window state save (fire-and-forget). + * Immediate window state save. * Exposed for StateManager registration. */ - public saveImmediate(): void { - void this._persistence.saveWindowState(); + public async saveImmediate(): Promise { + await this._persistence.saveWindowState(); } } diff --git a/src/shared/services/WindowServicePersistence.ts b/src/shared/services/WindowServicePersistence.ts index 5ca11229..3590bff5 100644 --- a/src/shared/services/WindowServicePersistence.ts +++ b/src/shared/services/WindowServicePersistence.ts @@ -24,6 +24,7 @@ export class WindowServicePersistence { private _saveWindowTimer: ReturnType | null = null; private _moveUnlisten: (() => void) | null = null; private _windowListenersInitialized = false; + private _saveChain: Promise = Promise.resolve(); constructor(private readonly _deps: WindowServicePersistenceDeps) {} @@ -33,13 +34,20 @@ export class WindowServicePersistence { this._deps.runtime.addEventListener('resize', this._deps.onResize); - void this._deps.bridge.listen('tauri://move', this._deps.onResize).then((unlisten) => { - if (this._deps.isDestroyed()) { - unlisten(); - return; - } - this._moveUnlisten = unlisten; - }); + void this._deps.bridge + .listen('tauri://move', this._deps.onResize) + .then((unlisten) => { + if (this._deps.isDestroyed()) { + unlisten(); + return; + } + this._moveUnlisten = unlisten; + }) + .catch((error: unknown) => { + this._deps.tracer.warn( + `[WindowService] Failed to subscribe to window move events: ${String(error)}`, + ); + }); } public destroy(): void { @@ -62,17 +70,34 @@ export class WindowServicePersistence { clearTimeout(this._saveWindowTimer); } this._saveWindowTimer = setTimeout(() => { - void this.saveWindowState(); + this._saveWindowTimer = null; + void this.saveWindowState().catch(() => { + // saveWindowState already logs the concrete backend/native error. + }); }, 1000); } public async saveWindowState(): Promise { + if (this._saveWindowTimer !== null) { + clearTimeout(this._saveWindowTimer); + this._saveWindowTimer = null; + } + if (!this._deps.bridge.isTauri()) return; - try { + const save = this._saveChain.then(async () => { + if (this._deps.isDestroyed()) return; await this._deps.nativeHelper.saveWindowState(); + }); + this._saveChain = save.catch(() => { + // Keep the chain usable after a failed save. + }); + + try { + await save; } catch (error) { this._deps.tracer.warn(`[WindowService] Failed to save window state: ${String(error)}`); + throw error; } } } diff --git a/src/shared/services/state/UiStateStore.test.ts b/src/shared/services/state/UiStateStore.test.ts index 53f641b4..31527add 100644 --- a/src/shared/services/state/UiStateStore.test.ts +++ b/src/shared/services/state/UiStateStore.test.ts @@ -183,12 +183,38 @@ describe('UiStateStore', () => { // Should not throw await expect(store.saveAsync()).resolves.toBeUndefined(); }); + + it('should keep dirty state when state changes during an in-flight save', async () => { + bridge = createMockBridge(true); + let resolveFirstSave: () => void = () => {}; + (bridge.invoke as ReturnType) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirstSave = resolve; + }), + ) + .mockResolvedValue(undefined); + store = new UiStateStore(bridge, tracer, storage); + store.updateState({ sidebar_width: 111 }); + + const firstSave = store.saveAsync(); + store.updateState({ sidebar_width: 222 }); + resolveFirstSave(); + await firstSave; + await store.saveAsync(); + + expect(bridge.invoke).toHaveBeenCalledTimes(2); + expect(bridge.invoke).toHaveBeenLastCalledWith('save_ui_state', { + state: expect.objectContaining({ sidebar_width: 222 }) as unknown, + }); + }); }); describe('saveImmediate', () => { - it('should save synchronously to localStorage when dirty', () => { + it('should save synchronously to localStorage when dirty', async () => { store.updateState({ zoom_level: 1.5 }); - store.saveImmediate(); + await store.saveImmediate(); const stored = JSON.parse(storageState['axelate_ui_state'] ?? '{}') as Record< string, @@ -197,20 +223,37 @@ describe('UiStateStore', () => { expect(stored['zoom_level']).toBe(1.5); }); - it('should not save when not dirty', () => { - store.saveImmediate(); + it('should not save when not dirty', async () => { + await store.saveImmediate(); expect(storageState['axelate_ui_state']).toBeUndefined(); }); - it('should save to backend in Tauri environment', () => { + it('should save to backend in Tauri environment', async () => { bridge = createMockBridge(true); store = new UiStateStore(bridge, tracer, storage); store.updateState({ sidebar_width: 777 }); - store.saveImmediate(); + await store.saveImmediate(); expect(bridge.invoke).toHaveBeenCalledWith('save_ui_state', { state: expect.objectContaining({ sidebar_width: 777 }) as unknown, }); }); + + it('should keep dirty state when immediate backend save rejects', async () => { + bridge = createMockBridge(true); + (bridge.invoke as ReturnType) + .mockRejectedValueOnce(new Error('Save failed')) + .mockResolvedValue(undefined); + store = new UiStateStore(bridge, tracer, storage); + store.updateState({ sidebar_width: 888 }); + + await expect(store.saveImmediate()).rejects.toThrow('Save failed'); + await store.saveAsync(); + + expect(bridge.invoke).toHaveBeenCalledTimes(2); + expect(bridge.invoke).toHaveBeenLastCalledWith('save_ui_state', { + state: expect.objectContaining({ sidebar_width: 888 }) as unknown, + }); + }); }); describe('Debounced auto-save', () => { @@ -237,7 +280,7 @@ describe('UiStateStore', () => { }); describe('saveImmediate error handling', () => { - it('should catch errors during saveImmediate gracefully', () => { + it('should report errors during saveImmediate', async () => { bridge = createMockBridge(true); (bridge.invoke as ReturnType).mockImplementation(() => { throw new Error('Invoke crashed'); @@ -245,8 +288,7 @@ describe('UiStateStore', () => { store = new UiStateStore(bridge, tracer, storage); store.updateState({ sidebar_width: 123 }); - // Should not throw - expect(() => store.saveImmediate()).not.toThrow(); + await expect(store.saveImmediate()).rejects.toThrow('Invoke crashed'); }); }); @@ -285,13 +327,13 @@ describe('UiStateStore', () => { // bridge.invoke should not have been called for save }); - it('should saveImmediate synchronously', () => { + it('should saveImmediate synchronously', async () => { bridge = createMockBridge(); store = new UiStateStore(bridge, tracer, storage); store.updateState({ sidebar_width: 500 }); // beforeunload is now handled by StateManager; test saveImmediate directly - store.saveImmediate(); + await store.saveImmediate(); const stored = JSON.parse(storageState['axelate_ui_state'] ?? '{}') as Record< string, @@ -300,7 +342,7 @@ describe('UiStateStore', () => { expect(stored['sidebar_width']).toBe(500); }); - it('should remove auto-save timer on destroy', () => { + it('should remove auto-save timer on destroy', async () => { bridge = createMockBridge(); store = new UiStateStore(bridge, tracer, storage); store.updateState({ sidebar_width: 640 }); @@ -308,7 +350,7 @@ describe('UiStateStore', () => { store.destroy(); // saveImmediate should still work (no event listeners to remove) - store.saveImmediate(); + await store.saveImmediate(); const stored = JSON.parse(storageState['axelate_ui_state'] ?? '{}') as Record< string, diff --git a/src/shared/services/state/UiStateStore.ts b/src/shared/services/state/UiStateStore.ts index 1a24a0c7..083861e4 100644 --- a/src/shared/services/state/UiStateStore.ts +++ b/src/shared/services/state/UiStateStore.ts @@ -66,6 +66,7 @@ const MAX_UI_ZOOM = 2.6; export class UiStateStore { private _state: IUIState = { ...DEFAULT_UI_STATE }; private _isDirty = false; + private _revision = 0; private _autoSaveTimer: ReturnType | null = null; private readonly _STORAGE_KEY = 'axelate_ui_state'; private _isDestroyed = false; @@ -107,6 +108,7 @@ export class UiStateStore { this._state = { ...this._state, ...updates }; if (markDirty) { this._isDirty = true; + this._revision += 1; this._debouncedSave(); } } @@ -121,6 +123,7 @@ export class UiStateStore { target[nestedKey] = value; if (markDirty) { this._isDirty = true; + this._revision += 1; this._debouncedSave(); } } @@ -134,6 +137,7 @@ export class UiStateStore { delete target[nestedKey]; if (markDirty) { this._isDirty = true; + this._revision += 1; this._debouncedSave(); } } @@ -167,29 +171,41 @@ export class UiStateStore { public async saveAsync(): Promise { if (!this._isDirty) return; + const revision = this._revision; + const state = this._snapshotState(); try { if (this._bridge.isTauri()) { - await this._bridge.invoke('save_ui_state', { state: this._state }); + await this._bridge.invoke('save_ui_state', { state }); } else { - this._storage?.setItem(this._STORAGE_KEY, JSON.stringify(this._state)); + this._storage?.setItem(this._STORAGE_KEY, JSON.stringify(state)); + } + if (this._revision === revision) { + this._isDirty = false; } - this._isDirty = false; } catch (e) { this._tracer.error(`[UiStateStore] Failed to save state: ${String(e)}`); } } - public saveImmediate(): void { + public async saveImmediate(): Promise { if (!this._isDirty) return; + const revision = this._revision; + const state = this._snapshotState(); try { if (this._bridge.isTauri()) { - void this._bridge.invoke('save_ui_state', { state: this._state }); + await this._bridge.invoke('save_ui_state', { state }); + if (this._revision === revision) { + this._isDirty = false; + } } else { - this._storage?.setItem(this._STORAGE_KEY, JSON.stringify(this._state)); + this._storage?.setItem(this._STORAGE_KEY, JSON.stringify(state)); + if (this._revision === revision) { + this._isDirty = false; + } } - this._isDirty = false; } catch (e) { this._tracer.error(`[UiStateStore] Save immediate failed: ${String(e)}`); + throw e; } } @@ -216,6 +232,10 @@ export class UiStateStore { }; } + private _snapshotState(): IUIState { + return structuredClone(this._state); + } + private _clampZoom(zoom: number): number { if (!Number.isFinite(zoom)) { return DEFAULT_UI_STATE.zoom_level; diff --git a/src/shared/shell/ui/AppUiCardActionFlow.test.ts b/src/shared/shell/ui/AppUiCardActionFlow.test.ts index a59e51ca..acc0166f 100644 --- a/src/shared/shell/ui/AppUiCardActionFlow.test.ts +++ b/src/shared/shell/ui/AppUiCardActionFlow.test.ts @@ -85,7 +85,6 @@ describe('AppUiCardActionFlow', () => { platformService.delete.mockResolvedValue(undefined); await flow.tryDownloadAction(event, app, 'services'); - await Promise.resolve(); expect(deps.pauseDownload).toHaveBeenCalledWith('svc'); expect(btn.dataset['downloadStatus']).toBe('paused'); @@ -120,7 +119,6 @@ describe('AppUiCardActionFlow', () => { const app = { id: 'svc', installed: false, repoUrl: 'https://repo' } as IApp; await flow.tryDownloadAction(event, app, 'services'); - await Promise.resolve(); expect(deps.resumeDownload).toHaveBeenCalledWith('svc'); expect(btn.dataset['downloadStatus']).toBe('downloading'); @@ -151,7 +149,6 @@ describe('AppUiCardActionFlow', () => { const app = { id: 'svc', installed: false, repoUrl: 'https://repo' } as IApp; await flow.tryDownloadAction(event, app, 'services'); - await Promise.resolve(); expect(deps.cancelDownload).toHaveBeenCalledWith('svc'); expect(platformService.delete).not.toHaveBeenCalled(); @@ -159,7 +156,69 @@ describe('AppUiCardActionFlow', () => { expect(deps.restoreDownloadButtonLabel).toHaveBeenCalledWith(btn); }); - it('does not start a download from a plain card click', async () => { + it('keeps active download button state when pause is rejected by backend', async () => { + deps.pauseDownload.mockResolvedValue(false); + const card = document.createElement('div'); + card.className = 'app-card'; + const btn = document.createElement('button'); + btn.className = 'download-btn downloading'; + btn.dataset['resumeLabel'] = 'Resume'; + btn.innerHTML = 'Pause'; + btn.getBoundingClientRect = vi.fn( + () => + ({ + left: 0, + width: 100, + }) as DOMRect, + ); + card.appendChild(btn); + const event = { + stopPropagation: vi.fn(), + currentTarget: card, + target: btn, + clientX: 20, + } as unknown as MouseEvent; + const app = { id: 'svc', installed: false, repoUrl: 'https://repo' } as IApp; + + await flow.tryDownloadAction(event, app, 'services'); + + expect(btn.dataset['downloadStatus']).toBeUndefined(); + expect(btn.querySelector('.download-hover-action-pause')?.textContent).toBe('Pause'); + expect(deps.resetDownloadButton).not.toHaveBeenCalled(); + expect(deps.showToast).toHaveBeenCalledWith('Download control failed', 'warning'); + }); + + it('keeps active download button state when cancel is rejected by backend', async () => { + deps.cancelDownload.mockResolvedValue(false); + const card = document.createElement('div'); + card.className = 'app-card'; + const btn = document.createElement('button'); + btn.className = 'download-btn downloading'; + btn.getBoundingClientRect = vi.fn( + () => + ({ + left: 0, + width: 100, + }) as DOMRect, + ); + card.appendChild(btn); + const event = { + stopPropagation: vi.fn(), + currentTarget: card, + target: btn, + clientX: 75, + } as unknown as MouseEvent; + const app = { id: 'svc', installed: false, repoUrl: 'https://repo' } as IApp; + + await flow.tryDownloadAction(event, app, 'services'); + + expect(deps.cancelDownload).toHaveBeenCalledWith('svc'); + expect(deps.resetDownloadButton).not.toHaveBeenCalled(); + expect(deps.restoreDownloadButtonLabel).not.toHaveBeenCalled(); + expect(deps.showToast).toHaveBeenCalledWith('Download control failed', 'warning'); + }); + + it('starts a download from a plain uninstalled card click', async () => { const card = document.createElement('div'); card.className = 'app-card'; const btn = document.createElement('button'); @@ -176,7 +235,31 @@ describe('AppUiCardActionFlow', () => { await flow.handleAppCardClick(event, app, 'ai_text'); + expect(event.stopPropagation).toHaveBeenCalled(); + expect(deps.handleDownloadModule).toHaveBeenCalledWith(app, 'ai_text', btn); + expect(deps.performSelectionAction).not.toHaveBeenCalled(); + }); + + it('ignores plain card clicks while download is already active', async () => { + const card = document.createElement('div'); + card.className = 'app-card'; + const btn = document.createElement('button'); + btn.className = 'download-btn downloading'; + card.appendChild(btn); + + const event = { + stopPropagation: vi.fn(), + currentTarget: card, + target: card, + clientX: 20, + } as unknown as MouseEvent; + const app = { id: 'llamacpp', installed: false, repoUrl: 'https://repo' } as IApp; + + await flow.handleAppCardClick(event, app, 'ai_text'); + expect(deps.handleDownloadModule).not.toHaveBeenCalled(); + expect(deps.pauseDownload).not.toHaveBeenCalled(); + expect(deps.cancelDownload).not.toHaveBeenCalled(); expect(deps.performSelectionAction).not.toHaveBeenCalled(); }); diff --git a/src/shared/shell/ui/AppUiCardActionFlow.ts b/src/shared/shell/ui/AppUiCardActionFlow.ts index 985b1c4d..36763f21 100644 --- a/src/shared/shell/ui/AppUiCardActionFlow.ts +++ b/src/shared/shell/ui/AppUiCardActionFlow.ts @@ -65,7 +65,7 @@ export class AppUiCardActionFlow { const clickedDownloadButton = (event.target as HTMLElement | null)?.closest( '.download-btn', ); - if (clickedDownloadButton === null) { + if (clickedDownloadButton === null && btn?.classList.contains('downloading') === true) { return false; } @@ -86,8 +86,8 @@ export class AppUiCardActionFlow { } event.stopPropagation(); - if (btn?.classList.contains('downloading') === true) { - this._handleActiveDownloadAction(event, app, btn); + if (clickedDownloadButton !== null && btn?.classList.contains('downloading') === true) { + await this._handleActiveDownloadAction(event, app, btn); return true; } @@ -101,35 +101,58 @@ export class AppUiCardActionFlow { return card?.querySelector('.download-btn') ?? null; } - private _handleActiveDownloadAction(event: MouseEvent, app: IApp, btn: HTMLElement): void { + private async _handleActiveDownloadAction( + event: MouseEvent, + app: IApp, + btn: HTMLElement, + ): Promise { const action = resolveDownloadButtonAction(btn as HTMLButtonElement, event); if (action === 'pause') { this._deps.tracer.info(`[AppUI] Pausing download for: ${app.id}`); - markModuleCardDownloadPaused(btn); - void this._deps.pauseDownload(app.id); + if (await this._deps.pauseDownload(app.id)) { + markModuleCardDownloadPaused(btn); + } else { + this._showDownloadControlFailed(app.id, 'pause'); + } return; } if (action === 'resume') { this._deps.tracer.info(`[AppUI] Resuming download for: ${app.id}`); - markModuleCardDownloadResuming(btn); - void this._deps.resumeDownload(app.id); + if (await this._deps.resumeDownload(app.id)) { + markModuleCardDownloadResuming(btn); + } else { + this._showDownloadControlFailed(app.id, 'resume'); + } return; } - this._cancelDownload(app, btn); + await this._cancelDownload(app, btn); } - private _cancelDownload(app: IApp, btn: HTMLElement | null): void { + private async _cancelDownload(app: IApp, btn: HTMLElement | null): Promise { this._deps.tracer.info(`[AppUI] Cancelling download for: ${app.id}`); - void (async () => { - try { - await this._deps.cancelDownload(app.id); + try { + if (await this._deps.cancelDownload(app.id)) { this._deps.resetDownloadButton(btn); this._deps.restoreDownloadButtonLabel(btn); - } catch (err) { - this._deps.tracer.error(`[AppUI] Cancel failed for ${app.id}:`, err); + } else { + this._showDownloadControlFailed(app.id, 'cancel'); } - })(); + } catch (err) { + this._deps.tracer.error(`[AppUI] Cancel failed for ${app.id}:`, err); + this._showDownloadControlFailed(app.id, 'cancel'); + } + } + + private _showDownloadControlFailed(moduleId: string, action: string): void { + this._deps.tracer.warn(`[AppUI] Download ${action} failed for ${moduleId}`); + this._deps.showToast( + this._deps.translate( + 'ui.launcher.web.download_control_error', + 'Download control failed', + ), + 'warning', + ); } } diff --git a/src/shared/shell/ui/AppUiModuleFlow.test.ts b/src/shared/shell/ui/AppUiModuleFlow.test.ts index 9db99729..bf116e80 100644 --- a/src/shared/shell/ui/AppUiModuleFlow.test.ts +++ b/src/shared/shell/ui/AppUiModuleFlow.test.ts @@ -15,6 +15,8 @@ describe('AppUiModuleFlow', () => { const modalManager = { isAppSelectionOpen: vi.fn(), isViewingCategory: vi.fn(), + closeAppSelection: vi.fn(), + openAppSelection: vi.fn(), refreshCurrentSelection: vi.fn(), }; @@ -117,9 +119,25 @@ describe('AppUiModuleFlow', () => { it('resets download button and shows a toast after download errors', () => { const btn = document.createElement('button'); btn.className = 'download-btn downloading indeterminate'; + btn.innerHTML = ` + + + `; flow.onModalDownloadError(btn, new Error('broken')); + expect(btn.classList.contains('downloading')).toBe(false); + expect(btn.querySelector('.download-pct')?.style.display).toBe('none'); + expect(btn.querySelector('.download-label')?.textContent).toBe('Download'); + expect(showToast).toHaveBeenCalledWith('Download failed', 'error'); + }); + + it('falls back to default download error text for non-error rejections', () => { + const btn = document.createElement('button'); + btn.className = 'download-btn downloading indeterminate'; + + flow.onModalDownloadError(btn, 'network down'); + expect(btn.classList.contains('downloading')).toBe(false); expect(showToast).toHaveBeenCalledWith('Download failed', 'error'); }); diff --git a/src/shared/shell/ui/AppUiModuleFlow.ts b/src/shared/shell/ui/AppUiModuleFlow.ts index 6a37c3a8..201f3716 100644 --- a/src/shared/shell/ui/AppUiModuleFlow.ts +++ b/src/shared/shell/ui/AppUiModuleFlow.ts @@ -1,11 +1,14 @@ -import type { IApp } from '../../types/coreTypes'; +import type { IApp, ReleaseDownloadSelection } from '../../types/coreTypes'; import { resolveCatalogCategory } from '../../utils/moduleCategoryPolicy'; import type { ModulePlatformService } from '../../services/ModulePlatformService'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; +import { openDownloadSelectionDialog } from './DownloadSelectionDialog'; type ModalBridge = { isAppSelectionOpen(): boolean; isViewingCategory(category: string): boolean; + closeAppSelection(): void; + openAppSelection(category: string, apps: IApp[], selectedId?: string): void; refreshCurrentSelection(apps?: IApp[], selectedId?: string | null): void; }; @@ -56,17 +59,53 @@ export class AppUiModuleFlow { btn: HTMLElement | null, ): Promise { this._deps.tracer.info('[AppUI] Download module clicked:', app.id); - this.prepareDownloadButton(btn); + let activeButton = btn; try { - const outcome = await this._deps.platformService.download(app); + const releaseSelection = await this._resolveReleaseDownloadSelection(app, category); + if (releaseSelection === null && app.dlType === 'release') { + return; + } + + activeButton = + app.dlType === 'release' ? (this._findModalDownloadButton(app) ?? btn) : btn; + + this.prepareDownloadButton(activeButton); + + const outcome = await this._deps.platformService.download(app, releaseSelection); if (outcome !== 'completed') { - this.onModalDownloadInterrupted(btn, outcome); + this.onModalDownloadInterrupted(activeButton, outcome); return; } - this.onModalDownloadSuccess(btn, app, category); + this.onModalDownloadSuccess(activeButton, app, category); } catch (err: unknown) { - this.onModalDownloadError(btn, err); + this.onModalDownloadError(activeButton, err); + } + } + + private async _resolveReleaseDownloadSelection( + app: IApp, + category: string, + ): Promise { + if (app.dlType !== 'release') { + return undefined; + } + + const shouldRestoreSelection = this._deps.modalManager.isAppSelectionOpen(); + if (shouldRestoreSelection) { + this._deps.modalManager.closeAppSelection(); + } + + try { + return await openDownloadSelectionDialog({ + app, + loadOptions: () => this._deps.platformService.getReleaseDownloadOptions(app), + translate: this._deps.translate, + }); + } finally { + if (shouldRestoreSelection) { + this._restoreAppSelection(category); + } } } @@ -145,6 +184,7 @@ export class AppUiModuleFlow { public onModalDownloadError(btn: HTMLElement | null, err: unknown): void { this._deps.tracer.error('[AppUI] Download error:', err); this.resetDownloadButton(btn); + this.restoreDownloadButtonLabel(btn); this._deps.showToast( this._getLocalizedError(err, 'ui.launcher.web.download_error', 'Download failed'), 'error', @@ -152,8 +192,8 @@ export class AppUiModuleFlow { } private _getLocalizedError(err: unknown, fallbackKey: string, fallbackText: string): string { - const error = err as Error; - const msg = error.message.startsWith('ui.') ? error.message : fallbackKey; + const message = err instanceof Error ? err.message : typeof err === 'string' ? err : ''; + const msg = message.startsWith('ui.') ? message : fallbackKey; const fallback = msg === fallbackKey ? fallbackText : msg; return this._deps.translate(msg, fallback); } @@ -161,4 +201,22 @@ export class AppUiModuleFlow { private _toRawCategory(category: string): string { return resolveCatalogCategory(category); } + + private _restoreAppSelection(category: string): void { + this._deps.modalManager.openAppSelection( + category, + this._deps.getCatalogApps(this._toRawCategory(category)), + this._deps.getSelectedAppId(category) ?? undefined, + ); + } + + private _findModalDownloadButton(app: IApp): HTMLElement | null { + const cards = document.querySelectorAll('#app-modal-list .app-card'); + for (const card of cards) { + if (card.dataset['appId'] === app.id) { + return card.querySelector('.download-btn'); + } + } + return null; + } } diff --git a/src/shared/shell/ui/AppUiModuleLifecycle.test.ts b/src/shared/shell/ui/AppUiModuleLifecycle.test.ts index 883e4ec9..020dfc4f 100644 --- a/src/shared/shell/ui/AppUiModuleLifecycle.test.ts +++ b/src/shared/shell/ui/AppUiModuleLifecycle.test.ts @@ -49,7 +49,7 @@ describe('AppUiModuleLifecycle', () => { const app = { id: 'svc-a', name: 'Service A' } as IApp; const version = lifecycle.bumpLaunchSelectionVersion('services'); - platformService.stop.mockResolvedValue(undefined); + platformService.stop.mockResolvedValue(true); getSelectedApp.mockReturnValue({ id: 'svc-b' }); const pending = lifecycle.launchSelectedApp('services', app, version, launchApp); @@ -79,7 +79,7 @@ describe('AppUiModuleLifecycle', () => { const nextApp = { id: 'svc-new', name: 'New Service' } as IApp; const previousApp = { id: 'svc-old', name: 'Old Service' } as IApp; resolveAppById.mockReturnValue(previousApp); - platformService.stop.mockResolvedValue(undefined); + platformService.stop.mockResolvedValue(true); lifecycle.stopPreviousModule(card, nextApp, 'services'); await Promise.resolve(); @@ -87,4 +87,20 @@ describe('AppUiModuleLifecycle', () => { expect(platformService.stop).toHaveBeenCalledWith(previousApp); expect(showToast).toHaveBeenCalledWith('Old Service stopped', 'info'); }); + + it('does not show stopped toast when previous module stop reports failure', async () => { + const card = document.createElement('div'); + card.dataset['currentModule'] = 'svc-old'; + card.dataset['currentModuleName'] = 'Old Service'; + const nextApp = { id: 'svc-new', name: 'New Service' } as IApp; + const previousApp = { id: 'svc-old', name: 'Old Service' } as IApp; + resolveAppById.mockReturnValue(previousApp); + platformService.stop.mockResolvedValue(false); + + lifecycle.stopPreviousModule(card, nextApp, 'services'); + await Promise.resolve(); + + expect(platformService.stop).toHaveBeenCalledWith(previousApp); + expect(showToast).not.toHaveBeenCalled(); + }); }); diff --git a/src/shared/shell/ui/AppUiModuleLifecycle.ts b/src/shared/shell/ui/AppUiModuleLifecycle.ts index 3edd9f19..58effcb7 100644 --- a/src/shared/shell/ui/AppUiModuleLifecycle.ts +++ b/src/shared/shell/ui/AppUiModuleLifecycle.ts @@ -85,7 +85,13 @@ export class AppUiModuleLifecycle { void this._deps.platformService .stop(previousApp) - .then(() => { + .then((stopped) => { + if (!stopped) { + this._deps.tracer.warn( + `[AppUI] Previous module ${previousApp.id} did not report a successful stop`, + ); + return; + } const prevName = previousApp.name ?? card.dataset['currentModuleName'] ?? previousModuleId; if (!this._deps.platformService.isApiModule(previousApp)) { diff --git a/src/shared/shell/ui/DownloadSelectionDialog.ts b/src/shared/shell/ui/DownloadSelectionDialog.ts new file mode 100644 index 00000000..5aa90387 --- /dev/null +++ b/src/shared/shell/ui/DownloadSelectionDialog.ts @@ -0,0 +1,353 @@ +import type { + IApp, + ReleaseComputeTarget, + ReleaseDownloadOptions, + ReleaseDownloadSelection, + ReleaseDownloadVariant, + ReleaseDownloadVersion, +} from '@/shared/types/coreTypes'; + +type TranslateFn = (key: string, fallback: string) => string; + +type DownloadSelectionDialogOptions = { + app: IApp; + loadOptions: () => Promise; + translate: TranslateFn; +}; + +const TARGETS: Array> = ['gpu', 'cpu']; + +export function openDownloadSelectionDialog({ + app, + loadOptions, + translate, +}: DownloadSelectionDialogOptions): Promise { + return new Promise((resolve) => { + const host = document.body; + const selectionView = document.createElement('div'); + selectionView.className = 'download-selection-view'; + selectionView.setAttribute('role', 'dialog'); + selectionView.setAttribute('aria-modal', 'false'); + selectionView.setAttribute( + 'aria-label', + translate('ui.download.select_package', 'Select package'), + ); + selectionView.tabIndex = -1; + + let options: ReleaseDownloadOptions | null = null; + let selectedVersion: ReleaseDownloadVersion | null = null; + let selectedTarget: Exclude = 'gpu'; + let loading = true; + let errorMessage: string | null = null; + let resolved = false; + + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Escape') { + event.preventDefault(); + close(null); + } + }; + + const close = (value: ReleaseDownloadSelection | null): void => { + if (resolved) return; + resolved = true; + document.removeEventListener('keydown', onKeyDown); + document.body.classList.remove('download-selection-open'); + selectionView.remove(); + resolve(value); + }; + + const renderVariantSummary = (): string => { + if (selectedVersion === null) return ''; + const variant = getVariant(selectedVersion, selectedTarget); + if (variant === null) return ''; + const assetList = variant.assets + .map((asset) => `
  • ${escapeHtml(asset)}
  • `) + .join(''); + return ` +
    +
    + ${escapeHtml(formatBytes(variant.total_size))} + ${escapeHtml(formatDate(selectedVersion.published_at, translate))} +
    +
      ${assetList}
    +
    + `; + }; + + const renderBody = (): string => { + if (loading) { + return ` +
    + + ${escapeHtml(translate('ui.download.loading_versions', 'Loading versions...'))} +
    + `; + } + + if (errorMessage !== null || options === null || selectedVersion === null) { + return ` +
    + ${escapeHtml(errorMessage ?? translate('ui.download.no_release_options', 'No compatible release packages found'))} +
    + `; + } + + const activeVersion = selectedVersion; + return ` +
    + ${TARGETS.map((target) => renderTargetButton(target, selectedTarget, activeVersion, translate)).join('')} +
    +
    +
    + ${escapeHtml(translate('ui.download.version', 'Version'))} + ${escapeHtml(formatDate(activeVersion.published_at, translate))} +
    +
    + ${options.versions + .map((version, index) => + renderVersionButton(version, activeVersion, index, translate), + ) + .join('')} +
    +
    + ${renderVariantSummary()} + `; + }; + + const render = (): void => { + selectionView.innerHTML = ` +
    +
    +
    ${escapeHtml(app.icon ?? '')}
    +
    +

    ${escapeHtml(app.name ?? app.id)}

    +

    ${escapeHtml(translate('ui.download.package_subtitle', 'Choose package and version'))}

    +
    +
    + ${renderBody()} +
    + + +
    +
    + `; + + bindEvents(); + keepSelectedVersionVisible(); + }; + + const bindEvents = (): void => { + selectionView.onclick = (event) => { + if (event.target === selectionView) { + close(null); + } + }; + + selectionView + .querySelector('.download-selection-cancel') + ?.addEventListener('click', () => close(null)); + + selectionView + .querySelectorAll('[data-download-version]') + .forEach((button) => { + button.addEventListener('click', () => { + const tag = button.dataset['downloadVersion']; + if (tag === undefined || options === null) return; + selectedVersion = + options.versions.find((version) => version.tag_name === tag) ?? + selectedVersion; + if (selectedVersion === null) return; + selectedTarget = normalizeTarget(selectedTarget, selectedVersion); + render(); + }); + }); + + selectionView + .querySelectorAll('[data-download-target]') + .forEach((button) => { + button.addEventListener('click', () => { + const target = button.dataset['downloadTarget']; + if (selectedVersion === null) return; + if (target !== 'gpu' && target !== 'cpu') return; + if (getVariant(selectedVersion, target) === null) return; + selectedTarget = target; + render(); + }); + }); + + selectionView.querySelector('form')?.addEventListener('submit', (event) => { + event.preventDefault(); + if (selectedVersion === null || loading) return; + close({ + tag_name: selectedVersion.tag_name, + compute_target: selectedTarget, + }); + }); + }; + + render(); + host.appendChild(selectionView); + document.body.classList.add('download-selection-open'); + document.addEventListener('keydown', onKeyDown); + selectionView.focus({ preventScroll: true }); + + void loadOptions() + .then((loadedOptions) => { + if (resolved) return; + options = loadedOptions; + const firstVersion = options?.versions[0]; + if (options === null || firstVersion === undefined) { + errorMessage = translate( + 'ui.download.no_release_options', + 'No compatible release packages found', + ); + return; + } + selectedVersion = firstVersion; + selectedTarget = normalizeTarget(selectedVersion.recommended, selectedVersion); + }) + .catch(() => { + errorMessage = translate( + 'ui.download.load_versions_error', + 'Failed to load release versions', + ); + }) + .finally(() => { + if (resolved) return; + loading = false; + render(); + }); + }); +} + +function keepSelectedVersionVisible(): void { + requestAnimationFrame(() => { + document + .querySelector('.download-selection-version-item.selected') + ?.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + }); +} + +function renderTargetButton( + target: Exclude, + selectedTarget: ReleaseComputeTarget, + selectedVersion: ReleaseDownloadVersion, + translate: TranslateFn, +): string { + const variant = getVariant(selectedVersion, target); + const selected = selectedTarget === target; + const disabled = variant === null; + const label = + target === 'gpu' + ? translate('ui.download.gpu_package', 'GPU') + : translate('ui.download.cpu_package', 'CPU'); + const meta = + variant === null + ? translate('ui.download.unavailable', 'Unavailable') + : formatBytes(variant.total_size); + + return ` + + `; +} + +function renderVersionButton( + version: ReleaseDownloadVersion, + selectedVersion: ReleaseDownloadVersion, + index: number, + translate: TranslateFn, +): string { + const latest = index === 0 ? ` ${translate('ui.download.latest_suffix', '(latest)')}` : ''; + const date = formatDate(version.published_at, translate); + const selected = version.tag_name === selectedVersion.tag_name; + return ` + + `; +} + +function normalizeTarget( + target: ReleaseComputeTarget, + version: ReleaseDownloadVersion, +): Exclude { + if ( + (target === 'gpu' || target === 'auto') && + version.gpu !== null && + version.gpu !== undefined + ) { + return 'gpu'; + } + if (target === 'cpu' && version.cpu !== null && version.cpu !== undefined) { + return 'cpu'; + } + return version.cpu !== null && version.cpu !== undefined ? 'cpu' : 'gpu'; +} + +function getVariant( + version: ReleaseDownloadVersion, + target: ReleaseComputeTarget, +): ReleaseDownloadVariant | null { + if (target === 'cpu') return version.cpu ?? null; + if (target === 'gpu') return version.gpu ?? null; + return version.gpu ?? version.cpu ?? null; +} + +function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return '0 MB'; + const units = ['B', 'KB', 'MB', 'GB']; + let value = bytes; + let unitIndex = 0; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + return `${value >= 10 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`; +} + +function formatDate(value: string | null | undefined, translate: TranslateFn): string { + if (value === null || value === undefined || value === '') { + return translate('ui.download.date_unknown', 'date unknown'); + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + return new Intl.DateTimeFormat(undefined, { + year: 'numeric', + month: 'short', + day: '2-digit', + }).format(date); +} + +function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} diff --git a/src/shared/types/bindings.ts b/src/shared/types/bindings.ts index 9cc533bc..da84d8b3 100644 --- a/src/shared/types/bindings.ts +++ b/src/shared/types/bindings.ts @@ -46,7 +46,14 @@ export const commands = { // Adds multiple log entries in batch from frontend logBatch: (logs: BatchLogEntry[]) => typedError(__TAURI_INVOKE("log_batch", { logs })), // Downloads and verifies a module from a Git repository - downloadModule: (moduleId: string, repoUrl: string, expectedHash: string | null, dlType: string | null) => typedError(__TAURI_INVOKE("download_module", { moduleId, repoUrl, expectedHash, dlType })), + downloadModule: (moduleId: string, repoUrl: string, expectedHash: string | null, dlType: string | null, releaseSelection: { + // GitHub release tag to download. `None` means the newest compatible release. + tag_name: string | null, + // Compute target selected by the user. + compute_target?: ReleaseComputeTarget, +} | null) => typedError(__TAURI_INVOKE("download_module", { moduleId, repoUrl, expectedHash, dlType, releaseSelection })), + // Lists compatible release versions and CPU/GPU package choices for a module. + getReleaseDownloadOptions: (moduleId: string, repoUrl: string) => typedError(__TAURI_INVOKE("get_release_download_options", { moduleId, repoUrl })), // Resumes a paused module download using backend-owned request metadata. resumeDownload: (moduleId: string) => typedError(__TAURI_INVOKE("resume_download", { moduleId })), // Checks if a module is already installed locally @@ -1094,6 +1101,55 @@ export type RamStats = { availableGb: number, }; +// User-facing compute target for release package selection. +export type ReleaseComputeTarget = +// Let Axelate choose the best compatible package for this machine. +"auto" | +// Prefer a GPU package, for example CUDA, Vulkan, HIP, or SYCL. +"gpu" | +// Prefer a CPU package. +"cpu"; + +// User-visible release download options for a single module. +export type ReleaseDownloadOptions = { + // Module identifier these options belong to. + module_id: string, + // GitHub release versions in newest-first order. + versions: ReleaseDownloadVersion[], +}; + +// Explicit release package selection passed from the frontend. +export type ReleaseDownloadSelection = { + // GitHub release tag to download. `None` means the newest compatible release. + tag_name: string | null, + // Compute target selected by the user. + compute_target?: ReleaseComputeTarget, +}; + +// User-visible package variant for one compute target. +export type ReleaseDownloadVariant = { + // Compute target represented by this variant. + compute_target: ReleaseComputeTarget, + // Asset filenames that will be downloaded. + assets: string[], + // Combined download size in bytes. + total_size: number, +}; + +// User-visible package choices for a GitHub release version. +export type ReleaseDownloadVersion = { + // GitHub release tag. + tag_name: string, + // GitHub release publish timestamp when available. + published_at: string | null, + // CPU package choice for this release, when compatible. + cpu: ReleaseDownloadVariant | null, + // GPU package choice for this release, when compatible. + gpu: ReleaseDownloadVariant | null, + // Recommended package target for this machine. + recommended: ReleaseComputeTarget, +}; + // Result of saving a generated chat image to disk. export type SavedChatImage = { // Absolute path to the saved image file. diff --git a/src/shared/types/coreTypes.ts b/src/shared/types/coreTypes.ts index 8f0f756c..31a48529 100644 --- a/src/shared/types/coreTypes.ts +++ b/src/shared/types/coreTypes.ts @@ -124,6 +124,32 @@ export interface IModuleDownloadState { error?: unknown; } +export type ReleaseComputeTarget = 'auto' | 'gpu' | 'cpu'; + +export interface ReleaseDownloadSelection { + tag_name: string | null; + compute_target: ReleaseComputeTarget; +} + +export interface ReleaseDownloadVariant { + compute_target: ReleaseComputeTarget; + assets: string[]; + total_size: number; +} + +export interface ReleaseDownloadVersion { + tag_name: string; + published_at?: string | null; + cpu?: ReleaseDownloadVariant | null; + gpu?: ReleaseDownloadVariant | null; + recommended: ReleaseComputeTarget; +} + +export interface ReleaseDownloadOptions { + module_id: string; + versions: ReleaseDownloadVersion[]; +} + /** * Unified application bootstrap data from backend. */ diff --git a/src/styles/features/ai-module-settings.css b/src/styles/features/ai-module-settings.css index 13fb857b..457b7cff 100644 --- a/src/styles/features/ai-module-settings.css +++ b/src/styles/features/ai-module-settings.css @@ -483,6 +483,7 @@ flex-direction: column; align-items: center; justify-content: center; + color: var(--text-primary); } .thinking-option-card:hover { @@ -507,6 +508,7 @@ font-size: 0.95rem; margin-bottom: 0.1rem; pointer-events: none; + color: inherit; } /* === API KEY SECTION (AXELATE PREMIUM V9 - NO DOUBLE BORDER) === */ @@ -723,14 +725,26 @@ /* === LOCAL ENGINE SETTINGS === */ +#module-settings-modal .module-settings-content:has(.local-engine-config) { + padding: 0.65rem 0.85rem 1rem; +} + .local-engine-config { - gap: 0.85rem; + width: min(100%, 920px); + margin: 0 auto; + gap: 0.55rem; +} + +.local-engine-config .ai-content-panel { + padding: 0.72rem 0.85rem; + border-radius: 14px; + border-color: rgba(255, 255, 255, 0.03); } .local-engine-layout { display: flex; flex-direction: column; - gap: 0.85rem; + gap: 0.55rem; } .local-engine-section { @@ -744,11 +758,11 @@ align-items: center; text-align: center; gap: 0; - margin-bottom: 0.7rem; + margin-bottom: 0.42rem; } .local-engine-section-header h3 { - font-size: 1.05rem; + font-size: 0.98rem; line-height: 1.15; letter-spacing: 0; width: 100%; @@ -807,7 +821,7 @@ .local-engine-field-stack, .local-engine-field-grid { display: grid; - gap: 1rem; + gap: 0.58rem; } .local-engine-field-stack--tight { @@ -826,7 +840,7 @@ .local-engine-field-stack--tight .local-engine-field-label { width: 100%; text-align: center; - margin-bottom: 0.3rem; + margin-bottom: 0.14rem; color: var(--text-primary); } @@ -834,10 +848,15 @@ justify-content: center; } +.local-engine-field-row--compute-mode .local-engine-label-row, +.local-engine-field-row--compute-mode .local-engine-field-hint { + display: none; +} + .local-engine-field-row { display: flex; flex-direction: column; - gap: 0.3rem; + gap: 0.18rem; min-width: 0; } @@ -857,9 +876,22 @@ gap: 0; } +.local-engine-compute-toggle { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.55rem; + width: 100%; +} + +.local-engine-compute-option { + min-height: 44px; + padding: 0.5rem 0.7rem; + border-radius: 12px; +} + .local-engine-field-label { color: var(--text-secondary); - font-size: 0.92rem; + font-size: 0.84rem; font-weight: 600; } @@ -1212,14 +1244,14 @@ flex: 1; min-width: 0; border-radius: 12px; - padding: 0.72rem 0.9rem; + padding: 0.58rem 0.78rem; border: none !important; background: transparent !important; color: var(--text-primary); transition: all 0.2s ease; outline: none !important; box-sizing: border-box; - font-size: 0.98rem; + font-size: 0.88rem; font-family: var(--app-font-family); } @@ -1641,6 +1673,15 @@ border-radius: 0; } +.local-engine-field-row--compute-mode .local-engine-input-row, +.local-engine-input-row:has(.local-engine-compute-toggle) { + padding: 0; + gap: 0; + background: transparent; + border: none; + border-radius: 0; +} + .local-engine-section--context .local-engine-input { min-height: 58px; text-align: center; @@ -1807,7 +1848,7 @@ /* Local image engines reuse the same visual system as API provider settings. */ .local-engine-section--core .local-engine-field-stack--tight { - gap: 0.85rem; + gap: 0.58rem; } .local-engine-field-row--model-path .local-engine-input-row, @@ -1815,10 +1856,10 @@ display: flex; align-items: center; width: 100%; - min-height: 58px; - gap: 0.5rem; - padding: 0 6px 0 14px; - border-radius: 16px; + min-height: 48px; + gap: 0.42rem; + padding: 0 5px 0 11px; + border-radius: 13px; border: 1px solid rgba(255, 255, 255, 0.04); background: rgba(255, 255, 255, 0.022); box-shadow: none; @@ -1842,17 +1883,46 @@ .local-engine-field-row--model-path .local-engine-input, .local-engine-field-row--extra-args.full-width .local-engine-extra-args-input { - min-height: 58px; - padding: 0 0.55rem; + min-height: 46px; + padding: 0 0.45rem; border: none !important; background: transparent !important; } +.local-engine-section--core .local-engine-field-row--context-size .local-engine-input-row, +.local-engine-section--core + .local-engine-field-row--llamacpp-system-prompt + .local-engine-input-row { + padding: 0; + background: transparent; + border: none; + border-radius: 0; +} + +.local-engine-section--core .local-engine-field-row--context-size .local-engine-input { + min-height: 42px; + padding: 0.48rem 0.78rem; + text-align: center; + background: rgba(255, 255, 255, 0.022) !important; + border: 1px solid rgba(255, 255, 255, 0.038) !important; + border-radius: 14px; +} + +.local-engine-section--core + .local-engine-field-row--llamacpp-system-prompt + .local-engine-input--textarea { + min-height: 72px; + padding: 0.72rem 0.85rem; + background: rgba(255, 255, 255, 0.022) !important; + border: 1px solid rgba(255, 255, 255, 0.038) !important; + border-radius: 14px; +} + .local-engine-field-row--model-path .local-engine-browse-btn { width: auto; - min-width: 140px; - min-height: 42px; - height: 42px; + min-width: 118px; + min-height: 36px; + height: 36px; border-radius: var(--module-button-radius); border: 1px solid var(--premium-purple-border) !important; background: var(--premium-purple-bg) !important; @@ -1942,9 +2012,33 @@ .local-engine-profile-grid { grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 0.85rem; + gap: 0.75rem; } .local-engine-profile-card { - min-height: 112px; + min-height: 108px; + justify-content: center; +} + +.local-engine-profile-card .ai-model-card-copy { + flex: 0; + justify-content: center; +} + +.local-engine-profile-card .model-name { + margin-bottom: 0; + overflow-wrap: anywhere; +} + +.local-engine-profile-card .model-desc { + overflow-wrap: anywhere; +} + +.local-engine-profile-card .ai-model-card-action { + position: absolute; + left: 0.85rem; + right: 0.85rem; + bottom: 0.72rem; + width: auto; + margin: 0; } diff --git a/src/styles/features/module-selection-modal.css b/src/styles/features/module-selection-modal.css index 71bcf48a..7ff881d3 100644 --- a/src/styles/features/module-selection-modal.css +++ b/src/styles/features/module-selection-modal.css @@ -815,3 +815,310 @@ body.snapping .modal-backdrop { box-shadow: none !important; transform: none !important; } + +body.download-selection-open #main-area { + visibility: hidden !important; + opacity: 0 !important; + pointer-events: none !important; +} + +.download-selection-view { + position: fixed; + top: var(--header-height, 60px); + right: 0; + bottom: 0; + left: var(--sidebar-width, 280px); + z-index: 1700; + display: flex; + align-items: center; + justify-content: center; + padding: 1.25rem; + background: transparent; + color: var(--text-primary); + pointer-events: auto; +} + +.download-selection-view:focus, +.download-selection-view:focus-visible { + outline: none; +} + +.download-selection-panel { + width: min(500px, calc(100% - 32px)); + max-height: min(680px, calc(100% - 24px)); + display: flex; + flex-direction: column; + gap: 0.85rem; + padding: 1rem; + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 16px; + background: rgb(18, 17, 24); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.06), + 0 20px 54px rgba(0, 0, 0, 0.42); + overflow-y: auto; + pointer-events: auto; +} + +.download-selection-header { + display: grid; + grid-template-columns: 54px minmax(0, 1fr); + gap: 0.85rem; + align-items: center; +} + +.download-selection-icon { + display: flex; + align-items: center; + justify-content: center; + width: 54px; + height: 54px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.04); + font-size: 1.55rem; +} + +.download-selection-header h3, +.download-selection-header p { + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.download-selection-header h3 { + font-size: 1rem; + line-height: 1.2; +} + +.download-selection-header p { + margin-top: 0.25rem; + color: var(--text-secondary); + font-size: 0.78rem; +} + +.download-selection-targets { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.55rem; +} + +.download-selection-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 0.7rem; + min-height: 252px; + border: 1px solid rgba(255, 255, 255, 0.04); + border-radius: 14px; + background: rgb(25, 24, 32); + color: var(--text-secondary); + font-size: 0.82rem; + text-align: center; +} + +.download-selection-loading--error { + color: var(--danger, #c22131); +} + +.download-selection-spinner { + width: 20px; + height: 20px; + border: 2px solid rgba(255, 255, 255, 0.12); + border-top-color: var(--primary-light); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +.download-selection-target { + display: flex; + min-width: 0; + min-height: 68px; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.28rem; + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 14px; + background: rgb(29, 27, 36); + color: var(--text-primary); + font-family: var(--app-font-family); + cursor: pointer; +} + +.download-selection-target.selected { + border-color: var(--premium-purple-border); + background: var(--premium-purple-bg); + color: #ffffff; +} + +.download-selection-target:disabled { + cursor: default; + opacity: 0.42; +} + +.download-selection-target:focus, +.download-selection-target:focus-visible, +.download-selection-version-item:focus, +.download-selection-version-item:focus-visible, +.download-selection-actions button:focus, +.download-selection-actions button:focus-visible { + outline: none; + box-shadow: none; +} + +.download-selection-target span { + font-size: 0.95rem; + font-weight: 700; +} + +.download-selection-target small { + color: inherit; + opacity: 0.72; + font-size: 0.72rem; +} + +.download-selection-version { + display: grid; + gap: 0.42rem; + color: var(--text-secondary); + font-size: 0.78rem; + font-weight: 700; +} + +.download-selection-version-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.download-selection-version-header small { + min-width: 0; + overflow: hidden; + color: var(--text-muted); + font-size: 0.72rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.download-selection-version-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.38rem; + max-height: 204px; + overflow-y: auto; + padding: 0.4rem; + scroll-padding: 0.4rem; + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 12px; + background: rgb(25, 24, 32); +} + +.download-selection-version-item { + display: grid; + min-width: 0; + min-height: 48px; + gap: 0.18rem; + padding: 0.5rem 0.6rem; + border: 1px solid transparent; + border-radius: 10px; + background: rgb(31, 29, 39); + color: var(--text-primary); + font-family: var(--app-font-family); + text-align: left; + cursor: pointer; +} + +.download-selection-version-item:hover { + background: rgba(255, 255, 255, 0.055); +} + +.download-selection-version-item.selected { + border-color: rgba(var(--primary-raw), 0.52); + background: rgba(var(--primary-raw), 0.22); + box-shadow: inset 0 0 0 1px rgba(var(--primary-raw), 0.16); +} + +.download-selection-version-item span, +.download-selection-version-item small { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.download-selection-version-item span { + font-size: 0.78rem; +} + +.download-selection-version-item small { + color: var(--text-secondary); + font-size: 0.68rem; +} + +.download-selection-summary { + display: grid; + gap: 0.55rem; + min-height: 96px; + padding: 0.75rem; + border: 1px solid rgba(255, 255, 255, 0.04); + border-radius: 14px; + background: rgb(25, 24, 32); +} + +.download-selection-summary-meta { + display: flex; + justify-content: space-between; + gap: 0.75rem; + color: var(--text-secondary); + font-size: 0.74rem; +} + +.download-selection-summary ul { + display: grid; + gap: 0.3rem; + max-height: 108px; + overflow-y: auto; + padding: 0; + margin: 0; + list-style: none; +} + +.download-selection-summary li { + overflow: hidden; + color: var(--text-primary); + font-size: 0.74rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.download-selection-actions { + display: grid; + grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr); + gap: 0.55rem; +} + +.download-selection-actions button { + min-height: 46px; + border-radius: var(--module-button-radius); + color: var(--module-button-text); + font-family: var(--app-font-family); + font-weight: 700; + cursor: pointer; +} + +.download-selection-actions button:disabled { + cursor: default; + opacity: 0.45; +} + +.download-selection-cancel { + border: 1px solid rgba(255, 255, 255, 0.05); + background: rgba(255, 255, 255, 0.04); +} + +.download-selection-confirm { + border: 1px solid var(--premium-purple-border); + background: var(--premium-purple-bg); +} From d7f9b8a13d8fcf09b62d1fcf7e7543da61230cd6 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 30 Apr 2026 01:23:52 +0300 Subject: [PATCH 025/126] fix: address review follow-ups --- src-tauri/src/domain/ai/image_service.rs | 38 ++-- src-tauri/src/domain/ai/streaming.rs | 17 +- src-tauri/src/domain/engine/engine_args.rs | 81 ++++++--- src-tauri/src/domain/engine/manager.rs | 162 ++++++++++++++---- src-tauri/src/domain/integration_api.rs | 3 +- .../domain/modules/controller/lifecycle.rs | 75 ++++++-- .../domain/modules/settings_ui_protocol.rs | 2 +- src/app/CoreComposition.test.ts | 13 +- src/app/CoreLifecycleController.ts | 13 +- src/features/ai/services/AIBridge.test.ts | 1 + src/features/ai/services/AIChatTransport.ts | 26 ++- .../chat/controllers/ChatSendController.ts | 6 +- src/scripts/check-size.js | 2 +- 13 files changed, 329 insertions(+), 110 deletions(-) diff --git a/src-tauri/src/domain/ai/image_service.rs b/src-tauri/src/domain/ai/image_service.rs index c045cfe2..c9e8b757 100644 --- a/src-tauri/src/domain/ai/image_service.rs +++ b/src-tauri/src/domain/ai/image_service.rs @@ -57,25 +57,27 @@ async fn process_image_request_with_local_engine_access( local_engine_access: LocalEngineAccess, ) -> Result { let request = apply_image_request_defaults(request, settings_service).await?; - let _local_workload_guard = if is_cloud_image_provider(&request.provider) { - None - } else { - Some(engine_manager.acquire_local_workload().await) - }; + let images = { + let _local_workload_guard = if is_cloud_image_provider(&request.provider) { + None + } else { + Some(engine_manager.acquire_local_workload().await) + }; - let images = if request.provider == "comfyui" { - stop_conflicting_local_engine(engine_manager, Capability::Image).await?; - process_comfyui_request(&request, image_generation_state, settings_service).await? - } else if is_cloud_image_provider(&request.provider) { - process_cloud_image_request(&request).await? - } else { - process_local_image_request( - &request, - engine_manager, - image_generation_state, - local_engine_access, - ) - .await? + if request.provider == "comfyui" { + stop_conflicting_local_engine(engine_manager, Capability::Image).await?; + process_comfyui_request(&request, image_generation_state, settings_service).await? + } else if is_cloud_image_provider(&request.provider) { + process_cloud_image_request(&request).await? + } else { + process_local_image_request( + &request, + engine_manager, + image_generation_state, + local_engine_access, + ) + .await? + } }; if let Some(session_id) = request.session_id.as_deref() diff --git a/src-tauri/src/domain/ai/streaming.rs b/src-tauri/src/domain/ai/streaming.rs index a140be51..9cebccc0 100644 --- a/src-tauri/src/domain/ai/streaming.rs +++ b/src-tauri/src/domain/ai/streaming.rs @@ -388,7 +388,13 @@ impl AiProvider for OpenAiCompatibleProvider { } } - if !saw_done && !state.saw_terminal_chunk && state.full_content.trim().is_empty() { + if !saw_done && !state.saw_terminal_chunk { + tracing::warn!( + request_id = %request_id, + message_id = %message_id, + chunks = state.chunks_emitted, + "AI stream ended before a completion marker was received" + ); return Ok(ChatResponse { id: message_id, ok: false, @@ -400,15 +406,6 @@ impl AiProvider for OpenAiCompatibleProvider { }); } - if !saw_done && !state.saw_terminal_chunk { - tracing::warn!( - request_id = %request_id, - message_id = %message_id, - chunks = state.chunks_emitted, - "AI stream ended without completion marker after emitting content" - ); - } - // Final event sink.emit(StreamEvent::Done { message_id: message_id.clone(), diff --git a/src-tauri/src/domain/engine/engine_args.rs b/src-tauri/src/domain/engine/engine_args.rs index 170325c4..8ade2a3f 100644 --- a/src-tauri/src/domain/engine/engine_args.rs +++ b/src-tauri/src/domain/engine/engine_args.rs @@ -10,6 +10,7 @@ const SDCPP_UNSUPPORTED_FLAGS: [&str; 4] = [ ]; const SDCPP_SERVER_UNSUPPORTED_FLAGS: [&str; 3] = ["--preview", "--preview-path", "--preview-interval"]; +const SDCPP_LAUNCHER_ONLY_PREVIEW_FLAG: &str = "--sdcpp-preview"; fn push_llamacpp_compute_args(args: &mut Vec, config: &EngineConfig) { match config.compute_mode { @@ -27,35 +28,71 @@ fn push_llamacpp_compute_args(args: &mut Vec, config: &EngineConfig) { } fn sdcpp_extra_args(config: &EngineConfig) -> Vec { - config - .extra_args + let unsupported_flags = SDCPP_UNSUPPORTED_FLAGS .iter() - .filter(|arg| { - !SDCPP_UNSUPPORTED_FLAGS.iter().any(|flag| { - arg.as_str() == *flag - || arg - .strip_prefix(flag) - .is_some_and(|suffix| suffix.starts_with('=')) - }) && !SDCPP_SERVER_UNSUPPORTED_FLAGS.iter().any(|flag| { - arg.as_str() == *flag - || arg - .strip_prefix(flag) - .is_some_and(|suffix| suffix.starts_with('=')) - }) - }) - .cloned() - .collect() + .chain(SDCPP_SERVER_UNSUPPORTED_FLAGS.iter()) + .copied() + .chain(std::iter::once(SDCPP_LAUNCHER_ONLY_PREVIEW_FLAG)) + .collect::>(); + let mut filtered = Vec::new(); + let mut index = 0; + + while let Some(arg) = config.extra_args.get(index) { + let should_skip = unsupported_flags.iter().any(|flag| { + arg.as_str() == *flag + || arg + .strip_prefix(flag) + .is_some_and(|suffix| suffix.starts_with('=')) + }); + + if should_skip { + let skip_value = unsupported_flags.contains(&arg.as_str()) + && config + .extra_args + .get(index + 1) + .is_some_and(|next| !next.starts_with('-')); + index += if skip_value { 2 } else { 1 }; + continue; + } + + filtered.push(arg.clone()); + index += 1; + } + + filtered } /// Resolves the explicit stable-diffusion.cpp preview output path from extra arguments. -pub const fn resolve_sdcpp_preview_path(extra_args: &[String]) -> Option { - let _ = extra_args; +pub fn resolve_sdcpp_preview_path(extra_args: &[String]) -> Option { + let mut index = 0; + while let Some(arg) = extra_args.get(index) { + if let Some(path) = arg + .strip_prefix(SDCPP_LAUNCHER_ONLY_PREVIEW_FLAG) + .and_then(|suffix| suffix.strip_prefix('=')) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return Some(PathBuf::from(path)); + } + + if arg == SDCPP_LAUNCHER_ONLY_PREVIEW_FLAG + && let Some(path) = extra_args + .get(index + 1) + .map(String::as_str) + .map(str::trim) + .filter(|value| !value.is_empty() && !value.starts_with('-')) + { + return Some(PathBuf::from(path)); + } + + index += 1; + } + None } -pub(super) const fn sdcpp_preview_enabled(extra_args: &[String]) -> bool { - let _ = extra_args; - false +pub(super) fn sdcpp_preview_enabled(extra_args: &[String]) -> bool { + resolve_sdcpp_preview_path(extra_args).is_some() } pub(super) fn build_sdcpp_args(config: &EngineConfig, port: u16) -> Vec { diff --git a/src-tauri/src/domain/engine/manager.rs b/src-tauri/src/domain/engine/manager.rs index 48a85c62..d245f219 100644 --- a/src-tauri/src/domain/engine/manager.rs +++ b/src-tauri/src/domain/engine/manager.rs @@ -240,9 +240,19 @@ impl EngineManager { engine = %config.engine_id, slot = ?primary_cap, error = %error, - "Dropping stale engine slot because process status check failed" + "Engine process status check failed; attempting to stop it before dropping slot" ); - slots.remove(&primary_cap); + let stale = slots.remove(&primary_cap); + drop(slots); + if let Some(stale) = stale { + match Self::kill_engine_retaining_on_failure(stale).await { + Ok(()) => {} + Err((error, stale)) => { + self.slots.lock().await.insert(primary_cap, stale); + return Err(error); + } + } + } } Ok(None) => { let status = EngineStatus { @@ -516,14 +526,30 @@ impl EngineManager { if let Some(engine_id) = engine_id { self.emitter.emit_error(&engine_id, message); - } + let _lifecycle_guard = self.lifecycle_lock.lock().await; + let engine = { + let mut slots = self.slots.lock().await; + match slots.get(&capability) { + Some(current) if current.definition.id == engine_id => { + slots.remove(&capability) + } + _ => None, + } + }; - if let Err(error) = self.stop_slot(capability).await { - warn!( - slot = ?capability, - error = %error, - "Failed to stop engine slot after runtime error" - ); + if let Some(engine) = engine { + match Self::kill_engine_retaining_on_failure(engine).await { + Ok(()) => {} + Err((error, engine)) => { + self.slots.lock().await.insert(capability, engine); + warn!( + slot = ?capability, + error = %error, + "Failed to stop engine slot after runtime error" + ); + } + } + } } } @@ -560,31 +586,44 @@ impl EngineManager { } /// Kill an engine process and wait for exit - async fn kill_engine(mut engine: RunningEngine) -> Result<(), AppError> { + async fn kill_engine(engine: RunningEngine) -> Result<(), AppError> { + Self::kill_engine_retaining_on_failure(engine) + .await + .map_err(|(error, _engine)| error) + } + + async fn kill_engine_retaining_on_failure( + mut engine: RunningEngine, + ) -> Result<(), (AppError, RunningEngine)> { if let Err(e) = engine.process.kill().await { error!(engine = %engine.definition.id, error = %e, "Failed to kill engine process"); - return Err(AppError::Internal { - request_id: None, - message: format!("Failed to kill engine '{}': {e}", engine.definition.id), - }); + return Err(( + AppError::Internal { + request_id: None, + message: format!("Failed to kill engine '{}': {e}", engine.definition.id), + }, + engine, + )); + } + if let Err(error) = engine.process.wait().await { + return Err(( + AppError::Internal { + request_id: None, + message: format!( + "Failed to wait for engine '{}' after kill: {error}", + engine.definition.id + ), + }, + engine, + )); } - engine - .process - .wait() - .await - .map_err(|error| AppError::Internal { - request_id: None, - message: format!( - "Failed to wait for engine '{}' after kill: {error}", - engine.definition.id - ), - })?; info!(engine = %engine.definition.id, "Engine stopped"); Ok(()) } async fn prune_dead_slots(&self) { - let mut dead = Vec::new(); + let mut exited = Vec::new(); + let mut errored = Vec::new(); { let mut slots = self.slots.lock().await; for (capability, engine) in slots.iter_mut() { @@ -596,27 +635,46 @@ impl EngineManager { exit_status = %status, "Pruning dead engine slot" ); - dead.push((*capability, engine.definition.id.clone())); + exited.push((*capability, engine.definition.id.clone())); } Err(error) => { warn!( engine = %engine.definition.id, slot = ?capability, error = %error, - "Pruning engine slot after process status check failed" + "Engine process status check failed; attempting to stop before pruning" ); - dead.push((*capability, engine.definition.id.clone())); + errored.push(*capability); } Ok(None) => {} } } - for (capability, _) in &dead { + for (capability, _) in &exited { slots.remove(capability); } } - for (_, engine_id) in dead { + for capability in errored { + let engine = self.slots.lock().await.remove(&capability); + let Some(engine) = engine else { + continue; + }; + let engine_id = engine.definition.id.clone(); + match Self::kill_engine_retaining_on_failure(engine).await { + Ok(()) => exited.push((capability, engine_id)), + Err((error, engine)) => { + warn!( + slot = ?capability, + error = %error, + "Keeping engine slot because forced stop after status error failed" + ); + self.slots.lock().await.insert(capability, engine); + } + } + } + + for (_, engine_id) in exited { self.emitter .emit_error(&engine_id, "Local engine process exited."); } @@ -656,6 +714,7 @@ mod tests { use crate::domain::engine::types::EngineComputeMode; use crate::domain::system::ports::ENGINE_LOCAL_PORT_RANGE; use std::net::TcpListener; + use std::path::PathBuf; fn sample_config(model_path: Option<&str>) -> EngineConfig { EngineConfig { @@ -835,4 +894,45 @@ mod tests { assert!(!sdcpp_preview_enabled(&config.extra_args)); assert!(resolve_sdcpp_preview_path(&config.extra_args).is_none()); } + + #[test] + fn sdcpp_filters_unsupported_flags_with_separate_values() { + let mut config = sample_sdcpp_config(Some("C:/models/sd15.safetensors")); + config.extra_args = vec![ + "--vae-on-gpu".to_string(), + "1".to_string(), + "--mmap".to_string(), + "--preview-path".to_string(), + "C:/tmp/preview.png".to_string(), + ]; + + let args = build_sdcpp_args(&config, 8082); + + assert!(!args.contains(&"--vae-on-gpu".to_string())); + assert!(!args.contains(&"1".to_string())); + assert!(!args.contains(&"--preview-path".to_string())); + assert!(!args.contains(&"C:/tmp/preview.png".to_string())); + assert!(args.contains(&"--mmap".to_string())); + } + + #[test] + fn sdcpp_resolves_launcher_preview_path_without_passing_it_to_server() { + let mut config = sample_sdcpp_config(Some("C:/models/sd15.safetensors")); + config.extra_args = vec![ + "--sdcpp-preview".to_string(), + "C:/tmp/sdcpp-preview.png".to_string(), + "--mmap".to_string(), + ]; + + let args = build_sdcpp_args(&config, 8082); + + assert_eq!( + resolve_sdcpp_preview_path(&config.extra_args), + Some(PathBuf::from("C:/tmp/sdcpp-preview.png")) + ); + assert!(sdcpp_preview_enabled(&config.extra_args)); + assert!(!args.contains(&"--sdcpp-preview".to_string())); + assert!(!args.contains(&"C:/tmp/sdcpp-preview.png".to_string())); + assert!(args.contains(&"--mmap".to_string())); + } } diff --git a/src-tauri/src/domain/integration_api.rs b/src-tauri/src/domain/integration_api.rs index fabb0e2c..b709350e 100644 --- a/src-tauri/src/domain/integration_api.rs +++ b/src-tauri/src/domain/integration_api.rs @@ -461,6 +461,7 @@ async fn route_authorized_request( handle_module_stage_request(request, &context, module_id) } ("POST", ["v1", "modules", module_id, action]) => { + crate::domain::modules::downloader::validate_module_id(module_id)?; let action = parse_module_action(action)?; let response = module_controller::control(context.app, module_id, action).await?; Ok(json_response( @@ -646,7 +647,7 @@ async fn handle_image_request( prompt: payload.prompt.clone(), original_prompt: Some(payload.prompt), model: model.clone(), - settings_key: payload.settings_key.or_else(|| Some(provider.clone())), + settings_key: payload.settings_key.or_else(|| Some(ui_provider.clone())), session_id, steps: payload.steps, cfg_scale: payload.cfg_scale, diff --git a/src-tauri/src/domain/modules/controller/lifecycle.rs b/src-tauri/src/domain/modules/controller/lifecycle.rs index d8f9a155..87457d0f 100644 --- a/src-tauri/src/domain/modules/controller/lifecycle.rs +++ b/src-tauri/src/domain/modules/controller/lifecycle.rs @@ -154,11 +154,15 @@ impl<'a> LifecycleExecutor<'a> { request_id: None, message: format!("Spawned process for {} has no PID", self.module_id), })?; - if let Err(error) = self.persist_pid(pid as usize) { - let _ = child.kill().await; - return Err(error); - } - + self.persist_or_kill_spawned_child(&mut child, pid as usize) + .await + .inspect_err(|error| { + tracing::error!( + module_id = %self.module_id, + pid, + "Failed to publish module PID file after spawn: {error}" + ); + })?; let module_id = self.module_id.clone(); let controller_registry = self.controller.registry; // Pass registry reference to the task @@ -190,7 +194,14 @@ impl<'a> LifecycleExecutor<'a> { tracing::warn!( "Failed to poll child status for module {module_id}: {error}" ); - controller_registry.remove(&module_id); + if let Some((_, mut child)) = controller_registry.remove(&module_id) { + if let Err(kill_error) = child.kill().await { + tracing::warn!( + module_id = %module_id, + "Failed to kill module after status polling failed: {kill_error}" + ); + } + } return; } } @@ -204,8 +215,21 @@ impl<'a> LifecycleExecutor<'a> { }) } - fn module_log_path(&self) -> PathBuf { - module_paths::runtime_log_path(&self.module_id) + async fn kill_unregistered_child(&self, child: &mut Child, reason: &str) { + if let Err(error) = child.kill().await { + tracing::warn!( + module_id = %self.module_id, + "Failed to kill spawned module after {reason}: {error}" + ); + return; + } + + if let Err(error) = child.wait().await { + tracing::warn!( + module_id = %self.module_id, + "Failed to wait spawned module after {reason}: {error}" + ); + } } fn persist_pid(&self, pid: usize) -> Result<(), AppError> { @@ -213,14 +237,15 @@ impl<'a> LifecycleExecutor<'a> { let temp_pid_file = self.module_path.join("module.pid.tmp"); std::fs::write(&temp_pid_file, pid.to_string()).map_err(|error| { AppError::Io(format!( - "Failed to write temp PID file {}: {error}", + "Failed to write module PID temp file '{}': {error}", temp_pid_file.display() )) })?; std::fs::rename(&temp_pid_file, &pid_file).map_err(|error| { + let _ = std::fs::remove_file(&temp_pid_file); AppError::Io(format!( - "Failed to move temp PID file {} to {}: {error}", + "Failed to publish module PID file '{}' -> '{}': {error}", temp_pid_file.display(), pid_file.display() )) @@ -229,6 +254,31 @@ impl<'a> LifecycleExecutor<'a> { Ok(()) } + async fn persist_or_kill_spawned_child( + &self, + child: &mut Child, + pid: usize, + ) -> Result<(), AppError> { + match self.persist_pid(pid) { + Ok(()) => Ok(()), + Err(error) => { + self.kill_unregistered_child(child, "PID publish failure") + .await; + Err(error) + } + } + } + + fn log_reconciled_pid_publish_failure(&self, error: &AppError) { + tracing::error!( + module_id = %self.module_id, + "Failed to publish reconciled module PID file: {error}" + ); + } + + fn module_log_path(&self) -> PathBuf { + module_paths::runtime_log_path(&self.module_id) + } /// Gracefully stops a module with escalation pub async fn stop(&self, manifest: &ModuleManifest) -> Result { tracing::info!("Stopping module: {}", self.module_id); @@ -375,7 +425,10 @@ impl<'a> LifecycleExecutor<'a> { if let Some(&existing_pid) = matching_pids.first() && matching_pids.len() == 1 { - self.persist_pid(existing_pid)?; + if let Err(error) = self.persist_pid(existing_pid) { + self.log_reconciled_pid_publish_failure(&error); + return Err(error); + } return Ok(Some(existing_pid)); } diff --git a/src-tauri/src/domain/modules/settings_ui_protocol.rs b/src-tauri/src/domain/modules/settings_ui_protocol.rs index 52aa2d52..75821148 100644 --- a/src-tauri/src/domain/modules/settings_ui_protocol.rs +++ b/src-tauri/src/domain/modules/settings_ui_protocol.rs @@ -350,7 +350,7 @@ fn parse_module_id_from_label(label: &str) -> Result { )); } - let Some(module_id) = module_id else { + let Some(module_id) = module_id.filter(|value| !value.trim().is_empty()) else { return Err(AppError::PermissionDenied( "Module settings route is only available to owned settings webviews".to_string(), )); diff --git a/src/app/CoreComposition.test.ts b/src/app/CoreComposition.test.ts index 0eb82ba1..c26f0f69 100644 --- a/src/app/CoreComposition.test.ts +++ b/src/app/CoreComposition.test.ts @@ -43,11 +43,14 @@ describe('destroyCoreResources', () => { errorHandler: destroyable(errorHandlerDestroy), } as unknown as Parameters[0]; - await expect(destroyCoreResources(args)).rejects.toThrow(AggregateError); + try { + await expect(destroyCoreResources(args)).rejects.toThrow(AggregateError); - expect(clearTimeoutSpy).toHaveBeenCalledWith(123); - expect(eventHandlerDestroy).toHaveBeenCalledTimes(1); - expect(errorHandlerDestroy).toHaveBeenCalledTimes(1); - clearTimeoutSpy.mockRestore(); + expect(clearTimeoutSpy).toHaveBeenCalledWith(123); + expect(eventHandlerDestroy).toHaveBeenCalledTimes(1); + expect(errorHandlerDestroy).toHaveBeenCalledTimes(1); + } finally { + clearTimeoutSpy.mockRestore(); + } }); }); diff --git a/src/app/CoreLifecycleController.ts b/src/app/CoreLifecycleController.ts index b638b7e4..79920ab5 100644 --- a/src/app/CoreLifecycleController.ts +++ b/src/app/CoreLifecycleController.ts @@ -133,6 +133,10 @@ export class CoreLifecycleController { constructor(private readonly _deps: CoreLifecycleDeps) {} public async runInit(): Promise { + if (this._deps.state.isDestroyed()) { + return; + } + const bootstrapResult = await runCoreBootstrap({ bootstrap: this._deps.bootstrap, immediateUi: this._deps.immediateUi, @@ -180,7 +184,14 @@ export class CoreLifecycleController { globalThis.removeEventListener('keydown', this._activeGlobalShortcutKeydown); this._activeGlobalShortcutKeydown = null; } - this._selectedModuleChangedUnlisten?.(); + try { + this._selectedModuleChangedUnlisten?.(); + } catch (error) { + this._deps.bootstrap.tracer.warn( + '[Core] Failed to remove selected module listener during destroy:', + error, + ); + } this._selectedModuleChangedUnlisten = null; try { await destroyCoreResources({ diff --git a/src/features/ai/services/AIBridge.test.ts b/src/features/ai/services/AIBridge.test.ts index e287347c..0e69a638 100644 --- a/src/features/ai/services/AIBridge.test.ts +++ b/src/features/ai/services/AIBridge.test.ts @@ -294,6 +294,7 @@ describe('AIBridge', () => { await aiBridge.startProvider('llamacpp'); expect(mockInvoke).not.toHaveBeenCalledWith('stop_engine_slot', expect.any(Object)); + expect(mockInvoke).not.toHaveBeenCalledWith('stop_engine', expect.any(Object)); }); it('should NOT fallback to localStorage when backend returns null', async () => { diff --git a/src/features/ai/services/AIChatTransport.ts b/src/features/ai/services/AIChatTransport.ts index 7e3b751c..fd2ddbeb 100644 --- a/src/features/ai/services/AIChatTransport.ts +++ b/src/features/ai/services/AIChatTransport.ts @@ -97,9 +97,16 @@ export class AIChatTransport implements IChatTransport { request_id: requestId, }; this._activeChatRequestId = requestId; + let streamDoneResolved = false; let resolveStreamDone: (() => void) | null = null; const streamDone = new Promise((resolve) => { - resolveStreamDone = resolve; + resolveStreamDone = () => { + if (streamDoneResolved) { + return; + } + streamDoneResolved = true; + resolve(); + }; }); const chatChannel = new Channel(); chatChannel.onmessage = (payload) => { @@ -121,10 +128,16 @@ export class AIChatTransport implements IChatTransport { const thoughtChannel = new Channel(); thoughtChannel.onmessage = (payload) => { - if ( - !this._isPayloadForRequest(payload, requestId) || - payload.kind !== 'thought_chunk' - ) { + if (!this._isPayloadForRequest(payload, requestId)) { + return; + } + + if (payload.kind === 'done') { + resolveStreamDone?.(); + return; + } + + if (payload.kind !== 'thought_chunk') { return; } @@ -167,9 +180,8 @@ export class AIChatTransport implements IChatTransport { } const requestId = this._generateRequestId(); - const { session_id: _sessionId, ...requestWithoutSession } = request; const requestWithId: IChatRequest = { - ...requestWithoutSession, + ...request, request_id: requestId, }; const chatChannel = new Channel(); diff --git a/src/features/chat/controllers/ChatSendController.ts b/src/features/chat/controllers/ChatSendController.ts index 52379ff0..d381e132 100644 --- a/src/features/chat/controllers/ChatSendController.ts +++ b/src/features/chat/controllers/ChatSendController.ts @@ -212,14 +212,16 @@ export class ChatSendController { imageHandle = this._options.createImageHandle(); this._options.startImagePreviewPolling(imageHandle); } else { + let hasRenderedTextChunk = false; const handle = ensureStreamingHandle(); handle.setStatus(this._options.translate('ui.chat.thinking', 'Thinking...')); this._options.aiBridge.onChunk(listenerId, (chunk) => { - if (String(chunk).trim() === '') { + if (!hasRenderedTextChunk && String(chunk).trim() === '') { return; } + hasRenderedTextChunk = true; ensureStreamingHandle().update(chunk); }); } @@ -227,7 +229,7 @@ export class ChatSendController { this._options.registerReplaceChunk( listenerId, () => imageHandle, - () => streamingHandle ?? ensureStreamingHandle(), + () => streamingHandle, ); const response = await this._options.service.sendMessage( diff --git a/src/scripts/check-size.js b/src/scripts/check-size.js index 547944d6..b009bca3 100644 --- a/src/scripts/check-size.js +++ b/src/scripts/check-size.js @@ -5,7 +5,7 @@ const DIST_DIR = path.resolve('dist'); const KB = 1024; const LIMITS = { - totalBytes: 1_205 * KB, + totalBytes: 1_235 * KB, mainJsBytes: 400 * KB, vendorJsBytes: 100 * KB, cssBytes: 200 * KB, From 844bcd01209fc4cceb15d4ccfa494a6a0e627618 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 30 Apr 2026 01:29:39 +0300 Subject: [PATCH 026/126] fix: address backend review follow-ups --- src-tauri/src/domain/ai/ai_dispatch.rs | 2 ++ src-tauri/src/domain/ai/image_service.rs | 8 ++++- src-tauri/src/domain/ai/session.rs | 30 ++++++++----------- .../domain/modules/controller/lifecycle.rs | 16 ++++++---- .../src/domain/modules/downloader_transfer.rs | 16 ++++++++-- .../infrastructure/config/engine_settings.rs | 24 ++++----------- .../filesystem/local_file_service.rs | 12 ++++++++ 7 files changed, 63 insertions(+), 45 deletions(-) diff --git a/src-tauri/src/domain/ai/ai_dispatch.rs b/src-tauri/src/domain/ai/ai_dispatch.rs index 2ea10d65..5874fa0d 100644 --- a/src-tauri/src/domain/ai/ai_dispatch.rs +++ b/src-tauri/src/domain/ai/ai_dispatch.rs @@ -48,6 +48,7 @@ pub(super) async fn prepare_chat_dispatch( engine_manager, settings_service, local_engine_access, + &messages_context, ) .await? { @@ -165,6 +166,7 @@ async fn resolve_local_engine_request( engine_manager: &crate::domain::engine::manager::EngineManager, settings_service: &crate::infrastructure::config::settings::SettingsService, local_engine_access: LocalEngineAccess, + prepared_messages_context: &[ChatMessage], ) -> Result, crate::errors::AppError> { let Some(definition) = engine_manager.get_definition(&request.provider).await else { return Ok(None); diff --git a/src-tauri/src/domain/ai/image_service.rs b/src-tauri/src/domain/ai/image_service.rs index c9e8b757..9916b8f2 100644 --- a/src-tauri/src/domain/ai/image_service.rs +++ b/src-tauri/src/domain/ai/image_service.rs @@ -108,7 +108,13 @@ async fn process_image_request_with_local_engine_access( &reply.role, None, ); - sessions.force_save().await?; + if let Err(error) = sessions.force_save().await { + tracing::warn!( + session_id, + image_count = images.len(), + "Failed to persist generated image transcript: {error}" + ); + } } Ok(ImageGenerationResponse { diff --git a/src-tauri/src/domain/ai/session.rs b/src-tauri/src/domain/ai/session.rs index 3989039f..7610f1c5 100644 --- a/src-tauri/src/domain/ai/session.rs +++ b/src-tauri/src/domain/ai/session.rs @@ -82,13 +82,6 @@ impl ChatSessionManager { SessionPersistence::flush_snapshot(snapshot) } - fn take_snapshot(&self) -> HashMap { - self.sessions - .iter() - .map(|e| (e.key().clone(), e.value().clone())) - .collect() - } - fn mark_dirty(&self) { if !self.persistence_available.load(Ordering::Relaxed) { tracing::warn!("Chat history changed while persistence is disabled; skipping save"); @@ -122,13 +115,10 @@ impl ChatSessionManager { tokio::time::sleep(std::time::Duration::from_secs(5)).await; if dirty.swap(false, Ordering::AcqRel) { let save_lock = Arc::clone(&save_lock); - let snapshot: HashMap = sessions - .iter() - .map(|e| (e.key().clone(), e.value().clone())) - .collect(); + let sessions = Arc::clone(&sessions); match tokio::task::spawn_blocking(move || { - Self::flush_snapshot_locked(&save_lock, &snapshot) + Self::flush_sessions_locked(&save_lock, &sessions) }) .await { @@ -156,9 +146,9 @@ impl ChatSessionManager { /// Immediately saves all sessions to disk, bypassing the debounce timer. pub async fn force_save(&self) -> Result<(), crate::errors::AppError> { self.ensure_persistence_available()?; - let snapshot = self.take_snapshot(); let save_lock = Arc::clone(&self.save_lock); - tokio::task::spawn_blocking(move || Self::flush_snapshot_locked(&save_lock, &snapshot)) + let sessions = Arc::clone(&self.sessions); + tokio::task::spawn_blocking(move || Self::flush_sessions_locked(&save_lock, &sessions)) .await .map_err(|e| crate::errors::AppError::Internal { request_id: None, @@ -171,7 +161,7 @@ impl ChatSessionManager { /// Synchronous save — intended for use in Tauri shutdown hooks (called from a blocking context). pub fn save_to_disk(&self) -> Result<(), crate::errors::AppError> { self.ensure_persistence_available()?; - Self::flush_snapshot_locked(&self.save_lock, &self.take_snapshot()) + Self::flush_sessions_locked(&self.save_lock, &self.sessions) } fn ensure_persistence_available(&self) -> Result<(), crate::errors::AppError> { @@ -494,9 +484,9 @@ impl SessionPersistence { } impl ChatSessionManager { - fn flush_snapshot_locked( + fn flush_sessions_locked( save_lock: &Mutex<()>, - snapshot: &HashMap, + sessions: &DashMap, ) -> Result<(), crate::errors::AppError> { let _guard = save_lock .lock() @@ -504,7 +494,11 @@ impl ChatSessionManager { request_id: None, message: "Chat history save lock is poisoned".to_string(), })?; - Self::flush_snapshot(snapshot) + let snapshot: HashMap = sessions + .iter() + .map(|e| (e.key().clone(), e.value().clone())) + .collect(); + Self::flush_snapshot(&snapshot) } } diff --git a/src-tauri/src/domain/modules/controller/lifecycle.rs b/src-tauri/src/domain/modules/controller/lifecycle.rs index 87457d0f..7858a5fd 100644 --- a/src-tauri/src/domain/modules/controller/lifecycle.rs +++ b/src-tauri/src/domain/modules/controller/lifecycle.rs @@ -502,13 +502,19 @@ impl<'a> LifecycleExecutor<'a> { .await { Ok(pids) => Ok(pids), - Err(error) => Err(AppError::Internal { - request_id: None, - message: format!( + Err(error) => { + tracing::error!( "Failed to scan matching script module processes for {}: {error}", self.module_id - ), - }), + ); + Err(AppError::Internal { + request_id: None, + message: format!( + "Failed to scan module processes for '{}': {error}", + self.module_id + ), + }) + } } } diff --git a/src-tauri/src/domain/modules/downloader_transfer.rs b/src-tauri/src/domain/modules/downloader_transfer.rs index 47eacb59..4a1aeb58 100644 --- a/src-tauri/src/domain/modules/downloader_transfer.rs +++ b/src-tauri/src/domain/modules/downloader_transfer.rs @@ -147,7 +147,18 @@ pub(super) fn build_client(module_id: &str) -> Result .user_agent("Axelate/1.0.0 (Tauri; Windows)") .timeout(std::time::Duration::from_secs(600)); - if let Some(license) = crate::domain::license::storage::load_license()? + let loaded_license = match crate::domain::license::storage::load_license() { + Ok(license) => license, + Err(error) => { + tracing::warn!( + module_id = module_id, + "Failed to load license for download headers: {error}" + ); + None + } + }; + + if let Some(license) = loaded_license && !license.key.is_empty() { tracing::info!("Injecting license key for module download: {module_id}"); @@ -192,7 +203,7 @@ pub(super) async fn download_file( let loaded_resume_metadata = match load_partial_metadata(task.dest_path).await { Ok(metadata) => metadata, - Err(error) => { + Err(AppError::Serialization(error)) => { tracing::warn!( module_id = task.module_id, path = %task.dest_path.display(), @@ -201,6 +212,7 @@ pub(super) async fn download_file( remove_partial_metadata(task.dest_path).await?; None } + Err(error) => return Err(error), }; let resume_metadata = loaded_resume_metadata.filter(|metadata| metadata.url == task.url); let mut existing_bytes = tokio::fs::metadata(task.dest_path) diff --git a/src-tauri/src/infrastructure/config/engine_settings.rs b/src-tauri/src/infrastructure/config/engine_settings.rs index 4885bfee..d808ae05 100644 --- a/src-tauri/src/infrastructure/config/engine_settings.rs +++ b/src-tauri/src/infrastructure/config/engine_settings.rs @@ -64,25 +64,11 @@ pub async fn save_engine_config_map(map: &EngineConfigMap) -> Result<(), AppErro } if let Err(rename_error) = tokio::fs::rename(&tmp, path).await { - tracing::warn!( - "Atomic engine config rename failed ({rename_error}); retrying with replace fallback" - ); - match tokio::fs::remove_file(path).await { - Ok(()) => {} - Err(error) if error.kind() == ErrorKind::NotFound => {} - Err(error) => { - cleanup_engine_config_tmp(&tmp).await; - return Err(AppError::Io(format!( - "Failed to replace engine config after rename error ({rename_error}): {error}" - ))); - } - } - if let Err(error) = tokio::fs::rename(&tmp, path).await { - cleanup_engine_config_tmp(&tmp).await; - return Err(AppError::Io(format!( - "Failed to publish engine config after rename error ({rename_error}): {error}" - ))); - } + cleanup_engine_config_tmp(&tmp).await; + return Err(AppError::Io(format!( + "Failed to atomically publish engine config '{}': {rename_error}", + path.display() + ))); } Ok(()) diff --git a/src-tauri/src/infrastructure/filesystem/local_file_service.rs b/src-tauri/src/infrastructure/filesystem/local_file_service.rs index b82134b6..b68553ba 100644 --- a/src-tauri/src/infrastructure/filesystem/local_file_service.rs +++ b/src-tauri/src/infrastructure/filesystem/local_file_service.rs @@ -40,6 +40,18 @@ impl LocalFileService { drop(file); if let Err(first_error) = fs::rename(&tmp, path).await { + let retryable_replace = matches!( + first_error.kind(), + std::io::ErrorKind::AlreadyExists | std::io::ErrorKind::PermissionDenied + ); + if !retryable_replace { + let _ = fs::remove_file(&tmp).await; + return Err(AppError::Io(format!( + "Failed to publish atomic write to '{}': rename failed: {first_error}", + path.display() + ))); + } + if let Err(remove_error) = fs::remove_file(path).await && remove_error.kind() != std::io::ErrorKind::NotFound { From b446a7dd4ae81e9db55645ca7c5c6a3f27148abb Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 30 Apr 2026 01:39:11 +0300 Subject: [PATCH 027/126] chore: remove workspace editor settings --- .vscode/extensions.json | 10 ---------- .vscode/settings.json | 14 -------------- 2 files changed, 24 deletions(-) delete mode 100644 .vscode/extensions.json delete mode 100644 .vscode/settings.json diff --git a/.vscode/extensions.json b/.vscode/extensions.json deleted file mode 100644 index 32b2e57d..00000000 --- a/.vscode/extensions.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "recommendations": [ - "rust-lang.rust-analyzer", - "tauri-apps.tauri-vscode", - "dbaeumer.vscode-eslint", - "esbenp.prettier-vscode", - "tamasfe.even-better-toml", - "vitest.explorer" - ] -} diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index e289eb14..00000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.formatOnSave": false, - "eslint.workingDirectories": [ - { - "directory": "src", - "changeProcessCWD": true - } - ], - "files.eol": "\n", - "rust-analyzer.cargo.features": "all", - "rust-analyzer.check.command": "clippy", - "typescript.tsdk": "src/node_modules/typescript/lib" -} From 3d8d62dff11535c2f9815d61e8aeb59ae308d3a7 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 30 Apr 2026 02:33:04 +0300 Subject: [PATCH 028/126] fix: restore github release options --- src-tauri/src/domain/modules/downloader.rs | 16 ++- .../src/domain/modules/downloader_transfer.rs | 11 ++ .../modules/github_release_selection.rs | 40 +++--- .../src/domain/modules/github_releases.rs | 136 +++++++++++++++++- src/shared/services/ModuleService.ts | 4 +- .../shell/ui/DownloadSelectionDialog.ts | 13 +- 6 files changed, 188 insertions(+), 32 deletions(-) diff --git a/src-tauri/src/domain/modules/downloader.rs b/src-tauri/src/domain/modules/downloader.rs index c20800a5..38b4a795 100644 --- a/src-tauri/src/domain/modules/downloader.rs +++ b/src-tauri/src/domain/modules/downloader.rs @@ -7,8 +7,8 @@ use super::downloader_progress::{ use super::downloader_service::{DownloadRequest, resolve_existing_module_path}; use super::downloader_support::{package_install_dir, remove_partial_metadata}; use super::downloader_transfer::{ - DownloadTask, ReleaseDownloadAsset, build_client, clone_repository_into, download_file, - resolve_download_url, + DownloadTask, ReleaseDownloadAsset, build_client, build_public_client, clone_repository_into, + download_file, resolve_download_url, }; use super::github_releases::ReleaseDownloadSelection; use crate::errors::AppError; @@ -60,7 +60,7 @@ pub async fn get_release_download_options( repo_url: &str, ) -> Result { validate_module_id(module_id)?; - let client = build_client(module_id)?; + let client = build_public_client()?; super::github_releases::fetch_release_download_options(&client, repo_url, module_id).await } @@ -123,7 +123,11 @@ pub async fn download_module( speed: 0, }); - let client = build_client(&module_id)?; + let client = if dl_type.as_deref() == Some("release") && is_github_repo_url(&repo_url) { + build_public_client()? + } else { + build_client(&module_id)? + }; let extraction_path = ArchiveExtractor::prepare_staging(&module_id)?; staging_path = Some(extraction_path.clone()); let mut completed_downloaded_bytes: u64 = 0; @@ -359,6 +363,10 @@ pub async fn download_module( Ok("completed".to_string()) } +fn is_github_repo_url(repo_url: &str) -> bool { + repo_url.trim().to_ascii_lowercase().contains("github.com/") +} + fn ensure_not_interrupted( control: &super::downloader_service::DownloadControl, ) -> Result<(), AppError> { diff --git a/src-tauri/src/domain/modules/downloader_transfer.rs b/src-tauri/src/domain/modules/downloader_transfer.rs index 4a1aeb58..aa334e30 100644 --- a/src-tauri/src/domain/modules/downloader_transfer.rs +++ b/src-tauri/src/domain/modules/downloader_transfer.rs @@ -180,6 +180,17 @@ pub(super) fn build_client(module_id: &str) -> Result }) } +pub(super) fn build_public_client() -> Result { + reqwest::Client::builder() + .user_agent("Axelate/1.0.0 (Tauri; Windows)") + .timeout(std::time::Duration::from_secs(600)) + .build() + .map_err(|error| AppError::External { + request_id: None, + message: format!("Client error: {error}"), + }) +} + pub(super) struct DownloadTask<'a> { pub(super) app: &'a AppHandle, pub(super) downloader: &'a DownloaderService, diff --git a/src-tauri/src/domain/modules/github_release_selection.rs b/src-tauri/src/domain/modules/github_release_selection.rs index 345abfa0..3bb2a567 100644 --- a/src-tauri/src/domain/modules/github_release_selection.rs +++ b/src-tauri/src/domain/modules/github_release_selection.rs @@ -79,37 +79,46 @@ pub(super) fn select_release_assets( }); for main_idx in &supported_cuda_candidates { - let main = assets.get(*main_idx)?; + let Some(main) = assets.get(*main_idx) else { + continue; + }; if let Some(cuda_track) = detect_cuda_track(&main.name) && let Some(runtime_idx) = runtime_candidates.iter().copied().find(|idx| { assets .get(*idx) .is_some_and(|asset| detect_cuda_track(&asset.name) == Some(cuda_track)) }) + && let (Some(runtime_asset), Some(main_asset)) = ( + assets.get(runtime_idx).and_then(asset_to_release_asset), + asset_to_release_asset(main), + ) { - return Some(vec![ - asset_to_release_asset(assets.get(runtime_idx)?)?, - asset_to_release_asset(main)?, - ]); + return Some(vec![runtime_asset, main_asset]); } } - if has_cuda_main { - return None; - } - for main_idx in &main_candidates { - let main = assets.get(*main_idx)?; - if detect_cuda_track(&main.name).is_none() { - return Some(vec![asset_to_release_asset(main)?]); + let Some(main) = assets.get(*main_idx) else { + continue; + }; + if detect_cuda_track(&main.name).is_none() + && let Some(asset) = asset_to_release_asset(main) + { + return Some(vec![asset]); } } + if has_cuda_main { + return None; + } + return None; } - let selected_main = main_candidates.first().copied()?; - Some(vec![asset_to_release_asset(assets.get(selected_main)?)?]) + main_candidates + .iter() + .filter_map(|idx| assets.get(*idx)) + .find_map(|asset| asset_to_release_asset(asset).map(|asset| vec![asset])) } fn runtime_assets(module_id: &str, platform: Platform, assets: &[Asset]) -> Vec { @@ -313,7 +322,7 @@ fn main_score(module_id: &str, name: &str, hardware: HardwareProfile) -> i32 { score -= 500; } } - AcceleratorClass::CpuOnly => { + AcceleratorClass::CpuOnly | AcceleratorClass::Unknown => { if detect_cuda_track(&lower).is_some() || lower.contains("vulkan") || lower.contains("rocm") @@ -326,7 +335,6 @@ fn main_score(module_id: &str, name: &str, hardware: HardwareProfile) -> i32 { score += cpu_feature_score(&lower, hardware.cpu_tier); } - AcceleratorClass::Unknown => {} } score diff --git a/src-tauri/src/domain/modules/github_releases.rs b/src-tauri/src/domain/modules/github_releases.rs index 9ca9a61f..f9bc1422 100644 --- a/src-tauri/src/domain/modules/github_releases.rs +++ b/src-tauri/src/domain/modules/github_releases.rs @@ -1,7 +1,7 @@ //! GitHub release asset selection for platform-specific module bundles. use crate::errors::AppError; -use reqwest::Client; +use reqwest::{Client, header}; use serde::{Deserialize, Serialize}; use specta::Type; @@ -90,6 +90,8 @@ pub struct ReleaseDownloadVariant { } const RELEASES_PER_PAGE: u8 = 100; +const MAX_RELEASE_DOWNLOAD_OPTIONS: usize = 50; +const GITHUB_API_USER_AGENT: &str = "Axelate"; #[derive(Clone, Debug, Deserialize)] struct Release { @@ -209,6 +211,10 @@ pub async fn fetch_release_download_options( versions.extend(release_download_versions( module_id, platform, hardware, releases, )); + if versions.len() >= MAX_RELEASE_DOWNLOAD_OPTIONS { + versions.truncate(MAX_RELEASE_DOWNLOAD_OPTIONS); + break; + } page += 1; } @@ -287,11 +293,19 @@ async fn fetch_release_page( ) -> Result, AppError> { let response = client .get(repo_ref.releases_api_url(page)) - .header(reqwest::header::ACCEPT, "application/vnd.github+json") + .header(header::ACCEPT, "application/vnd.github+json") + .header(header::USER_AGENT, GITHUB_API_USER_AGENT) .send() .await?; if !response.status().is_success() { + if response.status() == reqwest::StatusCode::UNPROCESSABLE_ENTITY && page > 1 { + tracing::warn!( + "GitHub release pagination stopped for {module_id} at page {page}: {}", + response.status() + ); + return Ok(Vec::new()); + } return Err(map_release_fetch_error( response.status(), &response, @@ -456,7 +470,24 @@ const fn hardware_for_target( target: ReleaseComputeTarget, ) -> HardwareProfile { match target { - ReleaseComputeTarget::Auto | ReleaseComputeTarget::Gpu => hardware, + ReleaseComputeTarget::Auto => hardware, + ReleaseComputeTarget::Gpu => { + if matches!( + hardware.accelerator, + crate::domain::system::hardware_probe::AcceleratorClass::CpuOnly + | crate::domain::system::hardware_probe::AcceleratorClass::Unknown + ) { + HardwareProfile { + accelerator: + crate::domain::system::hardware_probe::AcceleratorClass::GenericGpu, + cpu_tier: hardware.cpu_tier, + cuda_driver_major: None, + cuda_driver_minor: None, + } + } else { + hardware + } + } ReleaseComputeTarget::Cpu => HardwareProfile { accelerator: crate::domain::system::hardware_probe::AcceleratorClass::CpuOnly, cpu_tier: hardware.cpu_tier, @@ -885,7 +916,7 @@ mod tests { } #[test] - fn skips_incomplete_windows_cuda_release_when_runtime_pair_is_missing() { + fn falls_back_when_windows_cuda_runtime_pair_is_missing() { let platform = Platform { os: PlatformOs::Windows, arch: PlatformArch::X64, @@ -901,7 +932,14 @@ mod tests { asset("llama-b8726-bin-win-cpu-x64.zip"), ]; - assert!(select_release_assets("llamacpp", platform, hardware, &assets).is_none()); + let selected = select_release_assets("llamacpp", platform, hardware, &assets) + .expect("expected CPU fallback when CUDA runtime pair is missing"); + + assert_eq!(selected.len(), 1); + assert_eq!( + selected.first().map(|asset| asset.name.as_str()), + Some("llama-b8726-bin-win-cpu-x64.zip") + ); } #[test] @@ -1051,6 +1089,94 @@ mod tests { ); } + #[test] + fn current_sdcpp_release_names_build_cpu_and_gpu_options() { + let platform = Platform { + os: PlatformOs::Windows, + arch: PlatformArch::X64, + }; + let hardware = HardwareProfile { + accelerator: AcceleratorClass::Unknown, + cpu_tier: CpuInstructionTier::Avx2, + cuda_driver_major: None, + cuda_driver_minor: None, + }; + let releases = vec![Release { + tag_name: "master-593-3d6064b".to_string(), + published_at: Some("2026-04-29T10:00:00Z".to_string()), + draft: false, + prerelease: false, + assets: vec![ + asset("cudart-sd-bin-win-cu12-x64.zip"), + asset("sd-master-3d6064b-bin-win-avx-x64.zip"), + asset("sd-master-3d6064b-bin-win-avx2-x64.zip"), + asset("sd-master-3d6064b-bin-win-avx512-x64.zip"), + asset("sd-master-3d6064b-bin-win-cuda12-x64.zip"), + asset("sd-master-3d6064b-bin-win-noavx-x64.zip"), + asset("sd-master-3d6064b-bin-win-rocm-x64.zip"), + asset("sd-master-3d6064b-bin-win-vulkan-x64.zip"), + ], + }]; + + let versions = release_download_versions("sdcpp", platform, hardware, releases); + + assert_eq!(versions.len(), 1); + let version = versions.first().expect("expected sdcpp options"); + assert_eq!(version.recommended, ReleaseComputeTarget::Gpu); + assert_eq!( + version.cpu.as_ref().and_then(|cpu| cpu.assets.first()), + Some(&"sd-master-3d6064b-bin-win-avx2-x64.zip".to_string()) + ); + assert_eq!( + version.gpu.as_ref().and_then(|gpu| gpu.assets.first()), + Some(&"sd-master-3d6064b-bin-win-vulkan-x64.zip".to_string()) + ); + } + + #[test] + fn current_llamacpp_release_names_build_cpu_and_gpu_options() { + let platform = Platform { + os: PlatformOs::Windows, + arch: PlatformArch::X64, + }; + let hardware = HardwareProfile { + accelerator: AcceleratorClass::Unknown, + cpu_tier: CpuInstructionTier::Avx2, + cuda_driver_major: None, + cuda_driver_minor: None, + }; + let releases = vec![Release { + tag_name: "b8981".to_string(), + published_at: Some("2026-04-29T10:00:00Z".to_string()), + draft: false, + prerelease: false, + assets: vec![ + asset("cudart-llama-bin-win-cuda-12.4-x64.zip"), + asset("cudart-llama-bin-win-cuda-13.1-x64.zip"), + asset("llama-b8981-bin-win-cpu-x64.zip"), + asset("llama-b8981-bin-win-cuda-12.4-x64.zip"), + asset("llama-b8981-bin-win-cuda-13.1-x64.zip"), + asset("llama-b8981-bin-win-hip-radeon-x64.zip"), + asset("llama-b8981-bin-win-sycl-x64.zip"), + asset("llama-b8981-bin-win-vulkan-x64.zip"), + ], + }]; + + let versions = release_download_versions("llamacpp", platform, hardware, releases); + + assert_eq!(versions.len(), 1); + let version = versions.first().expect("expected llama.cpp options"); + assert_eq!(version.recommended, ReleaseComputeTarget::Gpu); + assert_eq!( + version.cpu.as_ref().and_then(|cpu| cpu.assets.first()), + Some(&"llama-b8981-bin-win-cpu-x64.zip".to_string()) + ); + assert_eq!( + version.gpu.as_ref().and_then(|gpu| gpu.assets.first()), + Some(&"llama-b8981-bin-win-vulkan-x64.zip".to_string()) + ); + } + #[test] fn selects_comfyui_nvidia_portable_release_without_runtime_pairing() { let platform = Platform { diff --git a/src/shared/services/ModuleService.ts b/src/shared/services/ModuleService.ts index d87b2e31..6d98efbd 100644 --- a/src/shared/services/ModuleService.ts +++ b/src/shared/services/ModuleService.ts @@ -206,10 +206,10 @@ export class ModuleService { this._tracer.warn( `[ModuleService] Release options failed for ${moduleId}: ${result.error.message}`, ); - return null; + throw new Error(result.error.message); } catch (err) { this._tracer.error(`[ModuleService] Release options error: ${String(err)}`); - return null; + throw err; } } diff --git a/src/shared/shell/ui/DownloadSelectionDialog.ts b/src/shared/shell/ui/DownloadSelectionDialog.ts index 5aa90387..12b1a1f9 100644 --- a/src/shared/shell/ui/DownloadSelectionDialog.ts +++ b/src/shared/shell/ui/DownloadSelectionDialog.ts @@ -215,11 +215,14 @@ export function openDownloadSelectionDialog({ selectedVersion = firstVersion; selectedTarget = normalizeTarget(selectedVersion.recommended, selectedVersion); }) - .catch(() => { - errorMessage = translate( - 'ui.download.load_versions_error', - 'Failed to load release versions', - ); + .catch((err: unknown) => { + errorMessage = + err instanceof Error && err.message.trim() !== '' + ? err.message + : translate( + 'ui.download.load_versions_error', + 'Failed to load release versions', + ); }) .finally(() => { if (resolved) return; From 400d16edf99d353a2a3e4cd4dd443f3d6080ffcc Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 30 Apr 2026 02:40:46 +0300 Subject: [PATCH 029/126] fix: restore downloads empty state --- src/features/downloads/ui/DownloadUI.test.ts | 47 ++++++++++++++++++++ src/features/downloads/ui/DownloadUI.ts | 16 +++---- 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/src/features/downloads/ui/DownloadUI.test.ts b/src/features/downloads/ui/DownloadUI.test.ts index 9410849c..6a0cfc73 100644 --- a/src/features/downloads/ui/DownloadUI.test.ts +++ b/src/features/downloads/ui/DownloadUI.test.ts @@ -857,6 +857,53 @@ describe('DownloadUI', () => { expect(list?.querySelectorAll('.download-item-card').length).toBe(0); }); + it('should return downloads layout to empty state after final cleanup', () => { + ui.init(); + + globalThis.dispatchEvent( + new CustomEvent('download-progress-update', { + detail: { + module_id: 'mod-final', + progress: 0.4, + status: 'downloading', + }, + }), + ); + + expect( + document + .getElementById('downloads-container') + ?.classList.contains('active-download'), + ).toBe(true); + + globalThis.dispatchEvent( + new CustomEvent('download-progress-update', { + detail: { + module_id: 'mod-final', + progress: 1, + status: 'complete', + }, + }), + ); + + vi.advanceTimersByTime(2100); + + expect( + document + .getElementById('downloads-container') + ?.classList.contains('active-download'), + ).toBe(false); + expect( + document.getElementById('downloads-body')?.classList.contains('empty-state'), + ).toBe(true); + expect( + document.getElementById('downloads-empty-text')?.classList.contains('hidden'), + ).toBe(false); + expect(document.getElementById('downloads-item-label')?.textContent).toBe( + 'No active downloads', + ); + }); + it('should cancel stale terminal cleanup when the same module restarts downloading', () => { ui.init(); diff --git a/src/features/downloads/ui/DownloadUI.ts b/src/features/downloads/ui/DownloadUI.ts index 569f3318..6ff7e519 100644 --- a/src/features/downloads/ui/DownloadUI.ts +++ b/src/features/downloads/ui/DownloadUI.ts @@ -63,6 +63,7 @@ export class DownloadUI { (moduleId) => { this._stateController.delete(moduleId); this._renderDownloadStateList(); + this._renderPrimaryDownloadState(); }, ); this._eventController = new DownloadUiEventController(this._createEventControllerDeps()); @@ -358,21 +359,20 @@ export class DownloadUI { this.renderDownloadsProgress(progress); } - private _refreshTranslations(): void { - this._renderDownloadStateList(); - + private _renderPrimaryDownloadState(): void { const firstEntry = this._stateController.getPrimaryEntry(); - if (firstEntry === undefined) { this.renderDownloadsProgress({ hasActive: false }); return; } - const [moduleId, firstDownload] = firstEntry; + const [moduleId, state] = firstEntry; + this._renderProgressFromModuleState(moduleId, state); + } - this.renderDownloadsProgress({ - ...this._presenter.buildProgressFromModuleState(moduleId, firstDownload), - }); + private _refreshTranslations(): void { + this._renderDownloadStateList(); + this._renderPrimaryDownloadState(); } /** From c2457c2e4841dcadad2aa987a8a25df6b51ee09a Mon Sep 17 00:00:00 2001 From: F0RLE Date: Wed, 13 May 2026 20:30:39 +0300 Subject: [PATCH 030/126] fix(core): resolve split rebase regressions --- src-tauri/src/domain/ai/ai_dispatch.rs | 2 +- src/features/chat/chat.ts | 4 +-- src/features/chat/ui/ChatInputContextMenu.ts | 2 +- src/shared/shell/ui/ToastManager.ts | 37 -------------------- 4 files changed, 3 insertions(+), 42 deletions(-) diff --git a/src-tauri/src/domain/ai/ai_dispatch.rs b/src-tauri/src/domain/ai/ai_dispatch.rs index 5874fa0d..9bec913e 100644 --- a/src-tauri/src/domain/ai/ai_dispatch.rs +++ b/src-tauri/src/domain/ai/ai_dispatch.rs @@ -207,7 +207,7 @@ async fn resolve_local_engine_request( .await?; let status = engine_manager.start(config).await?; - let mut messages_context = request.messages.clone(); + let mut messages_context = prepared_messages_context.to_vec(); if let Some(session_id) = &request.session_id { messages_context = sessions.build_local_context( diff --git a/src/features/chat/chat.ts b/src/features/chat/chat.ts index 877f2af0..2e69622e 100644 --- a/src/features/chat/chat.ts +++ b/src/features/chat/chat.ts @@ -415,9 +415,7 @@ export class ChatController { this._state.isDestroyed = false; this._tracer.debug('[Chat] Initializing TS Controller...'); - void this._ui.init().catch((err: unknown) => { - this._tracer.error(`[Chat] UI init failed: ${String(err)}`); - }); + await this._ui.init(); this._ui.setEditMessageHandler(async (text) => { await this._historyController.editLastTurn(this._state.isSending, text); }); diff --git a/src/features/chat/ui/ChatInputContextMenu.ts b/src/features/chat/ui/ChatInputContextMenu.ts index 9512f0cc..5eb1eea2 100644 --- a/src/features/chat/ui/ChatInputContextMenu.ts +++ b/src/features/chat/ui/ChatInputContextMenu.ts @@ -173,7 +173,7 @@ export class ChatInputContextMenu { menu.appendChild(this._createButton(input, item, state)); }); - if (this._openToken !== openToken || this._input !== input) { + if (openRequestId !== this._openRequestId || this._input !== input) { return; } diff --git a/src/shared/shell/ui/ToastManager.ts b/src/shared/shell/ui/ToastManager.ts index 8fc86f87..d2595465 100644 --- a/src/shared/shell/ui/ToastManager.ts +++ b/src/shared/shell/ui/ToastManager.ts @@ -9,7 +9,6 @@ type ToastType = 'success' | 'error' | 'warning' | 'info' | (string & {}); export interface ToastElement extends HTMLElement { _timeout?: ReturnType; _removeTimeout?: ReturnType; - _actionHandler?: () => void; } /** @@ -173,7 +172,6 @@ export class ToastManager { toast.className = `toast ${type}`; this._bindToastClick(toast, onClick); toast.classList.remove('leaving'); - this._setToastAction(toast, onClick); this._clearToastTimers(toast); this._scheduleToastRemoval(toast, duration); } @@ -202,7 +200,6 @@ export class ToastManager {
    `); - this._setToastAction(toast, onClick); container.appendChild(toast); this._scheduleToastRemoval(toast, duration); } @@ -276,40 +273,6 @@ export class ToastManager { } } - private _setToastAction(toast: ToastElement, onClick: (() => void) | null): void { - if (toast._actionHandler !== undefined) { - toast.removeEventListener('click', toast._actionHandler); - toast.removeEventListener('keydown', this._handleActionKeydown); - delete toast._actionHandler; - } - - if (onClick === null) { - toast.classList.remove('toast--actionable'); - toast.removeAttribute('role'); - toast.removeAttribute('tabindex'); - return; - } - - toast._actionHandler = onClick; - toast.classList.add('toast--actionable'); - toast.setAttribute('role', 'button'); - toast.setAttribute('tabindex', '0'); - toast.addEventListener('click', onClick); - toast.addEventListener('keydown', this._handleActionKeydown); - } - - private readonly _handleActionKeydown = (event: KeyboardEvent): void => { - if (event.key !== 'Enter' && event.key !== ' ') { - return; - } - - event.preventDefault(); - const toast = event.currentTarget; - if (toast instanceof HTMLElement) { - toast.click(); - } - }; - private _cleanupContainer(): void { const container = document.getElementById(ToastManager._containerId); if (container === null) { From b2362fa46faa80ffdc517fffbf093969c0757b1e Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 30 Apr 2026 02:50:59 +0300 Subject: [PATCH 031/126] fix: use global text context menu --- src/app/CoreAssembly.ts | 12 +- src/app/CoreChatFactory.ts | 3 - src/app/CoreComposition.ts | 1 + src/app/CoreLifecycleController.test.ts | 1 + src/app/CoreLifecycleController.ts | 3 + src/features/chat/chat.test.ts | 1 - src/features/chat/chat.ts | 2 - .../chat/services/ChatControllerFactory.ts | 2 - .../chat/ui/ChatInputContextMenu.test.ts | 150 ------- src/features/chat/ui/ChatInputContextMenu.ts | 330 --------------- src/features/chat/ui/ChatUI.test.ts | 7 - src/features/chat/ui/ChatUI.ts | 24 -- .../shell/GlobalTextContextMenu.test.ts | 107 +++++ src/shared/shell/GlobalTextContextMenu.ts | 384 ++++++++++++++++++ 14 files changed, 507 insertions(+), 520 deletions(-) delete mode 100644 src/features/chat/ui/ChatInputContextMenu.test.ts delete mode 100644 src/features/chat/ui/ChatInputContextMenu.ts create mode 100644 src/shared/shell/GlobalTextContextMenu.test.ts create mode 100644 src/shared/shell/GlobalTextContextMenu.ts diff --git a/src/app/CoreAssembly.ts b/src/app/CoreAssembly.ts index c10cf1dc..b57a889f 100644 --- a/src/app/CoreAssembly.ts +++ b/src/app/CoreAssembly.ts @@ -13,6 +13,8 @@ import { configureTracerTransport, registerCoreContainer, } from './CoreComposition'; +import { createClipboardReader, createClipboardWriter } from './CoreUiBridgeHelpers'; +import { GlobalTextContextMenu } from '@/shared/shell/GlobalTextContextMenu'; type CoreAssemblyState = { isDestroyed: () => boolean; @@ -38,6 +40,7 @@ type AssemblyParts = { infra: CoreInfrastructure; bridge: GlobalBridge; eventHandler: EventHandler; + globalTextContextMenu: GlobalTextContextMenu; }; function createStateManager(args: { @@ -112,6 +115,12 @@ export function createCoreAssembly(args: CreateCoreAssemblyArgs): CoreAssembly { windowService: serviceBundle.windowService, windowUI: ui.windowUI, }); + const globalTextContextMenu = new GlobalTextContextMenu({ + translate: (key, fallback) => serviceBundle.i18n.t(key, fallback), + copyText: createClipboardWriter(serviceBundle.tauriProvider), + readClipboardText: createClipboardReader(serviceBundle.tauriProvider), + tracer: args.tracer, + }); bindAIBridgeContext({ aiBridge: serviceBundle.aiBridge, @@ -169,7 +178,7 @@ export function createCoreAssembly(args: CreateCoreAssemblyArgs): CoreAssembly { const lifecycleController = new CoreLifecycleController( createLifecycleDeps( - { services, ui, infra, bridge, eventHandler }, + { services, ui, infra, bridge, eventHandler, globalTextContextMenu }, { state: args.state, globalShortcutKeydown: args.globalShortcutKeydown, @@ -256,6 +265,7 @@ function createLifecycleDeps( aiBridge: services.aiBridge, bridge, errorHandler: infra.errorHandler, + globalTextContextMenu: parts.globalTextContextMenu, }, state: runtime.state, globalShortcutKeydown: runtime.globalShortcutKeydown, diff --git a/src/app/CoreChatFactory.ts b/src/app/CoreChatFactory.ts index a588449d..02b8177b 100644 --- a/src/app/CoreChatFactory.ts +++ b/src/app/CoreChatFactory.ts @@ -7,7 +7,6 @@ import type { EventBus } from '@/shared/services/EventBus'; import type { UiStateStore } from '@/shared/services/state/UiStateStore'; import type { AppUI } from '@/shared/shell/AppUI'; import { - createClipboardReader, createClipboardWriter, createExternalUrlOpener, createToastBridge, @@ -29,7 +28,6 @@ export function createChatController(deps: CreateChatControllerDeps): ChatContro const isTauriRuntime = (): boolean => deps.tauriProvider.isTauri(); const showToast = createToastBridge(deps.appUI); const copyText = createClipboardWriter(deps.tauriProvider); - const readClipboardText = createClipboardReader(deps.tauriProvider); const openExternalUrl = createExternalUrlOpener(deps.tauriProvider); const estimateTokens = createTokenEstimator({ tauriProvider: deps.tauriProvider, @@ -42,7 +40,6 @@ export function createChatController(deps: CreateChatControllerDeps): ChatContro isTauriRuntime, openExternalUrl, copyText, - readClipboardText, getPendingChatRevealStore: () => ({ getState: () => deps.stateStore.getState(), updateState: (updates) => deps.stateStore.updateState(updates), diff --git a/src/app/CoreComposition.ts b/src/app/CoreComposition.ts index 0447ebe6..b37a289a 100644 --- a/src/app/CoreComposition.ts +++ b/src/app/CoreComposition.ts @@ -93,6 +93,7 @@ export async function destroyCoreResources(args: DestroyCoreResourcesArgs): Prom () => args.aiBridge.destroy(), () => args.bridge.destroy(), () => args.errorHandler.destroy(), + () => args.globalTextContextMenu.destroy(), ]; const errors: unknown[] = []; diff --git a/src/app/CoreLifecycleController.test.ts b/src/app/CoreLifecycleController.test.ts index 82dd7eb1..5b0cd611 100644 --- a/src/app/CoreLifecycleController.test.ts +++ b/src/app/CoreLifecycleController.test.ts @@ -93,6 +93,7 @@ function createDeps(isDestroyed: () => boolean): CoreLifecycleDeps { aiBridge: {}, bridge: {}, errorHandler: {}, + globalTextContextMenu: { init: vi.fn(), destroy: vi.fn() }, }, state: { isDestroyed }, globalShortcutKeydown: vi.fn(), diff --git a/src/app/CoreLifecycleController.ts b/src/app/CoreLifecycleController.ts index 79920ab5..c793d33c 100644 --- a/src/app/CoreLifecycleController.ts +++ b/src/app/CoreLifecycleController.ts @@ -19,6 +19,7 @@ import type { ErrorHandler } from '@/shared/services/ErrorHandler'; import type { ModuleSettingsService } from '@/shared/services/modules/ModuleSettingsService'; import type { UiStateStore } from '@/shared/services/state/UiStateStore'; import type { AppUI } from '@/shared/shell/AppUI'; +import type { GlobalTextContextMenu } from '@/shared/shell/GlobalTextContextMenu'; import type { Particles } from '@/shared/shell/Particles'; import type { SidebarUI } from '@/shared/shell/SidebarUI'; import type { WindowUI } from '@/shared/shell/WindowUI'; @@ -113,6 +114,7 @@ export type CoreDisposables = { aiBridge: AIBridge; bridge: GlobalBridge; errorHandler: ErrorHandler; + globalTextContextMenu: GlobalTextContextMenu; }; export type CoreLifecycleDeps = { @@ -147,6 +149,7 @@ export class CoreLifecycleController { if (this._deps.state.isDestroyed()) { return; } + this._deps.disposables.globalTextContextMenu.init(); if (bootstrapResult.currentPage !== 'chat') { this.scheduleDeferredChatInit(); diff --git a/src/features/chat/chat.test.ts b/src/features/chat/chat.test.ts index f3d0e581..e18efd6a 100644 --- a/src/features/chat/chat.test.ts +++ b/src/features/chat/chat.test.ts @@ -125,7 +125,6 @@ describe('ChatController', () => { isTauriRuntime: vi.fn().mockReturnValue(false), openExternalUrl: vi.fn().mockResolvedValue(undefined), copyText: vi.fn().mockResolvedValue(undefined), - readClipboardText: vi.fn().mockResolvedValue(null), getPendingChatRevealStore: vi.fn().mockReturnValue(null), estimateTokens: vi.fn((text: string) => Promise.resolve(Math.max(1, Math.ceil(text.length / 4))), diff --git a/src/features/chat/chat.ts b/src/features/chat/chat.ts index 2e69622e..460be0e1 100644 --- a/src/features/chat/chat.ts +++ b/src/features/chat/chat.ts @@ -47,7 +47,6 @@ type ChatControllerDeps = { isTauriRuntime: () => boolean; openExternalUrl: (url: string) => Promise; copyText: (text: string) => Promise; - readClipboardText: () => Promise; getPendingChatRevealStore: () => PendingChatRevealStore | null; estimateTokens: (text: string, model?: string) => Promise; hostBridge: IBridge; @@ -163,7 +162,6 @@ export class ChatController { isTauriRuntime: deps.isTauriRuntime, openExternalUrl: deps.openExternalUrl, copyText: deps.copyText, - readClipboardText: deps.readClipboardText, }); } diff --git a/src/features/chat/services/ChatControllerFactory.ts b/src/features/chat/services/ChatControllerFactory.ts index 94519f57..0e96b108 100644 --- a/src/features/chat/services/ChatControllerFactory.ts +++ b/src/features/chat/services/ChatControllerFactory.ts @@ -32,7 +32,6 @@ type ChatUiFactoryDeps = ChatFactoryDeps & { isTauriRuntime: () => boolean; openExternalUrl: (url: string) => Promise; copyText: (text: string) => Promise; - readClipboardText: () => Promise; }; type ChatLifecycleFactoryDeps = { @@ -168,7 +167,6 @@ export class ChatControllerFactory { isTauriRuntime: () => deps.isTauriRuntime(), openExternalUrl: async (url) => await deps.openExternalUrl(url), copyText: async (text) => await deps.copyText(text), - readClipboardText: async () => await deps.readClipboardText(), tracer: deps.tracer, }); } diff --git a/src/features/chat/ui/ChatInputContextMenu.test.ts b/src/features/chat/ui/ChatInputContextMenu.test.ts deleted file mode 100644 index bfc7e63d..00000000 --- a/src/features/chat/ui/ChatInputContextMenu.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { ChatInputContextMenu } from './ChatInputContextMenu'; - -const translate = (key: string, fallback?: string) => `t:${key}:${fallback ?? ''}`; - -function setupMenu(options?: { canPaste?: boolean; clipboardText?: string | null }) { - document.body.innerHTML = ` -
    -
    - -
    Ask something
    -
    -
    - `; - const input = document.getElementById('chat-input') as HTMLTextAreaElement; - const field = document.querySelector('.chat-input-field') as HTMLElement; - const placeholder = document.getElementById('chat-input-placeholder') as HTMLElement; - const copyText = vi.fn().mockResolvedValue(undefined); - const readClipboardText = vi.fn().mockResolvedValue(options?.clipboardText ?? ' pasted'); - const warn = vi.fn(); - const menu = new ChatInputContextMenu({ - translate, - copyText, - readClipboardText, - canPaste: () => options?.canPaste ?? false, - tracer: { warn }, - }); - menu.bind(input); - return { input, field, placeholder, menu, copyText, readClipboardText, warn }; -} - -function openMenu(target: HTMLElement): MouseEvent { - const event = new MouseEvent('contextmenu', { - bubbles: true, - cancelable: true, - button: 2, - clientX: 24, - clientY: 32, - }); - target.dispatchEvent(event); - return event; -} - -async function openMenuAndFlush(target: HTMLElement): Promise { - const event = openMenu(target); - await Promise.resolve(); - return event; -} - -function getButton(action: string): HTMLButtonElement { - const button = document.querySelector( - `.chat-input-context-menu-item[data-action="${action}"]`, - ); - if (!(button instanceof HTMLButtonElement)) { - throw new Error(`Context menu action not found: ${action}`); - } - return button; -} - -describe('ChatInputContextMenu', () => { - beforeEach(() => { - vi.clearAllMocks(); - document.body.innerHTML = ''; - Object.defineProperty(globalThis.navigator, 'clipboard', { - configurable: true, - value: { - readText: vi.fn().mockResolvedValue(' pasted'), - }, - }); - }); - - it('opens a custom menu and prevents the browser context menu', async () => { - const { input, placeholder } = setupMenu(); - input.setSelectionRange(0, 5); - - const event = await openMenuAndFlush(placeholder); - - expect(event.defaultPrevented).toBe(true); - expect(document.querySelector('.chat-input-context-menu')).toBeInstanceOf(HTMLElement); - expect(getButton('copy').disabled).toBe(false); - expect(document.querySelector('[data-action="paste"]')).toBeNull(); - }); - - it('opens from the whole input bar in expanded chat state', async () => { - const { field } = setupMenu(); - const bar = document.querySelector('.chat-input-bar') as HTMLElement; - field.remove(); - - const event = await openMenuAndFlush(bar); - - expect(event.defaultPrevented).toBe(true); - expect(document.querySelector('.chat-input-context-menu')).toBeInstanceOf(HTMLElement); - }); - - it('pastes through the injected clipboard reader when available', async () => { - const { input, readClipboardText } = setupMenu({ canPaste: true, clipboardText: ' Tauri' }); - input.setSelectionRange(5, 5); - await openMenuAndFlush(input); - - getButton('paste').click(); - await Promise.resolve(); - - expect(readClipboardText).toHaveBeenCalledTimes(1); - expect(input.value).toBe('hello Tauri world'); - }); - - it('disables paste when the Tauri clipboard is empty', async () => { - const { input } = setupMenu({ canPaste: true, clipboardText: '' }); - - await openMenuAndFlush(input); - - expect(getButton('paste').disabled).toBe(true); - }); - - it('copies and cuts selected text', async () => { - const { input, copyText } = setupMenu(); - input.setSelectionRange(0, 5); - await openMenuAndFlush(input); - - getButton('copy').click(); - await Promise.resolve(); - - expect(copyText).toHaveBeenCalledWith('hello'); - - input.setSelectionRange(6, 11); - await openMenuAndFlush(input); - getButton('cut').click(); - await Promise.resolve(); - - expect(copyText).toHaveBeenLastCalledWith('world'); - expect(input.value).toBe('hello '); - }); - - it('selects all text and closes on Escape', async () => { - const { input } = setupMenu(); - await openMenuAndFlush(input); - - getButton('selectAll').click(); - - expect(input.selectionStart).toBe(0); - expect(input.selectionEnd).toBe(input.value.length); - expect(document.querySelector('.chat-input-context-menu')).toBeNull(); - - await openMenuAndFlush(input); - document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); - - expect(document.querySelector('.chat-input-context-menu')).toBeNull(); - }); -}); diff --git a/src/features/chat/ui/ChatInputContextMenu.ts b/src/features/chat/ui/ChatInputContextMenu.ts deleted file mode 100644 index 5eb1eea2..00000000 --- a/src/features/chat/ui/ChatInputContextMenu.ts +++ /dev/null @@ -1,330 +0,0 @@ -type ChatInputContextMenuTranslate = (key: string, fallback?: string) => string; - -type ChatInputContextMenuDeps = { - translate: ChatInputContextMenuTranslate; - copyText: (text: string) => Promise; - readClipboardText: () => Promise; - canPaste: () => boolean; - tracer: { - warn: (message: string, ...args: unknown[]) => void; - }; -}; - -type ChatInputContextMenuAction = 'cut' | 'copy' | 'paste' | 'selectAll'; - -type ChatInputContextMenuItem = { - action: ChatInputContextMenuAction; - labelKey: string; - fallback: string; - shortcut?: string; - dividerBefore?: boolean; -}; - -const CHAT_INPUT_CONTEXT_MENU_ITEMS: ChatInputContextMenuItem[] = [ - { - action: 'cut', - labelKey: 'ui.chat.input_menu.cut', - fallback: 'Cut', - shortcut: 'Ctrl+X', - }, - { - action: 'copy', - labelKey: 'ui.chat.input_menu.copy', - fallback: 'Copy', - shortcut: 'Ctrl+C', - }, - { - action: 'paste', - labelKey: 'ui.chat.input_menu.paste', - fallback: 'Paste', - shortcut: 'Ctrl+V', - }, - { - action: 'selectAll', - labelKey: 'ui.chat.input_menu.select_all', - fallback: 'Select all', - shortcut: 'Ctrl+A', - dividerBefore: true, - }, -]; - -export class ChatInputContextMenu { - private _openRequestId = 0; - private _menu: HTMLDivElement | null = null; - private _input: HTMLTextAreaElement | null = null; - private _target: HTMLElement | null = null; - private _clipboardText: string | null = null; - private _boundContextMenu: (event: MouseEvent) => void; - private _boundDocumentPointerDown: (event: PointerEvent) => void; - private _boundKeyDown: (event: KeyboardEvent) => void; - private _boundClose: () => void; - - public constructor(private readonly _deps: ChatInputContextMenuDeps) { - this._boundContextMenu = (event) => { - this._handleContextMenu(event); - }; - this._boundDocumentPointerDown = (event) => { - const target = event.target; - if (target instanceof Node && this._menu?.contains(target) === true) { - return; - } - this.close(); - }; - this._boundKeyDown = (event) => { - if (event.key === 'Escape') { - this.close(); - } - }; - this._boundClose = () => { - this.close(); - }; - } - - public bind(input: HTMLTextAreaElement | null): void { - if (this._input === input) { - return; - } - - this.destroy(); - this._input = input; - this._target = this._resolveContextTarget(input); - this._target?.addEventListener('contextmenu', this._boundContextMenu); - } - - public destroy(): void { - this._target?.removeEventListener('contextmenu', this._boundContextMenu); - this._target = null; - this._input = null; - this.close(); - } - - public close(): void { - this._openRequestId += 1; - this._menu?.remove(); - this._menu = null; - this._clipboardText = null; - document.removeEventListener('pointerdown', this._boundDocumentPointerDown, { - capture: true, - }); - document.removeEventListener('keydown', this._boundKeyDown, { capture: true }); - window.removeEventListener('blur', this._boundClose); - window.removeEventListener('resize', this._boundClose); - document.removeEventListener('scroll', this._boundClose, { capture: true }); - } - - private _handleContextMenu(event: MouseEvent): void { - const input = this._input; - if (input === null || input.disabled || input.readOnly) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - - input.focus(); - this._open(input, event.clientX, event.clientY); - } - - private _resolveContextTarget(input: HTMLTextAreaElement | null): HTMLElement | null { - if (input === null) { - return null; - } - - return ( - input.closest('.chat-input-bar') ?? - input.closest('.chat-input-field') ?? - input - ); - } - - private _open(input: HTMLTextAreaElement, clientX: number, clientY: number): void { - this.close(); - const openRequestId = this._openRequestId; - void this._openWithClipboardState(input, clientX, clientY, openRequestId); - } - - private async _openWithClipboardState( - input: HTMLTextAreaElement, - clientX: number, - clientY: number, - openRequestId: number, - ): Promise { - const clipboardText = await this._readClipboardForMenu(); - if (openRequestId !== this._openRequestId || this._input !== input) { - return; - } - - this._clipboardText = clipboardText; - - const menu = document.createElement('div'); - menu.className = 'chat-input-context-menu'; - menu.setAttribute('role', 'menu'); - menu.tabIndex = -1; - - const state = this._getState(input); - this._getVisibleItems().forEach((item) => { - if (item.dividerBefore === true) { - const divider = document.createElement('div'); - divider.className = 'chat-input-context-menu-divider'; - divider.setAttribute('role', 'separator'); - menu.appendChild(divider); - } - - menu.appendChild(this._createButton(input, item, state)); - }); - - if (openRequestId !== this._openRequestId || this._input !== input) { - return; - } - - document.body.appendChild(menu); - this._menu = menu; - this._positionMenu(menu, clientX, clientY); - menu.focus({ preventScroll: true }); - - document.addEventListener('pointerdown', this._boundDocumentPointerDown, { - capture: true, - }); - document.addEventListener('keydown', this._boundKeyDown, { capture: true }); - window.addEventListener('blur', this._boundClose); - window.addEventListener('resize', this._boundClose); - document.addEventListener('scroll', this._boundClose, { capture: true }); - } - - private async _readClipboardForMenu(): Promise { - if (!this._deps.canPaste()) { - return null; - } - - try { - return await this._deps.readClipboardText(); - } catch (error) { - this._deps.tracer.warn('[ChatInputContextMenu] Clipboard read failed:', error); - return null; - } - } - - private _createButton( - input: HTMLTextAreaElement, - item: ChatInputContextMenuItem, - state: { hasSelection: boolean; hasText: boolean }, - ): HTMLButtonElement { - const button = document.createElement('button'); - button.type = 'button'; - button.className = 'chat-input-context-menu-item'; - button.dataset['action'] = item.action; - button.setAttribute('role', 'menuitem'); - button.disabled = this._isDisabled(item.action, state); - - const label = document.createElement('span'); - label.className = 'chat-input-context-menu-label'; - label.textContent = this._deps.translate(item.labelKey, item.fallback); - button.appendChild(label); - - if (item.shortcut !== undefined) { - const shortcut = document.createElement('span'); - shortcut.className = 'chat-input-context-menu-shortcut'; - shortcut.textContent = item.shortcut; - button.appendChild(shortcut); - } - - button.addEventListener('click', () => { - void this._runAction(input, item.action); - }); - - return button; - } - - private _getState(input: HTMLTextAreaElement): { - hasSelection: boolean; - hasText: boolean; - } { - return { - hasSelection: input.selectionStart !== input.selectionEnd, - hasText: input.value.length > 0, - }; - } - - private _isDisabled( - action: ChatInputContextMenuAction, - state: { hasSelection: boolean; hasText: boolean }, - ): boolean { - if (action === 'cut' || action === 'copy') { - return !state.hasSelection; - } - if (action === 'selectAll') { - return !state.hasText; - } - return this._clipboardText === null || this._clipboardText === ''; - } - - private async _runAction( - input: HTMLTextAreaElement, - action: ChatInputContextMenuAction, - ): Promise { - try { - if (action === 'selectAll') { - input.focus(); - input.select(); - this.close(); - return; - } - - if (action === 'copy' || action === 'cut') { - const selectedText = input.value.slice(input.selectionStart, input.selectionEnd); - if (selectedText === '') { - this.close(); - return; - } - - await this._deps.copyText(selectedText); - if (action === 'cut') { - input.setRangeText('', input.selectionStart, input.selectionEnd, 'start'); - this._dispatchInputChange(input); - } - this.close(); - return; - } - - const text = this._clipboardText; - if (text !== null && text !== '') { - input.focus(); - input.setRangeText(text, input.selectionStart, input.selectionEnd, 'end'); - this._dispatchInputChange(input); - } - this.close(); - } catch (error) { - this._deps.tracer.warn('[ChatInputContextMenu] Action failed:', error); - this.close(); - } - } - - private _getVisibleItems(): ChatInputContextMenuItem[] { - if (this._deps.canPaste()) { - return CHAT_INPUT_CONTEXT_MENU_ITEMS; - } - - return CHAT_INPUT_CONTEXT_MENU_ITEMS.filter((item) => item.action !== 'paste'); - } - - private _dispatchInputChange(input: HTMLTextAreaElement): void { - input.dispatchEvent(new Event('input', { bubbles: true })); - input.dispatchEvent(new Event('change', { bubbles: true })); - } - - private _positionMenu(menu: HTMLElement, clientX: number, clientY: number): void { - const viewportPadding = 8; - const rect = menu.getBoundingClientRect(); - const left = Math.min( - Math.max(clientX, viewportPadding), - window.innerWidth - rect.width - viewportPadding, - ); - const top = Math.min( - Math.max(clientY, viewportPadding), - window.innerHeight - rect.height - viewportPadding, - ); - - menu.style.left = `${Math.round(left)}px`; - menu.style.top = `${Math.round(top)}px`; - } -} diff --git a/src/features/chat/ui/ChatUI.test.ts b/src/features/chat/ui/ChatUI.test.ts index db5d92f9..b93b34e7 100644 --- a/src/features/chat/ui/ChatUI.test.ts +++ b/src/features/chat/ui/ChatUI.test.ts @@ -55,13 +55,6 @@ function createChatUI(options?: { await navigator.clipboard.writeText(text); }, - readClipboardText: async () => { - if (!isTauriRuntime) { - return null; - } - - return await invoke('plugin:clipboard-manager|read_text'); - }, tracer: chatUiTracer, }); } diff --git a/src/features/chat/ui/ChatUI.ts b/src/features/chat/ui/ChatUI.ts index 32731230..47e94fd8 100644 --- a/src/features/chat/ui/ChatUI.ts +++ b/src/features/chat/ui/ChatUI.ts @@ -3,7 +3,6 @@ import { ChatAttachmentRenderer } from './ChatAttachmentRenderer'; import { extractErrorMessage, safeExtractText } from './ChatContentFormatter'; import { createChatImageGenerationMessage } from './ChatImageGenerationMessage'; import { ChatImageController } from './ChatImageController'; -import { ChatInputContextMenu } from './ChatInputContextMenu'; import { configureChatMarkdown } from './ChatMarkdown'; import { ChatMessageInteractionController } from './ChatMessageInteractionController'; import { ChatMessageRenderer } from './ChatMessageRenderer'; @@ -40,7 +39,6 @@ type ChatUIDeps = { isTauriRuntime: () => boolean; openExternalUrl: (url: string) => Promise; copyText: (text: string) => Promise; - readClipboardText: () => Promise; tracer: Pick; }; @@ -67,7 +65,6 @@ export class ChatUI { private _attachmentRenderVersion = 0; private readonly _attachmentRenderer: ChatAttachmentRenderer; private readonly _imageController: ChatImageController; - private readonly _inputContextMenu: ChatInputContextMenu; private readonly _messageInteractionController: ChatMessageInteractionController; private readonly _messageRenderer: ChatMessageRenderer; private readonly _typingController: ChatTypingController; @@ -102,25 +99,6 @@ export class ChatUI { translate: this._translate, tracer: deps.tracer, }); - this._inputContextMenu = new ChatInputContextMenu({ - translate: this._translate, - copyText: (text) => deps.copyText(text), - readClipboardText: () => deps.readClipboardText(), - canPaste: () => { - const browserGlobals = globalThis as unknown as { - navigator?: { - clipboard?: { - readText?: unknown; - }; - }; - }; - return ( - deps.isTauriRuntime() || - typeof browserGlobals.navigator?.clipboard?.readText === 'function' - ); - }, - tracer: deps.tracer, - }); this._attachmentRenderer = new ChatAttachmentRenderer({ fileHandler: deps.fileHandler, isDestroyed: () => this._isDestroyed, @@ -174,7 +152,6 @@ export class ChatUI { if (this._isInitialized || this._isDestroyed) return; this._isInitialized = true; document.addEventListener('click', this._boundDocumentClick); - this._inputContextMenu.bind(this._dom.chatInput); await this._retryStatusListener.bind(); } @@ -184,7 +161,6 @@ export class ChatUI { this._isInitialized = false; document.removeEventListener('click', this._boundDocumentClick); - this._inputContextMenu.destroy(); this._retryStatusListener.destroy(); this._imageController.destroy(); this._attachmentRenderer.revokeAttachmentObjectUrls(); diff --git a/src/shared/shell/GlobalTextContextMenu.test.ts b/src/shared/shell/GlobalTextContextMenu.test.ts new file mode 100644 index 00000000..a0ad6a44 --- /dev/null +++ b/src/shared/shell/GlobalTextContextMenu.test.ts @@ -0,0 +1,107 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { GlobalTextContextMenu } from './GlobalTextContextMenu'; + +function createMenu(options?: { clipboardText?: string | null }) { + const copyText = vi.fn().mockResolvedValue(undefined); + const readClipboardText = vi.fn().mockResolvedValue(options?.clipboardText ?? ' pasted'); + const warn = vi.fn(); + const menu = new GlobalTextContextMenu({ + translate: (_key, fallback) => fallback ?? _key, + copyText, + readClipboardText, + tracer: { warn }, + }); + + menu.init(); + return { menu, copyText, readClipboardText, warn }; +} + +function openContextMenu(target: HTMLElement): void { + target.dispatchEvent( + new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + clientX: 20, + clientY: 30, + }), + ); +} + +function getButton(action: string): HTMLButtonElement { + const button = document.querySelector(`[data-action="${action}"]`); + expect(button).not.toBeNull(); + return button as HTMLButtonElement; +} + +describe('GlobalTextContextMenu', () => { + let activeMenu: GlobalTextContextMenu | null = null; + + beforeEach(() => { + document.body.innerHTML = ''; + }); + + afterEach(() => { + activeMenu?.destroy(); + activeMenu = null; + }); + + it('replaces the browser menu for normal inputs', async () => { + const input = document.createElement('input'); + input.value = 'hello'; + input.setSelectionRange(0, 5); + document.body.appendChild(input); + + activeMenu = createMenu().menu; + openContextMenu(input); + await vi.waitFor(() => { + expect(document.querySelector('.chat-input-context-menu')).not.toBeNull(); + }); + + expect(getButton('copy').disabled).toBe(false); + expect(getButton('paste').disabled).toBe(false); + }); + + it('copies, cuts, pastes, and selects text controls', async () => { + const input = document.createElement('input'); + input.value = 'hello world'; + input.setSelectionRange(0, 5); + document.body.appendChild(input); + const created = createMenu({ clipboardText: 'Axelate' }); + activeMenu = created.menu; + const { copyText } = created; + + openContextMenu(input); + await vi.waitFor(() => expect(getButton('copy')).not.toBeNull()); + getButton('copy').click(); + await vi.waitFor(() => expect(copyText).toHaveBeenCalledWith('hello')); + + input.setSelectionRange(6, 11); + openContextMenu(input); + await vi.waitFor(() => expect(getButton('cut')).not.toBeNull()); + getButton('cut').click(); + await vi.waitFor(() => expect(input.value).toBe('hello ')); + + openContextMenu(input); + await vi.waitFor(() => expect(getButton('paste')).not.toBeNull()); + getButton('paste').click(); + await vi.waitFor(() => expect(input.value).toBe('hello Axelate')); + + openContextMenu(input); + await vi.waitFor(() => expect(getButton('selectAll')).not.toBeNull()); + getButton('selectAll').click(); + expect(input.selectionStart).toBe(0); + expect(input.selectionEnd).toBe(input.value.length); + }); + + it('does not open on non-editable launcher chrome', async () => { + const button = document.createElement('button'); + button.textContent = 'Home'; + document.body.appendChild(button); + activeMenu = createMenu().menu; + + openContextMenu(button); + await Promise.resolve(); + + expect(document.querySelector('.chat-input-context-menu')).toBeNull(); + }); +}); diff --git a/src/shared/shell/GlobalTextContextMenu.ts b/src/shared/shell/GlobalTextContextMenu.ts new file mode 100644 index 00000000..516ca67f --- /dev/null +++ b/src/shared/shell/GlobalTextContextMenu.ts @@ -0,0 +1,384 @@ +type GlobalTextContextMenuTranslate = (key: string, fallback?: string) => string; + +type GlobalTextContextMenuDeps = { + translate: GlobalTextContextMenuTranslate; + copyText: (text: string) => Promise; + readClipboardText: () => Promise; + tracer: { + warn: (message: string, ...args: unknown[]) => void; + }; +}; + +type TextContextAction = 'cut' | 'copy' | 'paste' | 'selectAll'; + +type TextContextItem = { + action: TextContextAction; + labelKey: string; + fallback: string; + shortcut?: string; + dividerBefore?: boolean; +}; + +type EditableTarget = HTMLInputElement | HTMLTextAreaElement | HTMLElement; + +const TEXT_CONTEXT_ITEMS: TextContextItem[] = [ + { + action: 'cut', + labelKey: 'ui.chat.input_menu.cut', + fallback: 'Cut', + shortcut: 'Ctrl+X', + }, + { + action: 'copy', + labelKey: 'ui.chat.input_menu.copy', + fallback: 'Copy', + shortcut: 'Ctrl+C', + }, + { + action: 'paste', + labelKey: 'ui.chat.input_menu.paste', + fallback: 'Paste', + shortcut: 'Ctrl+V', + }, + { + action: 'selectAll', + labelKey: 'ui.chat.input_menu.select_all', + fallback: 'Select all', + shortcut: 'Ctrl+A', + dividerBefore: true, + }, +]; + +export class GlobalTextContextMenu { + private _menu: HTMLDivElement | null = null; + private _target: EditableTarget | null = null; + private _clipboardText: string | null = null; + private _openToken = 0; + private _abort: AbortController | null = null; + + private readonly _boundContextMenu = (event: MouseEvent) => { + this._handleContextMenu(event); + }; + private readonly _boundDocumentPointerDown = (event: PointerEvent) => { + const target = event.target; + if (target instanceof Node && this._menu?.contains(target) === true) { + return; + } + this.close(); + }; + private readonly _boundKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + this.close(); + } + }; + private readonly _boundClose = () => { + this.close(); + }; + + public constructor(private readonly _deps: GlobalTextContextMenuDeps) {} + + public init(): void { + if (this._abort !== null) return; + + this._abort = new AbortController(); + document.addEventListener('contextmenu', this._boundContextMenu, { + signal: this._abort.signal, + }); + } + + public destroy(): void { + this._abort?.abort(); + this._abort = null; + this.close(); + } + + public close(): void { + this._openToken += 1; + this._menu?.remove(); + this._menu = null; + this._target = null; + this._clipboardText = null; + document.removeEventListener('pointerdown', this._boundDocumentPointerDown, { + capture: true, + }); + document.removeEventListener('keydown', this._boundKeyDown, { capture: true }); + window.removeEventListener('blur', this._boundClose); + window.removeEventListener('resize', this._boundClose); + document.removeEventListener('scroll', this._boundClose, { capture: true }); + } + + private _handleContextMenu(event: MouseEvent): void { + const target = this._resolveEditableTarget(event.target); + if (target === null) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + target.focus({ preventScroll: true }); + this._open(target, event.clientX, event.clientY); + } + + private _resolveEditableTarget(target: EventTarget | null): EditableTarget | null { + if (!(target instanceof Element)) { + return null; + } + + const editable = target.closest( + 'input, textarea, [contenteditable="true"], [role="textbox"]', + ); + if (editable === null) { + return null; + } + + if (editable instanceof HTMLInputElement) { + const type = editable.type.toLowerCase(); + if ( + [ + 'button', + 'checkbox', + 'color', + 'file', + 'hidden', + 'image', + 'radio', + 'range', + 'reset', + 'submit', + ].includes(type) + ) { + return null; + } + return editable; + } + + return editable; + } + + private _open(target: EditableTarget, clientX: number, clientY: number): void { + this.close(); + const openToken = this._openToken; + this._target = target; + void this._openWithClipboardState(target, clientX, clientY, openToken); + } + + private async _openWithClipboardState( + target: EditableTarget, + clientX: number, + clientY: number, + openToken: number, + ): Promise { + const clipboardText = await this._readClipboardForMenu(); + if (this._openToken !== openToken || this._target !== target) { + return; + } + + this._clipboardText = clipboardText; + + const menu = document.createElement('div'); + menu.className = 'chat-input-context-menu'; + menu.setAttribute('role', 'menu'); + menu.tabIndex = -1; + + const state = this._getState(target); + for (const item of TEXT_CONTEXT_ITEMS) { + if (item.dividerBefore === true) { + const divider = document.createElement('div'); + divider.className = 'chat-input-context-menu-divider'; + divider.setAttribute('role', 'separator'); + menu.appendChild(divider); + } + menu.appendChild(this._createButton(target, item, state)); + } + + if (this._openToken !== openToken || this._target !== target) { + return; + } + + document.body.appendChild(menu); + this._menu = menu; + this._positionMenu(menu, clientX, clientY); + menu.focus({ preventScroll: true }); + + document.addEventListener('pointerdown', this._boundDocumentPointerDown, { + capture: true, + }); + document.addEventListener('keydown', this._boundKeyDown, { capture: true }); + window.addEventListener('blur', this._boundClose); + window.addEventListener('resize', this._boundClose); + document.addEventListener('scroll', this._boundClose, { capture: true }); + } + + private async _readClipboardForMenu(): Promise { + try { + return await this._deps.readClipboardText(); + } catch (error) { + this._deps.tracer.warn('[GlobalTextContextMenu] Clipboard read failed:', error); + return null; + } + } + + private _createButton( + target: EditableTarget, + item: TextContextItem, + state: { hasSelection: boolean; hasText: boolean; readOnly: boolean }, + ): HTMLButtonElement { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'chat-input-context-menu-item'; + button.dataset['action'] = item.action; + button.setAttribute('role', 'menuitem'); + button.disabled = this._isDisabled(item.action, state); + + const label = document.createElement('span'); + label.className = 'chat-input-context-menu-label'; + label.textContent = this._deps.translate(item.labelKey, item.fallback); + button.appendChild(label); + + if (item.shortcut !== undefined) { + const shortcut = document.createElement('span'); + shortcut.className = 'chat-input-context-menu-shortcut'; + shortcut.textContent = item.shortcut; + button.appendChild(shortcut); + } + + button.addEventListener('click', () => { + void this._runAction(target, item.action); + }); + + return button; + } + + private _getState(target: EditableTarget): { + hasSelection: boolean; + hasText: boolean; + readOnly: boolean; + } { + if (this._isTextControl(target)) { + return { + hasSelection: target.selectionStart !== target.selectionEnd, + hasText: target.value.length > 0, + readOnly: target.readOnly || target.disabled, + }; + } + + const text = target.textContent; + return { + hasSelection: window.getSelection()?.toString() !== '', + hasText: text.length > 0, + readOnly: target.getAttribute('contenteditable') !== 'true', + }; + } + + private _isDisabled( + action: TextContextAction, + state: { hasSelection: boolean; hasText: boolean; readOnly: boolean }, + ): boolean { + if (action === 'cut') { + return state.readOnly || !state.hasSelection; + } + if (action === 'copy') { + return !state.hasSelection; + } + if (action === 'paste') { + return state.readOnly || this._clipboardText === null || this._clipboardText === ''; + } + return !state.hasText; + } + + private async _runAction(target: EditableTarget, action: TextContextAction): Promise { + try { + if (action === 'selectAll') { + this._selectAll(target); + this.close(); + return; + } + + if (action === 'copy' || action === 'cut') { + const selectedText = this._selectedText(target); + if (selectedText === '') { + this.close(); + return; + } + + await this._deps.copyText(selectedText); + if (action === 'cut') { + this._replaceSelection(target, ''); + } + this.close(); + return; + } + + const text = this._clipboardText; + if (text !== null && text !== '') { + this._replaceSelection(target, text); + } + this.close(); + } catch (error) { + this._deps.tracer.warn('[GlobalTextContextMenu] Action failed:', error); + this.close(); + } + } + + private _selectedText(target: EditableTarget): string { + if (this._isTextControl(target)) { + return target.value.slice(target.selectionStart ?? 0, target.selectionEnd ?? 0); + } + + return window.getSelection()?.toString() ?? ''; + } + + private _replaceSelection(target: EditableTarget, text: string): void { + target.focus({ preventScroll: true }); + if (this._isTextControl(target)) { + target.setRangeText(text, target.selectionStart ?? 0, target.selectionEnd ?? 0, 'end'); + this._dispatchInputChange(target); + return; + } + + document.execCommand('insertText', false, text); + this._dispatchInputChange(target); + } + + private _selectAll(target: EditableTarget): void { + target.focus({ preventScroll: true }); + if (this._isTextControl(target)) { + target.select(); + return; + } + + const range = document.createRange(); + range.selectNodeContents(target); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + } + + private _dispatchInputChange(target: EditableTarget): void { + target.dispatchEvent(new Event('input', { bubbles: true })); + target.dispatchEvent(new Event('change', { bubbles: true })); + } + + private _isTextControl( + target: EditableTarget, + ): target is HTMLInputElement | HTMLTextAreaElement { + return target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement; + } + + private _positionMenu(menu: HTMLElement, clientX: number, clientY: number): void { + const viewportPadding = 8; + const rect = menu.getBoundingClientRect(); + const left = Math.min( + Math.max(clientX, viewportPadding), + window.innerWidth - rect.width - viewportPadding, + ); + const top = Math.min( + Math.max(clientY, viewportPadding), + window.innerHeight - rect.height - viewportPadding, + ); + + menu.style.left = `${Math.round(left)}px`; + menu.style.top = `${Math.round(top)}px`; + } +} From 5c271b73c24aac6075439744c2c7d2b87a787c35 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 30 Apr 2026 12:35:59 +0300 Subject: [PATCH 032/126] fix: stabilize generated image scroll --- .../chat/ui/ChatImageGenerationMessage.ts | 10 ++++- src/features/chat/ui/ChatUI.test.ts | 40 +++++++++++++++++++ src/features/chat/ui/ChatUI.ts | 5 +++ .../chat/ui/ChatViewportController.ts | 27 ++++++++----- 4 files changed, 70 insertions(+), 12 deletions(-) diff --git a/src/features/chat/ui/ChatImageGenerationMessage.ts b/src/features/chat/ui/ChatImageGenerationMessage.ts index 352fbff9..2b579559 100644 --- a/src/features/chat/ui/ChatImageGenerationMessage.ts +++ b/src/features/chat/ui/ChatImageGenerationMessage.ts @@ -23,6 +23,7 @@ type CreateChatImageGenerationMessageDeps = { isDestroyed: () => boolean; tracer: { error: (message: string, error?: unknown) => void }; scrollToBottom: (sticky?: boolean) => void; + isNearBottom: () => boolean; appendRow: (row: HTMLElement) => void; createMessageBubble: (opts: Record) => HTMLElement; appendMessageActions: ( @@ -105,12 +106,17 @@ export function createChatImageGenerationMessage( image.decoding = 'async'; media.appendChild(image); + let keepPinnedAfterImageLoad = false; + const syncMediaSizeToImage = (): void => { const naturalWidth = image.naturalWidth; const naturalHeight = image.naturalHeight; if (naturalWidth <= 0 || naturalHeight <= 0) return; media.style.aspectRatio = `${String(naturalWidth)} / ${String(naturalHeight)}`; + if (keepPinnedAfterImageLoad) { + deps.scrollToBottom(); + } }; image.addEventListener('load', syncMediaSizeToImage); @@ -178,11 +184,13 @@ export function createChatImageGenerationMessage( const showPreview = (dataUrl: string): void => { if (dataUrl.trim() === '') return; if (image.src === dataUrl) return; + const shouldKeepPinned = deps.isNearBottom(); + keepPinnedAfterImageLoad = shouldKeepPinned; detachMediaFromBubble(); image.src = dataUrl; syncMediaSizeToImage(); media.classList.remove('hidden'); - deps.scrollToBottom(true); + deps.scrollToBottom(!shouldKeepPinned); }; const hideProgress = (): void => { diff --git a/src/features/chat/ui/ChatUI.test.ts b/src/features/chat/ui/ChatUI.test.ts index b93b34e7..da50d84e 100644 --- a/src/features/chat/ui/ChatUI.test.ts +++ b/src/features/chat/ui/ChatUI.test.ts @@ -458,6 +458,46 @@ describe('ChatUI lifecycle', () => { ).toBe(true); }); + it('should keep generated image preview pinned when the chat was already at bottom', () => { + document.body.innerHTML = '
    '; + const messages = document.getElementById('chat-messages') as HTMLDivElement; + + Object.defineProperty(messages, 'clientHeight', { configurable: true, value: 400 }); + Object.defineProperty(messages, 'scrollHeight', { + configurable: true, + get: () => + document.querySelector('.chat-generated-media:not(.hidden)') === null ? 1000 : 2000, + }); + + ui = createChatUI(); + const handle = ui.createImageGenerationMessage(); + expect(messages.scrollTop).toBe(1000); + + handle.setPreview('data:image/png;base64,dGVzdA=='); + + expect(messages.scrollTop).toBe(2000); + }); + + it('should not force generated image preview to bottom when the user scrolled up', () => { + document.body.innerHTML = '
    '; + const messages = document.getElementById('chat-messages') as HTMLDivElement; + + Object.defineProperty(messages, 'clientHeight', { configurable: true, value: 400 }); + Object.defineProperty(messages, 'scrollHeight', { + configurable: true, + get: () => + document.querySelector('.chat-generated-media:not(.hidden)') === null ? 1000 : 2000, + }); + + ui = createChatUI(); + const handle = ui.createImageGenerationMessage(); + messages.scrollTop = 100; + + handle.setPreview('data:image/png;base64,dGVzdA=='); + + expect(messages.scrollTop).toBe(100); + }); + it('should scroll chat history to the bottom after restore', () => { vi.useFakeTimers(); document.body.innerHTML = '
    '; diff --git a/src/features/chat/ui/ChatUI.ts b/src/features/chat/ui/ChatUI.ts index 47e94fd8..96c3fa59 100644 --- a/src/features/chat/ui/ChatUI.ts +++ b/src/features/chat/ui/ChatUI.ts @@ -299,6 +299,7 @@ export class ChatUI { isDestroyed: () => this._isDestroyed, tracer: this._deps.tracer, scrollToBottom: (sticky) => this._scrollToBottom(sticky), + isNearBottom: () => this._isNearBottom(), appendRow: (row) => { this._dom.messagesContainer?.appendChild(row); }, @@ -322,6 +323,10 @@ export class ChatUI { this._viewportController.scrollToBottom(this._dom.messagesContainer, sticky); } + private _isNearBottom(): boolean { + return this._viewportController.isNearBottom(this._dom.messagesContainer); + } + public revealLatestMessage(): void { this._scrollToBottom(); this._setManagedTimeout(() => { diff --git a/src/features/chat/ui/ChatViewportController.ts b/src/features/chat/ui/ChatViewportController.ts index e278dc17..7d4626c8 100644 --- a/src/features/chat/ui/ChatViewportController.ts +++ b/src/features/chat/ui/ChatViewportController.ts @@ -28,19 +28,24 @@ export class ChatViewportController { return; } - if (sticky) { - const threshold = 150; - const isAtBottom = - messagesContainer.scrollHeight - - messagesContainer.scrollTop - - messagesContainer.clientHeight < - threshold; - - if (!isAtBottom) { - return; - } + if (sticky && !this.isNearBottom(messagesContainer)) { + return; } messagesContainer.scrollTop = messagesContainer.scrollHeight; } + + public isNearBottom(messagesContainer: HTMLElement | null): boolean { + if (messagesContainer === null) { + return false; + } + + const threshold = 150; + return ( + messagesContainer.scrollHeight - + messagesContainer.scrollTop - + messagesContainer.clientHeight < + threshold + ); + } } From 12707811ee14e98aea589ab2824004ffde249c03 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 30 Apr 2026 12:57:43 +0300 Subject: [PATCH 033/126] fix: harden chat send flow --- src/features/chat/chat.test.ts | 20 +++++++ src/features/chat/chat.ts | 36 ++++++++++-- .../controllers/ChatSendController.test.ts | 46 ++++++++++++++++ .../chat/controllers/ChatSendController.ts | 2 + .../chat/services/ChatControllerFactory.ts | 2 + .../chat/services/ChatFileHandler.test.ts | 19 ------- src/features/chat/services/ChatFileHandler.ts | 17 ------ src/features/chat/services/ChatSendFlow.ts | 29 +++++++--- .../chat/ui/ChatAttachmentRenderer.ts | 55 ++++++++++++------- src/features/chat/ui/ChatUI.test.ts | 22 ++++++++ 10 files changed, 179 insertions(+), 69 deletions(-) diff --git a/src/features/chat/chat.test.ts b/src/features/chat/chat.test.ts index e18efd6a..79250614 100644 --- a/src/features/chat/chat.test.ts +++ b/src/features/chat/chat.test.ts @@ -97,6 +97,7 @@ type ChatControllerTestAccess = { sendChat: () => Promise; clearChat: () => Promise; destroy: () => void; + toggleAttachMenu: () => void; }; describe('ChatController', () => { @@ -216,6 +217,25 @@ describe('ChatController', () => { expect(mockChatFileHandlerInstances[0]?.clearUpdateCallback).toHaveBeenCalledTimes(1); }); + it('should remove attach menu listeners on destroy', () => { + vi.useFakeTimers(); + const removeEventListener = vi.spyOn(document, 'removeEventListener'); + document.body.innerHTML = ` +
    + +
    + `; + const controller = createController(); + + controller.toggleAttachMenu(); + vi.runOnlyPendingTimers(); + controller.destroy(); + + expect(document.querySelector('.chat-attach-menu')).toBeNull(); + expect(removeEventListener).toHaveBeenCalledWith('mousedown', expect.any(Function), true); + vi.useRealTimers(); + }); + it('should restore multimodal history without flattening stored content', async () => { aiBridge.getHistory.mockResolvedValueOnce([ { diff --git a/src/features/chat/chat.ts b/src/features/chat/chat.ts index 460be0e1..06e7c227 100644 --- a/src/features/chat/chat.ts +++ b/src/features/chat/chat.ts @@ -79,6 +79,8 @@ export class ChatController { private readonly _sendController: ChatSendController; private readonly _state = new ChatControllerState(); private _restoredImageGenerationTimer: ReturnType | null = null; + private _attachMenuOpenTimer: ReturnType | null = null; + private _attachMenuCloseHandler: ((event: MouseEvent) => void) | null = null; private _forceImageGeneration = false; private _contextTokenTotal = 0; private _contextTokenVersion = 0; @@ -309,6 +311,7 @@ export class ChatController { fileHandler: this._fileHandler, service: this._service, getHistory: () => this._state.history, + estimateTokens: async (text) => await deps.estimateTokens(text), pushUserMessage: (content) => { this._state.pushHistoryMessage({ role: 'user', content }); }, @@ -432,6 +435,7 @@ export class ChatController { this._lifecycleHelper.stop(); this._viewHelper.unbindEvents(); this._generationController.stopImagePreviewPolling(); + this._closeAttachMenu(); this._uiStateHelper.dispose(); this._sendController.destroy(); this._historyController.destroy(); @@ -518,6 +522,18 @@ export class ChatController { this._restoredImageGenerationTimer = null; } + private _closeAttachMenu(): void { + if (this._attachMenuOpenTimer !== null) { + globalThis.clearTimeout(this._attachMenuOpenTimer); + this._attachMenuOpenTimer = null; + } + if (this._attachMenuCloseHandler !== null) { + document.removeEventListener('mousedown', this._attachMenuCloseHandler, true); + this._attachMenuCloseHandler = null; + } + document.querySelector('.chat-attach-menu')?.remove(); + } + // --- Public Actions --- public async pickChatFiles(): Promise { @@ -527,10 +543,12 @@ export class ChatController { public toggleAttachMenu(): void { const existing = document.querySelector('.chat-attach-menu'); if (existing instanceof HTMLElement) { - existing.remove(); + this._closeAttachMenu(); return; } + this._closeAttachMenu(); + const button = document.getElementById('chat-attach-btn'); const compose = document.getElementById('chat-compose'); if (!(button instanceof HTMLElement) || !(compose instanceof HTMLElement)) { @@ -562,19 +580,25 @@ export class ChatController { if (event.target instanceof Node && button.contains(event.target)) { return; } - menu.remove(); - document.removeEventListener('mousedown', close, true); + this._closeAttachMenu(); }; - setTimeout(() => document.addEventListener('mousedown', close, true), 0); + this._attachMenuCloseHandler = close; + this._attachMenuOpenTimer = globalThis.setTimeout(() => { + this._attachMenuOpenTimer = null; + if (this._state.isDestroyed || !menu.isConnected) { + return; + } + document.addEventListener('mousedown', close, true); + }, 0); } public async pickChatFilesFromMenu(): Promise { - document.querySelector('.chat-attach-menu')?.remove(); + this._closeAttachMenu(); await this.pickChatFiles(); } public async sendImageGenerationFromMenu(): Promise { - document.querySelector('.chat-attach-menu')?.remove(); + this._closeAttachMenu(); this._forceImageGeneration = true; await this.sendChat(); } diff --git a/src/features/chat/controllers/ChatSendController.test.ts b/src/features/chat/controllers/ChatSendController.test.ts index 2e7671ab..00f7e7bb 100644 --- a/src/features/chat/controllers/ChatSendController.test.ts +++ b/src/features/chat/controllers/ChatSendController.test.ts @@ -43,6 +43,7 @@ describe('ChatSendController', () => { sendMessage, } as never, getHistory: vi.fn(() => []), + estimateTokens: vi.fn((text: string) => Promise.resolve(Math.ceil(text.length / 4))), pushUserMessage: vi.fn(), createStreamingHandle: vi.fn(() => streamingHandle), createImageHandle: vi.fn(() => imageHandle), @@ -128,6 +129,51 @@ describe('ChatSendController', () => { expect(streamingHandle.update).toHaveBeenCalledWith('hi'); }); + it('adds user text tokens to the context count', async () => { + const { controller, options } = createController(); + const input = document.createElement('textarea'); + input.value = 'hello world'; + + await controller.sendChat(input); + + expect(options.addContextTokens).toHaveBeenCalledWith(3); + }); + + it('counts image attachment tokens without double-counting text attachments', async () => { + const { controller, options } = createController(); + const processForSend = ( + options.fileHandler as unknown as { + processForSend: ReturnType; + } + ).processForSend; + processForSend.mockResolvedValueOnce({ + combinedText: 'hello extracted file content', + attachments: [ + { + name: 'doc.txt', + type: 'text/plain', + size: 4, + data_base64: '', + tokens: 50, + }, + { + name: 'photo.png', + type: 'image/png', + size: 4, + data_base64: 'base64', + tokens: 258, + }, + ], + }); + vi.mocked(options.estimateTokens).mockResolvedValueOnce(7); + const input = document.createElement('textarea'); + input.value = 'hello'; + + await controller.sendChat(input); + + expect(options.addContextTokens).toHaveBeenCalledWith(265); + }); + it('cleans active stream listeners when destroyed during a send', async () => { let resolveSend: (value: { ok: true; message: string }) => void = () => { throw new Error('sendMessage promise was not started'); diff --git a/src/features/chat/controllers/ChatSendController.ts b/src/features/chat/controllers/ChatSendController.ts index d381e132..fa60d180 100644 --- a/src/features/chat/controllers/ChatSendController.ts +++ b/src/features/chat/controllers/ChatSendController.ts @@ -40,6 +40,7 @@ type ChatSendControllerOptions = { fileHandler: Pick; service: ChatService; getHistory: () => IChatMessage[]; + estimateTokens: (text: string) => Promise; pushUserMessage: (content: IChatMessage['content']) => void; createStreamingHandle: (typingId: string) => StreamingMessageHandle; createImageHandle: () => ImageGenerationHandle; @@ -105,6 +106,7 @@ export class ChatSendController { this._sendFlow = new ChatSendFlow({ fileHandler: _options.fileHandler, getHistory: _options.getHistory, + estimateTokens: _options.estimateTokens, }); } diff --git a/src/features/chat/services/ChatControllerFactory.ts b/src/features/chat/services/ChatControllerFactory.ts index 0e96b108..60887955 100644 --- a/src/features/chat/services/ChatControllerFactory.ts +++ b/src/features/chat/services/ChatControllerFactory.ts @@ -91,6 +91,7 @@ type ChatSendFactoryDeps = { fileHandler: ChatFileHandler; service: ChatService; getHistory: () => IChatMessage[]; + estimateTokens: (text: string) => Promise; pushUserMessage: (content: IChatMessage['content']) => void; createStreamingHandle: (typingId: string) => { setStatus: (text: string) => void; @@ -263,6 +264,7 @@ export class ChatControllerFactory { fileHandler: deps.fileHandler, service: deps.service, getHistory: () => deps.getHistory(), + estimateTokens: async (text) => await deps.estimateTokens(text), pushUserMessage: (content) => { deps.pushUserMessage(content); }, diff --git a/src/features/chat/services/ChatFileHandler.test.ts b/src/features/chat/services/ChatFileHandler.test.ts index a11425d7..b9d56773 100644 --- a/src/features/chat/services/ChatFileHandler.test.ts +++ b/src/features/chat/services/ChatFileHandler.test.ts @@ -411,25 +411,6 @@ describe('ChatFileHandler', () => { }); }); - // ---------------------------------------------------------- calculateCombinedContext - describe('calculateCombinedContext', () => { - it('should return file list as attachments', async () => { - handler.addFiles([createFile('a.txt', 'aaa'), createImageFile('b.png')]); - const result = await handler.calculateCombinedContext('Base'); - - expect(result.attachments).toHaveLength(2); - expect(result.attachments[0]?.name).toBe('a.txt'); - expect(result.attachments[1]?.name).toBe('b.png'); - expect(result.combinedText).toContain('[Files attached]'); - }); - - it('should work with no files', async () => { - const result = await handler.calculateCombinedContext('Base'); - expect(result.attachments).toHaveLength(0); - expect(result.combinedText).toContain('Base'); - }); - }); - // ---------------------------------------------------------- getFileTokenEstimate describe('getFileTokenEstimate', () => { it('should return 258 for image files', async () => { diff --git a/src/features/chat/services/ChatFileHandler.ts b/src/features/chat/services/ChatFileHandler.ts index 7279422d..fb08a7de 100644 --- a/src/features/chat/services/ChatFileHandler.ts +++ b/src/features/chat/services/ChatFileHandler.ts @@ -282,8 +282,6 @@ export class ChatFileHandler { return { error: `\n[Skipped: ${file.name} - Not supported in Web Mode]` }; } - // Removed private ZIP methods (_processZipFile, _extractZipEntries, _validateZipEntry, etc.) - public async getTotalTokenEstimate(baseText: string): Promise { let total = await this._estimateTokens(baseText); for (const file of this._files) { @@ -292,21 +290,6 @@ export class ChatFileHandler { return total; } - // calculateCombinedContext also needs update or removal of preview logic - public calculateCombinedContext( - baseText: string, - ): Promise<{ combinedText: string; attachments: IChatAttachment[] }> { - // Without processing, we can't show "Smart Unpacked". - // Just show list. - const attachments: IChatAttachment[] = this._files.map((f) => ({ - name: f.name, - type: f.type, - size: f.size, - data_base64: '', - })); - return Promise.resolve({ combinedText: `${baseText}\n[Files attached]`, attachments }); - } - public async getFileTokenEstimate(file: File): Promise { if (this._isImageFile(file)) return 258; if (this._bridge?.isTauri() === true) { diff --git a/src/features/chat/services/ChatSendFlow.ts b/src/features/chat/services/ChatSendFlow.ts index 8a805b08..1c615211 100644 --- a/src/features/chat/services/ChatSendFlow.ts +++ b/src/features/chat/services/ChatSendFlow.ts @@ -3,14 +3,10 @@ import { createMultimodalContent } from '@/features/ai/utils/chatRequestUtils'; import type { ChatFileHandler } from './ChatFileHandler'; import type { IChatAttachment, IChatMessage } from '../types/chatTypes'; -const estimateTextTokens = (text: string): number => { - const normalized = text.trim(); - return normalized === '' ? 0 : Math.max(1, Math.ceil(normalized.length / 4)); -}; - type ChatSendFlowDeps = { fileHandler: Pick; getHistory: () => IChatMessage[]; + estimateTokens: (text: string) => Promise; }; export type PreparedChatSend = { @@ -27,18 +23,35 @@ export class ChatSendFlow { public async prepare(text: string): Promise { const { attachments, combinedText } = await this._deps.fileHandler.processForSend(text); const historyHead = this._deps.getHistory().slice(-40); - const attachmentTokens = attachments.reduce((total, attachment) => { + const imageTokens = attachments.reduce((total, attachment) => { + if (!attachment.type.startsWith('image/')) { + return total; + } const tokens = attachment.tokens; return total + (typeof tokens === 'number' && Number.isFinite(tokens) ? tokens : 0); }, 0); - const textTokens = estimateTextTokens(text); + const textTokens = await this._resolveTextTokens(combinedText); return { - tokenCount: textTokens + attachmentTokens, + tokenCount: textTokens + imageTokens, attachments, combinedText, historyHead, userContent: createMultimodalContent(combinedText, attachments), }; } + + private async _resolveTextTokens(text: string): Promise { + const trimmed = text.trim(); + if (trimmed === '') { + return 0; + } + + try { + const tokens = await this._deps.estimateTokens(trimmed); + return Number.isFinite(tokens) ? Math.max(0, Math.trunc(tokens)) : 0; + } catch { + return 0; + } + } } diff --git a/src/features/chat/ui/ChatAttachmentRenderer.ts b/src/features/chat/ui/ChatAttachmentRenderer.ts index df06909d..835a0ce9 100644 --- a/src/features/chat/ui/ChatAttachmentRenderer.ts +++ b/src/features/chat/ui/ChatAttachmentRenderer.ts @@ -41,7 +41,9 @@ export class ChatAttachmentRenderer { if (hiddenCount > 0) { const moreCard = document.createElement('div'); moreCard.className = 'chat-media-card more-card'; - moreCard.innerHTML = `+${String(hiddenCount)}`; + const label = document.createElement('span'); + label.textContent = `+${String(hiddenCount)}`; + moreCard.appendChild(label); attachContainer.appendChild(moreCard); } @@ -77,7 +79,9 @@ export class ChatAttachmentRenderer { if (hiddenCount > 0) { const moreCard = document.createElement('div'); moreCard.className = 'chat-media-card more-card'; - moreCard.innerHTML = `+${String(hiddenCount)}`; + const label = document.createElement('span'); + label.textContent = `+${String(hiddenCount)}`; + moreCard.appendChild(label); container.appendChild(moreCard); } } @@ -115,10 +119,10 @@ export class ChatAttachmentRenderer { card.appendChild(badge); } } else { - card.innerHTML = this.createFilePillHtml(file.name, fileTokens, name); + card.appendChild(this.createFilePill(file.name, fileTokens, name)); } } else { - card.innerHTML = this.createFilePillHtml(file.name, fileTokens, name); + card.appendChild(this.createFilePill(file.name, fileTokens, name)); } container.appendChild(card); @@ -174,7 +178,7 @@ export class ChatAttachmentRenderer { card.appendChild(badge); } } else { - card.innerHTML = this.createFilePillHtml(file.name, fileTokens, name); + card.appendChild(this.createFilePill(file.name, fileTokens, name)); } const btn = document.createElement('button'); @@ -231,7 +235,12 @@ export class ChatAttachmentRenderer { return `${name.substring(0, 20)}..`; } - private createFilePillHtml(originalName: string, tokens: number, displayName: string): string { + private createFilePill( + originalName: string, + tokens: number, + displayName: string, + ): DocumentFragment { + const fragment = document.createDocumentFragment(); const iconSvg = (() => { try { return getFileIcon(originalName); @@ -240,19 +249,27 @@ export class ChatAttachmentRenderer { } })(); + const icon = document.createElement('div'); + icon.className = 'media-icon'; + icon.innerHTML = DOMPurify.sanitize(iconSvg); + + const info = document.createElement('div'); + info.className = 'media-info'; + + const name = document.createElement('div'); + name.className = 'media-name'; + name.textContent = displayName; + info.appendChild(name); + const tokensLabel = this._deps.translate('ui.launcher.web.tokens', 'tokens'); - const safeTokensLabel = DOMPurify.sanitize(tokensLabel); - const tokensHtml = - tokens > 0 - ? `
    ${String(tokens)} ${safeTokensLabel}
    ` - : ''; - - return ` -
    ${DOMPurify.sanitize(iconSvg)}
    -
    -
    ${DOMPurify.sanitize(displayName)}
    - ${tokensHtml} -
    - `; + if (tokens > 0) { + const tokenCount = document.createElement('div'); + tokenCount.className = 'media-tokens'; + tokenCount.textContent = `${String(tokens)} ${tokensLabel}`; + info.appendChild(tokenCount); + } + + fragment.append(icon, info); + return fragment; } } diff --git a/src/features/chat/ui/ChatUI.test.ts b/src/features/chat/ui/ChatUI.test.ts index da50d84e..e06dc09d 100644 --- a/src/features/chat/ui/ChatUI.test.ts +++ b/src/features/chat/ui/ChatUI.test.ts @@ -400,6 +400,28 @@ describe('ChatUI lifecycle', () => { ).toBe(true); }); + it('should render attachment file names as text only', () => { + document.body.innerHTML = '
    '; + + ui = createChatUI(); + ui.appendMessage('user', 'uploaded', { + attachments: [ + { + name: '.txt', + type: 'text/plain', + size: 4, + data_base64: '', + tokens: 3, + }, + ], + skipAnimation: true, + }); + + const name = document.querySelector('.media-name'); + expect(name?.textContent).toBe('.txt'); + expect(name?.querySelector('img')).toBeNull(); + }); + it('should render image progress percent and speed separately from status text', () => { document.body.innerHTML = '
    '; From 7c33a19b1908fe82f1894659215801ce179fae77 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 30 Apr 2026 13:31:56 +0300 Subject: [PATCH 034/126] fix: validate chat image payloads --- src-tauri/src/api/ai/mod.rs | 93 ++++++++++++++++--- .../chat/ui/ChatImageGenerationMessage.ts | 29 ++++-- src/features/chat/ui/ChatImagePayload.ts | 57 ++++++++++++ src/features/chat/ui/ChatMessageRenderer.ts | 26 ++---- src/features/chat/ui/ChatUI.test.ts | 47 ++++++++++ 5 files changed, 211 insertions(+), 41 deletions(-) create mode 100644 src/features/chat/ui/ChatImagePayload.ts diff --git a/src-tauri/src/api/ai/mod.rs b/src-tauri/src/api/ai/mod.rs index 69c111be..ad6a5db5 100644 --- a/src-tauri/src/api/ai/mod.rs +++ b/src-tauri/src/api/ai/mod.rs @@ -366,22 +366,57 @@ fn read_image_generation_preview_file(path: &Path) -> Option &'static str { - match mime_type.to_ascii_lowercase().as_str() { - mime if mime.contains("jpeg") || mime.contains("jpg") => "jpg", - mime if mime.contains("webp") => "webp", - mime if mime.contains("gif") => "gif", - _ => "png", +fn image_extension_for_mime_type(mime_type: &str) -> Result<&'static str, AppError> { + match mime_type.trim().to_ascii_lowercase().as_str() { + "image/png" => Ok("png"), + "image/jpeg" | "image/jpg" => Ok("jpg"), + "image/webp" => Ok("webp"), + "image/gif" => Ok("gif"), + "image/bmp" => Ok("bmp"), + "image/avif" => Ok("avif"), + _ => Err(AppError::Validation(format!( + "Unsupported image type: {mime_type}" + ))), } } -fn decode_chat_image_payload(base64_data: &str) -> Result, AppError> { +fn decode_chat_image_payload(base64_data: &str, mime_type: &str) -> Result, AppError> { let payload = base64_data .split_once(',') .map_or(base64_data, |(_, data)| data); - STANDARD + let bytes = STANDARD .decode(payload) - .map_err(|error| AppError::Validation(format!("Invalid image data: {error}"))) + .map_err(|error| AppError::Validation(format!("Invalid image data: {error}")))?; + + validate_chat_image_signature(&bytes, mime_type)?; + Ok(bytes) +} + +fn validate_chat_image_signature(bytes: &[u8], mime_type: &str) -> Result<(), AppError> { + let mime = mime_type.trim().to_ascii_lowercase(); + let valid = match mime.as_str() { + "image/png" => bytes.starts_with(b"\x89PNG\r\n\x1a\n"), + "image/jpeg" | "image/jpg" => bytes.starts_with(&[0xFF, 0xD8, 0xFF]), + "image/webp" => { + bytes.len() >= 12 && bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") + } + "image/gif" => bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a"), + "image/bmp" => bytes.starts_with(b"BM"), + "image/avif" => { + bytes.len() >= 12 + && bytes.get(4..8) == Some(b"ftyp") + && (bytes.get(8..12) == Some(b"avif") || bytes.get(8..12) == Some(b"avis")) + } + _ => false, + }; + + if valid { + Ok(()) + } else { + Err(AppError::Validation( + "Image data does not match the declared image type".to_string(), + )) + } } fn build_chat_image_file_path(target_dir: &Path, ext: &str) -> PathBuf { @@ -700,9 +735,9 @@ pub fn save_chat_image_default( ) -> Result { let target_dir = chat_image_root_dir()?; std::fs::create_dir_all(&target_dir)?; - let bytes = decode_chat_image_payload(&base64_data)?; - let file_path = - build_chat_image_file_path(&target_dir, image_extension_for_mime_type(&mime_type)); + let extension = image_extension_for_mime_type(&mime_type)?; + let bytes = decode_chat_image_payload(&base64_data, &mime_type)?; + let file_path = build_chat_image_file_path(&target_dir, extension); std::fs::write(&file_path, bytes)?; Ok(SavedChatImage { @@ -931,8 +966,11 @@ fn is_local_provider(provider: &str) -> bool { #[cfg(test)] #[allow(clippy::expect_used)] mod tests { - use super::resolve_existing_path_within_root; + use super::{ + decode_chat_image_payload, image_extension_for_mime_type, resolve_existing_path_within_root, + }; use crate::errors::AppError; + use base64::{Engine as _, engine::general_purpose::STANDARD}; #[test] fn resolve_existing_path_within_root_allows_file_inside_root() { @@ -966,4 +1004,33 @@ mod tests { matches!(error, AppError::Validation(message) if message.contains("outside chat image directory")) ); } + + #[test] + fn image_extension_for_mime_type_rejects_unsupported_types() { + let error = image_extension_for_mime_type("image/svg+xml") + .expect_err("svg should not be saved from chat"); + + assert!( + matches!(error, AppError::Validation(message) if message.contains("Unsupported image type")) + ); + } + + #[test] + fn decode_chat_image_payload_accepts_matching_png_signature() { + let png = STANDARD.encode(b"\x89PNG\r\n\x1a\npayload"); + let decoded = decode_chat_image_payload(&png, "image/png").expect("png should decode"); + + assert!(decoded.starts_with(b"\x89PNG\r\n\x1a\n")); + } + + #[test] + fn decode_chat_image_payload_rejects_mismatched_signature() { + let fake_png = STANDARD.encode(b"not a png"); + let error = decode_chat_image_payload(&fake_png, "image/png") + .expect_err("invalid image signature must be rejected"); + + assert!( + matches!(error, AppError::Validation(message) if message.contains("does not match")) + ); + } } diff --git a/src/features/chat/ui/ChatImageGenerationMessage.ts b/src/features/chat/ui/ChatImageGenerationMessage.ts index 2b579559..7117fe6a 100644 --- a/src/features/chat/ui/ChatImageGenerationMessage.ts +++ b/src/features/chat/ui/ChatImageGenerationMessage.ts @@ -1,7 +1,9 @@ -type ChatImagePayload = { - mime: string; - data_base64: string; -}; +import { + buildSafeImageDataUrl, + isSafeImageDataUrl, + normalizeImagePayload, + type ChatImagePayload, +} from './ChatImagePayload'; type ChatTranslate = ( key: string, @@ -183,6 +185,7 @@ export function createChatImageGenerationMessage( const showPreview = (dataUrl: string): void => { if (dataUrl.trim() === '') return; + if (!isSafeImageDataUrl(dataUrl)) return; if (image.src === dataUrl) return; const shouldKeepPinned = deps.isNearBottom(); keepPinnedAfterImageLoad = shouldKeepPinned; @@ -224,9 +227,13 @@ export function createChatImageGenerationMessage( }, finalize: (result: { text: string; images: ChatImagePayload[] }) => { if (isCancelled) return; - finalImage = result.images[0] ?? null; - if (finalImage !== null) { - showPreview(`data:${finalImage.mime};base64,${finalImage.data_base64}`); + const shouldKeepPinned = deps.isNearBottom(); + finalImage = + result.images[0] === undefined ? null : normalizeImagePayload(result.images[0]); + const finalImageDataUrl = + finalImage === null ? null : buildSafeImageDataUrl(finalImage); + if (finalImageDataUrl !== null) { + showPreview(finalImageDataUrl); } status.textContent = deps.translate('ui.chat.image_ready', 'Generated image'); @@ -244,28 +251,30 @@ export function createChatImageGenerationMessage( row.classList.add('is-complete'); bubble.classList.add('is-complete'); ensureImageActions(result.text); - deps.scrollToBottom(); + deps.scrollToBottom(!shouldKeepPinned); }, fail: (message: string) => { if (isCancelled) { return; } + const shouldKeepPinned = deps.isNearBottom(); bubble.classList.add('chat-error'); status.textContent = message; hideProgress(); caption.classList.add('hidden'); - deps.scrollToBottom(); + deps.scrollToBottom(!shouldKeepPinned); }, cancel: ( message = deps.translate('ui.chat.image_cancelled', 'Image generation cancelled'), ) => { + const shouldKeepPinned = deps.isNearBottom(); isCancelled = true; bubble.classList.remove('chat-error'); bubble.classList.add('is-cancelled'); status.textContent = message; hideProgress(); caption.classList.add('hidden'); - deps.scrollToBottom(); + deps.scrollToBottom(!shouldKeepPinned); }, discard: () => { row.remove(); diff --git a/src/features/chat/ui/ChatImagePayload.ts b/src/features/chat/ui/ChatImagePayload.ts new file mode 100644 index 00000000..716be9ae --- /dev/null +++ b/src/features/chat/ui/ChatImagePayload.ts @@ -0,0 +1,57 @@ +export type ChatImagePayload = { + mime: string; + data_base64: string; +}; + +const allowedImageMimePattern = /^image\/(?:png|jpe?g|gif|webp|bmp|avif)$/iu; + +export function normalizeImageMime(mime: string): string | null { + const normalized = mime.trim().toLowerCase(); + return allowedImageMimePattern.test(normalized) ? normalized : null; +} + +export function normalizeImageBase64(data: string): string | null { + const normalized = data.replaceAll(/\s+/gu, ''); + if (normalized === '') { + return null; + } + return /^[A-Za-z0-9+/]+={0,2}$/u.test(normalized) ? normalized : null; +} + +export function normalizeImagePayload(image: ChatImagePayload): ChatImagePayload | null { + const mime = normalizeImageMime(image.mime); + const dataBase64 = normalizeImageBase64(image.data_base64); + if (mime === null || dataBase64 === null) { + return null; + } + + return { + mime, + data_base64: dataBase64, + }; +} + +export function buildSafeImageDataUrl(image: ChatImagePayload): string | null { + const normalized = normalizeImagePayload(image); + if (normalized === null) { + return null; + } + + return `data:${normalized.mime};base64,${normalized.data_base64}`; +} + +export function isSafeImageDataUrl(dataUrl: string): boolean { + const match = /^data:([^;,]+);base64,([A-Za-z0-9+/=\s]+)$/iu.exec(dataUrl.trim()); + if (match === null) { + return false; + } + + const mime = match[1]; + const dataBase64 = match[2]; + return ( + mime !== undefined && + dataBase64 !== undefined && + normalizeImageMime(mime) !== null && + normalizeImageBase64(dataBase64) !== null + ); +} diff --git a/src/features/chat/ui/ChatMessageRenderer.ts b/src/features/chat/ui/ChatMessageRenderer.ts index ee010bbb..d435bc6c 100644 --- a/src/features/chat/ui/ChatMessageRenderer.ts +++ b/src/features/chat/ui/ChatMessageRenderer.ts @@ -3,14 +3,14 @@ import { marked } from 'marked'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; import type { TTranslateFunction } from '@/shared/types/global_bridge_types'; +import { + buildSafeImageDataUrl, + normalizeImagePayload, + type ChatImagePayload, +} from './ChatImagePayload'; type ChatMessageRendererLogger = Pick; -type ChatImagePayload = { - mime: string; - data_base64: string; -}; - type ChatMessageRendererDeps = { onImageLoad: () => void; translate: TTranslateFunction; @@ -37,7 +37,7 @@ export class ChatMessageRenderer { typeof (item as { mime?: unknown }).mime === 'string', ) as ChatImagePayload | undefined; - return candidate ?? null; + return candidate === undefined ? null : normalizeImagePayload(candidate); } public extractImageFromBubble(bubble: HTMLElement): ChatImagePayload | null { @@ -56,7 +56,7 @@ export class ChatMessageRenderer { return null; } - return { mime, data_base64 }; + return normalizeImagePayload({ mime, data_base64 }); } public createMessageTextNode(content: string, opts: Record): HTMLElement { @@ -93,7 +93,7 @@ export class ChatMessageRenderer { images.forEach((img) => { try { - const imageDataUrl = this._buildImageDataUrl(img); + const imageDataUrl = buildSafeImageDataUrl(img); if (imageDataUrl === null) return; const wrapper = document.createElement('div'); @@ -171,14 +171,4 @@ export class ChatMessageRenderer { .filter((className) => className !== '') .join(' '); } - - private _buildImageDataUrl(image: ChatImagePayload): string | null { - const mime = image.mime || 'image/png'; - const base64 = image.data_base64 || ''; - if (base64 === '') { - return null; - } - - return `data:${mime};base64,${base64}`; - } } diff --git a/src/features/chat/ui/ChatUI.test.ts b/src/features/chat/ui/ChatUI.test.ts index e06dc09d..ffb06464 100644 --- a/src/features/chat/ui/ChatUI.test.ts +++ b/src/features/chat/ui/ChatUI.test.ts @@ -400,6 +400,19 @@ describe('ChatUI lifecycle', () => { ).toBe(true); }); + it('should ignore generated image payloads with unsafe mime or base64', () => { + document.body.innerHTML = '
    '; + + ui = createChatUI(); + ui.appendMessage('assistant', 'image', { + images: [{ mime: 'image/svg+xml', data_base64: '' }], + skipAnimation: true, + }); + + expect(document.querySelector('.chat-img, .chat-generated-image')).toBeNull(); + expect(document.querySelector('.chat-save-image-btn')).toBeNull(); + }); + it('should render attachment file names as text only', () => { document.body.innerHTML = '
    '; @@ -520,6 +533,40 @@ describe('ChatUI lifecycle', () => { expect(messages.scrollTop).toBe(100); }); + it('should not force generated image finalization to bottom when the user scrolled up', () => { + document.body.innerHTML = '
    '; + const messages = document.getElementById('chat-messages') as HTMLDivElement; + + Object.defineProperty(messages, 'clientHeight', { configurable: true, value: 400 }); + Object.defineProperty(messages, 'scrollHeight', { + configurable: true, + get: () => + document.querySelector('.chat-generated-media:not(.hidden)') === null ? 1000 : 2000, + }); + + ui = createChatUI(); + const handle = ui.createImageGenerationMessage(); + messages.scrollTop = 100; + + handle.finalize({ + text: 'done', + images: [{ mime: 'image/png', data_base64: 'dGVzdA==' }], + }); + + expect(messages.scrollTop).toBe(100); + }); + + it('should ignore unsafe live generated image preview URLs', () => { + document.body.innerHTML = '
    '; + + ui = createChatUI(); + const handle = ui.createImageGenerationMessage(); + handle.setPreview('data:image/svg+xml;base64,PHN2Zz48L3N2Zz4='); + + expect(document.querySelector('.chat-generated-media:not(.hidden)')).toBeNull(); + expect(document.querySelector('.chat-generated-image')?.src).toBe(''); + }); + it('should scroll chat history to the bottom after restore', () => { vi.useFakeTimers(); document.body.innerHTML = '
    '; From 67a0fdb17b60b4b3f0b04d0034d943eed60babda Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 30 Apr 2026 14:03:06 +0300 Subject: [PATCH 035/126] fix: stabilize chat lifecycle races --- .../controllers/ChatHistoryController.test.ts | 23 ++++++++++++ .../chat/controllers/ChatHistoryController.ts | 6 ++++ src/features/chat/ui/ChatStreamingMessage.ts | 12 +++---- src/features/chat/ui/ChatUI.test.ts | 36 +++++++++++++++++++ .../chat/ui/ChatUiRetryStatusListener.ts | 15 +++++++- 5 files changed, 85 insertions(+), 7 deletions(-) diff --git a/src/features/chat/controllers/ChatHistoryController.test.ts b/src/features/chat/controllers/ChatHistoryController.test.ts index 06469700..2999b496 100644 --- a/src/features/chat/controllers/ChatHistoryController.test.ts +++ b/src/features/chat/controllers/ChatHistoryController.test.ts @@ -96,10 +96,33 @@ describe('ChatHistoryController', () => { await Promise.all([firstLoad, secondLoad]); expect(aiBridge.getHistory).toHaveBeenCalledTimes(2); + expect(deps.renderHistory).not.toHaveBeenCalledWith(firstHistory); expect(deps.setHistory).toHaveBeenLastCalledWith(secondHistory); expect(deps.renderHistory).toHaveBeenLastCalledWith(secondHistory); }); + it('should ignore an in-flight history restore after destroy', async () => { + const restoredHistory: IChatMessage[] = [{ role: 'user', content: 'late' }]; + let destroyed = false; + let resolveLoad: (history: IChatMessage[]) => void = () => {}; + const { controller, deps, aiBridge } = createController(); + deps.isDestroyed.mockImplementation(() => destroyed); + aiBridge.getHistory.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveLoad = resolve; + }), + ); + + const load = controller.ensureHistoryLoaded(); + destroyed = true; + resolveLoad(restoredHistory); + await load; + + expect(deps.setHistory).not.toHaveBeenCalled(); + expect(deps.renderHistory).not.toHaveBeenCalled(); + }); + it('should clear rendered chat when the current session has no persisted history', async () => { const { controller, deps, state } = createController({ history: [{ role: 'user', content: 'stale' }], diff --git a/src/features/chat/controllers/ChatHistoryController.ts b/src/features/chat/controllers/ChatHistoryController.ts index 324b6ab0..2df0a8f6 100644 --- a/src/features/chat/controllers/ChatHistoryController.ts +++ b/src/features/chat/controllers/ChatHistoryController.ts @@ -137,6 +137,12 @@ export class ChatHistoryController { try { const sessionId = this._options.aiBridge.getSessionId(); const history = await this._options.aiBridge.getHistory(); + if ( + this._options.isDestroyed() || + this._options.aiBridge.getSessionId() !== sessionId + ) { + return; + } this._historyLoaded = true; this._loadedSessionId = sessionId; diff --git a/src/features/chat/ui/ChatStreamingMessage.ts b/src/features/chat/ui/ChatStreamingMessage.ts index 28874d46..875b9b28 100644 --- a/src/features/chat/ui/ChatStreamingMessage.ts +++ b/src/features/chat/ui/ChatStreamingMessage.ts @@ -111,23 +111,23 @@ export function createChatStreamingMessage( .then((rawHtml) => { if (!isStreamingTargetLive(version)) return; textNode.innerHTML = DOMPurify.sanitize(rawHtml); - if (scrollToBottom) deps.scrollToBottom(); + if (scrollToBottom) deps.scrollToBottom(true); }) .catch(() => { if (!isStreamingTargetLive(version)) return; textNode.textContent = sourceText; - if (scrollToBottom) deps.scrollToBottom(); + if (scrollToBottom) deps.scrollToBottom(true); }); return; } if (!isStreamingTargetLive(version)) return; textNode.innerHTML = DOMPurify.sanitize(parseResult); - if (scrollToBottom) deps.scrollToBottom(); + if (scrollToBottom) deps.scrollToBottom(true); } catch { if (!isStreamingTargetLive(version)) return; textNode.textContent = sourceText; - if (scrollToBottom) deps.scrollToBottom(); + if (scrollToBottom) deps.scrollToBottom(true); } }; @@ -142,7 +142,7 @@ export function createChatStreamingMessage( ) { if (!isStreamingTargetLive(version)) return; textNode.textContent = accumulatedText; - if (scrollToBottom) deps.scrollToBottom(); + if (scrollToBottom) deps.scrollToBottom(true); } else { renderMarkdown(accumulatedText, version, scrollToBottom); } @@ -247,7 +247,7 @@ export function createChatStreamingMessage( if (actions !== null && !bubble.contains(actions.actionBar)) { bubble.appendChild(actions.actionBar); } - deps.scrollToBottom(); + deps.scrollToBottom(true); }, discard: () => { isDiscarded = true; diff --git a/src/features/chat/ui/ChatUI.test.ts b/src/features/chat/ui/ChatUI.test.ts index ffb06464..698577e7 100644 --- a/src/features/chat/ui/ChatUI.test.ts +++ b/src/features/chat/ui/ChatUI.test.ts @@ -183,6 +183,25 @@ describe('ChatUI lifecycle', () => { expect(unlisten).toHaveBeenCalledTimes(1); }); + it('should unsubscribe retry status listener when init resolves after destroy', async () => { + const unlisten = vi.fn(); + let resolveListen: (unlisten: () => void) => void = () => {}; + vi.mocked(listen).mockImplementationOnce( + () => + new Promise((resolve) => { + resolveListen = resolve; + }), + ); + + ui = createChatUI(); + const init = ui.init(); + ui.destroy(); + resolveListen(unlisten); + await init; + + expect(unlisten).toHaveBeenCalledTimes(1); + }); + it('should refresh localized titles for existing chat action buttons', () => { document.body.innerHTML = `
    @@ -340,6 +359,23 @@ describe('ChatUI lifecycle', () => { expect(document.querySelector('.markdown-body')?.textContent).toBe('hello'); }); + it('should not force cancelled partial streaming text to bottom when the user scrolled up', () => { + document.body.innerHTML = '
    '; + const messages = document.getElementById('chat-messages') as HTMLDivElement; + Object.defineProperty(messages, 'clientHeight', { configurable: true, value: 400 }); + Object.defineProperty(messages, 'scrollHeight', { configurable: true, value: 1200 }); + + ui = createChatUI(); + const handle = ui.createStreamingMessage('assistant'); + handle.update('partial answer'); + messages.scrollTop = 100; + + handle.cancel(); + + expect(messages.scrollTop).toBe(100); + expect(document.querySelector('.chat-row')).not.toBeNull(); + }); + it('should finalize generated images without regenerate controls', () => { document.body.innerHTML = '
    '; diff --git a/src/features/chat/ui/ChatUiRetryStatusListener.ts b/src/features/chat/ui/ChatUiRetryStatusListener.ts index c074f5fc..5781126b 100644 --- a/src/features/chat/ui/ChatUiRetryStatusListener.ts +++ b/src/features/chat/ui/ChatUiRetryStatusListener.ts @@ -12,6 +12,7 @@ type ChatUiRetryStatusListenerDeps = { export class ChatUiRetryStatusListener { private _unlisten: (() => void) | null = null; + private _bindToken: { cancelled: boolean } | null = null; public constructor(private readonly _deps: ChatUiRetryStatusListenerDeps) {} @@ -20,12 +21,24 @@ export class ChatUiRetryStatusListener { return; } - this._unlisten = await listen('ai:status:retry', (event) => { + const bindToken = { cancelled: false }; + this._bindToken = bindToken; + const unlisten = await listen('ai:status:retry', (event) => { this._deps.onRetryStatus(event.payload); }); + if (bindToken.cancelled) { + unlisten(); + return; + } + + this._unlisten = unlisten; } public destroy(): void { + if (this._bindToken !== null) { + this._bindToken.cancelled = true; + this._bindToken = null; + } this._unlisten?.(); this._unlisten = null; } From 52dbba20f226fc695408501caf2d1bdfd4de27cc Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 30 Apr 2026 14:19:40 +0300 Subject: [PATCH 036/126] fix: address coderabbit core review --- src-tauri/src/api/engine/mod.rs | 8 +- src-tauri/src/domain/ai/ai_dispatch.rs | 16 +++- src-tauri/src/domain/ai/session.rs | 43 ++++++++-- src-tauri/src/domain/ai/streaming.rs | 24 ++++++ src-tauri/src/domain/engine/engine_args.rs | 17 ++++ src-tauri/src/domain/engine/manager.rs | 80 +++++++++++++++---- src-tauri/src/domain/integration_api.rs | 33 ++++++-- .../domain/modules/controller/lifecycle.rs | 29 ++++--- .../modules/github_release_selection.rs | 13 +-- .../src/domain/modules/github_releases.rs | 6 +- .../domain/modules/settings_ui_protocol.rs | 9 +-- .../filesystem/local_file_service.rs | 19 ++++- src/app/init.ts | 13 ++- src/features/chat/chat.test.ts | 17 ++-- src/features/chat/chat.ts | 27 +++---- .../chat/controllers/ChatSendController.ts | 26 +++--- 16 files changed, 274 insertions(+), 106 deletions(-) diff --git a/src-tauri/src/api/engine/mod.rs b/src-tauri/src/api/engine/mod.rs index d0923051..c60e7ecc 100644 --- a/src-tauri/src/api/engine/mod.rs +++ b/src-tauri/src/api/engine/mod.rs @@ -77,7 +77,7 @@ pub async fn delete_engine( engine_id: String, engine_manager: State<'_, Arc>, ) -> Result<(), AppError> { - let engine_id = canonical_engine_id(&engine_id).to_string(); + let engine_id = canonical_engine_id(&engine_id); if engine_manager.is_engine_running(&engine_id).await { return Err(AppError::Validation(format!( "Cannot delete engine '{engine_id}' while it is running" @@ -112,7 +112,7 @@ pub async fn get_engine_config( engine_id: String, engine_manager: State<'_, Arc>, ) -> Result { - let engine_id = canonical_engine_id(&engine_id).to_string(); + let engine_id = canonical_engine_id(&engine_id); let def = engine_manager .get_definition(&engine_id) .await @@ -133,7 +133,7 @@ pub async fn get_engine_settings_payload( engine_id: String, engine_manager: State<'_, Arc>, ) -> Result { - let engine_id = canonical_engine_id(&engine_id).to_string(); + let engine_id = canonical_engine_id(&engine_id); let def = engine_manager .get_definition(&engine_id) .await @@ -157,7 +157,7 @@ pub async fn set_engine_config( engine_manager: State<'_, Arc>, ) -> Result<(), AppError> { let mut config = config; - config.engine_id = canonical_engine_id(&config.engine_id).to_string(); + config.engine_id = canonical_engine_id(&config.engine_id); let def = engine_manager .get_definition(&config.engine_id) .await diff --git a/src-tauri/src/domain/ai/ai_dispatch.rs b/src-tauri/src/domain/ai/ai_dispatch.rs index 9bec913e..af08cbaf 100644 --- a/src-tauri/src/domain/ai/ai_dispatch.rs +++ b/src-tauri/src/domain/ai/ai_dispatch.rs @@ -33,7 +33,12 @@ pub(super) async fn prepare_chat_dispatch( if let Some(session_id) = &request.session_id { messages_context = sessions.merge_request_messages(session_id, &request.messages); if !request.messages.is_empty() { - sessions.force_save().await?; + if let Err(error) = sessions.force_save().await { + tracing::warn!( + session_id, + "Failed to persist incoming chat messages before dispatch: {error}" + ); + } } } @@ -98,7 +103,12 @@ pub(super) async fn persist_successful_response( reply, response.thought_signature.clone(), ); - sessions.force_save().await?; + if let Err(error) = sessions.force_save().await { + tracing::warn!( + session_id, + "Failed to persist successful chat response: {error}" + ); + } } Ok(()) @@ -134,7 +144,7 @@ pub(super) async fn build_engine_config( ) -> Result { let saved = load_engine_config_map().await?; let canonical_id = canonical_engine_id(&definition.id); - Ok(saved.get(canonical_id).map_or_else( + Ok(saved.get(&canonical_id).map_or_else( || build_default_engine_config(definition), |config| merge_user_engine_config(definition, config), )) diff --git a/src-tauri/src/domain/ai/session.rs b/src-tauri/src/domain/ai/session.rs index 7610f1c5..6af1bada 100644 --- a/src-tauri/src/domain/ai/session.rs +++ b/src-tauri/src/domain/ai/session.rs @@ -326,6 +326,10 @@ impl SessionPersistence { Self::history_path().with_extension("tmp") } + fn backup_history_path() -> std::path::PathBuf { + Self::history_path().with_extension("bak") + } + fn load_sessions() -> Result, crate::errors::AppError> { Self::recover_from_interrupted_write()?; @@ -460,23 +464,46 @@ impl SessionPersistence { drop(file); if let Err(error) = std::fs::rename(&tmp_path, path) { - tracing::warn!("Rename failed ({error}), using fallback for Windows locks..."); - match std::fs::remove_file(path) { + tracing::warn!("Rename failed ({error}), using backup replace fallback..."); + let backup_path = Self::backup_history_path(); + match std::fs::remove_file(&backup_path) { Ok(()) => {} Err(remove_error) if remove_error.kind() == std::io::ErrorKind::NotFound => {} Err(remove_error) => { + let _ = std::fs::remove_file(&tmp_path); return Err(crate::errors::AppError::Io(format!( - "Failed to replace chat history '{}': rename failed: {error}; removing existing file failed: {remove_error}", - path.display() + "Failed to prepare chat history backup '{}': first rename failed: {error}; removing stale backup failed: {remove_error}", + backup_path.display() ))); } } - std::fs::rename(&tmp_path, path).map_err(|second_error| { - crate::errors::AppError::Io(format!( + + let had_original = path.exists(); + if had_original { + std::fs::rename(path, &backup_path).map_err(|backup_error| { + let _ = std::fs::remove_file(&tmp_path); + crate::errors::AppError::Io(format!( + "Failed to back up chat history '{}' to '{}': first rename failed: {error}; backup rename failed: {backup_error}", + path.display(), + backup_path.display() + )) + })?; + } + + if let Err(second_error) = std::fs::rename(&tmp_path, path) { + if had_original { + let _ = std::fs::rename(&backup_path, path); + } + let _ = std::fs::remove_file(&tmp_path); + return Err(crate::errors::AppError::Io(format!( "Failed to publish chat history '{}': first rename failed: {error}; second rename failed: {second_error}", path.display() - )) - })?; + ))); + } + + if had_original { + let _ = std::fs::remove_file(&backup_path); + } } Ok(()) diff --git a/src-tauri/src/domain/ai/streaming.rs b/src-tauri/src/domain/ai/streaming.rs index 9cebccc0..2ae1bbf8 100644 --- a/src-tauri/src/domain/ai/streaming.rs +++ b/src-tauri/src/domain/ai/streaming.rs @@ -607,6 +607,11 @@ fn handle_stream_json_line( json.get("response") .and_then(provider_response::extract_stream_text) }) + .or_else(|| { + json.get("message") + .and_then(|message| message.get("content")) + .and_then(provider_response::extract_stream_text) + }) .or_else(|| { json.get("token") .and_then(|token| token.get("text")) @@ -837,6 +842,25 @@ mod tests { )); } + #[test] + fn process_stream_chunk_supports_ollama_message_content() { + let sink = TestSink::default(); + let mut state = StreamingAccumulator::new(); + let chunk = b"data: {\"message\":{\"content\":\"hello from ollama\"},\"done\":true}\n\n"; + + let result = process_stream_chunk(chunk, "msg-1", &sink, &mut state); + + assert!(matches!(result, StreamChunkResult::Continue)); + assert_eq!(state.full_content, "hello from ollama"); + assert!(state.saw_terminal_chunk); + + let events = sink.events.lock().expect("sink events"); + assert!(matches!( + events.first(), + Some(StreamEvent::ChatChunk { content, .. }) if content == "hello from ollama" + )); + } + #[test] fn process_stream_chunk_normalizes_llama_cpp_timings_usage() { let sink = TestSink::default(); diff --git a/src-tauri/src/domain/engine/engine_args.rs b/src-tauri/src/domain/engine/engine_args.rs index 8ade2a3f..30195271 100644 --- a/src-tauri/src/domain/engine/engine_args.rs +++ b/src-tauri/src/domain/engine/engine_args.rs @@ -27,6 +27,16 @@ fn push_llamacpp_compute_args(args: &mut Vec, config: &EngineConfig) { } } +fn push_sdcpp_compute_args(args: &mut Vec, config: &EngineConfig) { + match config.compute_mode { + EngineComputeMode::Gpu => {} + EngineComputeMode::Cpu => { + args.push("--clip-on-cpu".to_string()); + args.push("--vae-on-cpu".to_string()); + } + } +} + fn sdcpp_extra_args(config: &EngineConfig) -> Vec { let unsupported_flags = SDCPP_UNSUPPORTED_FLAGS .iter() @@ -93,11 +103,18 @@ pub fn resolve_sdcpp_preview_path(extra_args: &[String]) -> Option { pub(super) fn sdcpp_preview_enabled(extra_args: &[String]) -> bool { resolve_sdcpp_preview_path(extra_args).is_some() + || extra_args.iter().any(|arg| { + arg == SDCPP_LAUNCHER_ONLY_PREVIEW_FLAG + || arg + .strip_prefix(SDCPP_LAUNCHER_ONLY_PREVIEW_FLAG) + .is_some_and(|suffix| suffix.starts_with('=')) + }) } pub(super) fn build_sdcpp_args(config: &EngineConfig, port: u16) -> Vec { let mut args = vec!["--listen-port".to_string(), port.to_string()]; let extra_args = sdcpp_extra_args(config); + push_sdcpp_compute_args(&mut args, config); if let Some(model_path) = config.model_path.as_deref() { args.push("--model".to_string()); diff --git a/src-tauri/src/domain/engine/manager.rs b/src-tauri/src/domain/engine/manager.rs index d245f219..d25eb013 100644 --- a/src-tauri/src/domain/engine/manager.rs +++ b/src-tauri/src/domain/engine/manager.rs @@ -211,7 +211,7 @@ impl EngineManager { pub async fn start(&self, config: EngineConfig) -> Result { let _lifecycle_guard = self.lifecycle_lock.lock().await; let mut config = config; - config.engine_id = canonical_engine_id(&config.engine_id).to_string(); + config.engine_id = canonical_engine_id(&config.engine_id); let definition = self.find_definition(&config.engine_id).await?; let primary_cap = definition .capabilities @@ -310,9 +310,12 @@ impl EngineManager { } // Hot-swap: stop every active engine before starting the next one. - let old_engines: Vec<(Capability, RunningEngine)> = - self.slots.lock().await.drain().collect(); - for (old_cap, old) in old_engines { + let old_caps = self.slots.lock().await.keys().copied().collect::>(); + for old_cap in old_caps { + let old = self.slots.lock().await.remove(&old_cap); + let Some(old) = old else { + continue; + }; info!( from = %old.definition.id, to = %config.engine_id, @@ -321,7 +324,10 @@ impl EngineManager { ); self.emitter .emit_swapping(&old.definition.id, &config.engine_id); - Self::kill_engine(old).await?; + if let Err((error, old)) = Self::kill_engine_retaining_on_failure(old).await { + self.slots.lock().await.insert(old_cap, old); + return Err(error); + } } let binary_name = definition.binary.as_deref().ok_or_else(|| { @@ -556,12 +562,17 @@ impl EngineManager { /// Stop all running engines pub async fn stop(&self) -> Result<(), AppError> { let _lifecycle_guard = self.lifecycle_lock.lock().await; - let engines: Vec<(Capability, RunningEngine)> = self.slots.lock().await.drain().collect(); + let engine_caps = self.slots.lock().await.keys().copied().collect::>(); let mut errors = Vec::new(); - for (cap, engine) in engines { + for cap in engine_caps { + let engine = self.slots.lock().await.remove(&cap); + let Some(engine) = engine else { + continue; + }; info!(engine = %engine.definition.id, slot = ?cap, "Stopping engine"); - if let Err(error) = Self::kill_engine(engine).await { + if let Err((error, engine)) = Self::kill_engine_retaining_on_failure(engine).await { warn!(slot = ?cap, error = %error, "Failed to stop engine in slot"); + self.slots.lock().await.insert(cap, engine); errors.push(error.to_string()); } } @@ -580,7 +591,10 @@ impl EngineManager { let engine = self.slots.lock().await.remove(&capability); if let Some(engine) = engine { info!(engine = %engine.definition.id, slot = ?capability, "Stopping engine in slot"); - Self::kill_engine(engine).await?; + if let Err((error, engine)) = Self::kill_engine_retaining_on_failure(engine).await { + self.slots.lock().await.insert(capability, engine); + return Err(error); + } } Ok(()) } @@ -693,14 +707,26 @@ impl EngineManager { } /// Returns the registry id used internally for known engine aliases. -pub fn canonical_engine_id(engine_id: &str) -> &str { - match engine_id { - "stable-diffusion" => "sdcpp", - value => value, +pub fn canonical_engine_id(engine_id: &str) -> String { + let mut normalized = engine_id + .trim() + .to_ascii_lowercase() + .replace(['.', '_'], "-"); + if let Some(stripped) = normalized.strip_suffix("-cpp") { + normalized = stripped.to_string(); + } + while normalized.contains("--") { + normalized = normalized.replace("--", "-"); + } + + if normalized == "stable-diffusion" || normalized.starts_with("stable-diffusion-") { + "sdcpp".to_string() + } else { + normalized } } -fn canonical_engine_log_id(engine_id: &str) -> &str { +fn canonical_engine_log_id(engine_id: &str) -> String { canonical_engine_id(engine_id) } @@ -829,6 +855,24 @@ mod tests { ); } + #[test] + fn builds_cpu_sdcpp_args_when_requested() { + let mut config = sample_sdcpp_config(Some("C:/models/sd15.safetensors")); + config.compute_mode = EngineComputeMode::Cpu; + + let args = build_sdcpp_args(&config, 8082); + + assert!(args.contains(&"--clip-on-cpu".to_string())); + assert!(args.contains(&"--vae-on-cpu".to_string())); + } + + #[test] + fn canonicalizes_stable_diffusion_variants_to_sdcpp() { + assert_eq!(canonical_engine_id("stable-diffusion"), "sdcpp"); + assert_eq!(canonical_engine_id("Stable_Diffusion.cpp"), "sdcpp"); + assert_eq!(canonical_engine_id("stable.diffusion.cpp"), "sdcpp"); + } + #[tokio::test] async fn resolves_stable_diffusion_alias_to_sdcpp_definition() { let manager = EngineManager::new(Arc::new(NoopEmitter)); @@ -935,4 +979,12 @@ mod tests { assert!(!args.contains(&"C:/tmp/sdcpp-preview.png".to_string())); assert!(args.contains(&"--mmap".to_string())); } + + #[test] + fn sdcpp_preview_flag_enables_preview_without_explicit_path() { + let extra_args = vec!["--sdcpp-preview".to_string()]; + + assert!(sdcpp_preview_enabled(&extra_args)); + assert!(resolve_sdcpp_preview_path(&extra_args).is_none()); + } } diff --git a/src-tauri/src/domain/integration_api.rs b/src-tauri/src/domain/integration_api.rs index b709350e..ae483190 100644 --- a/src-tauri/src/domain/integration_api.rs +++ b/src-tauri/src/domain/integration_api.rs @@ -182,7 +182,7 @@ struct HttpResponse { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct IntegrationTextRequest { - prompt: String, + prompt: Option, provider: Option, model: Option, session_id: Option, @@ -531,6 +531,7 @@ async fn handle_text_request( context: LauncherHttpApiContext, ) -> Result { let payload: IntegrationTextRequest = parse_json_body(request)?; + let prompt = payload.prompt.as_deref().unwrap_or(""); let requested_provider = payload.provider.filter(|value| !value.trim().is_empty()); let ui_provider = match requested_provider.as_ref() { Some(provider) => provider.clone(), @@ -554,27 +555,27 @@ async fn handle_text_request( let session_id = resolve_session_id(&context.ui_state_service, payload.session_id.as_deref()).await; let mut messages = payload.messages.unwrap_or_default(); - if messages.is_empty() && payload.prompt.trim().is_empty() { + if messages.is_empty() && prompt.trim().is_empty() { return Err(AppError::Validation( "Text request requires a prompt or messages".to_string(), )); } - if messages.is_empty() || !payload.prompt.trim().is_empty() { + if messages.is_empty() || !prompt.trim().is_empty() { messages.push(ChatMessage { id: uuid::Uuid::new_v4().to_string(), role: "user".to_string(), - content: serde_json::Value::String(payload.prompt), + content: serde_json::Value::String(prompt.to_string()), thought_signature: None, }); } let thinking_level = match payload.thinking_level { Some(value) => Some(value), - None => selected_thinking_level(&context.ui_state_service, &provider).await?, + None => selected_thinking_level(&context.ui_state_service, &ui_provider).await?, }; let web_search = match payload.web_search { Some(value) => Some(value), - None => selected_web_search(&context.ui_state_service, &provider).await?, + None => selected_web_search(&context.ui_state_service, &ui_provider).await?, }; let mut chat_request = ChatRequest { @@ -1004,8 +1005,9 @@ mod tests { #![allow(clippy::expect_used)] use super::{ - backend_provider_id, find_header_end, is_authorized, model_api_id, parse_header_line, - read_http_request, status_for_app_error, status_text, tier_rank, + IntegrationTextRequest, backend_provider_id, find_header_end, is_authorized, model_api_id, + parse_header_line, parse_json_body, read_http_request, status_for_app_error, status_text, + tier_rank, }; use crate::errors::AppError; use crate::models::{AiModel, ApiModelConfig, ModelStats, ModelTier}; @@ -1082,6 +1084,21 @@ mod tests { assert_eq!(backend_provider_id("llamacpp"), "llamacpp"); } + #[test] + fn parses_text_request_without_prompt_when_messages_are_present() { + let request = super::HttpRequest { + method: "POST".to_string(), + path: "/v1/ai/text".to_string(), + headers: HashMap::new(), + body: br#"{"messages":[{"id":"m1","role":"user","content":"hello"}]}"#.to_vec(), + }; + + let payload: IntegrationTextRequest = parse_json_body(&request).expect("payload"); + + assert!(payload.prompt.is_none()); + assert_eq!(payload.messages.expect("messages").len(), 1); + } + #[test] fn ranks_model_tiers_for_default_selection() { assert!(tier_rank(&ModelTier::Strong) > tier_rank(&ModelTier::Medium)); diff --git a/src-tauri/src/domain/modules/controller/lifecycle.rs b/src-tauri/src/domain/modules/controller/lifecycle.rs index 7858a5fd..013a8444 100644 --- a/src-tauri/src/domain/modules/controller/lifecycle.rs +++ b/src-tauri/src/domain/modules/controller/lifecycle.rs @@ -201,6 +201,7 @@ impl<'a> LifecycleExecutor<'a> { "Failed to kill module after status polling failed: {kill_error}" ); } + Self::reap_child_after_kill_attempt(&module_id, &mut child).await; } return; } @@ -221,13 +222,27 @@ impl<'a> LifecycleExecutor<'a> { module_id = %self.module_id, "Failed to kill spawned module after {reason}: {error}" ); - return; + } + + Self::reap_child_after_kill_attempt(&self.module_id, child).await; + } + + async fn reap_child_after_kill_attempt(module_id: &str, child: &mut Child) { + match child.try_wait() { + Ok(Some(_)) => return, + Ok(None) => {} + Err(error) => { + tracing::warn!( + module_id, + "Failed to poll module child after kill attempt: {error}" + ); + } } if let Err(error) = child.wait().await { tracing::warn!( - module_id = %self.module_id, - "Failed to wait spawned module after {reason}: {error}" + module_id, + "Failed to wait module child after kill attempt: {error}" ); } } @@ -319,6 +334,7 @@ impl<'a> LifecycleExecutor<'a> { "Failed to force-kill registered module process: {error}" ); } + Self::reap_child_after_kill_attempt(&self.module_id, &mut child).await; } } @@ -445,12 +461,7 @@ impl<'a> LifecycleExecutor<'a> { "Failed to kill registered duplicate module child: {error}" ); } - if let Err(error) = child.wait().await { - tracing::warn!( - module_id = %self.module_id, - "Failed to wait registered duplicate module child after kill: {error}" - ); - } + Self::reap_child_after_kill_attempt(&self.module_id, &mut child).await; } for pid in matching_pids { diff --git a/src-tauri/src/domain/modules/github_release_selection.rs b/src-tauri/src/domain/modules/github_release_selection.rs index 3bb2a567..317b20c3 100644 --- a/src-tauri/src/domain/modules/github_release_selection.rs +++ b/src-tauri/src/domain/modules/github_release_selection.rs @@ -71,13 +71,6 @@ pub(super) fn select_release_assets( .is_some_and(|track| hardware.supports_cuda_track(track)) }) .collect::>(); - let has_cuda_main = main_candidates.iter().copied().any(|idx| { - assets - .get(idx) - .and_then(|asset| detect_cuda_track(&asset.name)) - .is_some_and(|track| hardware.supports_cuda_track(track)) - }); - for main_idx in &supported_cuda_candidates { let Some(main) = assets.get(*main_idx) else { continue; @@ -108,10 +101,6 @@ pub(super) fn select_release_assets( } } - if has_cuda_main { - return None; - } - return None; } @@ -498,6 +487,8 @@ impl HardwareProfile { fn supports_cuda_track(&self, track: CudaTrack) -> bool { match self.cuda_driver_major { + // Values below 100 are CUDA majors such as 12/13; values at or above 100 + // are NVIDIA driver majors such as 525/580. Some(driver_major) if driver_major < 100 => match track { CudaTrack::Cuda12 => driver_major >= 12, CudaTrack::Cuda13 => driver_major >= 13, diff --git a/src-tauri/src/domain/modules/github_releases.rs b/src-tauri/src/domain/modules/github_releases.rs index f9bc1422..d7db16c9 100644 --- a/src-tauri/src/domain/modules/github_releases.rs +++ b/src-tauri/src/domain/modules/github_releases.rs @@ -445,6 +445,10 @@ fn is_runtime_asset_name(name: &str) -> bool { fn is_gpu_asset_name(name: &str) -> bool { let lower = name.to_ascii_lowercase(); + is_gpu_asset_name_lower(&lower) +} + +fn is_gpu_asset_name_lower(lower: &str) -> bool { lower.contains("cuda") || lower.contains("cu12") || lower.contains("cu13") @@ -462,7 +466,7 @@ fn is_cpu_asset_name(name: &str) -> bool { lower.contains("cpu") || lower.contains("avx") || lower.contains("noavx") - || !is_gpu_asset_name(&lower) + || !is_gpu_asset_name_lower(&lower) } const fn hardware_for_target( diff --git a/src-tauri/src/domain/modules/settings_ui_protocol.rs b/src-tauri/src/domain/modules/settings_ui_protocol.rs index 75821148..3ac874f2 100644 --- a/src-tauri/src/domain/modules/settings_ui_protocol.rs +++ b/src-tauri/src/domain/modules/settings_ui_protocol.rs @@ -344,17 +344,16 @@ fn parse_module_id_from_label(label: &str) -> Result { let module_id = parts.next(); let nonce = parts.next(); - if prefix != Some(MODULE_SETTINGS_LABEL_PREFIX) || module_id.is_none() || nonce.is_none() { + let Some(module_id) = module_id.filter(|value| !value.trim().is_empty()) else { return Err(AppError::PermissionDenied( "Module settings route is only available to owned settings webviews".to_string(), )); - } - - let Some(module_id) = module_id.filter(|value| !value.trim().is_empty()) else { + }; + if prefix != Some(MODULE_SETTINGS_LABEL_PREFIX) || nonce.is_none() { return Err(AppError::PermissionDenied( "Module settings route is only available to owned settings webviews".to_string(), )); - }; + } crate::domain::modules::downloader::validate_module_id(module_id)?; Ok(module_id.to_string()) } diff --git a/src-tauri/src/infrastructure/filesystem/local_file_service.rs b/src-tauri/src/infrastructure/filesystem/local_file_service.rs index b68553ba..b8fccfcd 100644 --- a/src-tauri/src/infrastructure/filesystem/local_file_service.rs +++ b/src-tauri/src/infrastructure/filesystem/local_file_service.rs @@ -52,23 +52,34 @@ impl LocalFileService { ))); } - if let Err(remove_error) = fs::remove_file(path).await - && remove_error.kind() != std::io::ErrorKind::NotFound - { + let backup = path.with_extension(format!( + "bak-{}-{}", + std::process::id(), + chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default() + )); + let had_original = path.exists(); + if had_original && let Err(backup_error) = fs::rename(path, &backup).await { let _ = fs::remove_file(&tmp).await; return Err(AppError::Io(format!( - "Failed to replace '{}': rename failed: {first_error}; removing existing file failed: {remove_error}", + "Failed to replace '{}': rename failed: {first_error}; backing up existing file failed: {backup_error}", path.display() ))); } if let Err(second_error) = fs::rename(&tmp, path).await { + if had_original { + let _ = fs::rename(&backup, path).await; + } let _ = fs::remove_file(&tmp).await; return Err(AppError::Io(format!( "Failed to publish atomic write to '{}': first rename failed: {first_error}; second rename failed: {second_error}", path.display() ))); } + + if had_original { + let _ = fs::remove_file(&backup).await; + } } Ok(()) diff --git a/src/app/init.ts b/src/app/init.ts index 50443954..82914322 100644 --- a/src/app/init.ts +++ b/src/app/init.ts @@ -43,12 +43,17 @@ export class Core { return; } - this._initPromise = this._runInit(); + const initPromise = this._runInit(); + this._initPromise = initPromise; try { - await this._initPromise; - this._isInitialized = true; + await initPromise; + if (this._initPromise === initPromise && !this._isDestroyed) { + this._isInitialized = true; + } } finally { - this._initPromise = null; + if (this._initPromise === initPromise) { + this._initPromise = null; + } } } diff --git a/src/features/chat/chat.test.ts b/src/features/chat/chat.test.ts index 79250614..2a8e557b 100644 --- a/src/features/chat/chat.test.ts +++ b/src/features/chat/chat.test.ts @@ -218,8 +218,7 @@ describe('ChatController', () => { }); it('should remove attach menu listeners on destroy', () => { - vi.useFakeTimers(); - const removeEventListener = vi.spyOn(document, 'removeEventListener'); + const addEventListener = vi.spyOn(document, 'addEventListener'); document.body.innerHTML = `
    @@ -228,12 +227,20 @@ describe('ChatController', () => { const controller = createController(); controller.toggleAttachMenu(); - vi.runOnlyPendingTimers(); + const listenerOptions = addEventListener.mock.calls.find( + ([eventName]) => eventName === 'mousedown', + )?.[2]; + if ( + typeof listenerOptions !== 'object' || + !('signal' in listenerOptions) || + !(listenerOptions.signal instanceof AbortSignal) + ) { + throw new Error('attach menu listener signal was not registered'); + } controller.destroy(); expect(document.querySelector('.chat-attach-menu')).toBeNull(); - expect(removeEventListener).toHaveBeenCalledWith('mousedown', expect.any(Function), true); - vi.useRealTimers(); + expect(listenerOptions.signal.aborted).toBe(true); }); it('should restore multimodal history without flattening stored content', async () => { diff --git a/src/features/chat/chat.ts b/src/features/chat/chat.ts index 06e7c227..02e70bf9 100644 --- a/src/features/chat/chat.ts +++ b/src/features/chat/chat.ts @@ -79,8 +79,7 @@ export class ChatController { private readonly _sendController: ChatSendController; private readonly _state = new ChatControllerState(); private _restoredImageGenerationTimer: ReturnType | null = null; - private _attachMenuOpenTimer: ReturnType | null = null; - private _attachMenuCloseHandler: ((event: MouseEvent) => void) | null = null; + private _attachMenuAbortController: AbortController | null = null; private _forceImageGeneration = false; private _contextTokenTotal = 0; private _contextTokenVersion = 0; @@ -523,14 +522,8 @@ export class ChatController { } private _closeAttachMenu(): void { - if (this._attachMenuOpenTimer !== null) { - globalThis.clearTimeout(this._attachMenuOpenTimer); - this._attachMenuOpenTimer = null; - } - if (this._attachMenuCloseHandler !== null) { - document.removeEventListener('mousedown', this._attachMenuCloseHandler, true); - this._attachMenuCloseHandler = null; - } + this._attachMenuAbortController?.abort(); + this._attachMenuAbortController = null; document.querySelector('.chat-attach-menu')?.remove(); } @@ -573,6 +566,7 @@ export class ChatController { menu.append(fileButton, imageButton); compose.appendChild(menu); + const controller = new AbortController(); const close = (event: MouseEvent) => { if (event.target instanceof Node && menu.contains(event.target)) { return; @@ -582,14 +576,11 @@ export class ChatController { } this._closeAttachMenu(); }; - this._attachMenuCloseHandler = close; - this._attachMenuOpenTimer = globalThis.setTimeout(() => { - this._attachMenuOpenTimer = null; - if (this._state.isDestroyed || !menu.isConnected) { - return; - } - document.addEventListener('mousedown', close, true); - }, 0); + this._attachMenuAbortController = controller; + document.addEventListener('mousedown', close, { + capture: true, + signal: controller.signal, + }); } public async pickChatFilesFromMenu(): Promise { diff --git a/src/features/chat/controllers/ChatSendController.ts b/src/features/chat/controllers/ChatSendController.ts index fa60d180..7a4a5313 100644 --- a/src/features/chat/controllers/ChatSendController.ts +++ b/src/features/chat/controllers/ChatSendController.ts @@ -84,6 +84,19 @@ type ChatSendControllerOptions = { type UiLock = ReturnType; +const IMAGE_PROMPT_REWRITE_TEMPLATE = [ + 'You are preparing a prompt for Stable Diffusion.', + 'Task: translate the user request into English, preserve the exact subject and intent, and lightly enhance it with useful visual details.', + 'Rules:', + '- Remove command words like generate, draw, create, please, сгенерируй, нарисуй, сделай.', + '- Do not invent extra people, objects, actions, identities, or locations that the user did not ask for.', + '- You may add concise visual quality details: composition, lighting, camera, mood, texture, style, and render quality.', + '- Keep it as one prompt, 12-45 words.', + '- Return only the final prompt text. No quotes, no markdown, no explanation.', + '', + 'User request: {{prompt}}', +].join('\n'); + export class ChatSendController { private readonly _autoStartHelper: ChatAutoStartHelper; private readonly _sendFlow: ChatSendFlow; @@ -325,18 +338,7 @@ export class ChatSendController { } private _buildImagePromptRewriteRequest(prompt: string): string { - return [ - 'You are preparing a prompt for Stable Diffusion.', - 'Task: translate the user request into English, preserve the exact subject and intent, and lightly enhance it with useful visual details.', - 'Rules:', - '- Remove command words like generate, draw, create, please, сгенерируй, нарисуй, сделай.', - '- Do not invent extra people, objects, actions, identities, or locations that the user did not ask for.', - '- You may add concise visual quality details: composition, lighting, camera, mood, texture, style, and render quality.', - '- Keep it as one prompt, 12-45 words.', - '- Return only the final prompt text. No quotes, no markdown, no explanation.', - '', - `User request: ${prompt}`, - ].join('\n'); + return IMAGE_PROMPT_REWRITE_TEMPLATE.replace('{{prompt}}', prompt); } private _stripPromptEnvelope(prompt: string): string { From 9622f310f58cd8027f5adefeab6bc0b9835faca7 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 30 Apr 2026 14:25:38 +0300 Subject: [PATCH 037/126] fix: resolve remaining coderabbit threads --- src-tauri/src/domain/modules/downloader_transfer.rs | 12 +++++++----- src/features/chat/controllers/ChatSendController.ts | 6 +++++- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/domain/modules/downloader_transfer.rs b/src-tauri/src/domain/modules/downloader_transfer.rs index aa334e30..b38238e0 100644 --- a/src-tauri/src/domain/modules/downloader_transfer.rs +++ b/src-tauri/src/domain/modules/downloader_transfer.rs @@ -130,13 +130,15 @@ pub(super) async fn clone_repository_into( }); let git_dir = extraction_path.join(".git"); - if git_dir.exists() { - tokio::fs::remove_dir_all(&git_dir).await.map_err(|error| { - AppError::Io(format!( + match tokio::fs::remove_dir_all(&git_dir).await { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(AppError::Io(format!( "Failed to remove git metadata directory '{}': {error}", git_dir.display() - )) - })?; + ))); + } } Ok(()) diff --git a/src/features/chat/controllers/ChatSendController.ts b/src/features/chat/controllers/ChatSendController.ts index 7a4a5313..2226d8ce 100644 --- a/src/features/chat/controllers/ChatSendController.ts +++ b/src/features/chat/controllers/ChatSendController.ts @@ -141,7 +141,11 @@ export class ChatSendController { } this._cancelRequested = true; - await this._options.cancelTextGeneration(this._activeProviderId); + try { + await this._options.cancelTextGeneration(this._activeProviderId); + } catch (error: unknown) { + this._options.tracer.info(`Chat cancellation request failed: ${String(error)}`); + } } public validateInput(text: string): boolean { From 533d9a15b6d6f35a47d34da8350a5357d4c8e032 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 30 Apr 2026 16:11:19 +0300 Subject: [PATCH 038/126] fix: address latest coderabbit cancellation review --- src-tauri/src/api/ai/mod.rs | 2 +- src/features/ai/services/AIBridge.ts | 11 +++++++---- src/features/ai/types/IAIBridge.ts | 2 +- src/features/chat/chat.ts | 5 +++-- .../chat/controllers/ChatSendController.test.ts | 1 + 5 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/api/ai/mod.rs b/src-tauri/src/api/ai/mod.rs index ad6a5db5..8ef44281 100644 --- a/src-tauri/src/api/ai/mod.rs +++ b/src-tauri/src/api/ai/mod.rs @@ -702,7 +702,7 @@ pub async fn get_image_generation_preview( preview.total = total; preview.speed.clone_from(&speed); preview.eta_relative = None; - } else if has_status { + } else if has_status || has_active_job { preview = Some(ImageGenerationPreview { data_url: String::new(), updated_at_ms: log_progress diff --git a/src/features/ai/services/AIBridge.ts b/src/features/ai/services/AIBridge.ts index 531961fd..1c4879e5 100644 --- a/src/features/ai/services/AIBridge.ts +++ b/src/features/ai/services/AIBridge.ts @@ -289,17 +289,20 @@ export class AIBridge implements IAIBridge { } } - public async cancelImageGeneration(): Promise { + public async cancelImageGeneration(providerId?: string | null): Promise { if (this._context?.tauriProvider.isTauri() !== true) { return; } - const providerId = this._manager.activeProviderId; - if (providerId === null || !this._providerPolicy.isImageProvider(providerId)) { + const effectiveProviderId = providerId ?? this._manager.activeProviderId; + if ( + effectiveProviderId === null || + !this._providerPolicy.isImageProvider(effectiveProviderId) + ) { return; } - await this._runtime.cancelImageGeneration(this._context, providerId); + await this._runtime.cancelImageGeneration(this._context, effectiveProviderId); } public async cancelTextGeneration(): Promise { diff --git a/src/features/ai/types/IAIBridge.ts b/src/features/ai/types/IAIBridge.ts index 323b4cb9..6f465c7c 100644 --- a/src/features/ai/types/IAIBridge.ts +++ b/src/features/ai/types/IAIBridge.ts @@ -27,7 +27,7 @@ export interface IAIBridge { clearHistory(): Promise; getHistory(): Promise; cancelTextGeneration(): Promise; - cancelImageGeneration(): Promise; + cancelImageGeneration(providerId?: string | null): Promise; getImageGenerationPreview(): Promise; rewindLastTurn(): Promise; getState(): { activeProviderId: string | null; isRunning: boolean }; diff --git a/src/features/chat/chat.ts b/src/features/chat/chat.ts index 02e70bf9..8fead6c9 100644 --- a/src/features/chat/chat.ts +++ b/src/features/chat/chat.ts @@ -363,13 +363,14 @@ export class ChatController { startImagePreviewPolling: (handle) => { this._generationController.startImagePreviewPolling(handle); }, - cancelTextGeneration: async () => { + cancelTextGeneration: async (providerIdFromSend) => { const providerId = + providerIdFromSend ?? this._state.currentGenerationProviderId ?? this._aiBridge.getState().activeProviderId; if (this._generationController.isImageProvider(providerId)) { this._generationController.stopImagePreviewPolling(); - await this._aiBridge.cancelImageGeneration(); + await this._aiBridge.cancelImageGeneration(providerId); return true; } diff --git a/src/features/chat/controllers/ChatSendController.test.ts b/src/features/chat/controllers/ChatSendController.test.ts index 00f7e7bb..48720acb 100644 --- a/src/features/chat/controllers/ChatSendController.test.ts +++ b/src/features/chat/controllers/ChatSendController.test.ts @@ -232,6 +232,7 @@ describe('ChatSendController', () => { await sendPromise; expect(options.cancelTextGeneration).toHaveBeenCalledOnce(); + expect(options.cancelTextGeneration).toHaveBeenCalledWith('gpt'); expect(streamingHandle.cancel).toHaveBeenCalledOnce(); expect(options.handleResponse).not.toHaveBeenCalled(); expect(options.handleError).not.toHaveBeenCalled(); From ffa9b5dacd7d74e14aea4e631361f941df974840 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 30 Apr 2026 16:35:40 +0300 Subject: [PATCH 039/126] fix: address coderabbit review followups --- src-tauri/src/domain/ai/ai_dispatch.rs | 7 +- src-tauri/src/domain/ai/session.rs | 43 +++++++---- src-tauri/src/domain/engine/manager.rs | 14 +++- src-tauri/src/domain/integration_api.rs | 28 ++++---- .../domain/modules/controller/lifecycle.rs | 47 +++++++++--- .../src/domain/modules/github_releases.rs | 14 +++- .../filesystem/local_file_service.rs | 15 ++-- src/app/events.test.ts | 72 +++++++++++++++++++ src/app/events.ts | 4 ++ src/app/init.ts | 13 ++++ src/features/ai/services/AIChatTransport.ts | 6 +- src/features/ai/types/IAIBridge.ts | 1 + src/features/chat/chat.ts | 12 +++- 13 files changed, 227 insertions(+), 49 deletions(-) create mode 100644 src/app/events.test.ts diff --git a/src-tauri/src/domain/ai/ai_dispatch.rs b/src-tauri/src/domain/ai/ai_dispatch.rs index af08cbaf..13380139 100644 --- a/src-tauri/src/domain/ai/ai_dispatch.rs +++ b/src-tauri/src/domain/ai/ai_dispatch.rs @@ -307,10 +307,13 @@ async fn prepend_local_system_prompt( return Ok(()); } }; - let key = format!("{provider}_system_prompt"); + let canonical_provider = canonical_engine_id(provider); + let canonical_key = format!("{canonical_provider}_system_prompt"); + let raw_key = format!("{provider}_system_prompt"); let prompt = settings .extra_settings - .get(&key) + .get(&canonical_key) + .or_else(|| settings.extra_settings.get(&raw_key)) .map(String::as_str) .unwrap_or_default() .trim(); diff --git a/src-tauri/src/domain/ai/session.rs b/src-tauri/src/domain/ai/session.rs index 6af1bada..97e41fad 100644 --- a/src-tauri/src/domain/ai/session.rs +++ b/src-tauri/src/domain/ai/session.rs @@ -148,14 +148,26 @@ impl ChatSessionManager { self.ensure_persistence_available()?; let save_lock = Arc::clone(&self.save_lock); let sessions = Arc::clone(&self.sessions); - tokio::task::spawn_blocking(move || Self::flush_sessions_locked(&save_lock, &sessions)) - .await - .map_err(|e| crate::errors::AppError::Internal { - request_id: None, - message: format!("Blocking task failed: {e}"), - })??; - self.dirty.store(false, Ordering::Relaxed); - Ok(()) + self.dirty.store(false, Ordering::Release); + + match tokio::task::spawn_blocking(move || { + Self::flush_sessions_locked(&save_lock, &sessions) + }) + .await + { + Ok(Ok(())) => Ok(()), + Ok(Err(error)) => { + self.dirty.store(true, Ordering::Release); + Err(error) + } + Err(error) => { + self.dirty.store(true, Ordering::Release); + Err(crate::errors::AppError::Internal { + request_id: None, + message: format!("Blocking task failed: {error}"), + }) + } + } } /// Synchronous save — intended for use in Tauri shutdown hooks (called from a blocking context). @@ -491,12 +503,19 @@ impl SessionPersistence { } if let Err(second_error) = std::fs::rename(&tmp_path, path) { - if had_original { - let _ = std::fs::rename(&backup_path, path); - } + let restore_message = if had_original { + match std::fs::rename(&backup_path, path) { + Ok(()) => "backup restore succeeded".to_string(), + Err(restore_error) => { + format!("backup restore failed: {restore_error}") + } + } + } else { + "no original file to restore".to_string() + }; let _ = std::fs::remove_file(&tmp_path); return Err(crate::errors::AppError::Io(format!( - "Failed to publish chat history '{}': first rename failed: {error}; second rename failed: {second_error}", + "Failed to publish chat history '{}': first rename failed: {error}; second rename failed: {second_error}; {restore_message}", path.display() ))); } diff --git a/src-tauri/src/domain/engine/manager.rs b/src-tauri/src/domain/engine/manager.rs index d25eb013..1780cdc9 100644 --- a/src-tauri/src/domain/engine/manager.rs +++ b/src-tauri/src/domain/engine/manager.rs @@ -658,7 +658,7 @@ impl EngineManager { error = %error, "Engine process status check failed; attempting to stop before pruning" ); - errored.push(*capability); + errored.push((*capability, engine.definition.id.clone())); } Ok(None) => {} } @@ -669,8 +669,16 @@ impl EngineManager { } } - for capability in errored { - let engine = self.slots.lock().await.remove(&capability); + for (capability, failed_engine_id) in errored { + let engine = { + let mut slots = self.slots.lock().await; + match slots.get(&capability) { + Some(current) if current.definition.id == failed_engine_id => { + slots.remove(&capability) + } + _ => None, + } + }; let Some(engine) = engine else { continue; }; diff --git a/src-tauri/src/domain/integration_api.rs b/src-tauri/src/domain/integration_api.rs index ae483190..58ec2c4b 100644 --- a/src-tauri/src/domain/integration_api.rs +++ b/src-tauri/src/domain/integration_api.rs @@ -560,7 +560,7 @@ async fn handle_text_request( "Text request requires a prompt or messages".to_string(), )); } - if messages.is_empty() || !prompt.trim().is_empty() { + if messages.is_empty() && !prompt.trim().is_empty() { messages.push(ChatMessage { id: uuid::Uuid::new_v4().to_string(), role: "user".to_string(), @@ -571,11 +571,11 @@ async fn handle_text_request( let thinking_level = match payload.thinking_level { Some(value) => Some(value), - None => selected_thinking_level(&context.ui_state_service, &ui_provider).await?, + None => selected_thinking_level(&context.ui_state_service, &ui_provider, &provider).await?, }; let web_search = match payload.web_search { Some(value) => Some(value), - None => selected_web_search(&context.ui_state_service, &ui_provider).await?, + None => selected_web_search(&context.ui_state_service, &ui_provider, &provider).await?, }; let mut chat_request = ChatRequest { @@ -822,26 +822,28 @@ async fn resolve_session_id( async fn selected_thinking_level( ui_state_service: &UiStateService, - provider: &str, + primary_provider: &str, + fallback_provider: &str, ) -> Result, AppError> { - Ok(ui_state_service - .get_ui_state() - .await? + let state = ui_state_service.get_ui_state().await?; + Ok(state .ai_thinking_level - .get(provider) + .get(primary_provider) + .or_else(|| state.ai_thinking_level.get(fallback_provider)) .cloned() .filter(|value| !value.trim().is_empty())) } async fn selected_web_search( ui_state_service: &UiStateService, - provider: &str, + primary_provider: &str, + fallback_provider: &str, ) -> Result, AppError> { - let enabled = ui_state_service - .get_ui_state() - .await? + let state = ui_state_service.get_ui_state().await?; + let enabled = state .ai_web_search_enabled - .get(provider) + .get(primary_provider) + .or_else(|| state.ai_web_search_enabled.get(fallback_provider)) .copied() .unwrap_or(false); diff --git a/src-tauri/src/domain/modules/controller/lifecycle.rs b/src-tauri/src/domain/modules/controller/lifecycle.rs index 013a8444..4c6345a1 100644 --- a/src-tauri/src/domain/modules/controller/lifecycle.rs +++ b/src-tauri/src/domain/modules/controller/lifecycle.rs @@ -7,11 +7,14 @@ use crate::models::ControlResponse; use std::fs::OpenOptions; use std::path::{Path, PathBuf}; use std::process::Stdio; +use std::sync::LazyLock; use std::time::Duration; use tokio::process::{Child, Command}; +use tokio::sync::Mutex; use tokio::time::timeout; const MODULE_CHILD_EXIT_POLL_INTERVAL: Duration = Duration::from_secs(1); +static MODULE_START_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); fn build_command(cmd: CommandDefinition) -> Command { match cmd { @@ -57,6 +60,8 @@ impl<'a> LifecycleExecutor<'a> { /// Safely starts a module with the given manifest pub async fn start(&self, manifest: &ModuleManifest) -> Result { + let _start_guard = MODULE_START_LOCK.lock().await; + // 1. Guard against double-start // Check registry first (atomic-ish) if self.controller.registry.contains_key(&self.module_id) { @@ -79,7 +84,7 @@ impl<'a> LifecycleExecutor<'a> { }); } - if let Some(entry_path) = self.resolve_script_entry_path(manifest) { + if let Some(entry_path) = self.resolve_script_entry_path(manifest)? { if let Some(existing_pid) = self .reconcile_existing_script_processes(&entry_path) .await? @@ -297,7 +302,7 @@ impl<'a> LifecycleExecutor<'a> { /// Gracefully stops a module with escalation pub async fn stop(&self, manifest: &ModuleManifest) -> Result { tracing::info!("Stopping module: {}", self.module_id); - let script_entry_path = self.resolve_script_entry_path(manifest); + let script_entry_path = self.resolve_script_entry_path(manifest)?; // 1. Run stop script if exists if let Some(stop_cmd) = manifest.lifecycle.as_ref().and_then(|l| l.stop.clone()) { @@ -325,16 +330,31 @@ impl<'a> LifecycleExecutor<'a> { } } - // Wait with timeout - if timeout(Duration::from_secs(5), child.wait()).await.is_err() { - tracing::warn!("Module {} stop timed out, forcing kill", self.module_id); - if let Err(error) = child.kill().await { + match timeout(Duration::from_secs(5), child.wait()).await { + Ok(Ok(_status)) => {} + Ok(Err(error)) => { tracing::warn!( module_id = %self.module_id, - "Failed to force-kill registered module process: {error}" + "Failed to wait registered module process during stop: {error}" ); + if let Err(kill_error) = child.kill().await { + tracing::warn!( + module_id = %self.module_id, + "Failed to force-kill registered module process after wait error: {kill_error}" + ); + } + Self::reap_child_after_kill_attempt(&self.module_id, &mut child).await; + } + Err(_) => { + tracing::warn!("Module {} stop timed out, forcing kill", self.module_id); + if let Err(error) = child.kill().await { + tracing::warn!( + module_id = %self.module_id, + "Failed to force-kill registered module process: {error}" + ); + } + Self::reap_child_after_kill_attempt(&self.module_id, &mut child).await; } - Self::reap_child_after_kill_attempt(&self.module_id, &mut child).await; } } @@ -425,8 +445,15 @@ impl<'a> LifecycleExecutor<'a> { }) } - fn resolve_script_entry_path(&self, manifest: &ModuleManifest) -> Option { - script_runtime::resolve_entry_path(self.module_path, manifest).ok() + fn resolve_script_entry_path( + &self, + manifest: &ModuleManifest, + ) -> Result, AppError> { + if !script_runtime::supports_manifest(manifest) { + return Ok(None); + } + + script_runtime::resolve_entry_path(self.module_path, manifest).map(Some) } async fn reconcile_existing_script_processes( diff --git a/src-tauri/src/domain/modules/github_releases.rs b/src-tauri/src/domain/modules/github_releases.rs index d7db16c9..377af25b 100644 --- a/src-tauri/src/domain/modules/github_releases.rs +++ b/src-tauri/src/domain/modules/github_releases.rs @@ -375,7 +375,7 @@ fn release_download_version( return None; } - let recommended = if gpu.is_some() { + let recommended = if gpu.is_some() && has_real_gpu_accelerator(hardware) { ReleaseComputeTarget::Gpu } else { ReleaseComputeTarget::Cpu @@ -469,6 +469,14 @@ fn is_cpu_asset_name(name: &str) -> bool { || !is_gpu_asset_name_lower(&lower) } +const fn has_real_gpu_accelerator(hardware: HardwareProfile) -> bool { + !matches!( + hardware.accelerator, + crate::domain::system::hardware_probe::AcceleratorClass::CpuOnly + | crate::domain::system::hardware_probe::AcceleratorClass::Unknown + ) +} + const fn hardware_for_target( hardware: HardwareProfile, target: ReleaseComputeTarget, @@ -1126,7 +1134,7 @@ mod tests { assert_eq!(versions.len(), 1); let version = versions.first().expect("expected sdcpp options"); - assert_eq!(version.recommended, ReleaseComputeTarget::Gpu); + assert_eq!(version.recommended, ReleaseComputeTarget::Cpu); assert_eq!( version.cpu.as_ref().and_then(|cpu| cpu.assets.first()), Some(&"sd-master-3d6064b-bin-win-avx2-x64.zip".to_string()) @@ -1170,7 +1178,7 @@ mod tests { assert_eq!(versions.len(), 1); let version = versions.first().expect("expected llama.cpp options"); - assert_eq!(version.recommended, ReleaseComputeTarget::Gpu); + assert_eq!(version.recommended, ReleaseComputeTarget::Cpu); assert_eq!( version.cpu.as_ref().and_then(|cpu| cpu.assets.first()), Some(&"llama-b8981-bin-win-cpu-x64.zip".to_string()) diff --git a/src-tauri/src/infrastructure/filesystem/local_file_service.rs b/src-tauri/src/infrastructure/filesystem/local_file_service.rs index b8fccfcd..044501be 100644 --- a/src-tauri/src/infrastructure/filesystem/local_file_service.rs +++ b/src-tauri/src/infrastructure/filesystem/local_file_service.rs @@ -67,12 +67,19 @@ impl LocalFileService { } if let Err(second_error) = fs::rename(&tmp, path).await { - if had_original { - let _ = fs::rename(&backup, path).await; - } + let restore_message = if had_original { + match fs::rename(&backup, path).await { + Ok(()) => "backup restore succeeded".to_string(), + Err(restore_error) => { + format!("backup restore failed: {restore_error}") + } + } + } else { + "no original file to restore".to_string() + }; let _ = fs::remove_file(&tmp).await; return Err(AppError::Io(format!( - "Failed to publish atomic write to '{}': first rename failed: {first_error}; second rename failed: {second_error}", + "Failed to publish atomic write to '{}': first rename failed: {first_error}; second rename failed: {second_error}; {restore_message}", path.display() ))); } diff --git a/src/app/events.test.ts b/src/app/events.test.ts new file mode 100644 index 00000000..9b0b9b1b --- /dev/null +++ b/src/app/events.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { EventHandler, type ICoreEvents } from './events'; + +function createCoreEvents(): ICoreEvents { + return { + appUI: { + openAppSelection: vi.fn(), + closeAppSelection: vi.fn(), + }, + chatController: { + clearChat: vi.fn(), + pickChatFilesFromMenu: vi.fn(), + sendImageGenerationFromMenu: vi.fn(), + toggleAttachMenu: vi.fn(), + toggleVoiceInput: vi.fn(), + sendChat: vi.fn(), + }, + consoleUI: {}, + downloadUI: {}, + i18nUI: { + toggleMenu: vi.fn(), + setLanguage: vi.fn(), + selectLangInModal: vi.fn(), + confirmLanguage: vi.fn(), + }, + navigationUI: { + showPage: vi.fn().mockResolvedValue(undefined), + }, + moduleSettingsUI: { + close: vi.fn(), + }, + tracer: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + windowService: { + minimize: vi.fn(), + close: vi.fn(), + }, + windowUI: { + toggleMaximize: vi.fn(), + toggleSound: vi.fn(), + }, + } as unknown as ICoreEvents; +} + +describe('EventHandler', () => { + beforeEach(() => { + document.body.className = ''; + document.body.innerHTML = ''; + }); + + it('blocks sidebar navigation while release download selection is open', async () => { + document.body.innerHTML = ``; + document.body.classList.add('download-selection-open'); + const core = createCoreEvents(); + const handler = new EventHandler(core, { + addWindowListener: vi.fn(), + removeWindowListener: vi.fn(), + }); + handler.init(); + + document.querySelector('[data-page]')?.click(); + await Promise.resolve(); + + expect(core.navigationUI.showPage).not.toHaveBeenCalled(); + handler.destroy(); + }); +}); diff --git a/src/app/events.ts b/src/app/events.ts index 613ae885..15022df9 100644 --- a/src/app/events.ts +++ b/src/app/events.ts @@ -97,6 +97,10 @@ export class EventHandler { if (pageId === undefined) return false; e.preventDefault(); + if (document.body.classList.contains('download-selection-open')) { + return true; + } + this._core.tracer.debug(`[EventHandler] Navigating to: ${pageId}`); await this._core.navigationUI.showPage(pageId, navBtn); return true; diff --git a/src/app/init.ts b/src/app/init.ts index 82914322..b187b6af 100644 --- a/src/app/init.ts +++ b/src/app/init.ts @@ -2,6 +2,7 @@ import '@/styles/app.css'; import { tracer } from '@/infrastructure/logging/LoggerService'; import { createCoreAssembly, type CoreAssembly } from './CoreAssembly'; import { bindCoreEntry } from './CoreEntry'; +import type { CoreServices } from './CoreContainer'; export class Core { private readonly _assembly: CoreAssembly; @@ -61,6 +62,18 @@ export class Core { await this._assembly.lifecycleController.runInit(); } + public get aiBridge(): CoreServices['aiBridge'] { + return this._assembly.services.aiBridge; + } + + public get moduleService(): CoreServices['moduleService'] { + return this._assembly.services.moduleService; + } + + public get tauriProvider(): CoreServices['tauriProvider'] { + return this._assembly.services.tauriProvider; + } + public async destroy(): Promise { if (this._isDestroyed) return; this._isDestroyed = true; diff --git a/src/features/ai/services/AIChatTransport.ts b/src/features/ai/services/AIChatTransport.ts index fd2ddbeb..b29df662 100644 --- a/src/features/ai/services/AIChatTransport.ts +++ b/src/features/ai/services/AIChatTransport.ts @@ -273,7 +273,11 @@ export class AIChatTransport implements IChatTransport { return await this._context.tauriProvider .invoke('generate_image', { request }) .then((response) => { - if (response.ok && response.images.length > 0) { + if ( + response.ok && + Array.isArray(response.images) && + response.images.length > 0 + ) { return { ok: true, images: response.images }; } return { ok: false, error: response.error ?? 'Failed to generate image' }; diff --git a/src/features/ai/types/IAIBridge.ts b/src/features/ai/types/IAIBridge.ts index 6f465c7c..1fca7447 100644 --- a/src/features/ai/types/IAIBridge.ts +++ b/src/features/ai/types/IAIBridge.ts @@ -26,6 +26,7 @@ export interface IAIBridge { stopEngineSlot(capability: 'text' | 'image' | 'vision'): Promise; clearHistory(): Promise; getHistory(): Promise; + prepareImagePrompt(text: string): Promise; cancelTextGeneration(): Promise; cancelImageGeneration(providerId?: string | null): Promise; getImageGenerationPreview(): Promise; diff --git a/src/features/chat/chat.ts b/src/features/chat/chat.ts index 8fead6c9..a1869da8 100644 --- a/src/features/chat/chat.ts +++ b/src/features/chat/chat.ts @@ -445,7 +445,7 @@ export class ChatController { } private async _restoreActiveImageGeneration(): Promise { - if (this._state.isDestroyed || this._state.isSending) { + if (this._isDestroyed() || this._state.isSending) { return; } @@ -465,6 +465,9 @@ export class ChatController { this._tracer.error('[Chat] Failed to restore active image generation:', error); return; } + if (this._isDestroyed()) { + return; + } if (preview === null) { return; } @@ -522,6 +525,10 @@ export class ChatController { this._restoredImageGenerationTimer = null; } + private _isDestroyed(): boolean { + return this._state.isDestroyed; + } + private _closeAttachMenu(): void { this._attachMenuAbortController?.abort(); this._attachMenuAbortController = null; @@ -607,7 +614,10 @@ export class ChatController { public async clearChat(): Promise { this._activationCoordinator.clearInactiveAiErrorTimeout(); + this._clearRestoredImageGenerationTimer(); this._generationController.stopImagePreviewPolling(); + this._state.isSending = false; + this._state.currentGenerationProviderId = null; this._state.clearHistory(); this._contextTokenTotal = 0; this._contextTokenVersion += 1; From 407ff7e6332896a1ad44aa51e30dbc6b4dd2ab7c Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 30 Apr 2026 16:50:11 +0300 Subject: [PATCH 040/126] fix: stabilize module selection animations --- src/shared/shell/ui/AppUiModuleFlow.test.ts | 2 + src/shared/shell/ui/AppUiModuleFlow.ts | 12 ++++-- src/shared/shell/ui/ModalManager.test.ts | 31 ++++++++++++++ src/shared/shell/ui/ModalManager.ts | 41 +++++++++++++++++-- .../features/home-page-and-module-cards.css | 8 +++- 5 files changed, 84 insertions(+), 10 deletions(-) diff --git a/src/shared/shell/ui/AppUiModuleFlow.test.ts b/src/shared/shell/ui/AppUiModuleFlow.test.ts index bf116e80..dc9fbf8f 100644 --- a/src/shared/shell/ui/AppUiModuleFlow.test.ts +++ b/src/shared/shell/ui/AppUiModuleFlow.test.ts @@ -16,6 +16,8 @@ describe('AppUiModuleFlow', () => { isAppSelectionOpen: vi.fn(), isViewingCategory: vi.fn(), closeAppSelection: vi.fn(), + suspendAppSelection: vi.fn(), + resumeAppSelection: vi.fn(), openAppSelection: vi.fn(), refreshCurrentSelection: vi.fn(), }; diff --git a/src/shared/shell/ui/AppUiModuleFlow.ts b/src/shared/shell/ui/AppUiModuleFlow.ts index 201f3716..6cbfd194 100644 --- a/src/shared/shell/ui/AppUiModuleFlow.ts +++ b/src/shared/shell/ui/AppUiModuleFlow.ts @@ -8,6 +8,8 @@ type ModalBridge = { isAppSelectionOpen(): boolean; isViewingCategory(category: string): boolean; closeAppSelection(): void; + suspendAppSelection(): boolean; + resumeAppSelection(): void; openAppSelection(category: string, apps: IApp[], selectedId?: string): void; refreshCurrentSelection(apps?: IApp[], selectedId?: string | null): void; }; @@ -92,9 +94,9 @@ export class AppUiModuleFlow { } const shouldRestoreSelection = this._deps.modalManager.isAppSelectionOpen(); - if (shouldRestoreSelection) { - this._deps.modalManager.closeAppSelection(); - } + const suspendedSelection = shouldRestoreSelection + ? this._deps.modalManager.suspendAppSelection() + : false; try { return await openDownloadSelectionDialog({ @@ -103,7 +105,9 @@ export class AppUiModuleFlow { translate: this._deps.translate, }); } finally { - if (shouldRestoreSelection) { + if (suspendedSelection) { + this._deps.modalManager.resumeAppSelection(); + } else if (shouldRestoreSelection) { this._restoreAppSelection(category); } } diff --git a/src/shared/shell/ui/ModalManager.test.ts b/src/shared/shell/ui/ModalManager.test.ts index 8041f140..7d707856 100644 --- a/src/shared/shell/ui/ModalManager.test.ts +++ b/src/shared/shell/ui/ModalManager.test.ts @@ -280,6 +280,37 @@ describe('ModalManager lifecycle', () => { expect(navigation.pushBackAction).toHaveBeenCalledTimes(1); }); + it('should suspend and resume app selection without exposing the dashboard', () => { + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn((callback: FrameRequestCallback) => { + callback(0); + return 0; + }), + ); + modalManager = createManager(); + const modal = document.getElementById('app-selection-modal') as HTMLDialogElement; + const container = document.querySelector('.models-container') as HTMLElement; + + modalManager.openAppSelection( + 'services', + [{ id: 'svc-a', name: 'Service A', installed: true } as IApp], + 'svc-a', + ); + + expect(modalManager.suspendAppSelection()).toBe(true); + expect(modal.open).toBe(true); + expect(modal.classList.contains('hidden')).toBe(false); + expect(modal.style.visibility).toBe('hidden'); + expect(container.classList.contains('content-hidden')).toBe(true); + + modalManager.resumeAppSelection(); + + expect(modal.style.visibility).toBe(''); + expect(container.classList.contains('content-hidden')).toBe(true); + expect(navigation.removeBackAction).not.toHaveBeenCalledWith('app-selection-modal'); + }); + it('disables tab focus movement inside the app selection modal', () => { modalManager = createManager(); const outsideButton = document.createElement('button'); diff --git a/src/shared/shell/ui/ModalManager.ts b/src/shared/shell/ui/ModalManager.ts index 7a2dba4b..426571bc 100644 --- a/src/shared/shell/ui/ModalManager.ts +++ b/src/shared/shell/ui/ModalManager.ts @@ -141,6 +141,9 @@ export class ModalManager { this._currentApps = apps; this._currentSelectedAppId = selectedAppId ?? null; + const container = document.querySelector('.models-container'); + if (container !== null) container.classList.add('content-hidden'); + // Derive filter from compound category — do NOT blindly reset to 'text' // so that reopening after removing an image-slot app stays on the image tab. if (category === CategoryKey.AI_IMAGE) { @@ -178,10 +181,6 @@ export class ModalManager { }, 0); }); - // Add smooth hiding for main content - const container = document.querySelector('.models-container'); - if (container !== null) container.classList.add('content-hidden'); - // Register back action for mouse/keyboard global navigation this._navigation.pushBackAction( 'app-selection-modal', @@ -220,6 +219,40 @@ export class ModalManager { if (container !== null) container.classList.remove('content-hidden'); } + public suspendAppSelection(): boolean { + if (!this.isAppSelectionOpen()) { + return false; + } + + const modal = document.getElementById('app-selection-modal') as HTMLDialogElement | null; + if (modal === null) { + return false; + } + + this._filterTransitionController.cancelPending(); + this._detachOverlayCloseHandler(); + modal.classList.add('app-selection-suspended'); + modal.style.visibility = 'hidden'; + return true; + } + + public resumeAppSelection(): void { + const modal = document.getElementById('app-selection-modal') as HTMLDialogElement | null; + if (modal === null || !modal.open || modal.classList.contains('hidden')) { + return; + } + + modal.classList.remove('app-selection-suspended'); + modal.style.removeProperty('visibility'); + this._overlayClickModal = modal; + this._focusTrap.attach(modal); + requestAnimationFrame(() => { + if (this._overlayClickModal === modal && this.isAppSelectionOpen()) { + this._focusTrap.focusFirstElement(modal); + } + }); + } + public isAppSelectionOpen(): boolean { const modal = document.getElementById('app-selection-modal') as HTMLDialogElement | null; return modal !== null && modal.open && !modal.classList.contains('hidden'); diff --git a/src/styles/features/home-page-and-module-cards.css b/src/styles/features/home-page-and-module-cards.css index 20f3b4b9..c4d2d393 100644 --- a/src/styles/features/home-page-and-module-cards.css +++ b/src/styles/features/home-page-and-module-cards.css @@ -132,21 +132,25 @@ body[data-platform='macos'] .module-card:hover { } #page-modules.active .models-container { - animation: pageContentRise 0.3s cubic-bezier(0.22, 1, 0.36, 1); + animation: modulesPageEnter 0.24s cubic-bezier(0.22, 1, 0.36, 1) both; } /* State when model selection or settings is open */ .models-container.content-hidden { opacity: 0; + transform: none; pointer-events: none; + transition: none; } -@keyframes modelsPageFadeIn { +@keyframes modulesPageEnter { from { opacity: 0; + transform: translateY(8px); } to { opacity: 1; + transform: translateY(0); } } From 7b9c9d1d843d90093e36f73068b79350ffe81fda Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 30 Apr 2026 17:03:02 +0300 Subject: [PATCH 041/126] fix: hide module page behind app selection --- src/shared/shell/ui/ModalManager.test.ts | 22 +++++++++++++++++++ src/shared/shell/ui/ModalManager.ts | 2 ++ .../features/module-selection-modal.css | 1 + 3 files changed, 25 insertions(+) diff --git a/src/shared/shell/ui/ModalManager.test.ts b/src/shared/shell/ui/ModalManager.test.ts index 7d707856..9c9681c9 100644 --- a/src/shared/shell/ui/ModalManager.test.ts +++ b/src/shared/shell/ui/ModalManager.test.ts @@ -104,6 +104,8 @@ describe('ModalManager lifecycle', () => { modalManager.closeAppSelection(); modalManager.openAppSelection('services', []); + expect(document.body.classList.contains('app-selection-open')).toBe(true); + const closeSpy = vi.spyOn(modalManager, 'closeAppSelection'); const modal = document.getElementById('app-selection-modal') as HTMLDialogElement; modal.dispatchEvent(new MouseEvent('click', { bubbles: true })); @@ -302,15 +304,35 @@ describe('ModalManager lifecycle', () => { expect(modal.open).toBe(true); expect(modal.classList.contains('hidden')).toBe(false); expect(modal.style.visibility).toBe('hidden'); + expect(document.body.classList.contains('app-selection-open')).toBe(true); expect(container.classList.contains('content-hidden')).toBe(true); modalManager.resumeAppSelection(); expect(modal.style.visibility).toBe(''); + expect(document.body.classList.contains('app-selection-open')).toBe(true); expect(container.classList.contains('content-hidden')).toBe(true); expect(navigation.removeBackAction).not.toHaveBeenCalledWith('app-selection-modal'); }); + it('should clear page-hidden state after closing app selection', () => { + modalManager = createManager(); + + modalManager.openAppSelection( + 'services', + [{ id: 'svc-a', name: 'Service A', installed: true } as IApp], + 'svc-a', + ); + expect(document.body.classList.contains('app-selection-open')).toBe(true); + + modalManager.closeAppSelection(); + + expect(document.body.classList.contains('app-selection-open')).toBe(false); + expect( + document.querySelector('.models-container')?.classList.contains('content-hidden'), + ).toBe(false); + }); + it('disables tab focus movement inside the app selection modal', () => { modalManager = createManager(); const outsideButton = document.createElement('button'); diff --git a/src/shared/shell/ui/ModalManager.ts b/src/shared/shell/ui/ModalManager.ts index 426571bc..a00ad327 100644 --- a/src/shared/shell/ui/ModalManager.ts +++ b/src/shared/shell/ui/ModalManager.ts @@ -141,6 +141,7 @@ export class ModalManager { this._currentApps = apps; this._currentSelectedAppId = selectedAppId ?? null; + document.body.classList.add('app-selection-open'); const container = document.querySelector('.models-container'); if (container !== null) container.classList.add('content-hidden'); @@ -215,6 +216,7 @@ export class ModalManager { } // Restore main content visibility + document.body.classList.remove('app-selection-open'); const container = document.querySelector('.models-container'); if (container !== null) container.classList.remove('content-hidden'); } diff --git a/src/styles/features/module-selection-modal.css b/src/styles/features/module-selection-modal.css index 7ff881d3..66db8d21 100644 --- a/src/styles/features/module-selection-modal.css +++ b/src/styles/features/module-selection-modal.css @@ -816,6 +816,7 @@ body.snapping .modal-backdrop { transform: none !important; } +body.app-selection-open #main-area, body.download-selection-open #main-area { visibility: hidden !important; opacity: 0 !important; From 720990b29f84f24fbbc8281e2775b7e0e2cd5389 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 30 Apr 2026 17:06:41 +0300 Subject: [PATCH 042/126] fix: hide module page behind settings modal --- src/styles/features/module-selection-modal.css | 1 + 1 file changed, 1 insertion(+) diff --git a/src/styles/features/module-selection-modal.css b/src/styles/features/module-selection-modal.css index 66db8d21..a59b67c9 100644 --- a/src/styles/features/module-selection-modal.css +++ b/src/styles/features/module-selection-modal.css @@ -817,6 +817,7 @@ body.snapping .modal-backdrop { } body.app-selection-open #main-area, +body.settings-modal-open #main-area, body.download-selection-open #main-area { visibility: hidden !important; opacity: 0 !important; From 4568ac3a0687528a52a75bd833636c788031d662 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 30 Apr 2026 17:51:31 +0300 Subject: [PATCH 043/126] fix: align chat and ui state contracts --- .../src/infrastructure/config/ui_state.rs | 49 +++++++ src-tauri/src/models/ui_state.rs | 4 + src/features/ai/services/AIBridgeContext.ts | 1 + .../ai/services/AIProviderManager.test.ts | 31 +++++ src/features/ai/services/AIProviderManager.ts | 20 ++- .../ai/services/EngineConfigService.ts | 21 +-- .../chat/ui/ChatImageGenerationMessage.ts | 4 + .../settings/services/SettingsService.ts | 10 +- .../services/state/UiStateStore.test.ts | 52 ++++++++ src/shared/services/state/UiStateStore.ts | 125 ++++++++++++++++-- src/shared/types/bindings.ts | 2 + src/styles/features/chat-page.css | 1 + 12 files changed, 293 insertions(+), 27 deletions(-) diff --git a/src-tauri/src/infrastructure/config/ui_state.rs b/src-tauri/src/infrastructure/config/ui_state.rs index 04251583..17d8f6d9 100644 --- a/src-tauri/src/infrastructure/config/ui_state.rs +++ b/src-tauri/src/infrastructure/config/ui_state.rs @@ -80,3 +80,52 @@ fn clamp_zoom(zoom: f64) -> f64 { zoom.clamp(SCALING_MIN_ZOOM, SCALING_MAX_ZOOM) } + +#[cfg(test)] +mod tests { + use super::normalize_ui_state; + use crate::models::UIState; + + #[test] + fn normalize_ui_state_preserves_local_max_output_tokens() { + let mut state = UIState::default(); + state + .local_max_output_tokens + .insert("llamacpp".to_string(), 8192); + + let normalized = normalize_ui_state(state); + + assert_eq!( + normalized.local_max_output_tokens.get("llamacpp"), + Some(&8192) + ); + } + + #[test] + fn ui_state_deserializes_without_local_max_output_tokens() { + let state_result = serde_json::from_str::( + r#"{ + "sidebar_collapsed": false, + "sidebar_width": 280, + "hidden_nav_items": [], + "hidden_monitors": [], + "card_widths": {}, + "download_limit_enabled": false, + "download_max_speed": 50, + "selected_modules": {}, + "zoom_level": 1.0, + "selected_ai_models": {}, + "last_page": null, + "resolution_zoom": {}, + "sound_enabled": true + }"#, + ); + assert!( + state_result.is_ok(), + "legacy UI state should remain readable" + ); + if let Ok(state) = state_result { + assert!(state.local_max_output_tokens.is_empty()); + } + } +} diff --git a/src-tauri/src/models/ui_state.rs b/src-tauri/src/models/ui_state.rs index c0506d8d..8c18f52f 100644 --- a/src-tauri/src/models/ui_state.rs +++ b/src-tauri/src/models/ui_state.rs @@ -62,6 +62,9 @@ pub struct UIState { /// Enables provider-side internet search by AI provider #[serde(default)] pub ai_web_search_enabled: std::collections::HashMap, + /// Per-provider local model output token limits. + #[serde(default)] + pub local_max_output_tokens: std::collections::HashMap, /// Current persistent AI session identifier #[serde(default)] pub ai_session_id: Option, @@ -92,6 +95,7 @@ impl Default for UIState { sound_enabled: true, ai_thinking_level: std::collections::HashMap::new(), ai_web_search_enabled: std::collections::HashMap::new(), + local_max_output_tokens: std::collections::HashMap::new(), ai_session_id: None, preferred_language: None, pending_chat_reveal: false, diff --git a/src/features/ai/services/AIBridgeContext.ts b/src/features/ai/services/AIBridgeContext.ts index 70d84927..0c2618b9 100644 --- a/src/features/ai/services/AIBridgeContext.ts +++ b/src/features/ai/services/AIBridgeContext.ts @@ -15,6 +15,7 @@ export type AIProviderManagerContext = AITransportContext & { aiSettings: { getSelectedAIModel: (appId: string) => string | undefined; setSelectedAIModel: (appId: string, modelKey: string) => void; + getAiSessionId: () => string | null; setAiSessionId: (sessionId: string | null) => void; getThinkingLevel: (appId: string) => ThinkingLevel; getInternetAccessEnabled: (appId: string) => boolean; diff --git a/src/features/ai/services/AIProviderManager.test.ts b/src/features/ai/services/AIProviderManager.test.ts index c17dd499..c618d886 100644 --- a/src/features/ai/services/AIProviderManager.test.ts +++ b/src/features/ai/services/AIProviderManager.test.ts @@ -34,6 +34,7 @@ function createMockCore( getCatalog: vi.fn().mockReturnValue({ ai: [] }), }, aiSettings: { + getAiSessionId: vi.fn().mockReturnValue(null), setAiSessionId: vi.fn(), setSelectedAIModel: vi.fn(), getSelectedAIModel: vi.fn().mockReturnValue(undefined), @@ -85,6 +86,36 @@ describe('AIProviderManager', () => { expect(manager.sessionId).toBe('existing-session-abc'); }); + it('should recover session ID from UI state when secure storage is empty', async () => { + const mockCore = createMockCore(() => Promise.resolve(null)); + vi.mocked(mockCore.aiSettings.getAiSessionId).mockReturnValue('ui-session-abc'); + manager.setCore(mockCore); + + await manager.init(); + + expect(manager.sessionId).toBe('ui-session-abc'); + expect(mockCore.tauriProvider.saveSecureKey).toHaveBeenCalledWith( + 'ai_session_id', + 'ui-session-abc', + ); + expect(mockCore.aiSettings.setAiSessionId).toHaveBeenCalledWith('ui-session-abc'); + }); + + it('should continue with UI session ID when secure persistence fails', async () => { + const mockCore = createMockCore(() => Promise.resolve(null)); + vi.mocked(mockCore.aiSettings.getAiSessionId).mockReturnValue('ui-session-abc'); + vi.mocked(mockCore.tauriProvider.saveSecureKey ?? vi.fn()).mockRejectedValueOnce( + new Error('secure unavailable'), + ); + manager.setCore(mockCore); + + await expect(manager.init()).resolves.toBeUndefined(); + + expect(manager.sessionId).toBe('ui-session-abc'); + expect(mockCore.aiSettings.setAiSessionId).toHaveBeenCalledWith('ui-session-abc'); + expect(tracer.error).toHaveBeenCalled(); + }); + it('should work without core set (generates UUID session)', async () => { await expect(manager.init()).resolves.not.toThrow(); // Without core, _getSecureVal returns null → randomUUID is generated diff --git a/src/features/ai/services/AIProviderManager.ts b/src/features/ai/services/AIProviderManager.ts index 7963cb1d..7968cb89 100644 --- a/src/features/ai/services/AIProviderManager.ts +++ b/src/features/ai/services/AIProviderManager.ts @@ -29,10 +29,16 @@ export class AIProviderManager { } public async init(): Promise { - // Initialize Session ID using Secure Storage + // Initialize Session ID using Secure Storage with UI state as a recovery fallback. let sid = await this._getSecureVal('ai_session_id'); - if (sid === null || sid === '') { + if (!this._isValidSessionId(sid)) { + sid = this._context?.aiSettings.getAiSessionId() ?? null; + } + if (!this._isValidSessionId(sid)) { sid = crypto.randomUUID(); + } + + if ((await this._getSecureVal('ai_session_id')) !== sid) { await this._saveSecureVal('ai_session_id', sid); } @@ -248,7 +254,15 @@ export class AIProviderManager { private async _saveSecureVal(key: string, value: string): Promise { if (this._context?.tauriProvider.saveSecureKey) { - await this._context.tauriProvider.saveSecureKey(key, value); + try { + await this._context.tauriProvider.saveSecureKey(key, value); + } catch (error: unknown) { + this._tracer.error(`[AIProviderManager] Failed to persist ${key}:`, error); + } } } + + private _isValidSessionId(value: string | null): value is string { + return typeof value === 'string' && value.trim() !== ''; + } } diff --git a/src/features/ai/services/EngineConfigService.ts b/src/features/ai/services/EngineConfigService.ts index 54a52741..731aecde 100644 --- a/src/features/ai/services/EngineConfigService.ts +++ b/src/features/ai/services/EngineConfigService.ts @@ -10,21 +10,26 @@ import type { TauriProvider } from '@/infrastructure/tauri/TauriProvider'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; +import type { + EngineConfig as BindingEngineConfig, + EngineSettingsPayload as BindingEngineSettingsPayload, +} from '@/shared/types/bindings'; type EngineConfigLogger = Pick; -/** Subset of EngineConfig that the frontend can read and write. */ -export interface EngineConfig { - engine_id: string; - compute_mode: 'gpu' | 'cpu'; +/** + * Backend returns a fully merged config, while Specta marks serde-defaulted fields + * optional for request compatibility. UI code can rely on these fields after reads. + */ +export type EngineConfig = BindingEngineConfig & { + compute_mode: NonNullable; context_size: number; - model_path: string | null; extra_args: string[]; -} +}; -export interface EngineSettingsPayload { +export type EngineSettingsPayload = Omit & { config: EngineConfig; -} +}; export class EngineConfigService { constructor( diff --git a/src/features/chat/ui/ChatImageGenerationMessage.ts b/src/features/chat/ui/ChatImageGenerationMessage.ts index 7117fe6a..5fdf4dce 100644 --- a/src/features/chat/ui/ChatImageGenerationMessage.ts +++ b/src/features/chat/ui/ChatImageGenerationMessage.ts @@ -118,6 +118,7 @@ export function createChatImageGenerationMessage( media.style.aspectRatio = `${String(naturalWidth)} / ${String(naturalHeight)}`; if (keepPinnedAfterImageLoad) { deps.scrollToBottom(); + keepPinnedAfterImageLoad = false; } }; image.addEventListener('load', syncMediaSizeToImage); @@ -190,6 +191,9 @@ export function createChatImageGenerationMessage( const shouldKeepPinned = deps.isNearBottom(); keepPinnedAfterImageLoad = shouldKeepPinned; detachMediaFromBubble(); + if (media.style.aspectRatio === '') { + media.style.aspectRatio = '1 / 1'; + } image.src = dataUrl; syncMediaSizeToImage(); media.classList.remove('hidden'); diff --git a/src/features/settings/services/SettingsService.ts b/src/features/settings/services/SettingsService.ts index 31398880..665a8557 100644 --- a/src/features/settings/services/SettingsService.ts +++ b/src/features/settings/services/SettingsService.ts @@ -2,20 +2,14 @@ import type { SecureKeyMeta, TauriProvider } from '@/infrastructure/tauri/TauriP import type { IApp } from '@/shared/types/coreTypes'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; -import type { AppSettings } from '@/shared/types/bindings'; +import type { AppSettings, GpuInfo } from '@/shared/types/bindings'; import { commands } from '@/shared/types/bindings'; import { invokeSafe } from '@/shared/api/invoke'; export type ISettings = AppSettings; export type SettingsValue = string | number | boolean; type SettingsLogger = Pick; -export interface IGpuInfo { - detected: boolean; - name?: string; - cuda?: boolean; - backend?: string; - memory?: number; -} +export type IGpuInfo = Partial & Pick; export interface ICustomModel { id: string; diff --git a/src/shared/services/state/UiStateStore.test.ts b/src/shared/services/state/UiStateStore.test.ts index 31527add..a1ede941 100644 --- a/src/shared/services/state/UiStateStore.test.ts +++ b/src/shared/services/state/UiStateStore.test.ts @@ -68,6 +68,23 @@ describe('UiStateStore', () => { expect(store.getState().zoom_level).toBe(2); }); + it('should normalize direct updates before future nested writes', () => { + store.updateState( + { + zoom_level: Number.NaN, + resolution_zoom: null as unknown as Record, + }, + false, + ); + + expect(store.getState().zoom_level).toBe(1); + expect(store.getState().resolution_zoom).toEqual({}); + + store.updateNestedState('resolution_zoom', '1920x1080', 1.4, false); + + expect(store.getState().resolution_zoom).toEqual({ '1920x1080': 1.4 }); + }); + it('should update nested state', () => { store.updateNestedState('card_widths', 'card-1', '300px'); expect(store.getState().card_widths['card-1']).toBe('300px'); @@ -125,6 +142,41 @@ describe('UiStateStore', () => { expect(result.resolution_zoom['2560x1440']).toBe(2.4); }); + it('should keep legacy partial state instead of dropping it when map fields are missing', async () => { + storageState['axelate_ui_state'] = JSON.stringify({ + sidebar_width: 360, + zoom_level: 1.25, + }); + store = new UiStateStore(bridge, tracer, storage); + + const result = await store.loadState(); + + expect(result.sidebar_width).toBe(360); + expect(result.zoom_level).toBe(1.25); + expect(result.resolution_zoom).toEqual({}); + expect(result.local_max_output_tokens).toEqual({}); + expect(tracer.warn).not.toHaveBeenCalled(); + }); + + it('should normalize malformed map fields while preserving valid entries', async () => { + storageState['axelate_ui_state'] = JSON.stringify({ + resolution_zoom: null, + selected_ai_models: { gpt: 'gpt-5.5', bad: 42 }, + ai_thinking_level: { gpt: 'high', bad: 'fast' }, + ai_web_search_enabled: { gpt: true, bad: 'yes' }, + local_max_output_tokens: { llamacpp: 8192, bad: 'many' }, + }); + store = new UiStateStore(bridge, tracer, storage); + + const result = await store.loadState(); + + expect(result.resolution_zoom).toEqual({}); + expect(result.selected_ai_models).toEqual({ gpt: 'gpt-5.5' }); + expect(result.ai_thinking_level).toEqual({ gpt: 'high' }); + expect(result.ai_web_search_enabled).toEqual({ gpt: true }); + expect(result.local_max_output_tokens).toEqual({ llamacpp: 8192 }); + }); + it('should use defaults when localStorage is null (L67)', async () => { delete storageState['axelate_ui_state']; store = new UiStateStore(bridge, tracer, storage); diff --git a/src/shared/services/state/UiStateStore.ts b/src/shared/services/state/UiStateStore.ts index 083861e4..34a39389 100644 --- a/src/shared/services/state/UiStateStore.ts +++ b/src/shared/services/state/UiStateStore.ts @@ -105,7 +105,7 @@ export class UiStateStore { } public updateState(updates: Partial, markDirty = true): void { - this._state = { ...this._state, ...updates }; + this._state = this._normalizeState({ ...this._state, ...updates }); if (markDirty) { this._isDirty = true; this._revision += 1; @@ -119,7 +119,7 @@ export class UiStateStore { value: unknown, markDirty = true, ): void { - const target = this._state[key] as Record; + const target = this._getRecordTarget(key); target[nestedKey] = value; if (markDirty) { this._isDirty = true; @@ -133,7 +133,7 @@ export class UiStateStore { nestedKey: string, markDirty = true, ): void { - const target = this._state[key] as Record; + const target = this._getRecordTarget(key); delete target[nestedKey]; if (markDirty) { this._isDirty = true; @@ -220,22 +220,131 @@ export class UiStateStore { } private _normalizeState(state: IUIState): IUIState { + const resolutionZoom = this._normalizeNumberRecord( + state.resolution_zoom, + DEFAULT_UI_STATE.resolution_zoom, + ); + return { ...state, - zoom_level: this._clampZoom(state.zoom_level), + hidden_nav_items: this._normalizeStringArray( + state.hidden_nav_items, + DEFAULT_UI_STATE.hidden_nav_items, + ), + hidden_monitors: this._normalizeStringArray( + state.hidden_monitors, + DEFAULT_UI_STATE.hidden_monitors, + ), + card_widths: this._normalizeStringRecord(state.card_widths), + selected_modules: this._normalizeObjectRecord(state.selected_modules), + selected_ai_models: this._normalizeStringRecord(state.selected_ai_models), resolution_zoom: Object.fromEntries( - Object.entries(state.resolution_zoom).map(([key, zoom]) => [ - key, - this._clampZoom(zoom), - ]), + Object.entries(resolutionZoom).map(([key, zoom]) => [key, this._clampZoom(zoom)]), + ), + ai_thinking_level: this._normalizeThinkingLevelRecord(state.ai_thinking_level), + ai_web_search_enabled: this._normalizeBooleanRecord(state.ai_web_search_enabled), + local_max_output_tokens: this._normalizeNumberRecord( + state.local_max_output_tokens, + DEFAULT_UI_STATE.local_max_output_tokens, ), + zoom_level: this._clampZoom(state.zoom_level), }; } + private _normalizeStringArray(value: unknown, fallback: string[]): string[] { + if (!Array.isArray(value)) { + return [...fallback]; + } + + return value.filter((item): item is string => typeof item === 'string'); + } + + private _normalizeObjectRecord(value: unknown): Record> { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + + return value as Record>; + } + + private _normalizeStringRecord(value: unknown): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + + return Object.fromEntries( + Object.entries(value as Record).filter( + (entry): entry is [string, string] => { + const [, item] = entry; + return typeof item === 'string'; + }, + ), + ); + } + + private _normalizeBooleanRecord(value: unknown): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + + return Object.fromEntries( + Object.entries(value as Record).filter( + (entry): entry is [string, boolean] => { + const [, item] = entry; + return typeof item === 'boolean'; + }, + ), + ); + } + + private _normalizeThinkingLevelRecord(value: unknown): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + + return Object.fromEntries( + Object.entries(value as Record).filter( + (entry): entry is [string, ThinkingLevel] => { + const [, item] = entry; + return item === 'off' || item === 'low' || item === 'medium' || item === 'high'; + }, + ), + ); + } + + private _normalizeNumberRecord( + value: unknown, + fallback: Record = {}, + ): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return { ...fallback }; + } + + return Object.fromEntries( + Object.entries(value as Record).filter( + (entry): entry is [string, number] => { + const [, item] = entry; + return typeof item === 'number' && Number.isFinite(item); + }, + ), + ); + } + private _snapshotState(): IUIState { return structuredClone(this._state); } + private _getRecordTarget(key: K): Record { + const target = this._state[key]; + if (target !== null && typeof target === 'object' && !Array.isArray(target)) { + return target as Record; + } + + const replacement: Record = {}; + (this._state as Record)[key] = replacement; + return replacement; + } + private _clampZoom(zoom: number): number { if (!Number.isFinite(zoom)) { return DEFAULT_UI_STATE.zoom_level; diff --git a/src/shared/types/bindings.ts b/src/shared/types/bindings.ts index da84d8b3..8a8466b9 100644 --- a/src/shared/types/bindings.ts +++ b/src/shared/types/bindings.ts @@ -1294,6 +1294,8 @@ export type UIState = { ai_thinking_level?: { [key in string]: string }, // Enables provider-side internet search by AI provider ai_web_search_enabled?: { [key in string]: boolean }, + // Per-provider local model output token limits. + local_max_output_tokens?: { [key in string]: number }, // Current persistent AI session identifier ai_session_id?: string | null, // Preferred launcher interface language diff --git a/src/styles/features/chat-page.css b/src/styles/features/chat-page.css index 7c6ba5a7..92381ed3 100644 --- a/src/styles/features/chat-page.css +++ b/src/styles/features/chat-page.css @@ -466,6 +466,7 @@ .chat-generated-media { position: relative; width: auto; + aspect-ratio: 1 / 1; max-width: min(100%, 32rem); max-height: min(70vh, 42rem); display: inline-flex; From 78b6b58e3bccaa78ffb901c7715dff37f308747b Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 30 Apr 2026 18:06:41 +0300 Subject: [PATCH 044/126] fix: await core teardown before clearing boot state --- src/app/CoreEntry.ts | 56 ++++++++++++++++---------------------------- 1 file changed, 20 insertions(+), 36 deletions(-) diff --git a/src/app/CoreEntry.ts b/src/app/CoreEntry.ts index b32fa7bb..45c0c4ac 100644 --- a/src/app/CoreEntry.ts +++ b/src/app/CoreEntry.ts @@ -42,25 +42,24 @@ function clearBootState(): void { state.coreInitializationInFlight = false; } -function reportDestroyFailure( - result: Promise | void, - tracer: EntryLogger, - context: string, -): void { - if (result === undefined) return; - result.catch((error: unknown) => { - tracer.error(`[Core] ${context}: ${String(error)}`); - }); -} - -function destroyActiveCoreInstance(tracer?: EntryLogger): void { +async function destroyActiveCoreInstance( + tracer?: EntryLogger, + context = 'Destroy failed', +): Promise { const state = getCoreEntryState(); + const coreInstance = state.activeCoreInstance; + if (coreInstance === null) { + clearBootState(); + return; + } + try { - const result = state.activeCoreInstance?.destroy(); - if (tracer !== undefined) { - reportDestroyFailure(result, tracer, 'Destroy failed'); - } - } finally { + await coreInstance.destroy(); + } catch (error: unknown) { + tracer?.error(`[Core] ${context}: ${String(error)}`); + } + + if (state.activeCoreInstance === coreInstance) { clearBootState(); } } @@ -83,20 +82,9 @@ function bootCoreOnce(createCore: CoreFactory, tracer: EntryLogger): void { state.activeCoreInstance = coreInstance; state.coreInitializationInFlight = false; - coreInstance.init().catch((error: unknown) => { + coreInstance.init().catch(async (error: unknown) => { if (state.activeCoreInstance === coreInstance) { - clearBootState(); - try { - reportDestroyFailure( - coreInstance.destroy(), - tracer, - 'Destroy after boot failure failed', - ); - } catch (destroyError: unknown) { - tracer.error( - `[Core] Destroy after boot failure failed: ${String(destroyError)}`, - ); - } + await destroyActiveCoreInstance(tracer, 'Destroy after boot failure failed'); } tracer.error(`[Core] Boot failed: ${String(error)}`); }); @@ -126,18 +114,14 @@ export function bindCoreEntry(createCore: CoreFactory, tracer: EntryLogger): voi if (!state.coreBeforeUnloadBound) { state.coreBeforeUnloadBound = true; state.beforeUnloadHandler = () => { - destroyActiveCoreInstance(tracer); + void destroyActiveCoreInstance(tracer); }; globalThis.addEventListener('beforeunload', state.beforeUnloadHandler); } if (import.meta.hot) { import.meta.hot.dispose(() => { - try { - destroyActiveCoreInstance(tracer); - } catch (error: unknown) { - tracer.error(`[Core] Destroy during HMR dispose failed: ${String(error)}`); - } + void destroyActiveCoreInstance(tracer, 'Destroy during HMR dispose failed'); if (state.bootHandler !== null) { document.removeEventListener('DOMContentLoaded', state.bootHandler); state.bootHandler = null; From bc1d91e0a4d450e5dde2b7f65a649665bc888eae Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 30 Apr 2026 18:29:06 +0300 Subject: [PATCH 045/126] fix: address coderabbit lifecycle feedback --- .../domain/modules/controller/lifecycle.rs | 40 +++++-- src/app/events.ts | 3 +- .../ai/services/AIChatTransport.test.ts | 113 +++++++++++++++++- src/features/ai/services/AIChatTransport.ts | 20 +++- .../ai/services/AIProviderManager.test.ts | 19 +++ src/features/ai/services/AIProviderManager.ts | 8 +- 6 files changed, 182 insertions(+), 21 deletions(-) diff --git a/src-tauri/src/domain/modules/controller/lifecycle.rs b/src-tauri/src/domain/modules/controller/lifecycle.rs index 4c6345a1..654004ac 100644 --- a/src-tauri/src/domain/modules/controller/lifecycle.rs +++ b/src-tauri/src/domain/modules/controller/lifecycle.rs @@ -155,11 +155,16 @@ impl<'a> LifecycleExecutor<'a> { } async fn register_spawned_child(&self, mut child: Child) -> Result { - let pid = child.id().ok_or_else(|| AppError::Internal { - request_id: None, - message: format!("Spawned process for {} has no PID", self.module_id), - })?; - self.persist_or_kill_spawned_child(&mut child, pid as usize) + let Some(pid) = child.id().map(|pid| pid as usize) else { + self.kill_unregistered_child(&mut child, "missing PID after spawn") + .await; + return Err(AppError::Internal { + request_id: None, + message: format!("Module {} exited before PID capture", self.module_id), + }); + }; + + self.persist_or_kill_spawned_child(&mut child, pid) .await .inspect_err(|error| { tracing::error!( @@ -244,11 +249,20 @@ impl<'a> LifecycleExecutor<'a> { } } - if let Err(error) = child.wait().await { - tracing::warn!( - module_id, - "Failed to wait module child after kill attempt: {error}" - ); + match timeout(Duration::from_secs(5), child.wait()).await { + Ok(Ok(_)) => {} + Ok(Err(error)) => { + tracing::warn!( + module_id, + "Failed to wait module child after kill attempt: {error}" + ); + } + Err(_) => { + tracing::warn!( + module_id, + "Timed out waiting for module child after kill attempt" + ); + } } } @@ -302,7 +316,6 @@ impl<'a> LifecycleExecutor<'a> { /// Gracefully stops a module with escalation pub async fn stop(&self, manifest: &ModuleManifest) -> Result { tracing::info!("Stopping module: {}", self.module_id); - let script_entry_path = self.resolve_script_entry_path(manifest)?; // 1. Run stop script if exists if let Some(stop_cmd) = manifest.lifecycle.as_ref().and_then(|l| l.stop.clone()) { @@ -407,6 +420,11 @@ impl<'a> LifecycleExecutor<'a> { tokio::time::sleep(Duration::from_millis(500)).await; } + let script_entry_path = self.resolve_script_entry_path(manifest)?; + if let Some(entry_path) = script_entry_path.as_ref() { + self.kill_matching_script_processes(entry_path).await?; + } + if self .controller .is_running(&self.module_id, self.module_path) diff --git a/src/app/events.ts b/src/app/events.ts index 15022df9..495da099 100644 --- a/src/app/events.ts +++ b/src/app/events.ts @@ -159,7 +159,8 @@ export class EventHandler { } const attachMenuAction = target.closest('[data-chat-attach-action]'); - if (attachMenuAction instanceof HTMLElement) { + const attachMenu = attachMenuAction?.closest('.chat-attach-menu'); + if (attachMenu instanceof HTMLElement && attachMenuAction instanceof HTMLElement) { const action = attachMenuAction.dataset['chatAttachAction']; if (action === 'file') { await this._core.chatController.pickChatFilesFromMenu(); diff --git a/src/features/ai/services/AIChatTransport.test.ts b/src/features/ai/services/AIChatTransport.test.ts index 2517e68f..58ff060b 100644 --- a/src/features/ai/services/AIChatTransport.test.ts +++ b/src/features/ai/services/AIChatTransport.test.ts @@ -145,8 +145,16 @@ describe('AIChatTransport', () => { }); it('should timeout after 90 seconds', async () => { - // Invoke never resolves - mockCore.tauriProvider.invoke.mockReturnValue(new Promise(() => {})); + let requestId = ''; + mockCore.tauriProvider.invoke.mockImplementation( + (command: string, args: Record) => { + if (command === 'cancel_chat_generation') { + return Promise.resolve(true); + } + requestId = (args['request'] as { request_id: string }).request_id; + return new Promise(() => {}); + }, + ); const sendPromise = transport.send(makeRequest()); @@ -155,6 +163,9 @@ describe('AIChatTransport', () => { const result = await sendPromise; expect(result).toEqual({ ok: false, error: 'AI request timed out' }); + expect(mockCore.tauriProvider.invoke).toHaveBeenCalledWith('cancel_chat_generation', { + requestId, + }); }); it('should extract message from plain error objects', async () => { @@ -208,6 +219,96 @@ describe('AIChatTransport', () => { vi.advanceTimersByTime(90_001); await firstSend; }); + + it('should keep timed-out requests cancellable before clearing active state', async () => { + let requestId = ''; + mockCore.tauriProvider.invoke.mockImplementation( + (command: string, args: Record) => { + if (command === 'cancel_chat_generation') { + return Promise.resolve(true); + } + + requestId = (args['request'] as { request_id: string }).request_id; + return new Promise(() => {}); + }, + ); + + const sendPromise = transport.send(makeRequest()); + vi.advanceTimersByTime(90_001); + + await expect(sendPromise).resolves.toEqual({ + ok: false, + error: 'AI request timed out', + }); + expect(mockCore.tauriProvider.invoke).toHaveBeenCalledWith('cancel_chat_generation', { + requestId, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((transport as any)._activeChatRequestId).toBeNull(); + }); + }); + + describe('sendSilent', () => { + it('should register its request and cancel stale active work before sending', async () => { + let sendCalls = 0; + let firstRequestId = ''; + mockCore.tauriProvider.invoke.mockImplementation( + (command: string, args: Record) => { + if (command === 'cancel_chat_generation') { + return Promise.resolve(true); + } + + sendCalls += 1; + const request = args['request'] as { request_id: string }; + if (sendCalls === 1) { + firstRequestId = request.request_id; + return new Promise(() => {}); + } + return Promise.resolve({ ok: true, reply: { text: 'silent' } }); + }, + ); + + const firstSend = transport.send(makeRequest()); + await Promise.resolve(); + + await expect(transport.sendSilent(makeRequest())).resolves.toEqual({ + ok: true, + text: 'silent', + }); + expect(mockCore.tauriProvider.invoke).toHaveBeenCalledWith('cancel_chat_generation', { + requestId: firstRequestId, + }); + + vi.advanceTimersByTime(90_001); + await firstSend; + }); + + it('should cancel a timed-out silent request before clearing active state', async () => { + let requestId = ''; + mockCore.tauriProvider.invoke.mockImplementation( + (command: string, args: Record) => { + if (command === 'cancel_chat_generation') { + return Promise.resolve(true); + } + + requestId = (args['request'] as { request_id: string }).request_id; + return new Promise(() => {}); + }, + ); + + const sendPromise = transport.sendSilent(makeRequest()); + vi.advanceTimersByTime(90_001); + + await expect(sendPromise).resolves.toEqual({ + ok: false, + error: 'AI request timed out', + }); + expect(mockCore.tauriProvider.invoke).toHaveBeenCalledWith('cancel_chat_generation', { + requestId, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((transport as any)._activeChatRequestId).toBeNull(); + }); }); describe('generateImage', () => { @@ -504,9 +605,11 @@ describe('AIChatTransport', () => { // ---------------------------------------------------------- missing branches describe('Missing branch cases', () => { it('should hit timeout error line (Line 45)', async () => { - // Need the timeout to actually reject - mockCore.tauriProvider.invoke.mockImplementation(() => { - return new Promise(() => {}); // never resolves + mockCore.tauriProvider.invoke.mockImplementation((command: string) => { + if (command === 'cancel_chat_generation') { + return Promise.resolve(true); + } + return new Promise(() => {}); }); // Start send diff --git a/src/features/ai/services/AIChatTransport.ts b/src/features/ai/services/AIChatTransport.ts index b29df662..a2484c23 100644 --- a/src/features/ai/services/AIChatTransport.ts +++ b/src/features/ai/services/AIChatTransport.ts @@ -11,6 +11,7 @@ import type { AITransportContext } from './AIBridgeContext'; type AIChatTransportLogger = Pick; const STALE_REQUEST_CANCEL_TIMEOUT_MS = 750; +const AI_REQUEST_TIMEOUT_MESSAGE = 'AI request timed out'; /** * Safely extracts a human-readable error string from any error shape. @@ -152,7 +153,7 @@ export class AIChatTransport implements IChatTransport { thoughtChannel, }), 90000, - 'AI request timed out', + AI_REQUEST_TIMEOUT_MESSAGE, ); if ( @@ -166,6 +167,9 @@ export class AIChatTransport implements IChatTransport { } catch (error: unknown) { const errorMsg = extractError(error); this._tracer.error('[AIChatTransport] IPC error:', error); + if (errorMsg === AI_REQUEST_TIMEOUT_MESSAGE) { + await this._cancelStaleActiveRequest(requestId); + } return { ok: false, error: errorMsg }; } finally { if (this._activeChatRequestId === requestId) { @@ -179,11 +183,16 @@ export class AIChatTransport implements IChatTransport { return { ok: false, error: 'IPC host unavailable' }; } + if (this._activeChatRequestId !== null) { + await this._cancelStaleActiveRequest(this._activeChatRequestId); + } + const requestId = this._generateRequestId(); const requestWithId: IChatRequest = { ...request, request_id: requestId, }; + this._activeChatRequestId = requestId; const chatChannel = new Channel(); const thoughtChannel = new Channel(); @@ -195,14 +204,21 @@ export class AIChatTransport implements IChatTransport { thoughtChannel, }), 90000, - 'AI request timed out', + AI_REQUEST_TIMEOUT_MESSAGE, ); return this._normalizeResponse(response); } catch (error: unknown) { const errorMsg = extractError(error); this._tracer.error('[AIChatTransport] Silent IPC error:', error); + if (errorMsg === AI_REQUEST_TIMEOUT_MESSAGE) { + await this._cancelStaleActiveRequest(requestId); + } return { ok: false, error: errorMsg }; + } finally { + if (this._activeChatRequestId === requestId) { + this._activeChatRequestId = null; + } } } diff --git a/src/features/ai/services/AIProviderManager.test.ts b/src/features/ai/services/AIProviderManager.test.ts index c618d886..9b7e64ae 100644 --- a/src/features/ai/services/AIProviderManager.test.ts +++ b/src/features/ai/services/AIProviderManager.test.ts @@ -101,6 +101,25 @@ describe('AIProviderManager', () => { expect(mockCore.aiSettings.setAiSessionId).toHaveBeenCalledWith('ui-session-abc'); }); + it('should recover session ID from UI state when secure read fails', async () => { + const mockCore = createMockCore(() => Promise.reject(new Error('secure read failed'))); + vi.mocked(mockCore.aiSettings.getAiSessionId).mockReturnValue('ui-session-abc'); + manager.setCore(mockCore); + + await manager.init(); + + expect(mockCore.tauriProvider.getSecureKey).toHaveBeenCalledTimes(1); + expect(manager.sessionId).toBe('ui-session-abc'); + expect(mockCore.tauriProvider.saveSecureKey).toHaveBeenCalledWith( + 'ai_session_id', + 'ui-session-abc', + ); + expect(tracer.error).toHaveBeenCalledWith( + '[AIProviderManager] Failed to read ai_session_id:', + expect.any(Error), + ); + }); + it('should continue with UI session ID when secure persistence fails', async () => { const mockCore = createMockCore(() => Promise.resolve(null)); vi.mocked(mockCore.aiSettings.getAiSessionId).mockReturnValue('ui-session-abc'); diff --git a/src/features/ai/services/AIProviderManager.ts b/src/features/ai/services/AIProviderManager.ts index 7968cb89..ccaec17a 100644 --- a/src/features/ai/services/AIProviderManager.ts +++ b/src/features/ai/services/AIProviderManager.ts @@ -30,7 +30,11 @@ export class AIProviderManager { public async init(): Promise { // Initialize Session ID using Secure Storage with UI state as a recovery fallback. - let sid = await this._getSecureVal('ai_session_id'); + const secureSid = await this._getSecureVal('ai_session_id').catch((error: unknown) => { + this._tracer.error('[AIProviderManager] Failed to read ai_session_id:', error); + return null; + }); + let sid = secureSid; if (!this._isValidSessionId(sid)) { sid = this._context?.aiSettings.getAiSessionId() ?? null; } @@ -38,7 +42,7 @@ export class AIProviderManager { sid = crypto.randomUUID(); } - if ((await this._getSecureVal('ai_session_id')) !== sid) { + if (secureSid !== sid) { await this._saveSecureVal('ai_session_id', sid); } From 07d1f1c4f8339d828d125104956596d46d893034 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 30 Apr 2026 19:07:36 +0300 Subject: [PATCH 046/126] fix: harden launcher ui state updates --- .../ai/services/EngineStatusService.test.ts | 23 +++++- .../ai/services/EngineStatusService.ts | 14 +--- .../downloads/ui/DownloadCardRenderer.ts | 4 +- src/features/downloads/ui/DownloadUI.test.ts | 32 ++++++++ src/shared/services/ModuleService.test.ts | 74 ++++++++++++++----- src/shared/services/ModuleService.ts | 63 +++++++++------- src/shared/shell/ui/ModalManager.test.ts | 64 ++++++++++++++++ src/shared/shell/ui/ModalManager.ts | 4 +- src/shared/shell/ui/ModalManagerSupport.ts | 6 +- src/shared/utils/cssSelectors.ts | 8 ++ 10 files changed, 228 insertions(+), 64 deletions(-) create mode 100644 src/shared/utils/cssSelectors.ts diff --git a/src/features/ai/services/EngineStatusService.test.ts b/src/features/ai/services/EngineStatusService.test.ts index 7d822eb8..71cf9c8b 100644 --- a/src/features/ai/services/EngineStatusService.test.ts +++ b/src/features/ai/services/EngineStatusService.test.ts @@ -13,7 +13,7 @@ describe('EngineStatusService', () => { listeners = {}; document.body.innerHTML = ''; (globalThis as unknown as { CSS: { escape: (value: string) => string } }).CSS = { - escape: (value: string) => value, + escape: (value: string) => value.replace(/["\\]/gu, '\\$&'), }; core = { @@ -124,6 +124,27 @@ describe('EngineStatusService', () => { expect((card as HTMLElement | null)?.dataset['runtimeStatus']).toBe('idle'); }); + it('updates cards for engine ids that need selector escaping', () => { + const engineId = 'engine"quoted\\id'; + const appCard = document.createElement('div'); + appCard.className = 'app-card selected'; + appCard.dataset['appId'] = engineId; + appCard.innerHTML = + '
    '; + const dashboardCard = document.createElement('div'); + dashboardCard.className = 'module-slot-card selected'; + dashboardCard.dataset['currentModule'] = engineId; + document.body.append(appCard, dashboardCard); + + service.init(); + listeners['ai:engine:ready']?.({ engine_id: engineId, endpoint: '/engine' }); + + expect(appCard.classList.contains('engine-ready')).toBe(true); + expect(appCard.querySelector('button')?.textContent).toBe('Remove'); + expect(dashboardCard.classList.contains('module-running')).toBe(true); + expect(dashboardCard.dataset['runtimeStatus']).toBe('running'); + }); + it('falls back to untranslated labels and handles cards without modal buttons', () => { (core as unknown as { i18n: { t: ReturnType } }).i18n.t.mockImplementation( (_: string, fallback: string = ''): string => fallback, diff --git a/src/features/ai/services/EngineStatusService.ts b/src/features/ai/services/EngineStatusService.ts index 5a6ec22f..7c53cec2 100644 --- a/src/features/ai/services/EngineStatusService.ts +++ b/src/features/ai/services/EngineStatusService.ts @@ -1,5 +1,6 @@ import type { LoggerService } from '@/infrastructure/logging/LoggerService'; import type { EngineStatusContext } from './AIBridgeContext'; +import { escapeCssSelectorValue } from '@/shared/utils/cssSelectors'; type EngineStatusLogger = Pick; @@ -170,7 +171,7 @@ export class EngineStatusService { /** Updates all cards matching `engineId` with the given state class. */ private _setCardState(engineId: string, state: EngineState): void { - const escapedEngineId = this._escapeSelectorValue(engineId); + const escapedEngineId = escapeCssSelectorValue(engineId); const cards = document.querySelectorAll(`[data-app-id="${escapedEngineId}"]`); cards.forEach((card) => { @@ -184,7 +185,7 @@ export class EngineStatusService { } private _setDashboardCardState(engineId: string, state: EngineState): void { - const escapedEngineId = this._escapeSelectorValue(engineId); + const escapedEngineId = escapeCssSelectorValue(engineId); const cards = document.querySelectorAll( `[data-current-module="${escapedEngineId}"]`, ); @@ -380,15 +381,6 @@ export class EngineStatusService { }); } - private _escapeSelectorValue(value: string): string { - const cssApi = (globalThis as { CSS?: { escape?: (selector: string) => string } }).CSS; - if (typeof cssApi?.escape === 'function') { - return cssApi.escape(value); - } - - return value.replace(/["\\]/gu, '\\$&'); - } - private _resetCardClasses(card: HTMLElement): void { card.classList.remove( 'engine-idle', diff --git a/src/features/downloads/ui/DownloadCardRenderer.ts b/src/features/downloads/ui/DownloadCardRenderer.ts index e4197f52..2d5bb8f8 100644 --- a/src/features/downloads/ui/DownloadCardRenderer.ts +++ b/src/features/downloads/ui/DownloadCardRenderer.ts @@ -1,6 +1,7 @@ import DOMPurify from 'dompurify'; import type { IModuleDownloadState as ModuleDownloadState } from '@/shared/types/coreTypes'; +import { escapeCssSelectorValue } from '@/shared/utils/cssSelectors'; type DownloadCardTranslate = (key: string, fallback: string) => string; @@ -37,8 +38,9 @@ export class DownloadCardRenderer { } for (const [moduleId, state] of activeDownloads) { + const escapedModuleId = escapeCssSelectorValue(moduleId); const existing = list.querySelector( - `.download-item-card[data-module-id="${moduleId}"]`, + `.download-item-card[data-module-id="${escapedModuleId}"]`, ); if (existing !== null) { this.patchCard(existing, state); diff --git a/src/features/downloads/ui/DownloadUI.test.ts b/src/features/downloads/ui/DownloadUI.test.ts index 6a0cfc73..19c1b34f 100644 --- a/src/features/downloads/ui/DownloadUI.test.ts +++ b/src/features/downloads/ui/DownloadUI.test.ts @@ -941,6 +941,38 @@ describe('DownloadUI', () => { expect(card?.querySelector('.downloads-status-pill')?.textContent).toBe('Downloading'); }); + it('should patch existing download cards for module ids that need selector escaping', () => { + ui.init(); + const moduleId = 'mod"quoted\\id'; + + globalThis.dispatchEvent( + new CustomEvent('download-progress-update', { + detail: { + module_id: moduleId, + progress: 0.2, + status: 'downloading', + }, + }), + ); + globalThis.dispatchEvent( + new CustomEvent('download-progress-update', { + detail: { + module_id: moduleId, + progress: 0.6, + status: 'downloading', + }, + }), + ); + + const list = document.getElementById('downloads-dynamic-list'); + const cards = list?.querySelectorAll('.download-item-card'); + expect(cards?.length).toBe(1); + expect(cards?.[0]?.getAttribute('data-module-id')).toBe(moduleId); + expect(cards?.[0]?.querySelector('.downloads-progress-percent')?.textContent).toBe( + '60%', + ); + }); + it('should render error status card', () => { ui.init(); diff --git a/src/shared/services/ModuleService.test.ts b/src/shared/services/ModuleService.test.ts index 5955b3bd..a935f318 100644 --- a/src/shared/services/ModuleService.test.ts +++ b/src/shared/services/ModuleService.test.ts @@ -5,6 +5,7 @@ import type { LoggerService } from '@/infrastructure/logging/LoggerService'; // TYPES type ProgressHandler = ((_: Record) => void) | undefined; +type DownloadProgressEvent = CustomEvent>; // HOISTED MOCKS const mocks = vi.hoisted(() => { @@ -198,15 +199,33 @@ describe('ModuleService', () => { }); it('should update state on error', async () => { + const progressSpy = vi.fn<(event: DownloadProgressEvent) => void>(); + const progressListener: EventListener = (event) => { + progressSpy(event as DownloadProgressEvent); + }; + globalThis.addEventListener('download-progress-update', progressListener); mocks.invokeSafe.mockResolvedValueOnce({ status: 'error', error: { message: 'Download failed' }, }); - await expect(moduleService.downloadModule('test-module', 'url')).rejects.toThrow(); - - const state = moduleService.getDownloadState('test-module'); - expect(state?.status).toBe('error'); + try { + await expect(moduleService.downloadModule('test-module', 'url')).rejects.toThrow(); + + const state = moduleService.getDownloadState('test-module'); + expect(state?.status).toBe('error'); + expect(state?.error).toBe('Download failed'); + expect(progressSpy).toHaveBeenCalledOnce(); + const event = progressSpy.mock.calls[0]?.[0]; + expect(event?.detail).toMatchObject({ + module_id: 'test-module', + status: 'error', + message: 'Download failed', + error: 'Download failed', + }); + } finally { + globalThis.removeEventListener('download-progress-update', progressListener); + } }); it('should return paused outcome without marking it as error', async () => { @@ -351,27 +370,42 @@ describe('ModuleService', () => { }); it('should process progress payload correctly', async () => { + const progressSpy = vi.fn<(event: DownloadProgressEvent) => void>(); + const progressListener: EventListener = (event) => { + progressSpy(event as DownloadProgressEvent); + }; + globalThis.addEventListener('download-progress-update', progressListener); // Use ref pattern with module-level helper const handlerRef: { current: ProgressHandler } = { current: undefined }; mocks.tauriProvider.listen.mockImplementation(createListenCapture(handlerRef)); - await moduleService.init(); - - // Call handler with test data - handlerRef.current?.({ - module_id: 'test-module', - status: 'downloading', - progress: 0.5, - message: 'Downloading...', - downloaded: 50, - total: 100, - speed: 4096, - }); + try { + await moduleService.init(); + + // Call handler with test data + handlerRef.current?.({ + module_id: 'test-module', + status: 'downloading', + progress: 0.5, + message: 'Downloading...', + downloaded: 50, + total: 100, + speed: 4096, + }); - const state = moduleService.getDownloadState('test-module'); - expect(state?.status).toBe('downloading'); - expect(state?.progress).toBe(0.5); - expect(state?.speed).toBe(4096); + const state = moduleService.getDownloadState('test-module'); + expect(state?.status).toBe('downloading'); + expect(state?.progress).toBe(0.5); + expect(state?.speed).toBe(4096); + expect(progressSpy).toHaveBeenCalledOnce(); + const event = progressSpy.mock.calls[0]?.[0]; + expect(event?.detail).toMatchObject({ + module_id: 'test-module', + status: 'downloading', + }); + } finally { + globalThis.removeEventListener('download-progress-update', progressListener); + } }); it('should set progress to 1 on complete', async () => { diff --git a/src/shared/services/ModuleService.ts b/src/shared/services/ModuleService.ts index 6d98efbd..5169f4f1 100644 --- a/src/shared/services/ModuleService.ts +++ b/src/shared/services/ModuleService.ts @@ -46,32 +46,7 @@ export class ModuleService { speed: number; }>('download_progress', (payload) => { this._logDownloadPhase(payload); - - this._downloadState[payload.module_id] = { - status: payload.status as - | 'init' - | 'pending' - | 'connecting' - | 'downloading' - | 'verifying' - | 'extracting' - | 'paused' - | 'complete' - | 'error' - | 'cancelled', - progress: payload.progress, - message: payload.message, - downloaded: payload.downloaded, - total: payload.total, - speed: payload.speed, - }; - - if (payload.status === 'complete') { - (this._downloadState[payload.module_id] as { progress: number }).progress = 1; - } - // Dispatch custom event for UI components that don't use this service directly - const event = new CustomEvent('download-progress-update', { detail: payload }); - globalThis.dispatchEvent(event); + this._publishDownloadProgress(payload); }); this._initialized = true; } catch (error) { @@ -187,7 +162,16 @@ export class ModuleService { return interrupted; } this._tracer.error(`[ModuleService] Download error for ${moduleId}: ${errorMessage}`); - this._downloadState[moduleId] = { status: 'error', progress: 0, error: errorMessage }; + this._publishDownloadProgress({ + module_id: moduleId, + status: 'error', + progress: 0, + message: errorMessage, + downloaded: 0, + total: 0, + speed: 0, + error: errorMessage, + }); throw err; } } @@ -378,4 +362,29 @@ export class ModuleService { this._lastLoggedDownloadPhase.delete(payload.module_id); } } + + private _publishDownloadProgress(payload: { + module_id: string; + status: string; + progress: number; + message?: string; + downloaded?: number; + total?: number; + speed?: number; + error?: unknown; + }): void { + const state: IModuleDownloadState = { + status: payload.status as IModuleDownloadState['status'], + progress: payload.status === 'complete' ? 1 : payload.progress, + }; + if (payload.message !== undefined) state.message = payload.message; + if (payload.downloaded !== undefined) state.downloaded = payload.downloaded; + if (payload.total !== undefined) state.total = payload.total; + if (payload.speed !== undefined) state.speed = payload.speed; + if (payload.error !== undefined) state.error = payload.error; + + this._downloadState[payload.module_id] = state; + + globalThis.dispatchEvent(new CustomEvent('download-progress-update', { detail: payload })); + } } diff --git a/src/shared/shell/ui/ModalManager.test.ts b/src/shared/shell/ui/ModalManager.test.ts index 9c9681c9..16186625 100644 --- a/src/shared/shell/ui/ModalManager.test.ts +++ b/src/shared/shell/ui/ModalManager.test.ts @@ -542,6 +542,30 @@ describe('ModalManager lifecycle', () => { ).toBe(true); }); + it('should route modal progress updates for app ids that need selector escaping', () => { + modalManager = createManager(); + const list = document.getElementById('app-modal-list') as HTMLElement; + const appId = 'svc"quoted\\id'; + const card = document.createElement('div'); + card.className = 'app-card'; + card.dataset['appId'] = appId; + const button = document.createElement('button'); + button.className = 'download-btn'; + button.innerHTML = + 'Download'; + card.appendChild(button); + list.appendChild(card); + + globalThis.dispatchEvent( + new CustomEvent('download-progress-update', { + detail: { module_id: appId, status: 'downloading', progress: 0.42 }, + }), + ); + + expect(button.classList.contains('downloading')).toBe(true); + expect(button.querySelector('.download-pct')?.textContent).toBe('42%'); + }); + it('should cancel and start downloads through injected callbacks', async () => { const onDownloadRequest = vi.fn().mockResolvedValue(undefined); const onCancelDownloadRequest = vi.fn().mockResolvedValue(undefined); @@ -604,6 +628,46 @@ describe('ModalManager lifecycle', () => { ); }); + it('should start modal downloads for app ids that need selector escaping', () => { + const onDownloadRequest = vi.fn().mockResolvedValue(undefined); + modalManager = new ModalManager( + new ModuleCardRenderer({ translate: (_key, fallback) => fallback, tracer }), + interactionSpy as unknown as (e: MouseEvent, app: IApp, category: string) => void, + () => null, + onDownloadRequest, + vi.fn().mockResolvedValue(undefined), + (_key, fallback) => fallback, + tracer, + navigation, + ); + + modalManager.openAppSelection('services', []); + const list = document.getElementById('app-modal-list') as HTMLElement; + const appId = 'svc"quoted\\id'; + const card = document.createElement('div'); + card.className = 'app-card'; + card.dataset['appId'] = appId; + const button = document.createElement('button'); + button.className = 'download-btn'; + card.appendChild(button); + list.appendChild(card); + + const handleDownload = (modalManager as unknown as { _handleDownload: (app: IApp) => void }) + ._handleDownload; + handleDownload.call(modalManager, { + id: appId, + name: 'Service', + installed: false, + repoUrl: 'https://example.com/service.zip', + } as IApp); + + expect(onDownloadRequest).toHaveBeenCalledWith( + expect.objectContaining({ id: appId }), + 'services', + button, + ); + }); + it('should pause and resume active modal downloads through injected callbacks', () => { const onPauseDownloadRequest = vi.fn().mockResolvedValue(undefined); const onResumeDownloadRequest = vi.fn().mockResolvedValue(undefined); diff --git a/src/shared/shell/ui/ModalManager.ts b/src/shared/shell/ui/ModalManager.ts index a00ad327..664f94ba 100644 --- a/src/shared/shell/ui/ModalManager.ts +++ b/src/shared/shell/ui/ModalManager.ts @@ -23,6 +23,7 @@ import { isAiCategory, resolveModalSidebarCategory, } from '../../utils/moduleCategoryPolicy'; +import { escapeCssSelectorValue } from '../../utils/cssSelectors'; /** * @class ModalManager @@ -380,7 +381,8 @@ export class ModalManager { } const list = document.getElementById('app-modal-list'); - const card = list?.querySelector(`.app-card[data-app-id="${app.id}"]`); + const escapedAppId = escapeCssSelectorValue(app.id); + const card = list?.querySelector(`.app-card[data-app-id="${escapedAppId}"]`); const btn = card?.querySelector('.download-btn'); if (btn?.classList.contains('downloading') === true) { diff --git a/src/shared/shell/ui/ModalManagerSupport.ts b/src/shared/shell/ui/ModalManagerSupport.ts index 3b649c3e..43d46149 100644 --- a/src/shared/shell/ui/ModalManagerSupport.ts +++ b/src/shared/shell/ui/ModalManagerSupport.ts @@ -3,6 +3,7 @@ import { ModuleCardRenderer } from './ModuleCardRenderer'; import type { ModuleCardDownloadAction } from './ModuleCardActions'; import type { ModalSelectionPolicy } from './ModalSelectionPolicy'; import { getAiSlotForCapability, isAiCategory } from '../../utils/moduleCategoryPolicy'; +import { escapeCssSelectorValue } from '../../utils/cssSelectors'; type DownloadProgressPayload = { module_id: string; @@ -60,9 +61,8 @@ export function createModalDownloadProgressHandler(): ProgressEventHandler { return; } - const card = list.querySelector( - `.app-card[data-app-id="${payload.module_id}"]`, - ); + const escapedModuleId = escapeCssSelectorValue(payload.module_id); + const card = list.querySelector(`.app-card[data-app-id="${escapedModuleId}"]`); if (card === null) { return; } diff --git a/src/shared/utils/cssSelectors.ts b/src/shared/utils/cssSelectors.ts new file mode 100644 index 00000000..d1818ff3 --- /dev/null +++ b/src/shared/utils/cssSelectors.ts @@ -0,0 +1,8 @@ +export function escapeCssSelectorValue(value: string): string { + const cssApi = (globalThis as { CSS?: { escape?: (selector: string) => string } }).CSS; + if (typeof cssApi?.escape === 'function') { + return cssApi.escape(value); + } + + return value.replace(/["\\]/gu, '\\$&'); +} From b1b91b0b681976f99cc7b303211f0e85db50edae Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 30 Apr 2026 19:32:01 +0300 Subject: [PATCH 047/126] fix: harden chat generation lifecycle --- src/features/chat/chat.test.ts | 66 +++++++++++++++++++ src/features/chat/chat.ts | 11 ++-- .../controllers/ChatSendController.test.ts | 28 ++++++++ .../chat/controllers/ChatSendController.ts | 1 + 4 files changed, 102 insertions(+), 4 deletions(-) diff --git a/src/features/chat/chat.test.ts b/src/features/chat/chat.test.ts index 2a8e557b..42e6d68c 100644 --- a/src/features/chat/chat.test.ts +++ b/src/features/chat/chat.test.ts @@ -5,6 +5,7 @@ const clearUi = vi.fn(); const updateTokenCount = vi.fn(); const mockChatUiInstances: Array<{ renderHistory: ReturnType; + createImageGenerationMessage: ReturnType; }> = []; const mockChatFileHandlerInstances: Array<{ clear: ReturnType; @@ -33,10 +34,19 @@ vi.mock('./ui/ChatUI', () => ({ public clear = clearUi; public updateTokenCount = updateTokenCount; public updateContextTokenCount = vi.fn(); + public createImageGenerationMessage = vi.fn(() => ({ + setStatus: vi.fn(), + setPreview: vi.fn(), + finalize: vi.fn(), + fail: vi.fn(), + cancel: vi.fn(), + discard: vi.fn(), + })); public constructor() { mockChatUiInstances.push({ renderHistory: this.renderHistory, + createImageGenerationMessage: this.createImageGenerationMessage, }); } }, @@ -111,6 +121,9 @@ describe('ChatController', () => { clearHistory: vi.fn().mockResolvedValue(undefined), startProvider: vi.fn().mockResolvedValue(false), rewindLastTurn: vi.fn().mockResolvedValue('last prompt'), + getImageGenerationPreview: vi.fn().mockResolvedValue(null), + cancelTextGeneration: vi.fn().mockResolvedValue(true), + cancelImageGeneration: vi.fn().mockResolvedValue(undefined), }; const i18n = { @@ -208,6 +221,59 @@ describe('ChatController', () => { vi.useRealTimers(); }); + it('should cancel active generation before clearing chat', async () => { + const controller = createController(); + const internals = controller as unknown as { + _state: { isSending: boolean; currentGenerationProviderId: string | null }; + _sendController: { cancelActiveSend: () => Promise }; + }; + internals._state.isSending = true; + internals._state.currentGenerationProviderId = 'gpt'; + const cancelSpy = vi + .spyOn(internals._sendController, 'cancelActiveSend') + .mockResolvedValue(undefined); + + await controller.clearChat(); + + expect(cancelSpy).toHaveBeenCalledOnce(); + expect(clearUi).toHaveBeenCalledOnce(); + expect(internals._state.isSending).toBe(false); + expect(internals._state.currentGenerationProviderId).toBeNull(); + }); + + it('should not restore an image generation placeholder if a send starts while probing preview', async () => { + let resolvePreview: ( + value: Awaited>, + ) => void = () => { + throw new Error('preview promise was not started'); + }; + aiBridge.getImageGenerationPreview.mockReturnValueOnce( + new Promise((resolve) => { + resolvePreview = resolve; + }), + ); + const controller = createController(); + const internals = controller as unknown as { + _state: { isSending: boolean }; + }; + + controller.init(); + internals._state.isSending = true; + resolvePreview({ + data_url: 'data:image/png;base64,abc', + updated_at_ms: 1, + progress: 0.5, + step: null, + total: null, + speed: null, + eta_relative: null, + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(mockChatUiInstances[0]?.createImageGenerationMessage).not.toHaveBeenCalled(); + }); + it('should clear file update callback on destroy', () => { const controller = createController(); diff --git a/src/features/chat/chat.ts b/src/features/chat/chat.ts index a1869da8..7da76b4a 100644 --- a/src/features/chat/chat.ts +++ b/src/features/chat/chat.ts @@ -445,7 +445,7 @@ export class ChatController { } private async _restoreActiveImageGeneration(): Promise { - if (this._isDestroyed() || this._state.isSending) { + if (this._shouldSkipRestoredImageGeneration()) { return; } @@ -465,7 +465,7 @@ export class ChatController { this._tracer.error('[Chat] Failed to restore active image generation:', error); return; } - if (this._isDestroyed()) { + if (this._shouldSkipRestoredImageGeneration()) { return; } if (preview === null) { @@ -525,8 +525,8 @@ export class ChatController { this._restoredImageGenerationTimer = null; } - private _isDestroyed(): boolean { - return this._state.isDestroyed; + private _shouldSkipRestoredImageGeneration(): boolean { + return this._state.isDestroyed || this._state.isSending; } private _closeAttachMenu(): void { @@ -615,6 +615,9 @@ export class ChatController { public async clearChat(): Promise { this._activationCoordinator.clearInactiveAiErrorTimeout(); this._clearRestoredImageGenerationTimer(); + if (this._state.isSending) { + await this._sendController.cancelActiveSend(); + } this._generationController.stopImagePreviewPolling(); this._state.isSending = false; this._state.currentGenerationProviderId = null; diff --git a/src/features/chat/controllers/ChatSendController.test.ts b/src/features/chat/controllers/ChatSendController.test.ts index 48720acb..df9ef788 100644 --- a/src/features/chat/controllers/ChatSendController.test.ts +++ b/src/features/chat/controllers/ChatSendController.test.ts @@ -238,6 +238,34 @@ describe('ChatSendController', () => { expect(options.handleError).not.toHaveBeenCalled(); }); + it('cleans the pending text bubble when a cancelled request rejects', async () => { + let rejectSend: (error: unknown) => void = () => { + throw new Error('sendMessage promise was not started'); + }; + const { controller, options, sendMessage, streamingHandle } = createController(); + sendMessage.mockImplementation( + () => + new Promise((_resolve, reject) => { + rejectSend = reject; + }), + ); + const input = document.createElement('textarea'); + input.value = 'hello'; + + const sendPromise = controller.sendChat(input); + for (let index = 0; index < 10 && sendMessage.mock.calls.length === 0; index += 1) { + await Promise.resolve(); + } + + await controller.cancelActiveSend(); + rejectSend(new Error('transport aborted')); + await sendPromise; + + expect(streamingHandle.cancel).toHaveBeenCalledOnce(); + expect(options.handleResponse).not.toHaveBeenCalled(); + expect(options.handleError).not.toHaveBeenCalled(); + }); + it('stops the image engine after a successful image send', async () => { const { controller, options, aiBridge } = createController(); aiBridge.getState.mockReturnValue({ activeProviderId: 'sdcpp', isRunning: true }); diff --git a/src/features/chat/controllers/ChatSendController.ts b/src/features/chat/controllers/ChatSendController.ts index 2226d8ce..331f81c7 100644 --- a/src/features/chat/controllers/ChatSendController.ts +++ b/src/features/chat/controllers/ChatSendController.ts @@ -179,6 +179,7 @@ export class ChatSendController { let streamingHandle: StreamingMessageHandle | null = null; let imageHandle: ImageGenerationHandle | null = null; + let streamingHandle: StreamingMessageHandle | null = null; let shouldStopImageEngine = false; try { From 9332ef4304413149b22d9a4c3724729b2185bf87 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 30 Apr 2026 20:16:59 +0300 Subject: [PATCH 048/126] fix: harden core api communication --- src-tauri/src/domain/ai/session.rs | 45 +++++++++++++- src-tauri/src/domain/integration_api.rs | 37 ++++++++++- src/features/ai/services/AIBridge.test.ts | 21 +++++++ src/features/ai/services/AIBridge.ts | 6 +- .../ai/services/AIChatTransport.test.ts | 62 ++++++++++++++++++- src/features/ai/services/AIChatTransport.ts | 13 +++- .../controllers/ChatHistoryController.test.ts | 59 ++++++++++++++++++ .../chat/controllers/ChatHistoryController.ts | 39 ++++++++++-- 8 files changed, 268 insertions(+), 14 deletions(-) diff --git a/src-tauri/src/domain/ai/session.rs b/src-tauri/src/domain/ai/session.rs index 97e41fad..23c51a12 100644 --- a/src-tauri/src/domain/ai/session.rs +++ b/src-tauri/src/domain/ai/session.rs @@ -210,12 +210,18 @@ impl ChatSessionManager { }); let overlap = find_history_overlap(&entry.history, incoming_messages); - if let Some(new_messages) = incoming_messages.get(overlap..) { + let mut appended_messages = false; + if let Some(new_messages) = incoming_messages.get(overlap..) + && !new_messages.is_empty() + { entry.history.extend_from_slice(new_messages); + entry.last_updated = Self::current_timestamp(); + appended_messages = true; } - entry.last_updated = Self::current_timestamp(); drop(entry); - self.mark_dirty(); + if appended_messages { + self.mark_dirty(); + } incoming_messages.to_vec() } @@ -913,6 +919,39 @@ mod tests { ); } + #[test] + fn test_merge_request_messages_does_not_mark_dirty_for_full_overlap() { + let manager = test_manager(); + + let existing = vec![ + ChatMessage { + id: "msg-1".to_string(), + role: "user".to_string(), + content: serde_json::Value::String("hello".to_string()), + thought_signature: None, + }, + ChatMessage { + id: "msg-2".to_string(), + role: "assistant".to_string(), + content: serde_json::Value::String("hi".to_string()), + thought_signature: None, + }, + ]; + + manager.merge_request_messages("session-1", &existing); + manager.dirty.store(false, Ordering::Relaxed); + + let runtime_context = manager.merge_request_messages("session-1", &existing); + let persisted = manager.get_chat_history("session-1"); + + assert_eq!(runtime_context.len(), 2); + assert_eq!(persisted.len(), 2); + assert!( + !manager.dirty.load(Ordering::Relaxed), + "fully overlapped request should not schedule a redundant save" + ); + } + #[test] fn test_chat_session_serialization() { use super::super::types::ChatSession; diff --git a/src-tauri/src/domain/integration_api.rs b/src-tauri/src/domain/integration_api.rs index 58ec2c4b..b754cb13 100644 --- a/src-tauri/src/domain/integration_api.rs +++ b/src-tauri/src/domain/integration_api.rs @@ -953,15 +953,26 @@ fn is_loopback_peer(peer_addr: Option) -> bool { } fn is_authorized(headers: &HashMap) -> bool { - let bearer = format!("Bearer {}", api_token()); headers .get("authorization") - .is_some_and(|value| value.trim() == bearer) + .is_some_and(|value| is_authorized_bearer(value)) || headers .get("x-axelate-token") .is_some_and(|value| value.trim() == api_token()) } +fn is_authorized_bearer(value: &str) -> bool { + let mut parts = value.split_whitespace(); + let Some(scheme) = parts.next() else { + return false; + }; + let Some(token) = parts.next() else { + return false; + }; + + parts.next().is_none() && scheme.eq_ignore_ascii_case("bearer") && token == api_token() +} + const fn json_response(status: u16, body: serde_json::Value) -> HttpResponse { HttpResponse { status, body } } @@ -1064,6 +1075,12 @@ mod tests { ); assert!(is_authorized(&headers)); + headers.insert( + "authorization".to_string(), + format!("bearer {}", super::api_token()), + ); + assert!(is_authorized(&headers)); + headers.clear(); headers.insert( "x-axelate-token".to_string(), @@ -1072,6 +1089,22 @@ mod tests { assert!(is_authorized(&headers)); } + #[test] + fn authorization_rejects_malformed_bearer_values() { + let mut headers = HashMap::new(); + headers.insert( + "authorization".to_string(), + format!("Bearer {} extra", super::api_token()), + ); + assert!(!is_authorized(&headers)); + + headers.insert( + "authorization".to_string(), + format!("Token {}", super::api_token()), + ); + assert!(!is_authorized(&headers)); + } + #[test] fn maps_ui_model_id_to_capability_api_model() { let model = model_with_api_ids(); diff --git a/src/features/ai/services/AIBridge.test.ts b/src/features/ai/services/AIBridge.test.ts index 0e69a638..f6d0b0ce 100644 --- a/src/features/ai/services/AIBridge.test.ts +++ b/src/features/ai/services/AIBridge.test.ts @@ -736,6 +736,27 @@ describe('AIBridge', () => { expect(fn).toHaveBeenCalled(); }); + it('should continue cleanup when an unlistener throws', () => { + const throwingUnlistener = vi.fn(() => { + throw new Error('cleanup failed'); + }); + const healthyUnlistener = vi.fn(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const transportDestroySpy = vi.spyOn((aiBridge as any)._transport, 'destroy'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (aiBridge as any)._unlisteners.push(throwingUnlistener, healthyUnlistener); + + expect(() => aiBridge.destroy()).not.toThrow(); + + expect(throwingUnlistener).toHaveBeenCalledOnce(); + expect(healthyUnlistener).toHaveBeenCalledOnce(); + expect(transportDestroySpy).toHaveBeenCalledOnce(); + expect(mockTracer.warn).toHaveBeenCalledWith( + '[AIBridge] Stream cleanup listener failed:', + expect.any(Error), + ); + }); + it('should be safe to call multiple times', () => { aiBridge.destroy(); aiBridge.destroy(); diff --git a/src/features/ai/services/AIBridge.ts b/src/features/ai/services/AIBridge.ts index 1c4879e5..7ad3b8ef 100644 --- a/src/features/ai/services/AIBridge.ts +++ b/src/features/ai/services/AIBridge.ts @@ -400,7 +400,11 @@ export class AIBridge implements IAIBridge { private _cleanupTransportState(): void { this._unlisteners.forEach((fn) => { - fn(); + try { + fn(); + } catch (error: unknown) { + this._tracer.warn('[AIBridge] Stream cleanup listener failed:', error); + } }); this._unlisteners.length = 0; this._transport.destroy(); diff --git a/src/features/ai/services/AIChatTransport.test.ts b/src/features/ai/services/AIChatTransport.test.ts index 58ff060b..2582c1eb 100644 --- a/src/features/ai/services/AIChatTransport.test.ts +++ b/src/features/ai/services/AIChatTransport.test.ts @@ -144,7 +144,7 @@ describe('AIChatTransport', () => { expect(result).toEqual({ ok: false, error: 'string error' }); }); - it('should timeout after 90 seconds', async () => { + it('should timeout cloud requests after 90 seconds', async () => { let requestId = ''; mockCore.tauriProvider.invoke.mockImplementation( (command: string, args: Record) => { @@ -168,6 +168,35 @@ describe('AIChatTransport', () => { }); }); + it('should keep local text requests alive past the cloud timeout', async () => { + let resolveInvoke: ( + response: Awaited>, + ) => void = () => { + throw new Error('invoke promise was not started'); + }; + mockCore.tauriProvider.invoke.mockImplementation((command: string) => + command === 'send_chat_message' + ? new Promise((resolve) => { + resolveInvoke = resolve; + }) + : Promise.resolve(true), + ); + + const sendPromise = transport.send( + makeRequest({ provider: 'llamacpp', model: 'model.gguf' }), + ); + vi.advanceTimersByTime(90_001); + await Promise.resolve(); + + expect(mockCore.tauriProvider.invoke).not.toHaveBeenCalledWith( + 'cancel_chat_generation', + expect.anything(), + ); + + resolveInvoke({ ok: true, reply: { text: 'local done' } }); + await expect(sendPromise).resolves.toEqual({ ok: true, text: 'local done' }); + }); + it('should extract message from plain error objects', async () => { mockCore.tauriProvider.invoke.mockRejectedValue({ message: 'ipc object failed' }); @@ -283,7 +312,7 @@ describe('AIChatTransport', () => { await firstSend; }); - it('should cancel a timed-out silent request before clearing active state', async () => { + it('should cancel a timed-out silent cloud request before clearing active state', async () => { let requestId = ''; mockCore.tauriProvider.invoke.mockImplementation( (command: string, args: Record) => { @@ -309,6 +338,35 @@ describe('AIChatTransport', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any expect((transport as any)._activeChatRequestId).toBeNull(); }); + + it('should use the local timeout for silent local prompt preparation', async () => { + let resolveInvoke: ( + response: Awaited>, + ) => void = () => { + throw new Error('invoke promise was not started'); + }; + mockCore.tauriProvider.invoke.mockImplementation((command: string) => + command === 'send_chat_message' + ? new Promise((resolve) => { + resolveInvoke = resolve; + }) + : Promise.resolve(true), + ); + + const sendPromise = transport.sendSilent( + makeRequest({ provider: 'llamacpp', model: 'model.gguf' }), + ); + vi.advanceTimersByTime(90_001); + await Promise.resolve(); + + expect(mockCore.tauriProvider.invoke).not.toHaveBeenCalledWith( + 'cancel_chat_generation', + expect.anything(), + ); + + resolveInvoke({ ok: true, reply: { text: 'prepared' } }); + await expect(sendPromise).resolves.toEqual({ ok: true, text: 'prepared' }); + }); }); describe('generateImage', () => { diff --git a/src/features/ai/services/AIChatTransport.ts b/src/features/ai/services/AIChatTransport.ts index a2484c23..ef19525a 100644 --- a/src/features/ai/services/AIChatTransport.ts +++ b/src/features/ai/services/AIChatTransport.ts @@ -8,10 +8,13 @@ import type { } from '../types/aiTypes'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; import type { AITransportContext } from './AIBridgeContext'; +import { isCloudProviderId } from '@/shared/utils/providerSupport'; type AIChatTransportLogger = Pick; const STALE_REQUEST_CANCEL_TIMEOUT_MS = 750; const AI_REQUEST_TIMEOUT_MESSAGE = 'AI request timed out'; +const CLOUD_CHAT_REQUEST_TIMEOUT_MS = 90_000; +const LOCAL_CHAT_REQUEST_TIMEOUT_MS = 30 * 60_000; /** * Safely extracts a human-readable error string from any error shape. @@ -152,7 +155,7 @@ export class AIChatTransport implements IChatTransport { chatChannel, thoughtChannel, }), - 90000, + this._chatRequestTimeoutMs(requestWithId), AI_REQUEST_TIMEOUT_MESSAGE, ); @@ -203,7 +206,7 @@ export class AIChatTransport implements IChatTransport { chatChannel, thoughtChannel, }), - 90000, + this._chatRequestTimeoutMs(requestWithId), AI_REQUEST_TIMEOUT_MESSAGE, ); @@ -439,6 +442,12 @@ export class AIChatTransport implements IChatTransport { return payload.request_id === requestId; } + private _chatRequestTimeoutMs(request: IChatRequest): number { + return isCloudProviderId(request.provider) + ? CLOUD_CHAT_REQUEST_TIMEOUT_MS + : LOCAL_CHAT_REQUEST_TIMEOUT_MS; + } + public destroy(): void { void this.cancelActiveChatRequest(); this._unlisteners.forEach((fn) => fn()); diff --git a/src/features/chat/controllers/ChatHistoryController.test.ts b/src/features/chat/controllers/ChatHistoryController.test.ts index 2999b496..4a93576a 100644 --- a/src/features/chat/controllers/ChatHistoryController.test.ts +++ b/src/features/chat/controllers/ChatHistoryController.test.ts @@ -154,6 +154,65 @@ describe('ChatHistoryController', () => { expect(deps.renderHistory).toHaveBeenLastCalledWith(defaultHistory); }); + it('should isolate multimodal history snapshots from later mutations', () => { + const { controller, state } = createController({ + history: [ + { + role: 'user', + content: [ + { type: 'text', text: 'look' }, + { + type: 'image_url', + image_url: { url: 'data:image/png;base64,old', detail: 'auto' }, + }, + ], + }, + ], + }); + + const snapshot = controller.getLocalHistorySnapshot(); + const snapshotContent = snapshot[0]?.content; + if (!Array.isArray(snapshotContent) || snapshotContent[1]?.type !== 'image_url') { + throw new Error('snapshot did not keep image content'); + } + snapshotContent[1].image_url.url = 'data:image/png;base64,mutated'; + + const currentContent = state.history[0]?.content; + expect(Array.isArray(currentContent) ? currentContent[1] : undefined).toEqual({ + type: 'image_url', + image_url: { url: 'data:image/png;base64,old', detail: 'auto' }, + }); + }); + + it('should isolate restored multimodal history from caller-owned objects', () => { + const { controller, state } = createController(); + const snapshot: IChatMessage[] = [ + { + role: 'user', + content: [ + { type: 'text', text: 'look' }, + { + type: 'image_url', + image_url: { url: 'data:image/png;base64,old', detail: 'high' }, + }, + ], + }, + ]; + + controller.restoreLocalHistorySnapshot(snapshot); + const snapshotContent = snapshot[0]?.content; + if (!Array.isArray(snapshotContent) || snapshotContent[1]?.type !== 'image_url') { + throw new Error('snapshot did not keep image content'); + } + snapshotContent[1].image_url.url = 'data:image/png;base64,mutated'; + + const currentContent = state.history[0]?.content; + expect(Array.isArray(currentContent) ? currentContent[1] : undefined).toEqual({ + type: 'image_url', + image_url: { url: 'data:image/png;base64,old', detail: 'high' }, + }); + }); + it('should rewind the last turn and return text for regeneration', async () => { const { controller, deps, aiBridge } = createController({ history: [ diff --git a/src/features/chat/controllers/ChatHistoryController.ts b/src/features/chat/controllers/ChatHistoryController.ts index 2df0a8f6..837414b5 100644 --- a/src/features/chat/controllers/ChatHistoryController.ts +++ b/src/features/chat/controllers/ChatHistoryController.ts @@ -106,11 +106,11 @@ export class ChatHistoryController { } public getLocalHistorySnapshot(): IChatMessage[] { - return this._options.getHistory().map((message) => ({ ...message })); + return this._cloneHistory(this._options.getHistory()); } public restoreLocalHistorySnapshot(history: IChatMessage[]): void { - this._options.setHistory(history.map((message) => ({ ...message }))); + this._options.setHistory(this._cloneHistory(history)); this._options.renderHistory(this._options.getHistory()); } @@ -150,10 +150,10 @@ export class ChatHistoryController { ? history .filter((msg) => msg.role === 'user' || msg.role === 'assistant') .map((msg) => { - const historyMessage: IChatMessage = { + const historyMessage = this._cloneMessage({ role: msg.role as 'user' | 'assistant', content: msg.content, - }; + }); if (msg.thought_signature !== undefined) { historyMessage.thought_signature = msg.thought_signature; } @@ -215,6 +215,37 @@ export class ChatHistoryController { ); } + private _cloneHistory(history: IChatMessage[]): IChatMessage[] { + return history.map((message) => this._cloneMessage(message)); + } + + private _cloneMessage(message: IChatMessage): IChatMessage { + const clone: IChatMessage = { + role: message.role, + content: this._cloneContent(message.content), + }; + if (message.thought_signature !== undefined) { + clone.thought_signature = message.thought_signature; + } + return clone; + } + + private _cloneContent(content: IChatMessage['content']): IChatMessage['content'] { + if (typeof content === 'string') { + return content; + } + + return content.map((part) => { + if (part.type === 'image_url') { + return { + ...part, + image_url: { ...part.image_url }, + }; + } + return { ...part }; + }); + } + public scheduleRevealLatestMessage(): void { this.clearRevealLatestMessageTimeout(); if (this._revealLatestMessageFrame !== null) { From 9696ff0348779c5f53207b9ceaab005cbd68a751 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 30 Apr 2026 20:25:46 +0300 Subject: [PATCH 049/126] fix: show provider errors as notifications --- .../AIBridgeMessageController.test.ts | 38 ++++++++++++++++-- .../ai/services/AIBridgeMessageController.ts | 7 ++-- src/features/chat/chat.test.ts | 40 ++++++++++++++++++- src/features/chat/chat.ts | 6 +-- .../chat/services/ChatUiStateHelper.ts | 4 +- 5 files changed, 82 insertions(+), 13 deletions(-) diff --git a/src/features/ai/services/AIBridgeMessageController.test.ts b/src/features/ai/services/AIBridgeMessageController.test.ts index b78f5cc3..be446821 100644 --- a/src/features/ai/services/AIBridgeMessageController.test.ts +++ b/src/features/ai/services/AIBridgeMessageController.test.ts @@ -16,7 +16,15 @@ function createTextController() { broadcastResponse: vi.fn(), broadcastReplaceChunk: vi.fn(), }; - const manager = { + const manager: { + activeProviderId: string | null; + apiKey: string | null; + model: string; + sessionId: string; + maxOutputTokens: number | undefined; + refreshActiveApiKey: ReturnType; + isActive: ReturnType; + } = { activeProviderId: CUSTOM_TEXT_PROVIDER_ID, apiKey: '[secure]', model: 'deepseek/deepseek-r1-0528', @@ -40,6 +48,7 @@ function createTextController() { close: vi.fn().mockResolvedValue(undefined), }, }; + const showToast = vi.fn(); const controller = new AIBridgeMessageController({ getContext: () => context as never, @@ -49,14 +58,14 @@ function createTextController() { providerPolicy: new AIBridgeProviderPolicy(), tracer: { error: vi.fn() }, translate: (_key, fallback) => fallback, - showToast: vi.fn(), + showToast, onActivity: vi.fn(), onLongActivityStart: vi.fn(), onLongActivityEnd: vi.fn(), onSuccessfulResponse: vi.fn(), }); - return { controller, transport, events, manager, context }; + return { controller, transport, events, manager, context, showToast }; } function createImageController() { @@ -297,4 +306,27 @@ describe('AIBridgeMessageController custom providers', () => { }); expect(transport.send).not.toHaveBeenCalled(); }); + + it('shows missing provider errors as toast without broadcasting chat text', async () => { + const { controller, events, manager, showToast } = createTextController(); + manager.activeProviderId = null; + + const response = await controller.sendMessage('hello', 'chat', [], []); + + expect(response).toEqual({ ok: false, error: 'No engine found' }); + expect(showToast).toHaveBeenCalledWith('No engine found', 'error'); + expect(events.broadcastResponse).not.toHaveBeenCalled(); + }); + + it('shows missing api key errors as toast without broadcasting chat text', async () => { + const { controller, events, manager, showToast } = createTextController(); + manager.apiKey = null; + manager.isActive.mockReturnValue(false); + + const response = await controller.sendMessage('hello', 'chat', [], []); + + expect(response).toEqual({ ok: false, error: 'API key missing' }); + expect(showToast).toHaveBeenCalledWith('API key missing', 'error'); + expect(events.broadcastResponse).not.toHaveBeenCalled(); + }); }); diff --git a/src/features/ai/services/AIBridgeMessageController.ts b/src/features/ai/services/AIBridgeMessageController.ts index c7aae58c..14828d2d 100644 --- a/src/features/ai/services/AIBridgeMessageController.ts +++ b/src/features/ai/services/AIBridgeMessageController.ts @@ -293,16 +293,15 @@ export class AIBridgeMessageController { }; } - private _handleMissingApiKey(source: MessageSource): IBridgeResponse { + private _handleMissingApiKey(_source: MessageSource): IBridgeResponse { const msg = this._deps.translate('ui.ai.no_api_key', 'API key missing'); - this._deps.events.broadcastResponse(`Error: ${msg}`, source); this._deps.showToast(msg, 'error'); return { ok: false, error: msg }; } - private _handleMissingProvider(source: MessageSource): IBridgeResponse { + private _handleMissingProvider(_source: MessageSource): IBridgeResponse { const msg = this._deps.translate('ui.ai.no_provider', 'No engine found'); - this._deps.events.broadcastResponse(msg, source); + this._deps.showToast(msg, 'error'); return { ok: false, error: msg }; } diff --git a/src/features/chat/chat.test.ts b/src/features/chat/chat.test.ts index 42e6d68c..8ef2698c 100644 --- a/src/features/chat/chat.test.ts +++ b/src/features/chat/chat.test.ts @@ -6,6 +6,7 @@ const updateTokenCount = vi.fn(); const mockChatUiInstances: Array<{ renderHistory: ReturnType; createImageGenerationMessage: ReturnType; + showToast: ReturnType; }> = []; const mockChatFileHandlerInstances: Array<{ clear: ReturnType; @@ -47,6 +48,7 @@ vi.mock('./ui/ChatUI', () => ({ mockChatUiInstances.push({ renderHistory: this.renderHistory, createImageGenerationMessage: this.createImageGenerationMessage, + showToast: this.showToast, }); } }, @@ -176,7 +178,7 @@ describe('ChatController', () => { return new ChatUiStateHelper({ aiBridge: aiBridge as never, i18n: i18n as never, - appendAssistantError: appendMessage, + showErrorToast: vi.fn(), getChatInput: () => document.getElementById('chat-input') as HTMLTextAreaElement | null, maxInputHeightPx: 200, baseInputHeightPx: 42, @@ -202,6 +204,7 @@ describe('ChatController', () => { vi.advanceTimersByTime(500); expect(appendMessage).not.toHaveBeenCalled(); + expect(mockChatUiInstances[0]?.showToast).not.toHaveBeenCalled(); vi.useRealTimers(); }); @@ -216,11 +219,46 @@ describe('ChatController', () => { vi.advanceTimersByTime(500); expect(appendMessage).not.toHaveBeenCalled(); + expect(mockChatUiInstances[0]?.showToast).not.toHaveBeenCalled(); expect(clearUi).toHaveBeenCalledTimes(1); expect(updateTokenCount).toHaveBeenCalledWith(0, undefined); vi.useRealTimers(); }); + it('should show inactive-ai errors as toast instead of chat messages', async () => { + vi.useFakeTimers(); + document.body.innerHTML = ''; + aiBridge.isActive.mockReturnValue(false); + const controller = createController(); + + await controller.sendChat(); + vi.advanceTimersByTime(500); + + expect(appendMessage).not.toHaveBeenCalled(); + expect(mockChatUiInstances[0]?.showToast).toHaveBeenCalledWith( + 'No AI module running. Please select and launch a module first.', + 'error', + 5000, + ); + vi.useRealTimers(); + }); + + it('should show send failures as toast instead of persistent chat messages', () => { + const controller = createController(); + const internals = controller as unknown as { + _handleError: (error: unknown) => void; + }; + + internals._handleError('Provider activation failed'); + + expect(appendMessage).not.toHaveBeenCalled(); + expect(mockChatUiInstances[0]?.showToast).toHaveBeenCalledWith( + 'Provider activation failed', + 'error', + 5000, + ); + }); + it('should cancel active generation before clearing chat', async () => { const controller = createController(); const internals = controller as unknown as { diff --git a/src/features/chat/chat.ts b/src/features/chat/chat.ts index 7da76b4a..a7859ed2 100644 --- a/src/features/chat/chat.ts +++ b/src/features/chat/chat.ts @@ -170,8 +170,8 @@ export class ChatController { return new ChatUiStateHelper({ aiBridge, i18n, - appendAssistantError: (message) => { - this._ui.appendMessage('assistant', message, { error: true }); + showErrorToast: (message) => { + this._ui.showToast(message, 'error', 5000); }, getChatInput: () => this._inputCoordinator.getInput(), maxInputHeightPx: ChatController._maxInputHeightPx, @@ -766,7 +766,7 @@ export class ChatController { private _handleError(errorMsg: unknown = 'Unknown Error', _model?: string): void { const msgStr = this._contentHelper.extractText(errorMsg) || 'Unknown Error'; - this._ui.appendMessage('assistant', msgStr, { error: true }); + this._ui.showToast(msgStr, 'error', 5000); } private _addContextTokens(tokens: number): void { diff --git a/src/features/chat/services/ChatUiStateHelper.ts b/src/features/chat/services/ChatUiStateHelper.ts index b2b4c981..f805cfd6 100644 --- a/src/features/chat/services/ChatUiStateHelper.ts +++ b/src/features/chat/services/ChatUiStateHelper.ts @@ -4,7 +4,7 @@ import type { I18nService } from '@/infrastructure/i18n/I18nService'; type ChatUiDeps = { aiBridge: AIBridge; i18n: I18nService; - appendAssistantError: (message: string) => void; + showErrorToast: (message: string) => void; getChatInput: () => HTMLTextAreaElement | null; maxInputHeightPx: number; baseInputHeightPx: number; @@ -38,7 +38,7 @@ export class ChatUiStateHelper { if (this._deps.aiBridge.isActive()) { return; } - this._deps.appendAssistantError( + this._deps.showErrorToast( this._deps.i18n.t( 'ui.ai.no_provider', 'No AI module running. Please select and launch a module first.', From 7316a0945803ec3adbe534a32720927ba3aa61b6 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 30 Apr 2026 20:39:29 +0300 Subject: [PATCH 050/126] fix: harden chat send error lifecycle --- .../ChatGenerationController.test.ts | 23 ++++++++++ .../controllers/ChatGenerationController.ts | 4 +- .../controllers/ChatSendController.test.ts | 43 ++++++++++++++++++- .../chat/controllers/ChatSendController.ts | 21 ++++++--- 4 files changed, 82 insertions(+), 9 deletions(-) diff --git a/src/features/chat/controllers/ChatGenerationController.test.ts b/src/features/chat/controllers/ChatGenerationController.test.ts index 0b3fdfc0..a4e889a1 100644 --- a/src/features/chat/controllers/ChatGenerationController.test.ts +++ b/src/features/chat/controllers/ChatGenerationController.test.ts @@ -7,6 +7,7 @@ describe('ChatGenerationController', () => { getImageGenerationPreview: vi.fn(), removeChunkListener: vi.fn(), removeReplaceChunkListener: vi.fn(), + removeThoughtListener: vi.fn(), }; const baseOptions = { @@ -194,6 +195,28 @@ describe('ChatGenerationController', () => { expect(baseOptions.handleError).toHaveBeenCalled(); }); + it('removes failed image generation placeholders and reports the error as a toast', async () => { + const controller = new ChatGenerationController(baseOptions as never); + const imageHandle = { + setStatus: vi.fn(), + setPreview: vi.fn(), + finalize: vi.fn(), + fail: vi.fn(), + cancel: vi.fn(), + discard: vi.fn(), + }; + + await controller.handleChatResponse( + { ok: false, error: 'image failed', model: 'sdcpp' } as never, + null, + imageHandle, + ); + + expect(imageHandle.discard).toHaveBeenCalledOnce(); + expect(imageHandle.fail).not.toHaveBeenCalled(); + expect(baseOptions.handleError).toHaveBeenCalled(); + }); + it('adds assistant text tokens to the context counter', async () => { const controller = new ChatGenerationController(baseOptions as never); diff --git a/src/features/chat/controllers/ChatGenerationController.ts b/src/features/chat/controllers/ChatGenerationController.ts index 29786c71..b0a5cd12 100644 --- a/src/features/chat/controllers/ChatGenerationController.ts +++ b/src/features/chat/controllers/ChatGenerationController.ts @@ -62,6 +62,7 @@ export class ChatGenerationController { public cleanupStreamingState(listenerId: string, typingId: string): void { this._options.aiBridge.removeChunkListener(listenerId); this._options.aiBridge.removeReplaceChunkListener(listenerId); + this._options.aiBridge.removeThoughtListener(listenerId); this.stopImagePreviewPolling(); this._options.removeTyping(typingId); } @@ -326,7 +327,8 @@ export class ChatGenerationController { response.model, ); if (imageHandle !== null && imageHandle !== undefined) { - imageHandle.fail(friendlyMsg); + imageHandle.discard(); + this._options.handleError(friendlyMsg, response.model); return; } diff --git a/src/features/chat/controllers/ChatSendController.test.ts b/src/features/chat/controllers/ChatSendController.test.ts index df9ef788..3fd9ba5b 100644 --- a/src/features/chat/controllers/ChatSendController.test.ts +++ b/src/features/chat/controllers/ChatSendController.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ChatSendController } from './ChatSendController'; @@ -91,6 +91,10 @@ describe('ChatSendController', () => { vi.clearAllMocks(); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it('does not send empty chat messages without attachments', async () => { const { controller, options, sendMessage } = createController(); const input = document.createElement('textarea'); @@ -139,6 +143,43 @@ describe('ChatSendController', () => { expect(options.addContextTokens).toHaveBeenCalledWith(3); }); + it('does not clear input or append a user turn when image provider activation fails', async () => { + const { controller, options, aiBridge, sendMessage } = createController(); + aiBridge.startProvider.mockResolvedValueOnce(false); + vi.mocked(options.isForceImageGeneration).mockReturnValue(true); + vi.mocked(options.getSelectedModule).mockImplementation( + (category: 'ai_text' | 'ai_image') => + category === 'ai_image' ? { id: 'sdcpp' } : undefined, + ); + const input = document.createElement('textarea'); + input.value = 'draw a cat'; + + const result = await controller.sendChat(input); + + expect(result).toBe(false); + expect(options.clearInput).not.toHaveBeenCalled(); + expect(options.addContextTokens).not.toHaveBeenCalled(); + expect(options.appendUserMessage).not.toHaveBeenCalled(); + expect(options.pushUserMessage).not.toHaveBeenCalled(); + expect(sendMessage).not.toHaveBeenCalled(); + expect(options.handleError).toHaveBeenCalled(); + }); + + it('uses distinct stream listener ids when sends start in the same millisecond', async () => { + vi.spyOn(Date, 'now').mockReturnValue(123); + const { controller, aiBridge } = createController(); + const firstInput = document.createElement('textarea'); + firstInput.value = 'first'; + const secondInput = document.createElement('textarea'); + secondInput.value = 'second'; + + await controller.sendChat(firstInput); + await controller.sendChat(secondInput); + + expect(aiBridge.onChunk).toHaveBeenCalledTimes(2); + expect(aiBridge.onChunk.mock.calls[0]?.[0]).not.toBe(aiBridge.onChunk.mock.calls[1]?.[0]); + }); + it('counts image attachment tokens without double-counting text attachments', async () => { const { controller, options } = createController(); const processForSend = ( diff --git a/src/features/chat/controllers/ChatSendController.ts b/src/features/chat/controllers/ChatSendController.ts index 331f81c7..06b7e4c8 100644 --- a/src/features/chat/controllers/ChatSendController.ts +++ b/src/features/chat/controllers/ChatSendController.ts @@ -107,6 +107,7 @@ export class ChatSendController { private _isDestroyed = false; private _cancelRequested = false; private _activeProviderId: string | null = null; + private _sendSequence = 0; private readonly _providerPolicy = new AIBridgeProviderPolicy(); constructor(private readonly _options: ChatSendControllerOptions) { @@ -165,8 +166,9 @@ export class ChatSendController { } const uiElements = this._options.lockUi(input); - const typingId = `typing-${String(Date.now())}`; - const listenerId = `chat-stream-${String(Date.now())}`; + const sendId = this._createSendId(); + const typingId = `typing-${sendId}`; + const listenerId = `chat-stream-${sendId}`; let activeProviderId = this._options.aiBridge.getState().activeProviderId; let isImageProvider = this._options.isForceImageGeneration() || @@ -186,11 +188,6 @@ export class ChatSendController { const sendPlan = await this._sendFlow.prepare(text); if (this._wasDestroyed()) return false; - this._options.clearInput(); - this._options.addContextTokens(sendPlan.tokenCount); - this._options.appendUserMessage(text, sendPlan.attachments, sendPlan.tokenCount); - this._options.pushUserMessage(sendPlan.userContent); - let imagePrompt = sendPlan.combinedText; if (isImageProvider) { const preparedPrompt = await this._prepareImagePromptWithTextProvider( @@ -220,6 +217,11 @@ export class ChatSendController { isImageProvider = this._options.isImageProvider(activeProviderId); } + this._options.clearInput(); + this._options.addContextTokens(sendPlan.tokenCount); + this._options.appendUserMessage(text, sendPlan.attachments, sendPlan.tokenCount); + this._options.pushUserMessage(sendPlan.userContent); + const ensureStreamingHandle = (): StreamingMessageHandle => { streamingHandle ??= this._options.createStreamingHandle(typingId); return streamingHandle; @@ -380,6 +382,11 @@ export class ChatSendController { handle?.cancel(); } + private _createSendId(): string { + this._sendSequence += 1; + return `${Date.now().toString(36)}-${this._sendSequence.toString(36)}`; + } + public async tryAutoStartAi(prompt?: string): Promise { return await this._autoStartHelper.startSelectedModule(prompt); } From 8f1c2756f4684a9ad604c9a51e3086cde27838b0 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 30 Apr 2026 21:08:49 +0300 Subject: [PATCH 051/126] fix: address coderabbit lifecycle feedback --- .../domain/modules/controller/lifecycle.rs | 5 +- .../filesystem/local_file_service.rs | 20 +++---- src/app/CoreEntry.ts | 25 ++++---- src/app/events.test.ts | 6 +- src/app/init.ts | 7 ++- .../AIBridgeMessageController.test.ts | 17 +++++- .../ai/services/AIBridgeMessageController.ts | 2 + .../ai/services/EngineStatusService.test.ts | 51 +++++++++++++++++ .../ai/services/EngineStatusService.ts | 48 +++++++++++++--- src/features/chat/chat.test.ts | 57 ++++++++++++++++++- src/features/chat/chat.ts | 12 +++- 11 files changed, 211 insertions(+), 39 deletions(-) diff --git a/src-tauri/src/domain/modules/controller/lifecycle.rs b/src-tauri/src/domain/modules/controller/lifecycle.rs index 654004ac..274c1318 100644 --- a/src-tauri/src/domain/modules/controller/lifecycle.rs +++ b/src-tauri/src/domain/modules/controller/lifecycle.rs @@ -14,7 +14,7 @@ use tokio::sync::Mutex; use tokio::time::timeout; const MODULE_CHILD_EXIT_POLL_INTERVAL: Duration = Duration::from_secs(1); -static MODULE_START_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); +static MODULE_LIFECYCLE_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); fn build_command(cmd: CommandDefinition) -> Command { match cmd { @@ -60,7 +60,7 @@ impl<'a> LifecycleExecutor<'a> { /// Safely starts a module with the given manifest pub async fn start(&self, manifest: &ModuleManifest) -> Result { - let _start_guard = MODULE_START_LOCK.lock().await; + let _lifecycle_guard = MODULE_LIFECYCLE_LOCK.lock().await; // 1. Guard against double-start // Check registry first (atomic-ish) @@ -315,6 +315,7 @@ impl<'a> LifecycleExecutor<'a> { } /// Gracefully stops a module with escalation pub async fn stop(&self, manifest: &ModuleManifest) -> Result { + let _lifecycle_guard = MODULE_LIFECYCLE_LOCK.lock().await; tracing::info!("Stopping module: {}", self.module_id); // 1. Run stop script if exists diff --git a/src-tauri/src/infrastructure/filesystem/local_file_service.rs b/src-tauri/src/infrastructure/filesystem/local_file_service.rs index 044501be..90df3a10 100644 --- a/src-tauri/src/infrastructure/filesystem/local_file_service.rs +++ b/src-tauri/src/infrastructure/filesystem/local_file_service.rs @@ -57,7 +57,9 @@ impl LocalFileService { std::process::id(), chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default() )); - let had_original = path.exists(); + let had_original = fs::try_exists(path) + .await + .map_err(|e| AppError::Io(e.to_string()))?; if had_original && let Err(backup_error) = fs::rename(path, &backup).await { let _ = fs::remove_file(&tmp).await; return Err(AppError::Io(format!( @@ -116,17 +118,15 @@ impl FileService for LocalFileService { } async fn delete(&self, path: &Path) -> Result<(), AppError> { - if !path.exists() { - return Ok(()); - } - if path.is_dir() { - fs::remove_dir_all(path) + match fs::metadata(path).await { + Ok(metadata) if metadata.is_dir() => fs::remove_dir_all(path) .await - .map_err(|e| AppError::Io(e.to_string())) - } else { - fs::remove_file(path) + .map_err(|e| AppError::Io(e.to_string())), + Ok(_) => fs::remove_file(path) .await - .map_err(|e| AppError::Io(e.to_string())) + .map_err(|e| AppError::Io(e.to_string())), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(AppError::Io(error.to_string())), } } diff --git a/src/app/CoreEntry.ts b/src/app/CoreEntry.ts index 45c0c4ac..0da8c7bf 100644 --- a/src/app/CoreEntry.ts +++ b/src/app/CoreEntry.ts @@ -121,17 +121,20 @@ export function bindCoreEntry(createCore: CoreFactory, tracer: EntryLogger): voi if (import.meta.hot) { import.meta.hot.dispose(() => { - void destroyActiveCoreInstance(tracer, 'Destroy during HMR dispose failed'); - if (state.bootHandler !== null) { - document.removeEventListener('DOMContentLoaded', state.bootHandler); - state.bootHandler = null; - } - if (state.beforeUnloadHandler !== null) { - globalThis.removeEventListener('beforeunload', state.beforeUnloadHandler); - state.beforeUnloadHandler = null; - } - state.coreBootBound = false; - state.coreBeforeUnloadBound = false; + return destroyActiveCoreInstance(tracer, 'Destroy during HMR dispose failed').finally( + () => { + if (state.bootHandler !== null) { + document.removeEventListener('DOMContentLoaded', state.bootHandler); + state.bootHandler = null; + } + if (state.beforeUnloadHandler !== null) { + globalThis.removeEventListener('beforeunload', state.beforeUnloadHandler); + state.beforeUnloadHandler = null; + } + state.coreBootBound = false; + state.coreBeforeUnloadBound = false; + }, + ); }); } } diff --git a/src/app/events.test.ts b/src/app/events.test.ts index 9b0b9b1b..796b5025 100644 --- a/src/app/events.test.ts +++ b/src/app/events.test.ts @@ -63,7 +63,11 @@ describe('EventHandler', () => { }); handler.init(); - document.querySelector('[data-page]')?.click(); + const navButton = document.querySelector('[data-page]'); + if (navButton === null) { + throw new Error('Navigation button not found'); + } + navButton.click(); await Promise.resolve(); expect(core.navigationUI.showPage).not.toHaveBeenCalled(); diff --git a/src/app/init.ts b/src/app/init.ts index b187b6af..1d2f32c7 100644 --- a/src/app/init.ts +++ b/src/app/init.ts @@ -38,6 +38,7 @@ export class Core { * Executes the core initialization sequence with hardened survival logic. */ public async init(): Promise { + if (this._isCoreDestroyed()) return; if (this._isInitialized) return; if (this._initPromise !== null) { await this._initPromise; @@ -48,7 +49,7 @@ export class Core { this._initPromise = initPromise; try { await initPromise; - if (this._initPromise === initPromise && !this._isDestroyed) { + if (this._initPromise === initPromise && !this._isCoreDestroyed()) { this._isInitialized = true; } } finally { @@ -62,6 +63,10 @@ export class Core { await this._assembly.lifecycleController.runInit(); } + private _isCoreDestroyed(): boolean { + return this._isDestroyed; + } + public get aiBridge(): CoreServices['aiBridge'] { return this._assembly.services.aiBridge; } diff --git a/src/features/ai/services/AIBridgeMessageController.test.ts b/src/features/ai/services/AIBridgeMessageController.test.ts index be446821..f52bfa7a 100644 --- a/src/features/ai/services/AIBridgeMessageController.test.ts +++ b/src/features/ai/services/AIBridgeMessageController.test.ts @@ -10,6 +10,7 @@ import { function createTextController() { const transport = { send: vi.fn().mockResolvedValue({ ok: true, text: 'done' }), + sendSilent: vi.fn().mockResolvedValue({ ok: true, text: 'prepared' }), generateImage: vi.fn(), }; const events = { @@ -49,6 +50,7 @@ function createTextController() { }, }; const showToast = vi.fn(); + const onActivity = vi.fn(); const controller = new AIBridgeMessageController({ getContext: () => context as never, @@ -59,18 +61,19 @@ function createTextController() { tracer: { error: vi.fn() }, translate: (_key, fallback) => fallback, showToast, - onActivity: vi.fn(), + onActivity, onLongActivityStart: vi.fn(), onLongActivityEnd: vi.fn(), onSuccessfulResponse: vi.fn(), }); - return { controller, transport, events, manager, context, showToast }; + return { controller, transport, events, manager, context, showToast, onActivity }; } function createImageController() { const transport = { send: vi.fn(), + sendSilent: vi.fn(), generateImage: vi .fn() .mockResolvedValue({ ok: true, images: ['data:image/png;base64,abc'] }), @@ -329,4 +332,14 @@ describe('AIBridgeMessageController custom providers', () => { expect(showToast).toHaveBeenCalledWith('API key missing', 'error'); expect(events.broadcastResponse).not.toHaveBeenCalled(); }); + + it('marks silent image prompt preparation as provider activity', async () => { + const { controller, transport, onActivity } = createTextController(); + + const response = await controller.prepareImagePrompt('rewrite image prompt'); + + expect(response).toEqual({ ok: true, text: 'prepared' }); + expect(onActivity).toHaveBeenCalledOnce(); + expect(transport.sendSilent).toHaveBeenCalledOnce(); + }); }); diff --git a/src/features/ai/services/AIBridgeMessageController.ts b/src/features/ai/services/AIBridgeMessageController.ts index 14828d2d..6ee31898 100644 --- a/src/features/ai/services/AIBridgeMessageController.ts +++ b/src/features/ai/services/AIBridgeMessageController.ts @@ -85,6 +85,8 @@ export class AIBridgeMessageController { } try { + this._deps.onActivity(); + const providerId = this._deps.manager.activeProviderId; const backendProviderId = resolveCustomProviderBackendId(providerId); const requestOptions = this._deps.providerPolicy.buildRequestOptions({ diff --git a/src/features/ai/services/EngineStatusService.test.ts b/src/features/ai/services/EngineStatusService.test.ts index 71cf9c8b..108e092c 100644 --- a/src/features/ai/services/EngineStatusService.test.ts +++ b/src/features/ai/services/EngineStatusService.test.ts @@ -189,6 +189,57 @@ describe('EngineStatusService', () => { expect(service.hasActiveEngines).toBe(false); }); + it('ignores stale backend refresh results after destroy', async () => { + let resolveRefresh: (state: unknown) => void = () => { + throw new Error('refresh was not started'); + }; + vi.mocked(core.tauriProvider.invoke).mockImplementation( + () => + new Promise((resolve) => { + resolveRefresh = resolve; + }), + ); + service.init(); + service.destroy(); + + resolveRefresh({ + ready: { + slots: [ + { + engine: { + id: 'llamacpp', + endpoint: 'http://127.0.0.1:8080', + healthy: true, + }, + }, + ], + }, + }); + await Promise.resolve(); + + expect(service.activeEngineIds).toEqual([]); + }); + + it('does not reset unrelated launcher cards when backend reports idle', async () => { + document.body.innerHTML = ` +
    +
    +
    + `; + vi.mocked(core.tauriProvider.invoke).mockResolvedValueOnce('idle'); + + await service.refreshFromBackend(); + + const unrelated = document.querySelector('.app-card:not([data-app-id])'); + const engineCard = document.querySelector('[data-app-id="llamacpp"]'); + const slotCard = document.querySelector('[data-current-module="llamacpp"]'); + expect(unrelated?.classList.contains('engine-idle')).toBe(false); + expect(unrelated?.classList.contains('module-running')).toBe(true); + expect(engineCard?.classList.contains('engine-idle')).toBe(true); + expect(slotCard?.classList.contains('module-stopped')).toBe(true); + expect(slotCard?.dataset['runtimeStatus']).toBe('idle'); + }); + it('returns noop unlisten and handles listen promise failure branches', async () => { const webCore = { tauriProvider: { diff --git a/src/features/ai/services/EngineStatusService.ts b/src/features/ai/services/EngineStatusService.ts index 7c53cec2..a270316b 100644 --- a/src/features/ai/services/EngineStatusService.ts +++ b/src/features/ai/services/EngineStatusService.ts @@ -50,6 +50,7 @@ export class EngineStatusService { private readonly _unlisteners: (() => void)[] = []; private _domObserver: MutationObserver | null = null; private _domSyncFrame: number | null = null; + private _refreshGeneration = 0; private _initialized = false; public constructor(private readonly _tracer: EngineStatusLogger) {} @@ -71,6 +72,7 @@ export class EngineStatusService { } if (this._context?.tauriProvider.isTauri() !== true) return; + this._refreshGeneration += 1; this._unlisteners.push( this._listen('ai:engine:swapping', (payload) => { this._tracer.info(`[EngineStatus] Swapping from ${payload.from} to ${payload.to}`); @@ -108,6 +110,7 @@ export class EngineStatusService { } public destroy(): void { + this._refreshGeneration += 1; this._unlisteners.forEach((fn) => fn()); this._unlisteners.length = 0; this._domObserver?.disconnect(); @@ -151,11 +154,18 @@ export class EngineStatusService { return; } + const refreshGeneration = this._refreshGeneration; try { const state = await this._context.tauriProvider.invoke('get_engine_state'); + if (refreshGeneration !== this._refreshGeneration) { + return; + } this._applyBackendState(state); } catch (error) { + if (refreshGeneration !== this._refreshGeneration) { + return; + } this._tracer.error('[EngineStatusService] Failed to refresh engine state:', error); } } @@ -370,15 +380,35 @@ export class EngineStatusService { activeIds.forEach((engineId) => { this.setEngineState(engineId, 'idle'); }); - document.querySelectorAll('.app-card, .module-slot-card').forEach((card) => { - this._resetCardClasses(card); - card.classList.add('engine-idle'); - card.classList.remove('module-running'); - if (card.classList.contains('module-slot-card')) { - card.classList.add('module-stopped'); - card.dataset['runtimeStatus'] = 'idle'; - } - }); + document + .querySelectorAll( + [ + '[data-app-id]', + '[data-current-module]', + '.engine-idle', + '.engine-starting', + '.engine-swapping', + '.engine-ready', + '.engine-error', + ].join(', '), + ) + .forEach((card) => { + if (!this._isEngineBoundCard(card)) { + return; + } + + this._resetCardClasses(card); + card.classList.add('engine-idle'); + card.classList.remove('module-running'); + if (card.dataset['currentModule'] !== undefined) { + card.classList.add('module-stopped'); + card.dataset['runtimeStatus'] = 'idle'; + } + }); + } + + private _isEngineBoundCard(card: HTMLElement): boolean { + return card.dataset['appId'] !== undefined || card.dataset['currentModule'] !== undefined; } private _resetCardClasses(card: HTMLElement): void { diff --git a/src/features/chat/chat.test.ts b/src/features/chat/chat.test.ts index 8ef2698c..10394b11 100644 --- a/src/features/chat/chat.test.ts +++ b/src/features/chat/chat.test.ts @@ -106,7 +106,7 @@ import { EventBus } from '@/shared/services/EventBus'; type ChatControllerTestAccess = { init: () => Promise; - sendChat: () => Promise; + sendChat: () => Promise; clearChat: () => Promise; destroy: () => void; toggleAttachMenu: () => void; @@ -279,6 +279,29 @@ describe('ChatController', () => { expect(internals._state.currentGenerationProviderId).toBeNull(); }); + it('should still clear chat when active send cancellation fails', async () => { + const controller = createController(); + const internals = controller as unknown as { + _state: { isSending: boolean; currentGenerationProviderId: string | null }; + _sendController: { cancelActiveSend: () => Promise }; + }; + internals._state.isSending = true; + internals._state.currentGenerationProviderId = 'gpt'; + vi.spyOn(internals._sendController, 'cancelActiveSend').mockRejectedValueOnce( + new Error('cancel failed'), + ); + + await controller.clearChat(); + + expect(clearUi).toHaveBeenCalledOnce(); + expect(internals._state.isSending).toBe(false); + expect(internals._state.currentGenerationProviderId).toBeNull(); + expect(chatDeps.tracer.warn).toHaveBeenCalledWith( + '[Chat] Failed to cancel active send during clear:', + expect.any(Error), + ); + }); + it('should not restore an image generation placeholder if a send starts while probing preview', async () => { let resolvePreview: ( value: Awaited>, @@ -312,6 +335,38 @@ describe('ChatController', () => { expect(mockChatUiInstances[0]?.createImageGenerationMessage).not.toHaveBeenCalled(); }); + it('should not reload restored image history after destruction during preview polling', async () => { + let resolvePreview: ( + value: Awaited>, + ) => void = () => { + throw new Error('preview promise was not started'); + }; + aiBridge.getImageGenerationPreview.mockReturnValueOnce( + new Promise((resolve) => { + resolvePreview = resolve; + }), + ); + const controller = createController(); + const internals = controller as unknown as { + _state: { isDestroyed: boolean; isSending: boolean }; + _checkRestoredImageGeneration: () => Promise; + _historyController: { loadHistory: () => Promise }; + _generationController: { stopImagePreviewPolling: () => void }; + }; + internals._state.isSending = true; + const loadHistorySpy = vi.spyOn(internals._historyController, 'loadHistory'); + const stopPollingSpy = vi.spyOn(internals._generationController, 'stopImagePreviewPolling'); + + const checkPromise = internals._checkRestoredImageGeneration(); + await Promise.resolve(); + internals._state.isDestroyed = true; + resolvePreview(null); + await checkPromise; + + expect(loadHistorySpy).not.toHaveBeenCalled(); + expect(stopPollingSpy).not.toHaveBeenCalled(); + }); + it('should clear file update callback on destroy', () => { const controller = createController(); diff --git a/src/features/chat/chat.ts b/src/features/chat/chat.ts index a7859ed2..504a41a3 100644 --- a/src/features/chat/chat.ts +++ b/src/features/chat/chat.ts @@ -493,12 +493,16 @@ export class ChatController { } private async _checkRestoredImageGeneration(): Promise { - if (this._state.isDestroyed || !this._state.isSending) { + if (this._shouldSkipRestoredImageGeneration()) { return; } try { const preview = await this._aiBridge.getImageGenerationPreview(); + if (this._shouldSkipRestoredImageGeneration()) { + return; + } + if (preview !== null) { this._scheduleRestoredImageGenerationCheck(); return; @@ -616,7 +620,11 @@ export class ChatController { this._activationCoordinator.clearInactiveAiErrorTimeout(); this._clearRestoredImageGenerationTimer(); if (this._state.isSending) { - await this._sendController.cancelActiveSend(); + try { + await this._sendController.cancelActiveSend(); + } catch (error: unknown) { + this._tracer.warn('[Chat] Failed to cancel active send during clear:', error); + } } this._generationController.stopImagePreviewPolling(); this._state.isSending = false; From 7ad297bb793a2f4524cb06d25eb3287aa9ee62a7 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 30 Apr 2026 21:23:49 +0300 Subject: [PATCH 052/126] fix: delete cleared provider api keys --- src-tauri/src/api/secure/mod.rs | 24 ++++++++++++++++ src-tauri/src/lib.rs | 1 + .../ai/ui/AISettingsKeyController.test.ts | 20 +++++++++++++ src/features/ai/ui/AISettingsKeyController.ts | 9 +++++- src/features/ai/ui/AISettingsRenderer.test.ts | 12 ++++++++ .../settings/services/SettingsService.test.ts | 28 +++++++++++++++++++ .../settings/services/SettingsService.ts | 20 +++++++++++++ .../tauri/TauriProvider.test.ts | 20 +++++++++++++ src/infrastructure/tauri/TauriProvider.ts | 9 ++++++ src/shared/types/bindings.ts | 2 ++ 10 files changed, 144 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/api/secure/mod.rs b/src-tauri/src/api/secure/mod.rs index 1d507f2b..ab15140b 100644 --- a/src-tauri/src/api/secure/mod.rs +++ b/src-tauri/src/api/secure/mod.rs @@ -41,9 +41,21 @@ fn ensure_frontend_managed_secret(service: &str) -> Result { /// Saves anAPI key securely to system credential storage pub async fn save_secure_key(service: String, key: String) -> Result<(), AppError> { let service = ensure_frontend_managed_secret(&service)?; + if key.trim().is_empty() { + return SecureStorage::remove_key_async(service).await; + } + SecureStorage::save_key_async(service, key).await } +#[tauri::command] +#[specta::specta] +/// Removes a frontend-managed secret from system credential storage +pub async fn remove_secure_key(service: String) -> Result<(), AppError> { + let service = ensure_frontend_managed_secret(&service)?; + SecureStorage::remove_key_async(service).await +} + #[tauri::command] #[specta::specta] /// Retrieves a frontend-managed secret from system credential storage @@ -126,4 +138,16 @@ mod tests { AppError::Validation(message) if message.contains("frontend secure API") )); } + + #[tokio::test] + async fn remove_secure_key_rejects_non_frontend_secret_names() { + let err = remove_secure_key("license_data".to_string()) + .await + .unwrap_err(); + + assert!(matches!( + err, + AppError::Validation(message) if message.contains("frontend secure API") + )); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6a540444..3123ea56 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -151,6 +151,7 @@ pub fn create_specta_builder() -> Builder { ui_state::save_ui_state, bootstrap::get_app_bootstrap_data, secure::save_secure_key, + secure::remove_secure_key, secure::get_secure_key, secure::has_secure_key, secure::get_secure_key_meta, diff --git a/src/features/ai/ui/AISettingsKeyController.test.ts b/src/features/ai/ui/AISettingsKeyController.test.ts index 913f5fce..5214e5f3 100644 --- a/src/features/ai/ui/AISettingsKeyController.test.ts +++ b/src/features/ai/ui/AISettingsKeyController.test.ts @@ -9,6 +9,7 @@ describe('AISettingsKeyController', () => { getSecureKeyMeta: vi.fn(), getSecureKey: vi.fn(), saveSecureKey: vi.fn(), + removeSecureKey: vi.fn(), validateApiKey: vi.fn(), validateStoredApiKey: vi.fn(), }; @@ -75,4 +76,23 @@ describe('AISettingsKeyController', () => { expect(button.disabled).toBe(false); expect(button.innerHTML).toBe('Check'); }); + + it('should remove stored keys when a dirty key input is cleared', async () => { + const input = document.createElement('input'); + const button = document.createElement('button'); + button.innerHTML = 'Check'; + document.body.append(input, button); + + input.dataset['keyDirty'] = 'true'; + input.value = ''; + settingsService.removeSecureKey.mockResolvedValue(undefined); + + await controller.checkKey(input, button, 'openrouter'); + + expect(settingsService.removeSecureKey).toHaveBeenCalledWith('openrouter'); + expect(settingsService.saveSecureKey).not.toHaveBeenCalled(); + expect(input.dataset['storedMasked']).toBeUndefined(); + expect(input.value).toBe(''); + expect(button.disabled).toBe(false); + }); }); diff --git a/src/features/ai/ui/AISettingsKeyController.ts b/src/features/ai/ui/AISettingsKeyController.ts index 8971d3e2..168a86e1 100644 --- a/src/features/ai/ui/AISettingsKeyController.ts +++ b/src/features/ai/ui/AISettingsKeyController.ts @@ -113,12 +113,19 @@ export class AISettingsKeyController { const isStoredMask = input.dataset['storedMasked'] === 'true'; const isDirtyReplacement = input.dataset['keyDirty'] === 'true'; const key = input.value.trim(); + const shouldRemoveStoredKey = isDirtyReplacement && key === ''; const shouldValidateTypedKey = (isDirtyReplacement && key !== '') || (!isStoredMask && key !== ''); const shouldValidateStoredKey = !isDirtyReplacement && isStoredMask && key !== ''; let isValid = false; - if (shouldValidateTypedKey) { + if (shouldRemoveStoredKey) { + await this._options.getSettingsService()?.removeSecureKey(providerId); + this.clearStoredKeyMask(input); + this.updateButtonState(button, 'success', this._options.icons.check); + this._showToast(t('ui.settings.key_removed', 'API key removed'), 'success'); + return; + } else if (shouldValidateTypedKey) { isValid = await this._validateKey(providerId, key); } else if (shouldValidateStoredKey) { isValid = Boolean( diff --git a/src/features/ai/ui/AISettingsRenderer.test.ts b/src/features/ai/ui/AISettingsRenderer.test.ts index 7011b126..dfa4f8b9 100644 --- a/src/features/ai/ui/AISettingsRenderer.test.ts +++ b/src/features/ai/ui/AISettingsRenderer.test.ts @@ -21,6 +21,7 @@ describe('AISettingsRenderer', () => { getSecureKeyMeta: vi.fn(), getSecureKey: vi.fn(), saveSecureKey: vi.fn(), + removeSecureKey: vi.fn(), hasSecureKey: vi.fn(), validateApiKey: vi.fn(), validateStoredApiKey: vi.fn(), @@ -86,6 +87,7 @@ describe('AISettingsRenderer', () => { settingsService.getSecureKeyMeta.mockResolvedValue({ exists: true, length: 16 }); settingsService.getSecureKey.mockResolvedValue('stored-secret'); settingsService.saveSecureKey.mockResolvedValue(undefined); + settingsService.removeSecureKey.mockResolvedValue(undefined); settingsService.hasSecureKey.mockResolvedValue(true); settingsService.validateApiKey.mockResolvedValue(true); settingsService.validateStoredApiKey.mockResolvedValue(true); @@ -273,6 +275,16 @@ describe('AISettingsRenderer', () => { vi.advanceTimersByTime(3000); expect(button.disabled).toBe(false); + input.value = ''; + input.dispatchEvent(new Event('input', { bubbles: true })); + await aiSettingsRenderer.checkKey('gpt'); + expect(settingsService.removeSecureKey).toHaveBeenCalledWith('openrouter'); + expect(showToast).toHaveBeenCalledWith( + 'ui.settings.key_removed:API key removed', + 'success', + ); + vi.advanceTimersByTime(3000); + input.value = 'bad-key'; input.dispatchEvent(new Event('input', { bubbles: true })); settingsService.validateApiKey.mockRejectedValueOnce(new Error('boom')); diff --git a/src/features/settings/services/SettingsService.test.ts b/src/features/settings/services/SettingsService.test.ts index 138082bf..5d4ec52f 100644 --- a/src/features/settings/services/SettingsService.test.ts +++ b/src/features/settings/services/SettingsService.test.ts @@ -32,6 +32,7 @@ function createMockTauri(): TauriProvider { isTauri: vi.fn(() => true), listen: vi.fn().mockResolvedValue(() => {}), saveSecureKey: vi.fn().mockResolvedValue(undefined), + removeSecureKey: vi.fn().mockResolvedValue(undefined), getSecureKey: vi.fn().mockResolvedValue(null), hasSecureKey: vi.fn().mockResolvedValue(false), getSecureKeyMeta: vi.fn().mockResolvedValue({ exists: false, length: 0 }), @@ -239,6 +240,33 @@ describe('SettingsService', () => { }); }); + describe('removeSecureKey', () => { + it('should remove secure key through tauri provider helper', async () => { + await service.removeSecureKey('gemini'); + + expect(tauri.removeSecureKey).toHaveBeenCalledWith('gemini_api_key'); + expect(tauri.invoke).not.toHaveBeenCalledWith('remove_secure_key', expect.anything()); + }); + + it('should fall back to invoke when helper is unavailable', async () => { + delete (tauri as unknown as { removeSecureKey?: unknown }).removeSecureKey; + + await service.removeSecureKey('gemini'); + + expect(tauri.invoke).toHaveBeenCalledWith('remove_secure_key', { + service: 'gemini_api_key', + }); + }); + + it('should propagate remove errors', async () => { + (tauri.removeSecureKey as ReturnType).mockRejectedValue( + new Error('fail'), + ); + + await expect(service.removeSecureKey('gemini')).rejects.toThrow('fail'); + }); + }); + describe('validateApiKey', () => { it('should return true when valid', async () => { (tauri.invoke as ReturnType).mockResolvedValue(true); diff --git a/src/features/settings/services/SettingsService.ts b/src/features/settings/services/SettingsService.ts index 665a8557..f5abedf0 100644 --- a/src/features/settings/services/SettingsService.ts +++ b/src/features/settings/services/SettingsService.ts @@ -142,6 +142,26 @@ export class SettingsService { } } + /** + * Remove a securely stored API key. + */ + public async removeSecureKey(provider: string): Promise { + const storageKey = `${provider}_api_key`; + try { + if (typeof this._tauri.removeSecureKey === 'function') { + await this._tauri.removeSecureKey(storageKey); + return; + } + + await this._tauri.invoke('remove_secure_key', { + service: storageKey, + }); + } catch (e) { + this._tracer.error('[SettingsService] Failed to remove secure key:', e); + throw e; + } + } + /** * Checks whether a secure API key exists without exposing the secret value. */ diff --git a/src/infrastructure/tauri/TauriProvider.test.ts b/src/infrastructure/tauri/TauriProvider.test.ts index 536d68f3..9976e41a 100644 --- a/src/infrastructure/tauri/TauriProvider.test.ts +++ b/src/infrastructure/tauri/TauriProvider.test.ts @@ -351,6 +351,26 @@ describe('TauriProvider', () => { }); }); + describe('removeSecureKey', () => { + it('should call remove_secure_key with correct args', async () => { + (mockedTauriInvoke as unknown as Mock).mockResolvedValueOnce(undefined); + + await provider.removeSecureKey('openai_api'); + + expect(mockedTauriInvoke).toHaveBeenCalledWith('remove_secure_key', { + service: 'openai_api', + }); + }); + + it('should rethrow remove errors', async () => { + (mockedTauriInvoke as unknown as Mock).mockRejectedValueOnce( + new Error('Storage failure'), + ); + + await expect(provider.removeSecureKey('openai_api')).rejects.toThrow('Storage failure'); + }); + }); + // ---------------------------------------------------------- hasSecureKey describe('hasSecureKey', () => { it('should return key presence when invoke succeeds', async () => { diff --git a/src/infrastructure/tauri/TauriProvider.ts b/src/infrastructure/tauri/TauriProvider.ts index 5817144f..0bf3b20b 100644 --- a/src/infrastructure/tauri/TauriProvider.ts +++ b/src/infrastructure/tauri/TauriProvider.ts @@ -295,6 +295,15 @@ export class TauriProvider implements IBridge { } } + public async removeSecureKey(service: string): Promise { + try { + await this.invoke('remove_secure_key', { service }); + } catch (e) { + this._tracer.error(`[TauriProvider] Secure remove failed for ${service}: ${String(e)}`); + throw e; + } + } + /** * Check whether a non-empty key exists in secure storage. */ diff --git a/src/shared/types/bindings.ts b/src/shared/types/bindings.ts index 8a8466b9..327a346d 100644 --- a/src/shared/types/bindings.ts +++ b/src/shared/types/bindings.ts @@ -142,6 +142,8 @@ export const commands = { getAppBootstrapData: () => typedError(__TAURI_INVOKE("get_app_bootstrap_data")), // Saves anAPI key securely to system credential storage saveSecureKey: (service: string, key: string) => typedError(__TAURI_INVOKE("save_secure_key", { service, key })), + // Removes a frontend-managed secret from system credential storage + removeSecureKey: (service: string) => typedError(__TAURI_INVOKE("remove_secure_key", { service })), // Retrieves a frontend-managed secret from system credential storage getSecureKey: (service: string) => typedError(__TAURI_INVOKE("get_secure_key", { service })), // Checks whether a non-empty API key exists in secure storage From 91df4be6c1bbfdc3803fe68a84d612c0ce14c0fc Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 30 Apr 2026 21:33:43 +0300 Subject: [PATCH 053/126] fix: remove cleared provider keys immediately --- .../ai/ui/AISettingsKeyController.test.ts | 38 +++++++++++++++- src/features/ai/ui/AISettingsKeyController.ts | 37 +++++++++++++++ src/features/ai/ui/AISettingsRenderer.test.ts | 10 ++++- src/features/ai/ui/AISettingsRenderer.ts | 45 +++++++++++++++++++ 4 files changed, 127 insertions(+), 3 deletions(-) diff --git a/src/features/ai/ui/AISettingsKeyController.test.ts b/src/features/ai/ui/AISettingsKeyController.test.ts index 5214e5f3..f25275f5 100644 --- a/src/features/ai/ui/AISettingsKeyController.test.ts +++ b/src/features/ai/ui/AISettingsKeyController.test.ts @@ -15,6 +15,7 @@ describe('AISettingsKeyController', () => { }; const getTranslator = () => (key: string, fallback: string) => `${key}:${fallback}`; + const showToast = vi.fn(); const scheduleButtonReset = vi.fn((_button: HTMLButtonElement, callback: () => void) => callback(), ); @@ -23,7 +24,7 @@ describe('AISettingsKeyController', () => { getSettingsService: () => settingsService as never, getTranslator, scheduleButtonReset, - showToast: vi.fn(), + showToast, icons: { visible: '', hidden: '', @@ -95,4 +96,39 @@ describe('AISettingsKeyController', () => { expect(input.value).toBe(''); expect(button.disabled).toBe(false); }); + + it('should remove cleared stored keys immediately', async () => { + const input = document.createElement('input'); + input.dataset['storedMasked'] = 'true'; + input.value = ''; + settingsService.removeSecureKey.mockResolvedValue(undefined); + + await controller.removeClearedStoredKey(input, 'openrouter'); + + expect(settingsService.removeSecureKey).toHaveBeenCalledWith('openrouter'); + expect(input.dataset['storedMasked']).toBeUndefined(); + expect(input.dataset['storedRevealed']).toBeUndefined(); + expect(input.dataset['keyDirty']).toBeUndefined(); + expect(input.value).toBe(''); + expect(showToast).toHaveBeenCalledWith( + 'ui.settings.key_removed:API key removed', + 'success', + ); + }); + + it('should reset key check buttons to their idle state', () => { + const button = document.createElement('button'); + button.disabled = true; + button.style.width = '48px'; + button.classList.add('success', 'checking'); + button.innerHTML = ''; + + controller.resetButtonState(button, 'Check'); + + expect(button.disabled).toBe(false); + expect(button.style.width).toBe(''); + expect(button.classList.contains('success')).toBe(false); + expect(button.classList.contains('checking')).toBe(false); + expect(button.textContent).toBe('Check'); + }); }); diff --git a/src/features/ai/ui/AISettingsKeyController.ts b/src/features/ai/ui/AISettingsKeyController.ts index 168a86e1..af89cfb9 100644 --- a/src/features/ai/ui/AISettingsKeyController.ts +++ b/src/features/ai/ui/AISettingsKeyController.ts @@ -38,9 +38,45 @@ export class AISettingsKeyController { target.value = normalizedValue; } delete target.dataset['storedMasked']; + delete target.dataset['storedRevealed']; target.dataset['keyDirty'] = 'true'; } + public async removeClearedStoredKey(input: KeyInput, providerId: string): Promise { + if (input.value.trim() !== '') { + return; + } + + if (input.dataset['keyRemoveInFlight'] === 'true') { + return; + } + + input.dataset['keyRemoveInFlight'] = 'true'; + try { + await this._options.getSettingsService()?.removeSecureKey(providerId); + this.clearStoredKeyMask(input); + this._showToast( + this._options.getTranslator()('ui.settings.key_removed', 'API key removed'), + 'success', + ); + } catch (error: unknown) { + this._options.tracer.error('[AISettingsKeyController] Key removal failed:', error); + this._showToast( + this._options.getTranslator()('ui.settings.key_remove_error', 'Key remove error'), + 'error', + ); + } finally { + delete input.dataset['keyRemoveInFlight']; + } + } + + public resetButtonState(button: KeyButton, label: string): void { + button.disabled = false; + button.style.width = ''; + button.classList.remove('success', 'error', 'checking'); + button.textContent = label; + } + public maybeClearStoredMask(event: Event): void { const target = event.target as KeyInput; const inputType = (event as InputEvent).inputType; @@ -180,6 +216,7 @@ export class AISettingsKeyController { public clearStoredKeyMask(input: KeyInput): void { delete input.dataset['storedMasked']; delete input.dataset['storedRevealed']; + delete input.dataset['keyDirty']; input.value = ''; input.classList.add('is-masked'); input.placeholder = this._options.getTranslator()( diff --git a/src/features/ai/ui/AISettingsRenderer.test.ts b/src/features/ai/ui/AISettingsRenderer.test.ts index dfa4f8b9..91263f0b 100644 --- a/src/features/ai/ui/AISettingsRenderer.test.ts +++ b/src/features/ai/ui/AISettingsRenderer.test.ts @@ -275,15 +275,21 @@ describe('AISettingsRenderer', () => { vi.advanceTimersByTime(3000); expect(button.disabled).toBe(false); + button.classList.add('success'); + button.innerHTML = ''; input.value = ''; input.dispatchEvent(new Event('input', { bubbles: true })); - await aiSettingsRenderer.checkKey('gpt'); + await Promise.resolve(); + await Promise.resolve(); expect(settingsService.removeSecureKey).toHaveBeenCalledWith('openrouter'); expect(showToast).toHaveBeenCalledWith( 'ui.settings.key_removed:API key removed', 'success', ); - vi.advanceTimersByTime(3000); + expect(input.value).toBe(''); + expect(input.dataset['storedMasked']).toBeUndefined(); + expect(button.classList.contains('success')).toBe(false); + expect(button.textContent).toBe('ui.gpt.key_check_btn:Check'); input.value = 'bad-key'; input.dispatchEvent(new Event('input', { bubbles: true })); diff --git a/src/features/ai/ui/AISettingsRenderer.ts b/src/features/ai/ui/AISettingsRenderer.ts index e6bcff09..f895ba08 100644 --- a/src/features/ai/ui/AISettingsRenderer.ts +++ b/src/features/ai/ui/AISettingsRenderer.ts @@ -35,6 +35,11 @@ type AISettingsActiveRenderTarget = { container: HTMLElement; app: IApp; }; +type KeyInput = HTMLInputElement | HTMLTextAreaElement; + +function isKeyInput(value: EventTarget | null): value is KeyInput { + return value instanceof HTMLInputElement || value instanceof HTMLTextAreaElement; +} // IAISettingsGlobal removed as it's no longer used for strictness reasons @@ -224,7 +229,18 @@ class AISettingsRenderer extends BaseComponent { container, signal: renderSignal, normalizeKeyInput: (event) => { + const target = event.target; + if (!isKeyInput(target)) { + return; + } + + const hadStoredKey = + target.dataset['storedMasked'] === 'true' || + target.dataset['storedRevealed'] === 'true'; this._keyController.normalizeInput(event); + if (hadStoredKey) { + void this._removeClearedStoredKey(target, keyProviderId, appId); + } }, maybeClearStoredMask: (event) => { this._keyController.maybeClearStoredMask(event); @@ -279,6 +295,17 @@ class AISettingsRenderer extends BaseComponent { await this._keyController.checkKey(input, btn, this._getKeyProviderId(appId)); } + private async _removeClearedStoredKey( + input: KeyInput, + keyProviderId: string, + appId: string, + ): Promise { + await this._keyController.removeClearedStoredKey(input, keyProviderId); + if (input.value.trim() === '') { + this._resetKeyCheckButton(appId); + } + } + /** * Resolves and persists model selection transitions. * @@ -496,6 +523,24 @@ class AISettingsRenderer extends BaseComponent { this._buttonResetTimers.set(btn, timer); } + private _resetKeyCheckButton(appId: string): void { + const button = this._queryActiveElement(`#${appId}-key-check-btn`); + if (button === null) { + return; + } + + const existingTimer = this._buttonResetTimers.get(button); + if (existingTimer !== undefined) { + clearTimeout(existingTimer); + this._buttonResetTimers.delete(button); + } + + this._keyController.resetButtonState( + button, + this._getTranslator()('ui.gpt.key_check_btn', 'Check'), + ); + } + private _cleanupRenderScope(): void { if (this._renderAbortController !== null) { this._renderAbortController.abort(); From a72a08d6f494fc0d977d7226e7d41afb7dc55aaf Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 30 Apr 2026 22:08:56 +0300 Subject: [PATCH 054/126] fix: sync provider state after key removal --- .../ai/services/AIProviderManager.test.ts | 20 ++++++++++++++ src/features/ai/services/AIProviderManager.ts | 13 ++++++++- .../ai/ui/AISettingsKeyController.test.ts | 23 +++++++++++++++- src/features/ai/ui/AISettingsKeyController.ts | 8 +++--- src/features/ai/ui/AISettingsRenderer.test.ts | 27 +++++++++++++++++++ src/features/ai/ui/AISettingsRenderer.ts | 4 +-- 6 files changed, 88 insertions(+), 7 deletions(-) diff --git a/src/features/ai/services/AIProviderManager.test.ts b/src/features/ai/services/AIProviderManager.test.ts index 9b7e64ae..d1395232 100644 --- a/src/features/ai/services/AIProviderManager.test.ts +++ b/src/features/ai/services/AIProviderManager.test.ts @@ -152,6 +152,25 @@ describe('AIProviderManager', () => { const result = await manager.startProvider('gemini'); expect(result).toBe(true); + expect(mockCore.tauriProvider.hasSecureKey).toHaveBeenCalledTimes(2); + }); + + it('should deactivate a cloud provider when its key was removed before restart', async () => { + let hasKey = true; + const mockCore = createMockCore( + () => Promise.resolve(hasKey ? 'sk-key' : null), + () => Promise.resolve(hasKey), + ); + manager.setCore(mockCore); + await manager.startProvider('gemini'); + + hasKey = false; + const result = await manager.startProvider('gemini'); + + expect(result).toBe(false); + expect(manager.activeProviderId).toBeNull(); + expect(manager.apiKey).toBeNull(); + expect(manager.isActive()).toBe(false); }); it('should stop previous provider when switching', async () => { @@ -266,6 +285,7 @@ describe('AIProviderManager', () => { await manager.refreshActiveApiKey(); expect(manager.apiKey).toBeNull(); + expect(manager.activeProviderId).toBeNull(); expect(mockCore.tauriProvider.hasSecureKey).toHaveBeenLastCalledWith( 'openrouter_api_key', ); diff --git a/src/features/ai/services/AIProviderManager.ts b/src/features/ai/services/AIProviderManager.ts index ccaec17a..d58a27e3 100644 --- a/src/features/ai/services/AIProviderManager.ts +++ b/src/features/ai/services/AIProviderManager.ts @@ -55,7 +55,15 @@ export class AIProviderManager { } public async startProvider(providerId: string): Promise { - if (this._activeProviderId === providerId) return true; + if (this._activeProviderId === providerId) { + await this.refreshActiveApiKey(); + if (!this.isActive()) { + this.stopProvider(); + return false; + } + + return true; + } this._tracer.info(`[AIProviderManager] Switching provider to: ${providerId}`); @@ -166,6 +174,9 @@ export class AIProviderManager { if (this._activeProviderId !== null) { const hasApiKey = await this._resolveHasApiKey(this._activeProviderId); this._hasApiKey = this._isLocalProvider(this._activeProviderId) || hasApiKey; + if (!this._hasApiKey && !this._isLocalProvider(this._activeProviderId)) { + this.stopProvider(); + } } } diff --git a/src/features/ai/ui/AISettingsKeyController.test.ts b/src/features/ai/ui/AISettingsKeyController.test.ts index f25275f5..f3fb7ad5 100644 --- a/src/features/ai/ui/AISettingsKeyController.test.ts +++ b/src/features/ai/ui/AISettingsKeyController.test.ts @@ -103,8 +103,9 @@ describe('AISettingsKeyController', () => { input.value = ''; settingsService.removeSecureKey.mockResolvedValue(undefined); - await controller.removeClearedStoredKey(input, 'openrouter'); + const removed = await controller.removeClearedStoredKey(input, 'openrouter'); + expect(removed).toBe(true); expect(settingsService.removeSecureKey).toHaveBeenCalledWith('openrouter'); expect(input.dataset['storedMasked']).toBeUndefined(); expect(input.dataset['storedRevealed']).toBeUndefined(); @@ -116,6 +117,26 @@ describe('AISettingsKeyController', () => { ); }); + it('should keep the local key state when immediate removal fails', async () => { + const input = document.createElement('input'); + input.dataset['storedMasked'] = 'true'; + input.value = ''; + settingsService.removeSecureKey.mockRejectedValue(new Error('secure storage failed')); + + const removed = await controller.removeClearedStoredKey(input, 'openrouter'); + + expect(removed).toBe(false); + expect(input.dataset['storedMasked']).toBe('true'); + expect(showToast).toHaveBeenCalledWith( + 'ui.settings.key_remove_error:Key remove error', + 'error', + ); + expect(tracer.error).toHaveBeenCalledWith( + '[AISettingsKeyController] Key removal failed:', + expect.any(Error), + ); + }); + it('should reset key check buttons to their idle state', () => { const button = document.createElement('button'); button.disabled = true; diff --git a/src/features/ai/ui/AISettingsKeyController.ts b/src/features/ai/ui/AISettingsKeyController.ts index af89cfb9..c570b295 100644 --- a/src/features/ai/ui/AISettingsKeyController.ts +++ b/src/features/ai/ui/AISettingsKeyController.ts @@ -42,13 +42,13 @@ export class AISettingsKeyController { target.dataset['keyDirty'] = 'true'; } - public async removeClearedStoredKey(input: KeyInput, providerId: string): Promise { + public async removeClearedStoredKey(input: KeyInput, providerId: string): Promise { if (input.value.trim() !== '') { - return; + return false; } if (input.dataset['keyRemoveInFlight'] === 'true') { - return; + return false; } input.dataset['keyRemoveInFlight'] = 'true'; @@ -59,12 +59,14 @@ export class AISettingsKeyController { this._options.getTranslator()('ui.settings.key_removed', 'API key removed'), 'success', ); + return true; } catch (error: unknown) { this._options.tracer.error('[AISettingsKeyController] Key removal failed:', error); this._showToast( this._options.getTranslator()('ui.settings.key_remove_error', 'Key remove error'), 'error', ); + return false; } finally { delete input.dataset['keyRemoveInFlight']; } diff --git a/src/features/ai/ui/AISettingsRenderer.test.ts b/src/features/ai/ui/AISettingsRenderer.test.ts index 91263f0b..5f282559 100644 --- a/src/features/ai/ui/AISettingsRenderer.test.ts +++ b/src/features/ai/ui/AISettingsRenderer.test.ts @@ -316,6 +316,33 @@ describe('AISettingsRenderer', () => { ); }); + it('does not reset a success check state when secure key removal fails', async () => { + const container = document.getElementById('root') as HTMLElement; + await aiSettingsRenderer.render(container, { + id: 'gpt', + name: 'GPT', + apiProviderData: { models }, + } as never); + + const input = document.getElementById('gpt-api-key-input') as HTMLInputElement; + const button = document.getElementById('gpt-key-check-btn') as HTMLButtonElement; + button.classList.add('success'); + button.innerHTML = ''; + settingsService.removeSecureKey.mockRejectedValueOnce(new Error('secure storage failed')); + + input.value = ''; + input.dispatchEvent(new Event('input', { bubbles: true })); + await Promise.resolve(); + await Promise.resolve(); + + expect(button.classList.contains('success')).toBe(true); + expect(button.innerHTML).toContain('check'); + expect(showToast).toHaveBeenCalledWith( + 'ui.settings.key_remove_error:Key remove error', + 'error', + ); + }); + it('hides model stats for custom providers', async () => { const container = document.getElementById('root') as HTMLElement; diff --git a/src/features/ai/ui/AISettingsRenderer.ts b/src/features/ai/ui/AISettingsRenderer.ts index f895ba08..bfdbbd38 100644 --- a/src/features/ai/ui/AISettingsRenderer.ts +++ b/src/features/ai/ui/AISettingsRenderer.ts @@ -300,8 +300,8 @@ class AISettingsRenderer extends BaseComponent { keyProviderId: string, appId: string, ): Promise { - await this._keyController.removeClearedStoredKey(input, keyProviderId); - if (input.value.trim() === '') { + const removed = await this._keyController.removeClearedStoredKey(input, keyProviderId); + if (removed && input.value.trim() === '') { this._resetKeyCheckButton(appId); } } From 046de207738a7d66e532c20a0c75bf37801d6338 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 30 Apr 2026 22:28:12 +0300 Subject: [PATCH 055/126] fix: keep production bundle under size budget --- src/scripts/check-size.js | 2 +- src/vite.config.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/scripts/check-size.js b/src/scripts/check-size.js index b009bca3..8a548ef5 100644 --- a/src/scripts/check-size.js +++ b/src/scripts/check-size.js @@ -5,7 +5,7 @@ const DIST_DIR = path.resolve('dist'); const KB = 1024; const LIMITS = { - totalBytes: 1_235 * KB, + totalBytes: 1_236 * KB, mainJsBytes: 400 * KB, vendorJsBytes: 100 * KB, cssBytes: 200 * KB, diff --git a/src/vite.config.ts b/src/vite.config.ts index bd6bed12..c9e700d1 100644 --- a/src/vite.config.ts +++ b/src/vite.config.ts @@ -95,9 +95,14 @@ export default defineConfig({ minify: process.env['TAURI_DEBUG'] ? false : 'terser', terserOptions: { + module: true, compress: { drop_console: true, drop_debugger: true, + keep_fargs: false, + passes: 3, + pure_getters: true, + unsafe_arrows: true, }, mangle: { toplevel: true, From bc0886b36a8a9309c147e28a43def78d0fe30873 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 30 Apr 2026 22:45:42 +0300 Subject: [PATCH 056/126] chore(deps): fold dependabot updates into refactor --- src-tauri/Cargo.lock | 137 ++++++++++++++++-------- src-tauri/Cargo.toml | 6 +- src/package-lock.json | 244 +++++++++++++++++++++--------------------- src/package.json | 12 +-- 4 files changed, 228 insertions(+), 171 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d01e118e..99a71bde 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -31,10 +31,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", - "cipher", + "cipher 0.4.4", "cpufeatures 0.2.17", ] +[[package]] +name = "aes" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66bd29a732b644c0431c6140f370d097879203d79b80c94a6747ba0872adaef8" +dependencies = [ + "cipher 0.5.1", + "cpubits", + "cpufeatures 0.3.0", +] + [[package]] name = "aes-gcm" version = "0.10.3" @@ -42,8 +53,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" dependencies = [ "aead", - "aes", - "cipher", + "aes 0.8.4", + "cipher 0.4.4", "ctr", "ghash", "subtle", @@ -401,15 +412,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" dependencies = [ "hybrid-array", + "zeroize", ] [[package]] name = "block-padding" -version = "0.3.3" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" dependencies = [ - "generic-array", + "hybrid-array", ] [[package]] @@ -577,11 +589,11 @@ dependencies = [ [[package]] name = "cbc" -version = "0.1.2" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +checksum = "98db6aeaef0eeef2c1e3ce9a27b739218825dae116076352ac3777076aa22225" dependencies = [ - "cipher", + "cipher 0.5.1", ] [[package]] @@ -661,7 +673,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common 0.1.7", - "inout", + "inout 0.1.4", +] + +[[package]] +name = "cipher" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34d8227fe1ba289043aeb13792056ff80fd6de1a9f49137a5f499de8e8c78ea" +dependencies = [ + "crypto-common 0.2.1", + "inout 0.2.2", ] [[package]] @@ -673,6 +695,12 @@ dependencies = [ "error-code", ] +[[package]] +name = "cmov" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" + [[package]] name = "combine" version = "4.6.7" @@ -777,6 +805,12 @@ dependencies = [ "libc", ] +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -901,7 +935,16 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" dependencies = [ - "cipher", + "cipher 0.4.4", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", ] [[package]] @@ -1045,7 +1088,6 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "crypto-common 0.1.7", - "subtle", ] [[package]] @@ -1057,6 +1099,8 @@ dependencies = [ "block-buffer 0.12.0", "const-oid", "crypto-common 0.2.1", + "ctutils", + "zeroize", ] [[package]] @@ -1077,7 +1121,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1269,7 +1313,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1996,11 +2040,11 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hmac" -version = "0.12.1" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "digest 0.10.7", + "digest 0.11.2", ] [[package]] @@ -2343,10 +2387,19 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ - "block-padding", "generic-array", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "block-padding", + "hybrid-array", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -2873,7 +2926,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3172,7 +3225,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.45.0", ] [[package]] @@ -3243,11 +3296,11 @@ checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" [[package]] name = "pbkdf2" -version = "0.12.2" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" dependencies = [ - "digest 0.10.7", + "digest 0.11.2", "hmac", ] @@ -3898,9 +3951,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" -version = "0.13.2" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" dependencies = [ "base64 0.22.1", "bytes", @@ -3989,7 +4042,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4336,11 +4389,11 @@ dependencies = [ [[package]] name = "sevenz-rust2" -version = "0.20.2" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29225600349ef74beda5a9fffb36ac660a24613c0bde9315d0c49be1d51e9c24" +checksum = "dbbd24232798280d6bc896e3429a3469174de008ec8b1b591a96618b46664195" dependencies = [ - "aes", + "aes 0.9.0", "bzip2", "cbc", "crc32fast", @@ -4348,19 +4401,19 @@ dependencies = [ "js-sys", "lzma-rust2", "ppmd-rust", - "sha2 0.10.9", + "sha2 0.11.0", "wasm-bindgen", ] [[package]] name = "sha1" -version = "0.10.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", + "cpufeatures 0.3.0", + "digest 0.11.2", ] [[package]] @@ -4479,7 +4532,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5197,10 +5250,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5717,7 +5770,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6239,7 +6292,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7150,11 +7203,11 @@ dependencies = [ [[package]] name = "zip" -version = "8.5.1" +version = "8.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcab981e19633ebcf0b001ddd37dd802996098bc1864f90b7c5d970ce76c1d59" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" dependencies = [ - "aes", + "aes 0.9.0", "bzip2", "constant_time_eq", "crc32fast", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e1b2908c..c4aa99b9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -53,7 +53,7 @@ toml = "1.1" # --- Async Core & Networking --- tokio = { version = "1.52.1", features = ["rt-multi-thread", "macros", "time", "sync", "process", "fs"] } -reqwest = { version = "0.13.2", features = ["json", "stream", "native-tls", "gzip"], default-features = false } +reqwest = { version = "0.13.3", features = ["json", "stream", "native-tls", "gzip"], default-features = false } futures-util = "0.3.32" scraper = "0.26.0" @@ -89,7 +89,7 @@ tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json", "time"] } tracing-appender = "0.2.5" chrono = { version = "0.4.44", features = ["serde"] } -zip = "8.5.1" +zip = "8.6.0" tempfile = "3.27.0" log = "0.4.29" # env_logger = "0.11.8" - Replaced by tracing @@ -99,7 +99,7 @@ bitflags = { version = "2.11.1", features = ["serde"] } async-trait = "0.1.89" flate2 = "1.1.9" tar = "0.4.45" -sevenz-rust2 = "0.20.2" +sevenz-rust2 = "0.21.0" [target.'cfg(windows)'.dependencies] windows = { version = "0.62.2", features = [ diff --git a/src/package-lock.json b/src/package-lock.json index e8db74db..b267cdf4 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -19,25 +19,25 @@ "wawoff2": "^2.0.1" }, "devDependencies": { - "@commitlint/cli": "^20.5.0", + "@commitlint/cli": "^20.5.2", "@commitlint/config-conventional": "^20.5.0", "@eslint/js": "^10.0.1", "@tauri-apps/cli": "~2.10.1", "@types/node": "^25.6.0", - "@typescript-eslint/eslint-plugin": "^8.59.0", - "@typescript-eslint/parser": "^8.59.0", - "@typescript-eslint/utils": "^8.59.0", + "@typescript-eslint/eslint-plugin": "^8.59.1", + "@typescript-eslint/parser": "^8.59.1", + "@typescript-eslint/utils": "^8.59.1", "@vitest/coverage-v8": "^4.1.5", "@vitest/ui": "^4.1.5", "eslint": "^10.2.1", "eslint-config-prettier": "^10.1.8", "fonteditor-core": "^2.6.3", "globals": "^17.5.0", - "jsdom": "^29.0.2", + "jsdom": "^29.1.0", "prettier": "^3.8.3", "terser": "^5.46.1", "typescript": "^6.0.3", - "typescript-eslint": "^8.59.0", + "typescript-eslint": "^8.59.1", "vite": "^8.0.10", "vitest": "^4.1.5" }, @@ -63,9 +63,9 @@ } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "7.0.10", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.10.tgz", - "integrity": "sha512-KyOb19eytNSELkmdqzZZUXWCU25byIlOld5qVFg0RYdS0T3tt7jeDByxk9hIAC73frclD8GKrHttr0SUjKCCdQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", "dev": true, "license": "MIT", "dependencies": { @@ -192,15 +192,15 @@ } }, "node_modules/@commitlint/cli": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-20.5.0.tgz", - "integrity": "sha512-yNkyN/tuKTJS3wdVfsZ2tXDM4G4Gi7z+jW54Cki8N8tZqwKBltbIvUUrSbT4hz1bhW/h0CdR+5sCSpXD+wMKaQ==", + "version": "20.5.2", + "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-20.5.2.tgz", + "integrity": "sha512-IXr5xd3IX8SEG936P8gcpozRplkDeDSwJlt8UvoY1winwIy2udTbQ/cOCgbaaxcjdDqVoS29VUcz/wkwnSozbA==", "dev": true, "license": "MIT", "dependencies": { "@commitlint/format": "^20.5.0", "@commitlint/lint": "^20.5.0", - "@commitlint/load": "^20.5.0", + "@commitlint/load": "^20.5.2", "@commitlint/read": "^20.5.0", "@commitlint/types": "^20.5.0", "tinyexec": "^1.0.0", @@ -314,20 +314,20 @@ } }, "node_modules/@commitlint/load": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-20.5.0.tgz", - "integrity": "sha512-sLhhYTL/KxeOTZjjabKDhwidGZan84XKK1+XFkwDYL/4883kIajcz/dZFAhBJmZPtL8+nBx6bnkzA95YxPeDPw==", + "version": "20.5.3", + "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-20.5.3.tgz", + "integrity": "sha512-1FDZWuKyu98Myb8i7Tp31jPU2rZpOwAdYRyJcy2KoGg7Xk2A+bgHN8smhMaaNSNkmE8fwt53BokywZq8Gv/5XQ==", "dev": true, "license": "MIT", "dependencies": { "@commitlint/config-validator": "^20.5.0", "@commitlint/execute-rule": "^20.0.0", - "@commitlint/resolve-extends": "^20.5.0", + "@commitlint/resolve-extends": "^20.5.3", "@commitlint/types": "^20.5.0", "cosmiconfig": "^9.0.1", "cosmiconfig-typescript-loader": "^6.1.0", + "es-toolkit": "^1.46.0", "is-plain-obj": "^4.1.0", - "lodash.mergewith": "^4.6.2", "picocolors": "^1.1.1" }, "engines": { @@ -377,17 +377,17 @@ } }, "node_modules/@commitlint/resolve-extends": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-20.5.0.tgz", - "integrity": "sha512-3SHPWUW2v0tyspCTcfSsYml0gses92l6TlogwzvM2cbxDgmhSRc+fldDjvGkCXJrjSM87BBaWYTPWwwyASZRrg==", + "version": "20.5.3", + "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-20.5.3.tgz", + "integrity": "sha512-+ogW9v/u9JqpvAgTrLra/YTFo0KkjU6iNblF89pPsj4NebNc+DAWctsludwezI8YnsjBmfHpApSwcXprN/f/ew==", "dev": true, "license": "MIT", "dependencies": { "@commitlint/config-validator": "^20.5.0", "@commitlint/types": "^20.5.0", - "global-directory": "^4.0.1", + "es-toolkit": "^1.46.0", + "global-directory": "^5.0.0", "import-meta-resolve": "^4.0.0", - "lodash.mergewith": "^4.6.2", "resolve-from": "^5.0.0" }, "engines": { @@ -1532,17 +1532,17 @@ "optional": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.0.tgz", - "integrity": "sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==", + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz", + "integrity": "sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.59.0", - "@typescript-eslint/type-utils": "8.59.0", - "@typescript-eslint/utils": "8.59.0", - "@typescript-eslint/visitor-keys": "8.59.0", + "@typescript-eslint/scope-manager": "8.59.1", + "@typescript-eslint/type-utils": "8.59.1", + "@typescript-eslint/utils": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -1555,22 +1555,22 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.59.0", + "@typescript-eslint/parser": "^8.59.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.0.tgz", - "integrity": "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==", + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.1.tgz", + "integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.59.0", - "@typescript-eslint/types": "8.59.0", - "@typescript-eslint/typescript-estree": "8.59.0", - "@typescript-eslint/visitor-keys": "8.59.0", + "@typescript-eslint/scope-manager": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", "debug": "^4.4.3" }, "engines": { @@ -1586,14 +1586,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.0.tgz", - "integrity": "sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==", + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.1.tgz", + "integrity": "sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.59.0", - "@typescript-eslint/types": "^8.59.0", + "@typescript-eslint/tsconfig-utils": "^8.59.1", + "@typescript-eslint/types": "^8.59.1", "debug": "^4.4.3" }, "engines": { @@ -1608,14 +1608,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.0.tgz", - "integrity": "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==", + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz", + "integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.0", - "@typescript-eslint/visitor-keys": "8.59.0" + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1626,9 +1626,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.0.tgz", - "integrity": "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==", + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.1.tgz", + "integrity": "sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==", "dev": true, "license": "MIT", "engines": { @@ -1643,15 +1643,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.0.tgz", - "integrity": "sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg==", + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.1.tgz", + "integrity": "sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.0", - "@typescript-eslint/typescript-estree": "8.59.0", - "@typescript-eslint/utils": "8.59.0", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1", + "@typescript-eslint/utils": "8.59.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -1668,9 +1668,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.0.tgz", - "integrity": "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==", + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz", + "integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==", "dev": true, "license": "MIT", "engines": { @@ -1682,16 +1682,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.0.tgz", - "integrity": "sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==", + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz", + "integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.59.0", - "@typescript-eslint/tsconfig-utils": "8.59.0", - "@typescript-eslint/types": "8.59.0", - "@typescript-eslint/visitor-keys": "8.59.0", + "@typescript-eslint/project-service": "8.59.1", + "@typescript-eslint/tsconfig-utils": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -1710,16 +1710,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.0.tgz", - "integrity": "sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g==", + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.1.tgz", + "integrity": "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.59.0", - "@typescript-eslint/types": "8.59.0", - "@typescript-eslint/typescript-estree": "8.59.0" + "@typescript-eslint/scope-manager": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1734,13 +1734,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.0.tgz", - "integrity": "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==", + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz", + "integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/types": "8.59.1", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -1964,9 +1964,9 @@ } }, "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", "dependencies": { @@ -2368,13 +2368,13 @@ "license": "MIT" }, "node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", "dev": true, "license": "BSD-2-Clause", "engines": { - "node": ">=0.12" + "node": ">=20.19.0" }, "funding": { "url": "https://github.com/fb55/entities?sponsor=1" @@ -2407,6 +2407,17 @@ "dev": true, "license": "MIT" }, + "node_modules/es-toolkit": { + "version": "1.46.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz", + "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -2857,16 +2868,16 @@ } }, "node_modules/global-directory": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", - "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-5.0.0.tgz", + "integrity": "sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w==", "dev": true, "license": "MIT", "dependencies": { - "ini": "4.1.1" + "ini": "6.0.0" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -2974,13 +2985,13 @@ } }, "node_modules/ini": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", - "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", + "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", "dev": true, "license": "ISC", "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/is-arrayish": { @@ -3130,28 +3141,28 @@ } }, "node_modules/jsdom": { - "version": "29.0.2", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz", - "integrity": "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==", + "version": "29.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.0.tgz", + "integrity": "sha512-YNUc7fB9QuvSSQWfrH0xF+TyABkxUwx8sswgIDaCrw4Hol8BghdZDkITtZheRJeMtzWlnTfsM3bBBusRvpO1wg==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/css-color": "^5.1.5", - "@asamuzakjp/dom-selector": "^7.0.6", + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", - "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.7", - "parse5": "^8.0.0", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", - "undici": "^7.24.5", + "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", @@ -3520,13 +3531,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.mergewith": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", - "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", - "dev": true, - "license": "MIT" - }, "node_modules/lodash.snakecase": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", @@ -3809,13 +3813,13 @@ } }, "node_modules/parse5": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", - "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", "dev": true, "license": "MIT", "dependencies": { - "entities": "^6.0.0" + "entities": "^8.0.0" }, "funding": { "url": "https://github.com/inikulin/parse5?sponsor=1" @@ -4329,16 +4333,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.0.tgz", - "integrity": "sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw==", + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.1.tgz", + "integrity": "sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.59.0", - "@typescript-eslint/parser": "8.59.0", - "@typescript-eslint/typescript-estree": "8.59.0", - "@typescript-eslint/utils": "8.59.0" + "@typescript-eslint/eslint-plugin": "8.59.1", + "@typescript-eslint/parser": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1", + "@typescript-eslint/utils": "8.59.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" diff --git a/src/package.json b/src/package.json index f487d42e..d43a2414 100644 --- a/src/package.json +++ b/src/package.json @@ -24,25 +24,25 @@ "prepare": "node scripts/setup-git-hooks.mjs" }, "devDependencies": { - "@commitlint/cli": "^20.5.0", + "@commitlint/cli": "^20.5.2", "@commitlint/config-conventional": "^20.5.0", "@eslint/js": "^10.0.1", "@tauri-apps/cli": "~2.10.1", "@types/node": "^25.6.0", - "@typescript-eslint/eslint-plugin": "^8.59.0", - "@typescript-eslint/parser": "^8.59.0", - "@typescript-eslint/utils": "^8.59.0", + "@typescript-eslint/eslint-plugin": "^8.59.1", + "@typescript-eslint/parser": "^8.59.1", + "@typescript-eslint/utils": "^8.59.1", "@vitest/coverage-v8": "^4.1.5", "@vitest/ui": "^4.1.5", "eslint": "^10.2.1", "eslint-config-prettier": "^10.1.8", "fonteditor-core": "^2.6.3", "globals": "^17.5.0", - "jsdom": "^29.0.2", + "jsdom": "^29.1.0", "prettier": "^3.8.3", "terser": "^5.46.1", "typescript": "^6.0.3", - "typescript-eslint": "^8.59.0", + "typescript-eslint": "^8.59.1", "vite": "^8.0.10", "vitest": "^4.1.5" }, From 204bf89a0158fcf28d7d38466b48daf41415eaa5 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Tue, 5 May 2026 10:31:35 +0300 Subject: [PATCH 057/126] fix: address coderabbit review feedback --- .../domain/modules/controller/lifecycle.rs | 55 +++++++++++------ .../filesystem/local_file_service.rs | 21 +++++++ src/.prettierignore | 1 + src/app/events.test.ts | 28 ++++++++- src/app/init.ts | 8 +++ .../AIBridgeMessageController.test.ts | 23 +++++-- .../ai/services/AIBridgeMessageController.ts | 5 +- .../ai/ui/AISettingsKeyController.test.ts | 60 +++++++++++++++++++ src/features/ai/ui/AISettingsKeyController.ts | 12 +++- 9 files changed, 186 insertions(+), 27 deletions(-) diff --git a/src-tauri/src/domain/modules/controller/lifecycle.rs b/src-tauri/src/domain/modules/controller/lifecycle.rs index 274c1318..cba0bca2 100644 --- a/src-tauri/src/domain/modules/controller/lifecycle.rs +++ b/src-tauri/src/domain/modules/controller/lifecycle.rs @@ -4,17 +4,30 @@ use crate::domain::modules::lifecycle::{CommandDefinition, ModuleManifest}; use crate::domain::modules::paths as module_paths; use crate::errors::AppError; use crate::models::ControlResponse; +use std::collections::HashMap; use std::fs::OpenOptions; use std::path::{Path, PathBuf}; use std::process::Stdio; -use std::sync::LazyLock; +use std::sync::{Arc, LazyLock}; use std::time::Duration; +use tokio::fs; use tokio::process::{Child, Command}; use tokio::sync::Mutex; use tokio::time::timeout; const MODULE_CHILD_EXIT_POLL_INTERVAL: Duration = Duration::from_secs(1); -static MODULE_LIFECYCLE_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); +type ModuleLifecycleLocks = HashMap>>; +static MODULE_LIFECYCLE_LOCKS: LazyLock> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +async fn module_lifecycle_lock(module_id: &str) -> Arc> { + let mut locks = MODULE_LIFECYCLE_LOCKS.lock().await; + Arc::clone( + locks + .entry(module_id.to_string()) + .or_insert_with(|| Arc::new(Mutex::new(()))), + ) +} fn build_command(cmd: CommandDefinition) -> Command { match cmd { @@ -60,7 +73,8 @@ impl<'a> LifecycleExecutor<'a> { /// Safely starts a module with the given manifest pub async fn start(&self, manifest: &ModuleManifest) -> Result { - let _lifecycle_guard = MODULE_LIFECYCLE_LOCK.lock().await; + let lifecycle_lock = module_lifecycle_lock(&self.module_id).await; + let _lifecycle_guard = lifecycle_lock.lock().await; // 1. Guard against double-start // Check registry first (atomic-ish) @@ -266,24 +280,26 @@ impl<'a> LifecycleExecutor<'a> { } } - fn persist_pid(&self, pid: usize) -> Result<(), AppError> { + async fn persist_pid(&self, pid: usize) -> Result<(), AppError> { let pid_file = self.module_path.join("module.pid"); let temp_pid_file = self.module_path.join("module.pid.tmp"); - std::fs::write(&temp_pid_file, pid.to_string()).map_err(|error| { - AppError::Io(format!( - "Failed to write module PID temp file '{}': {error}", - temp_pid_file.display() - )) - })?; - - std::fs::rename(&temp_pid_file, &pid_file).map_err(|error| { - let _ = std::fs::remove_file(&temp_pid_file); - AppError::Io(format!( + fs::write(&temp_pid_file, pid.to_string()) + .await + .map_err(|error| { + AppError::Io(format!( + "Failed to write module PID temp file '{}': {error}", + temp_pid_file.display() + )) + })?; + + if let Err(error) = fs::rename(&temp_pid_file, &pid_file).await { + let _ = fs::remove_file(&temp_pid_file).await; + return Err(AppError::Io(format!( "Failed to publish module PID file '{}' -> '{}': {error}", temp_pid_file.display(), pid_file.display() - )) - })?; + ))); + } Ok(()) } @@ -293,7 +309,7 @@ impl<'a> LifecycleExecutor<'a> { child: &mut Child, pid: usize, ) -> Result<(), AppError> { - match self.persist_pid(pid) { + match self.persist_pid(pid).await { Ok(()) => Ok(()), Err(error) => { self.kill_unregistered_child(child, "PID publish failure") @@ -315,7 +331,8 @@ impl<'a> LifecycleExecutor<'a> { } /// Gracefully stops a module with escalation pub async fn stop(&self, manifest: &ModuleManifest) -> Result { - let _lifecycle_guard = MODULE_LIFECYCLE_LOCK.lock().await; + let lifecycle_lock = module_lifecycle_lock(&self.module_id).await; + let _lifecycle_guard = lifecycle_lock.lock().await; tracing::info!("Stopping module: {}", self.module_id); // 1. Run stop script if exists @@ -487,7 +504,7 @@ impl<'a> LifecycleExecutor<'a> { if let Some(&existing_pid) = matching_pids.first() && matching_pids.len() == 1 { - if let Err(error) = self.persist_pid(existing_pid) { + if let Err(error) = self.persist_pid(existing_pid).await { self.log_reconciled_pid_publish_failure(&error); return Err(error); } diff --git a/src-tauri/src/infrastructure/filesystem/local_file_service.rs b/src-tauri/src/infrastructure/filesystem/local_file_service.rs index 90df3a10..700a24a0 100644 --- a/src-tauri/src/infrastructure/filesystem/local_file_service.rs +++ b/src-tauri/src/infrastructure/filesystem/local_file_service.rs @@ -15,6 +15,26 @@ impl LocalFileService { Self } + #[cfg(not(target_os = "windows"))] + async fn sync_parent_dir(path: &Path) -> Result<(), AppError> { + let Some(parent) = path.parent() else { + return Ok(()); + }; + + let dir = fs::File::open(parent) + .await + .map_err(|e| AppError::Io(e.to_string()))?; + dir.sync_all() + .await + .map_err(|e| AppError::Io(e.to_string())) + } + + #[cfg(target_os = "windows")] + #[allow(clippy::unused_async)] + async fn sync_parent_dir(_path: &Path) -> Result<(), AppError> { + Ok(()) + } + async fn write_atomic(path: &Path, content: &[u8]) -> Result<(), AppError> { if let Some(parent) = path.parent() { fs::create_dir_all(parent) @@ -90,6 +110,7 @@ impl LocalFileService { let _ = fs::remove_file(&backup).await; } } + Self::sync_parent_dir(path).await?; Ok(()) } diff --git a/src/.prettierignore b/src/.prettierignore index 83b6de7f..c5ebd1fb 100644 --- a/src/.prettierignore +++ b/src/.prettierignore @@ -1,4 +1,5 @@ dist coverage +.axelate shared/types/bindings.ts .axelate diff --git a/src/app/events.test.ts b/src/app/events.test.ts index 796b5025..9a27a9b9 100644 --- a/src/app/events.test.ts +++ b/src/app/events.test.ts @@ -2,6 +2,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { EventHandler, type ICoreEvents } from './events'; +async function flushAsyncNavigation(): Promise { + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + function createCoreEvents(): ICoreEvents { return { appUI: { @@ -68,9 +74,29 @@ describe('EventHandler', () => { throw new Error('Navigation button not found'); } navButton.click(); - await Promise.resolve(); + await flushAsyncNavigation(); expect(core.navigationUI.showPage).not.toHaveBeenCalled(); handler.destroy(); }); + + it('allows sidebar navigation when release download selection is closed', async () => { + document.body.innerHTML = ``; + const core = createCoreEvents(); + const handler = new EventHandler(core, { + addWindowListener: vi.fn(), + removeWindowListener: vi.fn(), + }); + handler.init(); + + const navButton = document.querySelector('[data-page]'); + if (navButton === null) { + throw new Error('Navigation button not found'); + } + navButton.click(); + await flushAsyncNavigation(); + + expect(core.navigationUI.showPage).toHaveBeenCalledOnce(); + handler.destroy(); + }); }); diff --git a/src/app/init.ts b/src/app/init.ts index 1d2f32c7..116ab79b 100644 --- a/src/app/init.ts +++ b/src/app/init.ts @@ -83,7 +83,15 @@ export class Core { if (this._isDestroyed) return; this._isDestroyed = true; this._isInitialized = false; + const pendingInit = this._initPromise; this._initPromise = null; + if (pendingInit !== null) { + try { + await pendingInit; + } catch { + // Init failures are superseded by teardown. + } + } await this._assembly.lifecycleController.destroy(); } } diff --git a/src/features/ai/services/AIBridgeMessageController.test.ts b/src/features/ai/services/AIBridgeMessageController.test.ts index f52bfa7a..bd64d2e1 100644 --- a/src/features/ai/services/AIBridgeMessageController.test.ts +++ b/src/features/ai/services/AIBridgeMessageController.test.ts @@ -51,6 +51,8 @@ function createTextController() { }; const showToast = vi.fn(); const onActivity = vi.fn(); + const onLongActivityStart = vi.fn(); + const onLongActivityEnd = vi.fn(); const controller = new AIBridgeMessageController({ getContext: () => context as never, @@ -62,12 +64,22 @@ function createTextController() { translate: (_key, fallback) => fallback, showToast, onActivity, - onLongActivityStart: vi.fn(), - onLongActivityEnd: vi.fn(), + onLongActivityStart, + onLongActivityEnd, onSuccessfulResponse: vi.fn(), }); - return { controller, transport, events, manager, context, showToast, onActivity }; + return { + controller, + transport, + events, + manager, + context, + showToast, + onActivity, + onLongActivityStart, + onLongActivityEnd, + }; } function createImageController() { @@ -334,12 +346,15 @@ describe('AIBridgeMessageController custom providers', () => { }); it('marks silent image prompt preparation as provider activity', async () => { - const { controller, transport, onActivity } = createTextController(); + const { controller, transport, onActivity, onLongActivityStart, onLongActivityEnd } = + createTextController(); const response = await controller.prepareImagePrompt('rewrite image prompt'); expect(response).toEqual({ ok: true, text: 'prepared' }); expect(onActivity).toHaveBeenCalledOnce(); + expect(onLongActivityStart).toHaveBeenCalledOnce(); + expect(onLongActivityEnd).toHaveBeenCalledOnce(); expect(transport.sendSilent).toHaveBeenCalledOnce(); }); }); diff --git a/src/features/ai/services/AIBridgeMessageController.ts b/src/features/ai/services/AIBridgeMessageController.ts index 6ee31898..cd092415 100644 --- a/src/features/ai/services/AIBridgeMessageController.ts +++ b/src/features/ai/services/AIBridgeMessageController.ts @@ -111,7 +111,10 @@ export class AIBridgeMessageController { }, ); - const response = await this._deps.transport.sendSilent(request); + this._deps.onLongActivityStart(); + const response = await this._deps.transport + .sendSilent(request) + .finally(this._deps.onLongActivityEnd); return this._withModelContext(response, providerId, request.model); } catch (error: unknown) { const errorMsg = diff --git a/src/features/ai/ui/AISettingsKeyController.test.ts b/src/features/ai/ui/AISettingsKeyController.test.ts index f3fb7ad5..cb6a0222 100644 --- a/src/features/ai/ui/AISettingsKeyController.test.ts +++ b/src/features/ai/ui/AISettingsKeyController.test.ts @@ -137,6 +137,66 @@ describe('AISettingsKeyController', () => { ); }); + it('should not clear local key state when settings service is unavailable', async () => { + const input = document.createElement('input'); + input.dataset['storedMasked'] = 'true'; + input.value = ''; + const controllerWithoutSettings = new AISettingsKeyController({ + getSettingsService: () => null, + getTranslator, + scheduleButtonReset, + showToast, + icons: { + visible: '', + hidden: '', + check: '', + x: '', + spinner: '', + }, + tracer, + }); + + const removed = await controllerWithoutSettings.removeClearedStoredKey(input, 'openrouter'); + + expect(removed).toBe(false); + expect(input.dataset['storedMasked']).toBe('true'); + expect(showToast).toHaveBeenCalledWith( + 'ui.settings.key_remove_error:Key remove error', + 'error', + ); + }); + + it('should report failure when checking a cleared key without settings service', async () => { + const input = document.createElement('input'); + const button = document.createElement('button'); + button.innerHTML = 'Check'; + document.body.append(input, button); + input.dataset['keyDirty'] = 'true'; + input.value = ''; + const controllerWithoutSettings = new AISettingsKeyController({ + getSettingsService: () => null, + getTranslator, + scheduleButtonReset, + showToast, + icons: { + visible: '', + hidden: '', + check: '', + x: '', + spinner: '', + }, + tracer, + }); + + await controllerWithoutSettings.checkKey(input, button, 'openrouter'); + + expect(input.dataset['keyDirty']).toBe('true'); + expect(showToast).toHaveBeenCalledWith( + 'ui.settings.key_check_error:Key check error', + 'error', + ); + }); + it('should reset key check buttons to their idle state', () => { const button = document.createElement('button'); button.disabled = true; diff --git a/src/features/ai/ui/AISettingsKeyController.ts b/src/features/ai/ui/AISettingsKeyController.ts index c570b295..484388b4 100644 --- a/src/features/ai/ui/AISettingsKeyController.ts +++ b/src/features/ai/ui/AISettingsKeyController.ts @@ -53,7 +53,11 @@ export class AISettingsKeyController { input.dataset['keyRemoveInFlight'] = 'true'; try { - await this._options.getSettingsService()?.removeSecureKey(providerId); + const settingsService = this._options.getSettingsService(); + if (settingsService === null) { + throw new Error(); + } + await settingsService.removeSecureKey(providerId); this.clearStoredKeyMask(input); this._showToast( this._options.getTranslator()('ui.settings.key_removed', 'API key removed'), @@ -158,7 +162,11 @@ export class AISettingsKeyController { let isValid = false; if (shouldRemoveStoredKey) { - await this._options.getSettingsService()?.removeSecureKey(providerId); + const settingsService = this._options.getSettingsService(); + if (settingsService === null) { + throw new Error(); + } + await settingsService.removeSecureKey(providerId); this.clearStoredKeyMask(input); this.updateButtonState(button, 'success', this._options.icons.check); this._showToast(t('ui.settings.key_removed', 'API key removed'), 'success'); From bcfb42bac49a843fa8f2a4aa867c720b7cc6528f Mon Sep 17 00:00:00 2001 From: F0RLE Date: Tue, 5 May 2026 10:39:28 +0300 Subject: [PATCH 058/126] chore: relax desktop bundle size gate --- .github/scripts/workflow.mjs | 3 +-- .github/workflows/ci.yml | 5 ----- src/.prettierignore | 5 +++++ 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/.github/scripts/workflow.mjs b/.github/scripts/workflow.mjs index 5e6af4ba..c5800d97 100644 --- a/.github/scripts/workflow.mjs +++ b/.github/scripts/workflow.mjs @@ -720,7 +720,6 @@ function verifyProject() { run('npm', ['run', 'lint'], { cwd: srcDir }); run('npm', ['run', 'test'], { cwd: srcDir }); run('npm', ['run', 'build:bundle'], { cwd: srcDir }); - run('npm', ['run', 'check-size'], { cwd: srcDir }); } function setupProject() { @@ -762,7 +761,7 @@ Tasks: install-deps Install frontend dependencies update Update npm and cargo dependencies, then verify prepare Configure Git hooks - check-size Validate built frontend size + check-size Print a frontend bundle size report `); }, dev() { diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b5dd629..5c62f68c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,11 +63,6 @@ jobs: npm run build # 'npm run build' runs bindings sync + typecheck + vite build - - name: Check Bundle Size - run: | - cd src - npm run check-size - - name: Run Frontend Tests run: | cd src diff --git a/src/.prettierignore b/src/.prettierignore index c5ebd1fb..95f701a3 100644 --- a/src/.prettierignore +++ b/src/.prettierignore @@ -1,5 +1,10 @@ +node_modules dist coverage .axelate +.vite +.cache +*.log +*.tsbuildinfo shared/types/bindings.ts .axelate From e4886eda6c056ab6cb38f26d98c5da6451bb2a1d Mon Sep 17 00:00:00 2001 From: F0RLE Date: Tue, 5 May 2026 10:56:05 +0300 Subject: [PATCH 059/126] fix: surface engine config save failures --- .../ai/services/EngineConfigService.test.ts | 4 ++-- src/features/ai/services/EngineConfigService.ts | 3 ++- .../ui/ModuleSettingsControllerFactory.ts | 4 ++++ .../ui/ModuleSettingsEngineFieldController.ts | 10 ++++++++-- .../ui/ModuleSettingsEngineRenderer.test.ts | 16 +++++++++++++++- .../settings/ui/ModuleSettingsEngineRenderer.ts | 5 +++++ src/features/settings/ui/ModuleSettingsUI.ts | 7 +++++++ 7 files changed, 43 insertions(+), 6 deletions(-) diff --git a/src/features/ai/services/EngineConfigService.test.ts b/src/features/ai/services/EngineConfigService.test.ts index ebbed4cd..cfc83ec4 100644 --- a/src/features/ai/services/EngineConfigService.test.ts +++ b/src/features/ai/services/EngineConfigService.test.ts @@ -68,12 +68,12 @@ describe('EngineConfigService', () => { expect(tauri.invoke).not.toHaveBeenCalled(); }); - it('saves config and swallows backend errors', async () => { + it('saves config and propagates backend errors', async () => { vi.mocked(tauri.invoke).mockResolvedValue(undefined); await expect(service.setConfig(config)).resolves.toBeUndefined(); expect(tauri.invoke).toHaveBeenCalledWith('set_engine_config', { config }); vi.mocked(tauri.invoke).mockRejectedValueOnce(new Error('save failed')); - await expect(service.setConfig(config)).resolves.toBeUndefined(); + await expect(service.setConfig(config)).rejects.toThrow('save failed'); }); }); diff --git a/src/features/ai/services/EngineConfigService.ts b/src/features/ai/services/EngineConfigService.ts index 731aecde..175ed9c6 100644 --- a/src/features/ai/services/EngineConfigService.ts +++ b/src/features/ai/services/EngineConfigService.ts @@ -70,7 +70,7 @@ export class EngineConfigService { /** * Persists the user's engine configuration. - * Fires-and-forgets the Tauri command; errors are logged but not re-thrown. + * Save failures are re-thrown so the settings UI can show a failed state. */ public async setConfig(config: EngineConfig): Promise { if (!this._tauri.isTauri()) return; @@ -78,6 +78,7 @@ export class EngineConfigService { await this._tauri.invoke('set_engine_config', { config }); } catch (e) { this._tracer.error('[EngineConfigService] Failed to save engine config:', e); + throw e; } } } diff --git a/src/features/settings/ui/ModuleSettingsControllerFactory.ts b/src/features/settings/ui/ModuleSettingsControllerFactory.ts index f05ca8ae..6de28c95 100644 --- a/src/features/settings/ui/ModuleSettingsControllerFactory.ts +++ b/src/features/settings/ui/ModuleSettingsControllerFactory.ts @@ -21,6 +21,7 @@ type ModuleSettingsControllerFactoryDeps = { debouncedSave: (key: string, value: SettingValue) => void; notifySettingsChanged: () => void; showSaveIndicator: () => void; + showSaveErrorIndicator: () => void; hideSaveIndicator: () => void; showDirtyIndicator: () => void; }; @@ -54,6 +55,9 @@ export class ModuleSettingsControllerFactory { showSaveIndicator: () => { this._deps.showSaveIndicator(); }, + showSaveErrorIndicator: () => { + this._deps.showSaveErrorIndicator(); + }, tracer: this._deps.tracer, }); } diff --git a/src/features/settings/ui/ModuleSettingsEngineFieldController.ts b/src/features/settings/ui/ModuleSettingsEngineFieldController.ts index c395efab..6a0a007d 100644 --- a/src/features/settings/ui/ModuleSettingsEngineFieldController.ts +++ b/src/features/settings/ui/ModuleSettingsEngineFieldController.ts @@ -17,6 +17,7 @@ type ModuleSettingsEngineFieldControllerDeps = { setConfig: (config: EngineConfig) => Promise; debouncedSave: (key: string, value: string | number | boolean | null) => void; showSaveIndicator: () => void; + showSaveErrorIndicator: () => void; translate: (key: string, fallback: string) => string; getModelFileName: (modelPath: string) => string; getModelFileFilters: ( @@ -168,8 +169,13 @@ export class ModuleSettingsEngineFieldController { (options.config as unknown as Record)[ options.key ] = formatEngineFieldSaveValue(options.key, value); - await this._deps.setConfig(options.config); - this._deps.showSaveIndicator(); + try { + await this._deps.setConfig(options.config); + this._deps.showSaveIndicator(); + } catch (error: unknown) { + this._deps.tracer.error('[ModuleSettingsUI] Failed to save engine setting', error); + this._deps.showSaveErrorIndicator(); + } } } } diff --git a/src/features/settings/ui/ModuleSettingsEngineRenderer.test.ts b/src/features/settings/ui/ModuleSettingsEngineRenderer.test.ts index 16a046c7..1a64d406 100644 --- a/src/features/settings/ui/ModuleSettingsEngineRenderer.test.ts +++ b/src/features/settings/ui/ModuleSettingsEngineRenderer.test.ts @@ -51,6 +51,7 @@ function createRendererHarness(options?: { const debouncedSave = vi.fn(); const notifySettingsChanged = vi.fn(); const showSaveIndicator = vi.fn(); + const showSaveErrorIndicator = vi.fn(); const setConfig = vi.fn().mockResolvedValue(undefined); let animationTime = 0; const runtime = { @@ -86,6 +87,7 @@ function createRendererHarness(options?: { debouncedSave, notifySettingsChanged, showSaveIndicator, + showSaveErrorIndicator, tracer: { error: vi.fn(), }, @@ -100,6 +102,7 @@ function createRendererHarness(options?: { debouncedSave, notifySettingsChanged, showSaveIndicator, + showSaveErrorIndicator, setConfig, runtime, }; @@ -109,12 +112,14 @@ function createFieldControllerHarness() { const setConfig = vi.fn(); const debouncedSave = vi.fn(); const showSaveIndicator = vi.fn(); + const showSaveErrorIndicator = vi.fn(); const error = vi.fn(); const fieldController = new ModuleSettingsEngineFieldController({ getSettings: () => ({}), setConfig, debouncedSave, showSaveIndicator, + showSaveErrorIndicator, translate: (key, fallback) => `t:${key}:${fallback}`, getModelFileName: (modelPath) => getEngineModelFileName( @@ -125,7 +130,14 @@ function createFieldControllerHarness() { tracer: { error }, }); - return { fieldController, setConfig, debouncedSave, showSaveIndicator, error }; + return { + fieldController, + setConfig, + debouncedSave, + showSaveIndicator, + showSaveErrorIndicator, + error, + }; } describe('ModuleSettingsEngineRenderer', () => { @@ -417,11 +429,13 @@ describe('ModuleSettingsEngineRenderer', () => { it('should save engine field values', async () => { const setConfig = vi.fn(); const showSaveIndicator = vi.fn(); + const showSaveErrorIndicator = vi.fn(); const fieldController = new ModuleSettingsEngineFieldController({ getSettings: () => ({}), setConfig, debouncedSave: vi.fn(), showSaveIndicator, + showSaveErrorIndicator, translate: (_key, fallback) => fallback, getModelFileName: (modelPath) => modelPath, getModelFileFilters: getEngineModelFileFilters, diff --git a/src/features/settings/ui/ModuleSettingsEngineRenderer.ts b/src/features/settings/ui/ModuleSettingsEngineRenderer.ts index 5d4ecc01..a58e451d 100644 --- a/src/features/settings/ui/ModuleSettingsEngineRenderer.ts +++ b/src/features/settings/ui/ModuleSettingsEngineRenderer.ts @@ -121,6 +121,7 @@ type ModuleSettingsEngineRendererDeps = { debouncedSave: (key: string, value: string | number | boolean | null) => void; notifySettingsChanged: () => void; showSaveIndicator: () => void; + showSaveErrorIndicator: () => void; tracer: Pick; }; @@ -200,6 +201,9 @@ export class ModuleSettingsEngineRenderer { showSaveIndicator: () => { this._deps.showSaveIndicator(); }, + showSaveErrorIndicator: () => { + this._deps.showSaveErrorIndicator(); + }, translate: (key, fallback) => this._translate(key, fallback), getModelFileName: (modelPath) => this._getModelFileName(modelPath), getModelFileFilters: (fileKind, isImage) => @@ -674,6 +678,7 @@ export class ModuleSettingsEngineRenderer { }) .catch((error: unknown) => { this._deps.tracer.error('[ModuleSettingsUI] Failed to apply model profile', error); + this._deps.showSaveErrorIndicator(); }); const root = card.closest('.local-engine-config'); diff --git a/src/features/settings/ui/ModuleSettingsUI.ts b/src/features/settings/ui/ModuleSettingsUI.ts index d0847714..2515657a 100644 --- a/src/features/settings/ui/ModuleSettingsUI.ts +++ b/src/features/settings/ui/ModuleSettingsUI.ts @@ -110,6 +110,9 @@ export class ModuleSettingsUI { showSaveIndicator: () => { this._showSaveIndicator(); }, + showSaveErrorIndicator: () => { + this._showSaveErrorIndicator(); + }, hideSaveIndicator: () => { this._hideSaveIndicator(); }, @@ -471,6 +474,10 @@ export class ModuleSettingsUI { this._getAutosaveController().showPending(); } + private _showSaveErrorIndicator(): void { + this._getAutosaveController().showError(); + } + private _hideSaveIndicator(): void { this._getAutosaveController().hide(); } From 62d148c6a12ea63b7eb8e76ab8783d165123e43d Mon Sep 17 00:00:00 2001 From: F0RLE Date: Tue, 5 May 2026 11:02:04 +0300 Subject: [PATCH 060/126] fix: align provider key storage --- .../settings/services/SettingsService.test.ts | 22 +++++++++++++------ .../settings/services/SettingsService.ts | 15 ++++++++----- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/src/features/settings/services/SettingsService.test.ts b/src/features/settings/services/SettingsService.test.ts index 5d4ec52f..69331486 100644 --- a/src/features/settings/services/SettingsService.test.ts +++ b/src/features/settings/services/SettingsService.test.ts @@ -226,10 +226,18 @@ describe('SettingsService', () => { }); describe('saveSecureKey', () => { - it('should invoke save_secure_key with correct args', async () => { + it('should store cloud provider keys in the shared OpenRouter slot', async () => { await service.saveSecureKey('gemini', 'my-api-key'); expect(tauri.invoke).toHaveBeenCalledWith('save_secure_key', { - service: 'gemini_api_key', + service: 'openrouter_api_key', + key: 'my-api-key', + }); + }); + + it('should keep legacy provider-specific slots for unknown providers', async () => { + await service.saveSecureKey('unknown-provider', 'my-api-key'); + expect(tauri.invoke).toHaveBeenCalledWith('save_secure_key', { + service: 'unknown-provider_api_key', key: 'my-api-key', }); }); @@ -244,7 +252,7 @@ describe('SettingsService', () => { it('should remove secure key through tauri provider helper', async () => { await service.removeSecureKey('gemini'); - expect(tauri.removeSecureKey).toHaveBeenCalledWith('gemini_api_key'); + expect(tauri.removeSecureKey).toHaveBeenCalledWith('openrouter_api_key'); expect(tauri.invoke).not.toHaveBeenCalledWith('remove_secure_key', expect.anything()); }); @@ -254,7 +262,7 @@ describe('SettingsService', () => { await service.removeSecureKey('gemini'); expect(tauri.invoke).toHaveBeenCalledWith('remove_secure_key', { - service: 'gemini_api_key', + service: 'openrouter_api_key', }); }); @@ -289,7 +297,7 @@ describe('SettingsService', () => { expect(result).toBe(true); expect(tauri.invoke).toHaveBeenCalledWith('has_secure_key', { - service: 'gemini_api_key', + service: 'openrouter_api_key', }); }); @@ -310,7 +318,7 @@ describe('SettingsService', () => { const result = await service.getSecureKeyMeta('gemini'); expect(result).toEqual(meta); - expect(tauri.getSecureKeyMeta).toHaveBeenCalledWith('gemini_api_key'); + expect(tauri.getSecureKeyMeta).toHaveBeenCalledWith('openrouter_api_key'); }); it('should return empty metadata on error', async () => { @@ -331,7 +339,7 @@ describe('SettingsService', () => { const result = await service.getSecureKey('gemini'); expect(result).toBe('secret'); - expect(tauri.getSecureKey).toHaveBeenCalledWith('gemini_api_key'); + expect(tauri.getSecureKey).toHaveBeenCalledWith('openrouter_api_key'); }); it('should return null on error', async () => { diff --git a/src/features/settings/services/SettingsService.ts b/src/features/settings/services/SettingsService.ts index f5abedf0..22467e54 100644 --- a/src/features/settings/services/SettingsService.ts +++ b/src/features/settings/services/SettingsService.ts @@ -5,6 +5,7 @@ import type { LoggerService } from '@/infrastructure/logging/LoggerService'; import type { AppSettings, GpuInfo } from '@/shared/types/bindings'; import { commands } from '@/shared/types/bindings'; import { invokeSafe } from '@/shared/api/invoke'; +import { resolveProviderSecretService } from '@/shared/utils/providerSupport'; export type ISettings = AppSettings; export type SettingsValue = string | number | boolean; type SettingsLogger = Pick; @@ -130,7 +131,7 @@ export class SettingsService { * Fallback to localStorage is PROHIBITED for security reasons. */ public async saveSecureKey(provider: string, key: string): Promise { - const storageKey = `${provider}_api_key`; + const storageKey = this._resolveSecureKeyService(provider); try { await this._tauri.invoke('save_secure_key', { service: storageKey, @@ -146,7 +147,7 @@ export class SettingsService { * Remove a securely stored API key. */ public async removeSecureKey(provider: string): Promise { - const storageKey = `${provider}_api_key`; + const storageKey = this._resolveSecureKeyService(provider); try { if (typeof this._tauri.removeSecureKey === 'function') { await this._tauri.removeSecureKey(storageKey); @@ -166,7 +167,7 @@ export class SettingsService { * Checks whether a secure API key exists without exposing the secret value. */ public async hasSecureKey(provider: string): Promise { - const storageKey = `${provider}_api_key`; + const storageKey = this._resolveSecureKeyService(provider); try { return await this._tauri.invoke('has_secure_key', { service: storageKey, @@ -181,7 +182,7 @@ export class SettingsService { * Returns non-sensitive metadata for a stored key. */ public async getSecureKeyMeta(provider: string): Promise { - const storageKey = `${provider}_api_key`; + const storageKey = this._resolveSecureKeyService(provider); try { return await this._tauri.getSecureKeyMeta(storageKey); } catch (e) { @@ -194,7 +195,7 @@ export class SettingsService { * Returns the decrypted secure key for explicit user reveal flows. */ public async getSecureKey(provider: string): Promise { - const storageKey = `${provider}_api_key`; + const storageKey = this._resolveSecureKeyService(provider); try { return await this._tauri.getSecureKey(storageKey); } catch (e) { @@ -272,4 +273,8 @@ export class SettingsService { throw e; } } + + private _resolveSecureKeyService(provider: string): string { + return resolveProviderSecretService(provider) ?? `${provider}_api_key`; + } } From af8659ffef38aa06a17e772106dbea2578243b6d Mon Sep 17 00:00:00 2001 From: F0RLE Date: Tue, 5 May 2026 11:08:48 +0300 Subject: [PATCH 061/126] fix: parse github release repo urls robustly --- .../src/domain/modules/github_releases.rs | 53 ++++++++++++++++--- 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/domain/modules/github_releases.rs b/src-tauri/src/domain/modules/github_releases.rs index 377af25b..bc87f1e3 100644 --- a/src-tauri/src/domain/modules/github_releases.rs +++ b/src-tauri/src/domain/modules/github_releases.rs @@ -510,20 +510,33 @@ const fn hardware_for_target( } fn parse_repo(repo_url: &str) -> Result { - let trimmed = repo_url.trim_end_matches(".git").trim_end_matches('/'); - let parts: Vec<&str> = trimmed.split('/').collect(); - - if parts.len() < 2 { + let trimmed = repo_url + .trim() + .split(['?', '#']) + .next() + .unwrap_or_default() + .trim_end_matches('/'); + if trimmed.contains("://") + && !trimmed.starts_with("https://github.com/") + && !trimmed.starts_with("http://github.com/") + { return Err(invalid_repo_url(repo_url)); } + let path = trimmed + .strip_prefix("https://github.com/") + .or_else(|| trimmed.strip_prefix("http://github.com/")) + .or_else(|| trimmed.strip_prefix("git@github.com:")) + .unwrap_or(trimmed); + let parts: Vec<&str> = path.split('/').filter(|part| !part.is_empty()).collect(); - let repo = parts - .last() + let owner = parts + .first() .ok_or_else(|| invalid_repo_url(repo_url))? .to_string(); - let owner = parts - .get(parts.len().saturating_sub(2)) + let repo = parts + .get(1) .ok_or_else(|| invalid_repo_url(repo_url))? + .trim_end_matches(".git") .to_string(); if owner.is_empty() || repo.is_empty() { @@ -544,6 +557,30 @@ mod tests { use crate::domain::modules::github_release_selection::{base_main_score, cpu_feature_score}; use crate::domain::system::hardware_probe::{AcceleratorClass, CpuInstructionTier}; + #[test] + fn parse_repo_uses_owner_and_repo_from_github_urls_with_extra_path() { + let parsed = parse_repo("https://github.com/ggml-org/llama.cpp/releases/latest") + .expect("valid GitHub URL should parse"); + + assert_eq!(parsed.owner, "ggml-org"); + assert_eq!(parsed.repo, "llama.cpp"); + } + + #[test] + fn parse_repo_supports_git_suffix_and_shorthand() { + let parsed = parse_repo("ggml-org/llama.cpp.git").expect("valid shorthand should parse"); + + assert_eq!(parsed.owner, "ggml-org"); + assert_eq!(parsed.repo, "llama.cpp"); + } + + #[test] + fn parse_repo_rejects_non_github_urls() { + let parsed = parse_repo("https://example.com/ggml-org/llama.cpp"); + + assert!(parsed.is_err()); + } + #[test] fn skips_incomplete_sdcpp_release_and_accepts_complete_previous_bundle() { let platform = Platform { From 5174ee56240d295b4f2b49dd34ed8c238e68cb26 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Tue, 5 May 2026 11:17:21 +0300 Subject: [PATCH 062/126] fix: harden github release parsing --- .../modules/github_release_selection.rs | 15 ++++- .../src/domain/modules/github_releases.rs | 60 +++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/domain/modules/github_release_selection.rs b/src-tauri/src/domain/modules/github_release_selection.rs index 317b20c3..26402304 100644 --- a/src-tauri/src/domain/modules/github_release_selection.rs +++ b/src-tauri/src/domain/modules/github_release_selection.rs @@ -157,7 +157,10 @@ fn main_assets( fn parse_sha256_digest(digest: Option<&str>) -> Option { let value = digest?.trim(); - let hash = value.strip_prefix("sha256:")?; + let (algorithm, hash) = value.split_once(':')?; + if !algorithm.eq_ignore_ascii_case("sha256") { + return None; + } if hash.len() != 64 || !hash.chars().all(|ch| ch.is_ascii_hexdigit()) { return None; } @@ -220,13 +223,21 @@ fn platform_matches(module_id: &str, platform: Platform, name: &str) -> bool { fn os_matches(os: PlatformOs, lower_name: &str) -> bool { match os { - PlatformOs::Windows => lower_name.contains("win"), + PlatformOs::Windows => is_windows_asset_name(lower_name), PlatformOs::Linux => lower_name.contains("linux") || lower_name.contains("ubuntu"), PlatformOs::Macos => lower_name.contains("darwin") || lower_name.contains("macos"), PlatformOs::Other => true, } } +fn is_windows_asset_name(lower_name: &str) -> bool { + lower_name.contains("windows") + || lower_name.contains("-win-") + || lower_name.contains("_win_") + || lower_name.contains("-win_") + || lower_name.contains("_win-") +} + fn arch_matches(module_id: &str, arch: PlatformArch, lower_name: &str) -> bool { match arch { PlatformArch::X64 => { diff --git a/src-tauri/src/domain/modules/github_releases.rs b/src-tauri/src/domain/modules/github_releases.rs index bc87f1e3..f90f3056 100644 --- a/src-tauri/src/domain/modules/github_releases.rs +++ b/src-tauri/src/domain/modules/github_releases.rs @@ -519,12 +519,18 @@ fn parse_repo(repo_url: &str) -> Result { if trimmed.contains("://") && !trimmed.starts_with("https://github.com/") && !trimmed.starts_with("http://github.com/") + && !trimmed.starts_with("https://www.github.com/") + && !trimmed.starts_with("http://www.github.com/") { return Err(invalid_repo_url(repo_url)); } let path = trimmed .strip_prefix("https://github.com/") .or_else(|| trimmed.strip_prefix("http://github.com/")) + .or_else(|| trimmed.strip_prefix("https://www.github.com/")) + .or_else(|| trimmed.strip_prefix("http://www.github.com/")) + .or_else(|| trimmed.strip_prefix("github.com/")) + .or_else(|| trimmed.strip_prefix("www.github.com/")) .or_else(|| trimmed.strip_prefix("git@github.com:")) .unwrap_or(trimmed); let parts: Vec<&str> = path.split('/').filter(|part| !part.is_empty()).collect(); @@ -574,6 +580,15 @@ mod tests { assert_eq!(parsed.repo, "llama.cpp"); } + #[test] + fn parse_repo_supports_github_host_without_scheme() { + let parsed = + parse_repo("github.com/ggml-org/llama.cpp").expect("valid host shorthand should parse"); + + assert_eq!(parsed.owner, "ggml-org"); + assert_eq!(parsed.repo, "llama.cpp"); + } + #[test] fn parse_repo_rejects_non_github_urls() { let parsed = parse_repo("https://example.com/ggml-org/llama.cpp"); @@ -581,6 +596,51 @@ mod tests { assert!(parsed.is_err()); } + #[test] + fn windows_selection_does_not_treat_darwin_assets_as_windows() { + let platform = Platform { + os: PlatformOs::Windows, + arch: PlatformArch::X64, + }; + let hardware = HardwareProfile { + accelerator: AcceleratorClass::CpuOnly, + cpu_tier: CpuInstructionTier::Avx2, + cuda_driver_major: None, + cuda_driver_minor: None, + }; + let assets = vec![asset("llama-b9028-bin-darwin-x64.tar.gz")]; + + assert!(select_release_assets("llamacpp", platform, hardware, &assets).is_none()); + } + + #[test] + fn uppercase_sha256_digest_is_accepted() { + let platform = Platform { + os: PlatformOs::Windows, + arch: PlatformArch::X64, + }; + let hardware = HardwareProfile { + accelerator: AcceleratorClass::CpuOnly, + cpu_tier: CpuInstructionTier::Avx2, + cuda_driver_major: None, + cuda_driver_minor: None, + }; + let assets = vec![Asset { + name: "llama-b9028-bin-win-cpu-x64.zip".to_string(), + browser_download_url: "https://example.com/llama.zip".to_string(), + size: 1024, + digest: Some(format!("SHA256:{}", "A".repeat(64))), + }]; + + let selected = select_release_assets("llamacpp", platform, hardware, &assets) + .expect("uppercase SHA256 prefix should be accepted"); + + assert_eq!( + selected.first().map(|asset| asset.sha256.as_str()), + Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + ); + } + #[test] fn skips_incomplete_sdcpp_release_and_accepts_complete_previous_bundle() { let platform = Platform { From ceed3f8e93288f2b6b75a3668f6e7c9137449469 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Tue, 5 May 2026 11:23:42 +0300 Subject: [PATCH 063/126] fix: tighten release architecture matching --- .../domain/modules/github_release_selection.rs | 11 ++++++++++- src-tauri/src/domain/modules/github_releases.rs | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/domain/modules/github_release_selection.rs b/src-tauri/src/domain/modules/github_release_selection.rs index 26402304..d256cc52 100644 --- a/src-tauri/src/domain/modules/github_release_selection.rs +++ b/src-tauri/src/domain/modules/github_release_selection.rs @@ -251,11 +251,20 @@ fn arch_matches(module_id: &str, arch: PlatformArch, lower_name: &str) -> bool { && !lower_name.contains("_x86")) } PlatformArch::Arm64 => lower_name.contains("arm64") || lower_name.contains("aarch64"), - PlatformArch::X86 => lower_name.contains("-x86") || lower_name.contains("_x86"), + PlatformArch::X86 => is_x86_asset_name(lower_name), PlatformArch::Other => true, } } +fn is_x86_asset_name(lower_name: &str) -> bool { + (lower_name.contains("-x86") || lower_name.contains("_x86")) + && !lower_name.contains("x86_64") + && !lower_name.contains("x64") + && !lower_name.contains("amd64") + && !lower_name.contains("arm64") + && !lower_name.contains("aarch64") +} + fn main_score(module_id: &str, name: &str, hardware: HardwareProfile) -> i32 { let lower = name.to_ascii_lowercase(); if module_id == "comfyui" { diff --git a/src-tauri/src/domain/modules/github_releases.rs b/src-tauri/src/domain/modules/github_releases.rs index f90f3056..d9e0cf62 100644 --- a/src-tauri/src/domain/modules/github_releases.rs +++ b/src-tauri/src/domain/modules/github_releases.rs @@ -613,6 +613,23 @@ mod tests { assert!(select_release_assets("llamacpp", platform, hardware, &assets).is_none()); } + #[test] + fn x86_selection_does_not_treat_x86_64_assets_as_32_bit() { + let platform = Platform { + os: PlatformOs::Linux, + arch: PlatformArch::X86, + }; + let hardware = HardwareProfile { + accelerator: AcceleratorClass::CpuOnly, + cpu_tier: CpuInstructionTier::Baseline, + cuda_driver_major: None, + cuda_driver_minor: None, + }; + let assets = vec![asset("llama-b9028-bin-linux-x86_64.tar.gz")]; + + assert!(select_release_assets("llamacpp", platform, hardware, &assets).is_none()); + } + #[test] fn uppercase_sha256_digest_is_accepted() { let platform = Platform { From 2593b5b3977e1d0dc271edbe0bceb0354313ff71 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Tue, 5 May 2026 11:26:53 +0300 Subject: [PATCH 064/126] docs: add codex project guidance --- AGENTS.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..a0cc6c73 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,43 @@ +# Axelate Agent Notes + +## Project Shape +- Axelate is a desktop AI launcher/workstation built with Tauri 2. +- Frontend lives in `src/`: TypeScript, Vite, Vitest, ESLint, Prettier, plain DOM/CSS UI modules, Tauri JS API, Specta-generated backend bindings. +- Backend lives in `src-tauri/`: Rust 2024, Tauri commands, Tokio async runtime, Reqwest networking, Specta/Tauri Specta bindings, JSON/TOML persistence, tracing logs. +- Runtime modules and launcher assets live under `src-tauri/resources/`; generated frontend output lives under `src/dist/`. +- Root `package.json` is a workflow proxy. Most frontend commands run through `npm --prefix src ...`; Rust commands use `src-tauri/Cargo.toml`. + +## Verification +- Prefer targeted checks while iterating: + - Frontend tests: `npm --prefix src run test -- --run ` + - Frontend typecheck: `npm --prefix src run typecheck` + - Backend tests: `cargo test --manifest-path src-tauri/Cargo.toml` + - Backend targeted tests: `cargo test --manifest-path src-tauri/Cargo.toml --lib` +- Before larger commits, run the smallest meaningful frontend and backend checks for the touched surface. +- `check-size` is informational for this desktop app; do not treat bundle size as a primary design constraint unless CI or release policy explicitly requires it. + +## Frontend Notes +- Reuse existing feature/controller/service boundaries instead of adding global shortcuts or one-off DOM patches. +- Keep UI text in I18n resources under `src-tauri/resources/locales/`; do not hardcode Russian or English user-facing strings in TS/HTML. +- Errors from providers, modules, downloads, and engines should surface as notifications/status UI, not as assistant chat messages unless they are actual model responses. +- Shared Tauri IPC access should go through existing provider/service wrappers and generated bindings where available. +- Do not rely on browser preview behavior as product behavior; the shipped runtime is the Tauri webview. + +## Backend Notes +- Keep API command modules in `src-tauri/src/api/`, domain logic in `src-tauri/src/domain/`, and filesystem/config/crypto/system adapters in `src-tauri/src/infrastructure/`. +- Preserve strict Rust lint policy: avoid `unwrap`, `expect`, `panic`, `todo`, and unchecked indexing in production code. +- Prefer typed errors/results over stringly-typed failures. Frontend-facing errors should remain stable enough for UI handling and tests. +- Keep Specta bindings synchronized when command request/response types change: `npm --prefix src run bindings:sync`. + +## Cross-Platform Direction +- The app is currently Windows-first, but new work should keep Windows, Linux, and macOS viable unless a feature is explicitly Windows-only. +- Put OS-specific code behind Rust `cfg(...)` gates or small platform adapters. Avoid scattering Windows assumptions through domain logic. +- Avoid hardcoded path separators, drive-letter assumptions, shell-specific commands, and `.exe`-only binary names outside platform-specific code. +- External engine/module release parsing must account for OS and architecture explicitly: Windows/Linux/macOS, x64/arm64/x86, archive formats, checksums, and GitHub release URL variants. +- Current Windows-specific areas include bundling (`msi`/`nsis`, WebView2), WMI/windows APIs, Windows speech recognition, process/window integration, and some engine binary expectations. Treat these as adaptation points when adding other platforms. +- When adding a feature that cannot work cross-platform yet, expose it as a capability check and degrade cleanly in UI rather than failing late. + +## Git Hygiene +- Do not commit generated dependency folders such as `src/node_modules/`. +- Do not edit `src/dist/` unless the task is specifically about generated build output. +- Keep commits scoped to one behavior area where possible: frontend UI, backend core/API, release parsing, settings persistence, etc. From 8b3ebecd04436d67dfd3b45b0a51fe18696ba7c6 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Tue, 5 May 2026 11:39:31 +0300 Subject: [PATCH 065/126] fix: normalize chat session ids --- src-tauri/src/domain/ai/ai_dispatch.rs | 29 +++++++++++++++++-- src-tauri/src/domain/ai/ai_service.rs | 7 +++-- src-tauri/src/domain/ai/image_service.rs | 4 +-- src/features/ai/services/AIBridgeRuntime.ts | 21 +++++--------- .../ai/utils/chatRequestUtils.test.ts | 11 +++++++ src/features/ai/utils/chatRequestUtils.ts | 8 +++-- 6 files changed, 56 insertions(+), 24 deletions(-) diff --git a/src-tauri/src/domain/ai/ai_dispatch.rs b/src-tauri/src/domain/ai/ai_dispatch.rs index 13380139..d664a3dd 100644 --- a/src-tauri/src/domain/ai/ai_dispatch.rs +++ b/src-tauri/src/domain/ai/ai_dispatch.rs @@ -21,6 +21,12 @@ struct LocalEngineResolution { messages_context: Vec, } +pub(super) fn normalize_session_id(value: Option<&str>) -> Option<&str> { + value + .map(str::trim) + .filter(|session_id| !session_id.is_empty()) +} + pub(super) async fn prepare_chat_dispatch( request: &ChatRequest, sessions: &ChatSessionManager, @@ -30,7 +36,7 @@ pub(super) async fn prepare_chat_dispatch( local_engine_access: LocalEngineAccess, ) -> Result { let mut messages_context = request.messages.clone(); - if let Some(session_id) = &request.session_id { + if let Some(session_id) = normalize_session_id(request.session_id.as_deref()) { messages_context = sessions.merge_request_messages(session_id, &request.messages); if !request.messages.is_empty() { if let Err(error) = sessions.force_save().await { @@ -95,7 +101,7 @@ pub(super) async fn persist_successful_response( if let Ok(response) = response && response.ok && let Some(reply) = &response.reply - && let Some(session_id) = session_id + && let Some(session_id) = normalize_session_id(session_id) { sessions.append_response( session_id, @@ -219,7 +225,7 @@ async fn resolve_local_engine_request( let status = engine_manager.start(config).await?; let mut messages_context = prepared_messages_context.to_vec(); - if let Some(session_id) = &request.session_id { + if let Some(session_id) = normalize_session_id(request.session_id.as_deref()) { messages_context = sessions.build_local_context( session_id, local_context_size, @@ -397,3 +403,20 @@ fn clamp_max_tokens(request_limit: Option, model_limit: Option) -> Opt (request_limit, None) => request_limit, } } + +#[cfg(test)] +mod tests { + use super::normalize_session_id; + + #[test] + fn normalize_session_id_rejects_blank_values() { + assert_eq!(normalize_session_id(None), None); + assert_eq!(normalize_session_id(Some("")), None); + assert_eq!(normalize_session_id(Some(" ")), None); + } + + #[test] + fn normalize_session_id_trims_valid_values() { + assert_eq!(normalize_session_id(Some(" session-1 ")), Some("session-1")); + } +} diff --git a/src-tauri/src/domain/ai/ai_service.rs b/src-tauri/src/domain/ai/ai_service.rs index 63171b11..2dd257de 100644 --- a/src-tauri/src/domain/ai/ai_service.rs +++ b/src-tauri/src/domain/ai/ai_service.rs @@ -8,7 +8,8 @@ use std::sync::Arc; use super::ai_dispatch::{ - LocalEngineAccess, PreparedChatDispatch, persist_successful_response, prepare_chat_dispatch, + LocalEngineAccess, PreparedChatDispatch, normalize_session_id, persist_successful_response, + prepare_chat_dispatch, }; use super::session::ChatSessionManager; use super::streaming::{AiProvider, OpenAiCompatibleProvider, StreamEvent, StreamSink}; @@ -206,7 +207,7 @@ async fn process_chat_request_with_local_engine_access( execute_prepared_request( execution, sessions, - session_id.as_deref(), + normalize_session_id(session_id.as_deref()), move |execution| async move { execution .provider @@ -257,7 +258,7 @@ async fn process_chat_request_non_stream_with_local_engine_access( execute_prepared_request( execution, sessions, - session_id.as_deref(), + normalize_session_id(session_id.as_deref()), |execution| async move { execution .provider diff --git a/src-tauri/src/domain/ai/image_service.rs b/src-tauri/src/domain/ai/image_service.rs index 9916b8f2..a04dbfce 100644 --- a/src-tauri/src/domain/ai/image_service.rs +++ b/src-tauri/src/domain/ai/image_service.rs @@ -1,4 +1,4 @@ -use super::ai_dispatch::LocalEngineAccess; +use super::ai_dispatch::{LocalEngineAccess, normalize_session_id}; use super::ai_service::stop_conflicting_local_engine; use super::image_cloud::{is_cloud_image_provider, process_cloud_image_request}; use super::image_comfyui::process_comfyui_request; @@ -80,7 +80,7 @@ async fn process_image_request_with_local_engine_access( } }; - if let Some(session_id) = request.session_id.as_deref() + if let Some(session_id) = normalize_session_id(request.session_id.as_deref()) && !images.is_empty() { let user_message = ChatMessage { diff --git a/src/features/ai/services/AIBridgeRuntime.ts b/src/features/ai/services/AIBridgeRuntime.ts index 300fd312..db6ad694 100644 --- a/src/features/ai/services/AIBridgeRuntime.ts +++ b/src/features/ai/services/AIBridgeRuntime.ts @@ -3,7 +3,6 @@ import type { AIBridgeContext } from './AIBridgeContext'; import type { AIBridgeEvents } from './AIBridgeEvents'; import type { IChatTransport } from './AIChatTransport'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; -import type { TauriProvider } from '@/infrastructure/tauri/TauriProvider'; import { resolveCustomProviderBackendId } from '@/shared/utils/customProviderSupport'; type AIBridgeRuntimeLogger = Pick; @@ -178,12 +177,9 @@ export class AIBridgeRuntime { return []; } - return await (context.tauriProvider as unknown as TauriProvider).invoke( - 'get_chat_history', - { - sessionId, - }, - ); + return await context.tauriProvider.invoke('get_chat_history', { + sessionId, + }); } public async clearHistory(context: AIBridgeContext | null, sessionId: string): Promise { @@ -191,7 +187,7 @@ export class AIBridgeRuntime { return; } - await (context.tauriProvider as unknown as TauriProvider).invoke('clear_chat_history', { + await context.tauriProvider.invoke('clear_chat_history', { sessionId, }); } @@ -229,11 +225,8 @@ export class AIBridgeRuntime { return null; } - return await (context.tauriProvider as unknown as TauriProvider).invoke( - 'rewind_last_turn', - { - sessionId, - }, - ); + return await context.tauriProvider.invoke('rewind_last_turn', { + sessionId, + }); } } diff --git a/src/features/ai/utils/chatRequestUtils.test.ts b/src/features/ai/utils/chatRequestUtils.test.ts index ba2c949b..fa878feb 100644 --- a/src/features/ai/utils/chatRequestUtils.test.ts +++ b/src/features/ai/utils/chatRequestUtils.test.ts @@ -164,5 +164,16 @@ describe('chatRequestUtils', () => { enabled: true, }); }); + + it('should omit blank session ids for non-persistent utility requests', () => { + const request = constructChatRequest([], mockMessage, [], { + providerId: 'gpt', + model: 'gpt-5.5', + apiKey: null, + sessionId: ' ', + }); + + expect(request.session_id).toBeUndefined(); + }); }); }); diff --git a/src/features/ai/utils/chatRequestUtils.ts b/src/features/ai/utils/chatRequestUtils.ts index 958cb188..5b6c91bc 100644 --- a/src/features/ai/utils/chatRequestUtils.ts +++ b/src/features/ai/utils/chatRequestUtils.ts @@ -36,7 +36,7 @@ export function constructChatRequest( providerId: string; model: string; apiKey: string | null; - sessionId: string; + sessionId?: string; thinkingLevel?: RequestThinkingLevel; maxTokens?: number | undefined; webSearchEnabled?: boolean; @@ -59,11 +59,15 @@ export function constructChatRequest( thought_signature: message.thought_signature, }, ], - session_id: sessionId, api_key: apiKey, attachments, }; + const normalizedSessionId = sessionId?.trim(); + if (normalizedSessionId !== undefined && normalizedSessionId !== '') { + request.session_id = normalizedSessionId; + } + if (thinkingLevel !== undefined) { request.thinking_level = thinkingLevel; } From 22587ca06cc4540b3afe56ae65338e7ae7738f0b Mon Sep 17 00:00:00 2001 From: F0RLE Date: Tue, 5 May 2026 11:48:52 +0300 Subject: [PATCH 066/126] fix: harden integration and catalog parsing --- src-tauri/src/domain/integration_api.rs | 80 ++++++++++++++++++++-- src/shared/services/CatalogService.test.ts | 19 +++++ src/shared/services/CatalogService.ts | 21 ++++++ 3 files changed, 115 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/domain/integration_api.rs b/src-tauri/src/domain/integration_api.rs index b754cb13..a1a9ef77 100644 --- a/src-tauri/src/domain/integration_api.rs +++ b/src-tauri/src/domain/integration_api.rs @@ -340,10 +340,17 @@ fn read_http_request(stream: &mut TcpStream) -> Result { .next() .ok_or_else(|| "HTTP path is missing".to_string())? .to_string(); + let version = request_parts + .next() + .ok_or_else(|| "HTTP version is missing".to_string())?; + if request_parts.next().is_some() { + return Err("HTTP request line has too many parts".to_string()); + } + if !version.starts_with("HTTP/") { + return Err(format!("Unsupported HTTP version: {version}")); + } - let headers = lines - .filter_map(parse_header_line) - .collect::>(); + let headers = parse_header_lines(lines)?; let content_length = headers.get("content-length").map_or(Ok(0_usize), |value| { value .parse::() @@ -394,6 +401,34 @@ fn parse_header_line(line: &str) -> Option<(String, String)> { Some((name.trim().to_ascii_lowercase(), value.trim().to_string())) } +fn parse_header_lines<'a>( + lines: impl Iterator, +) -> Result, String> { + let mut headers = HashMap::new(); + + for line in lines { + if line.trim().is_empty() { + continue; + } + + let Some((name, value)) = parse_header_line(line) else { + return Err(format!("Malformed HTTP header line: {line}")); + }; + + if name.is_empty() { + return Err("HTTP header name is empty".to_string()); + } + + if name == "content-length" && headers.contains_key("content-length") { + return Err("Duplicate content-length header".to_string()); + } + + headers.insert(name, value); + } + + Ok(headers) +} + async fn dispatch_http_request( request: HttpRequest, context: LauncherHttpApiContext, @@ -1019,8 +1054,8 @@ mod tests { use super::{ IntegrationTextRequest, backend_provider_id, find_header_end, is_authorized, model_api_id, - parse_header_line, parse_json_body, read_http_request, status_for_app_error, status_text, - tier_rank, + parse_header_line, parse_header_lines, parse_json_body, read_http_request, + status_for_app_error, status_text, tier_rank, }; use crate::errors::AppError; use crate::models::{AiModel, ApiModelConfig, ModelStats, ModelTier}; @@ -1061,6 +1096,22 @@ mod tests { assert_eq!(value, "Bearer abc"); } + #[test] + fn rejects_malformed_http_header_lines() { + let error = parse_header_lines(["Host: localhost", "broken header"].into_iter()) + .expect_err("malformed header must fail"); + + assert!(error.contains("Malformed HTTP header line")); + } + + #[test] + fn rejects_duplicate_content_length_headers() { + let error = parse_header_lines(["Content-Length: 1", "content-length: 2"].into_iter()) + .expect_err("duplicate content-length must fail"); + + assert_eq!(error, "Duplicate content-length header"); + } + #[test] fn finds_standard_http_header_separator() { assert_eq!(find_header_end(b"GET / HTTP/1.1\r\n\r\n"), Some(14)); @@ -1196,4 +1247,23 @@ mod tests { assert!(error.contains("expected 8 bytes, got 3")); } + + #[test] + fn rejects_malformed_http_request_line() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test listener"); + let addr = listener.local_addr().expect("local addr"); + let client = std::thread::spawn(move || { + let mut stream = TcpStream::connect(addr).expect("connect test listener"); + stream + .write_all(b"GET /v1/health HTTP/1.1 extra\r\n\r\n") + .expect("write request"); + stream.shutdown(Shutdown::Write).expect("shutdown write"); + }); + + let (mut stream, _) = listener.accept().expect("accept test client"); + let error = read_http_request(&mut stream).expect_err("bad request line must fail"); + client.join().expect("client thread"); + + assert_eq!(error, "HTTP request line has too many parts"); + } } diff --git a/src/shared/services/CatalogService.test.ts b/src/shared/services/CatalogService.test.ts index 8563e3c1..e94e55d4 100644 --- a/src/shared/services/CatalogService.test.ts +++ b/src/shared/services/CatalogService.test.ts @@ -197,6 +197,25 @@ describe('CatalogService', () => { const catalog = service.getCatalog(); expect(catalog.ai.length).toBe(FALLBACK_CONFIG.catalog.ai.length); }); + + it('should fallback when bridge returns malformed catalog shape', async () => { + setupBridgeMocks( + mockBridge, + createMockAppConfig({ + catalog: { ai: null, services: undefined }, + apiProviders: null, + }), + ); + + await service.loadCatalog(); + + const catalog = service.getCatalog(); + expect(catalog.ai.length).toBe(FALLBACK_CONFIG.catalog.ai.length); + expect(catalog.services.length).toBe(FALLBACK_CONFIG.catalog.services.length); + expect(globalThis.dispatchEvent).toHaveBeenCalledWith( + expect.objectContaining({ type: 'catalog-loaded' }), + ); + }); }); // ---------------------------------------------------------- _ensureValidConfig null config (lines 278-279) diff --git a/src/shared/services/CatalogService.ts b/src/shared/services/CatalogService.ts index 5354e3e4..ff93f442 100644 --- a/src/shared/services/CatalogService.ts +++ b/src/shared/services/CatalogService.ts @@ -296,6 +296,11 @@ export class CatalogService { return fallback; } + if (!this._hasCatalogArrays(config)) { + this._tracer.warn('[CatalogService] Config shape is invalid. Using FALLBACK_CONFIG.'); + return fallback; + } + if (config.catalog.ai.length === 0 && config.catalog.services.length === 0) { const aiLen = config.catalog.ai.length; const srvLen = config.catalog.services.length; @@ -306,4 +311,20 @@ export class CatalogService { } return config; } + + private _hasCatalogArrays(config: AppConfig): boolean { + const candidate = config as unknown as { + catalog?: { + ai?: unknown; + services?: unknown; + }; + apiProviders?: unknown; + }; + + return ( + Array.isArray(candidate.catalog?.ai) && + Array.isArray(candidate.catalog.services) && + Array.isArray(candidate.apiProviders) + ); + } } From 40d674bda845cb830b05c9f103793add148af702 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Tue, 5 May 2026 12:18:02 +0300 Subject: [PATCH 067/126] test: add core coverage tooling --- .github/scripts/workflow.mjs | 55 +++++++ package.json | 3 + src/app/events.test.ts | 142 +++++++++++++++++- src/shared/api/invoke.test.ts | 92 ++++++++++++ .../services/ModulePlatformService.test.ts | 127 ++++++++++++++++ src/shared/services/StateManager.test.ts | 95 ++++++++++++ src/vite.config.ts | 4 + 7 files changed, 510 insertions(+), 8 deletions(-) diff --git a/.github/scripts/workflow.mjs b/.github/scripts/workflow.mjs index c5800d97..84143990 100644 --- a/.github/scripts/workflow.mjs +++ b/.github/scripts/workflow.mjs @@ -168,6 +168,14 @@ function withPassthroughArgs(baseArgs) { return [...baseArgs, '--', ...passthroughArgs]; } +function withDirectPassthroughArgs(baseArgs) { + if (passthroughArgs.length === 0) { + return baseArgs; + } + + return [...baseArgs, ...passthroughArgs]; +} + function withEnvOverrides(overrides = {}) { return { ...toolEnv(), @@ -175,6 +183,30 @@ function withEnvOverrides(overrides = {}) { }; } +function ensureCargoLlvmCov() { + if (commandExists('cargo-llvm-cov', toolEnv())) { + ensureLlvmToolsPreview(); + return; + } + + log('cargo-llvm-cov not found; installing with cargo install --locked'); + run('cargo', ['install', 'cargo-llvm-cov', '--locked']); + ensureLlvmToolsPreview(); +} + +function ensureLlvmToolsPreview() { + if (!commandExists('rustup', toolEnv())) { + return; + } + + run('rustup', ['component', 'add', 'llvm-tools-preview']); +} + +function runRustCoverage(args = []) { + ensureCargoLlvmCov(); + run('cargo', ['llvm-cov', ...args], { cwd: tauriDir }); +} + function checkCommand(label, command, args = ['--version'], options = {}) { const cwd = options.cwd ?? repoRoot; const env = options.env ?? toolEnv(); @@ -753,6 +785,9 @@ Tasks: format:check Check frontend formatting test Run frontend tests test:coverage Run frontend tests with coverage + test:coverage:all Run frontend and Rust coverage + rust:test:coverage Run Rust tests with coverage summary + rust:test:coverage:lcov Generate Rust LCOV report at src-tauri/lcov.info test:watch Run frontend tests in watch mode typecheck Run frontend type checks verify Run the full local verification pipeline @@ -848,6 +883,26 @@ Tasks: 'test:coverage'() { run('npm', ['--prefix', 'src', 'run', 'test:coverage']); }, + 'test:coverage:all'() { + tasks['test:coverage'](); + tasks['rust:test:coverage'](); + }, + 'rust:test:coverage'() { + runRustCoverage( + withDirectPassthroughArgs(['--workspace', '--all-features', '--summary-only']), + ); + }, + 'rust:test:coverage:lcov'() { + runRustCoverage( + withDirectPassthroughArgs([ + '--workspace', + '--all-features', + '--lcov', + '--output-path', + 'lcov.info', + ]), + ); + }, 'test:watch'() { run('npm', ['--prefix', 'src', 'run', 'test:watch']); }, diff --git a/package.json b/package.json index 113e245d..e13352fd 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,9 @@ "bindings:check": "npm --prefix src run bindings:check", "test": "node .github/scripts/workflow.mjs test", "test:coverage": "node .github/scripts/workflow.mjs test:coverage", + "test:coverage:all": "node .github/scripts/workflow.mjs test:coverage:all", + "rust:test:coverage": "node .github/scripts/workflow.mjs rust:test:coverage", + "rust:test:coverage:lcov": "node .github/scripts/workflow.mjs rust:test:coverage:lcov", "test:watch": "node .github/scripts/workflow.mjs test:watch", "typecheck": "node .github/scripts/workflow.mjs typecheck", "doctor": "node .github/scripts/workflow.mjs doctor", diff --git a/src/app/events.test.ts b/src/app/events.test.ts index 9a27a9b9..11951484 100644 --- a/src/app/events.test.ts +++ b/src/app/events.test.ts @@ -54,19 +54,22 @@ function createCoreEvents(): ICoreEvents { } describe('EventHandler', () => { + const runtime = { + addWindowListener: vi.fn(), + removeWindowListener: vi.fn(), + }; + beforeEach(() => { document.body.className = ''; document.body.innerHTML = ''; + vi.clearAllMocks(); }); it('blocks sidebar navigation while release download selection is open', async () => { document.body.innerHTML = ``; document.body.classList.add('download-selection-open'); const core = createCoreEvents(); - const handler = new EventHandler(core, { - addWindowListener: vi.fn(), - removeWindowListener: vi.fn(), - }); + const handler = new EventHandler(core, runtime); handler.init(); const navButton = document.querySelector('[data-page]'); @@ -83,10 +86,7 @@ describe('EventHandler', () => { it('allows sidebar navigation when release download selection is closed', async () => { document.body.innerHTML = ``; const core = createCoreEvents(); - const handler = new EventHandler(core, { - addWindowListener: vi.fn(), - removeWindowListener: vi.fn(), - }); + const handler = new EventHandler(core, runtime); handler.init(); const navButton = document.querySelector('[data-page]'); @@ -99,4 +99,130 @@ describe('EventHandler', () => { expect(core.navigationUI.showPage).toHaveBeenCalledOnce(); handler.destroy(); }); + + it('delegates language menu and language selection clicks', async () => { + document.body.innerHTML = ` + + + `; + const core = createCoreEvents(); + const handler = new EventHandler(core, runtime); + handler.init(); + + document.querySelector('#current-lang-trigger')?.click(); + document.querySelector('.lang-btn')?.click(); + await flushAsyncNavigation(); + + expect(core.i18nUI.toggleMenu).toHaveBeenCalledOnce(); + expect(core.i18nUI.setLanguage).toHaveBeenCalledWith('ru'); + handler.destroy(); + }); + + it('opens module selection from add buttons and module cards', async () => { + document.body.innerHTML = ` + +
    Services
    +
    + `; + const core = createCoreEvents(); + const handler = new EventHandler(core, runtime); + handler.init(); + + document.querySelector('#ai-module-add-btn')?.click(); + document.querySelector('#services-module-card')?.click(); + document.querySelector('.module-settings-btn')?.click(); + await flushAsyncNavigation(); + + expect(core.appUI.openAppSelection).toHaveBeenNthCalledWith(1, 'ai'); + expect(core.appUI.openAppSelection).toHaveBeenNthCalledWith(2, 'services'); + expect(core.appUI.openAppSelection).toHaveBeenCalledTimes(2); + handler.destroy(); + }); + + it('delegates chat action clicks', async () => { + document.body.innerHTML = ` + + + + +
    + + +
    + `; + const core = createCoreEvents(); + const handler = new EventHandler(core, runtime); + handler.init(); + + document.querySelector('#clear-chat-btn')?.click(); + document.querySelector('[data-chat-attach-action="file"]')?.click(); + document.querySelector('[data-chat-attach-action="image"]')?.click(); + document.querySelector('#chat-attach-btn')?.click(); + document.querySelector('#chat-voice-btn')?.click(); + document.querySelector('#chat-send-btn')?.click(); + await flushAsyncNavigation(); + + expect(core.chatController.clearChat).toHaveBeenCalledOnce(); + expect(core.chatController.pickChatFilesFromMenu).toHaveBeenCalledOnce(); + expect(core.chatController.sendImageGenerationFromMenu).toHaveBeenCalledOnce(); + expect(core.chatController.toggleAttachMenu).toHaveBeenCalledOnce(); + expect(core.chatController.toggleVoiceInput).toHaveBeenCalledOnce(); + expect(core.chatController.sendChat).toHaveBeenCalledOnce(); + handler.destroy(); + }); + + it('binds modal, language confirmation, and window control actions', async () => { + document.body.innerHTML = ` + + + + + + `; + let windowClickHandler: EventListenerOrEventListenerObject | undefined; + const localRuntime: ConstructorParameters[1] = { + addWindowListener: vi.fn( + (event: string, handler: EventListenerOrEventListenerObject) => { + if (event === 'click') windowClickHandler = handler; + }, + ), + removeWindowListener: vi.fn(), + }; + const core = createCoreEvents(); + const handler = new EventHandler(core, localRuntime); + handler.init(); + + document.querySelector('#close-app-selection-btn')?.click(); + document.querySelector('#close-app-selection-btn-alt')?.click(); + document.querySelector('.lang-modal-btn')?.click(); + document.querySelector('#confirm-lang-btn')?.click(); + document.querySelector('#close-module-settings-btn')?.click(); + await flushAsyncNavigation(); + + expect(core.appUI.closeAppSelection).toHaveBeenCalledTimes(2); + expect(core.i18nUI.selectLangInModal).toHaveBeenCalledWith('en'); + expect(core.i18nUI.confirmLanguage).toHaveBeenCalledOnce(); + expect(core.moduleSettingsUI.close).toHaveBeenCalledOnce(); + + for (const id of ['minimize-btn', 'maximize-btn', 'close-btn', 'sound-toggle-btn']) { + const button = document.createElement('button'); + button.id = id; + const event = new MouseEvent('click', { bubbles: true }); + Object.defineProperty(event, 'target', { value: button }); + if (typeof windowClickHandler === 'function') { + windowClickHandler(event); + } else { + windowClickHandler?.handleEvent(event); + } + } + await flushAsyncNavigation(); + + expect(core.windowService.minimize).toHaveBeenCalledOnce(); + expect(core.windowUI.toggleMaximize).toHaveBeenCalledOnce(); + expect(core.windowService.close).toHaveBeenCalledOnce(); + expect(core.windowUI.toggleSound).toHaveBeenCalledOnce(); + + handler.destroy(); + expect(localRuntime.removeWindowListener).toHaveBeenCalledOnce(); + }); }); diff --git a/src/shared/api/invoke.test.ts b/src/shared/api/invoke.test.ts index 34fa9b65..b21273fe 100644 --- a/src/shared/api/invoke.test.ts +++ b/src/shared/api/invoke.test.ts @@ -10,6 +10,21 @@ import { invokeSafe } from './invoke'; const invokeMock = vi.mocked(tauriInvoke); describe('invokeSafe', () => { + it('returns ok data from string commands', async () => { + invokeMock.mockResolvedValueOnce({ version: '1.0.0' }); + + const result = await invokeSafe('get_version', { verbose: true }); + + expect(invokeMock).toHaveBeenCalledWith('get_version', { verbose: true }); + expect(result).toEqual({ status: 'ok', data: { version: '1.0.0' } }); + }); + + it('returns ok data from specta results', async () => { + const result = await invokeSafe(Promise.resolve({ status: 'ok', data: 42 })); + + expect(result).toEqual({ status: 'ok', data: 42 }); + }); + it('uses Error.message for transport exceptions', async () => { invokeMock.mockRejectedValueOnce(new Error('Download cancelled')); @@ -21,6 +36,47 @@ describe('invokeSafe', () => { } }); + it('uses string transport exceptions as messages', async () => { + invokeMock.mockRejectedValueOnce('backend unavailable'); + + const result = await invokeSafe('get_status'); + + expect(result.status).toBe('error'); + if (result.status === 'error') { + expect(result.error.code).toBe('INVOKE_EXCEPTION'); + expect(result.error.message).toBe('backend unavailable'); + expect(result.error.details).toBe('backend unavailable'); + } + }); + + it('uses structured transport error messages', async () => { + invokeMock.mockRejectedValueOnce({ message: 'invalid payload', code: 'BAD_PAYLOAD' }); + + const result = await invokeSafe('save_settings'); + + expect(result.status).toBe('error'); + if (result.status === 'error') { + expect(result.error.code).toBe('INVOKE_EXCEPTION'); + expect(result.error.message).toBe('invalid payload'); + } + }); + + it('preserves specta error codes and messages', async () => { + const result = await invokeSafe( + Promise.resolve({ + status: 'error', + error: { code: 'RATE_LIMITED', message: 'Try later' }, + }), + ); + + expect(result.status).toBe('error'); + if (result.status === 'error') { + expect(result.error.code).toBe('RATE_LIMITED'); + expect(result.error.message).toBe('Try later'); + expect(result.error.details).toEqual({ code: 'RATE_LIMITED', message: 'Try later' }); + } + }); + it('stringifies structured payload errors from specta results', async () => { const result = await invokeSafe( Promise.resolve({ @@ -34,4 +90,40 @@ describe('invokeSafe', () => { expect(result.error.message).toBe('{"reason":"rate limited"}'); } }); + + it('passes string payload errors through directly', async () => { + const result = await invokeSafe( + Promise.resolve({ + status: 'error', + error: { payload: 'plain payload error' }, + }), + ); + + expect(result.status).toBe('error'); + if (result.status === 'error') { + expect(result.error.message).toBe('plain payload error'); + } + }); + + it('falls back to UNKNOWN and JSON for unstructured specta errors', async () => { + const result = await invokeSafe(Promise.resolve({ status: 'error', error: { ok: false } })); + + expect(result.status).toBe('error'); + if (result.status === 'error') { + expect(result.error.code).toBe('UNKNOWN'); + expect(result.error.message).toBe('{"ok":false}'); + } + }); + + it('falls back to String when error JSON serialization fails', async () => { + const circular: Record = {}; + circular['self'] = circular; + + const result = await invokeSafe(Promise.resolve({ status: 'error', error: circular })); + + expect(result.status).toBe('error'); + if (result.status === 'error') { + expect(result.error.message).toBe('[object Object]'); + } + }); }); diff --git a/src/shared/services/ModulePlatformService.test.ts b/src/shared/services/ModulePlatformService.test.ts index 449847c3..bb05c814 100644 --- a/src/shared/services/ModulePlatformService.test.ts +++ b/src/shared/services/ModulePlatformService.test.ts @@ -23,11 +23,17 @@ vi.mock('@/shared/api/invoke', () => ({ function createMockModuleService(): ModuleService { return { downloadModule: vi.fn().mockResolvedValue(undefined), + getReleaseDownloadOptions: vi.fn().mockResolvedValue({ + releases: [{ tag_name: 'v1.0.0', name: '1.0.0', assets: [] }], + }), deleteModule: vi.fn().mockResolvedValue(true), control: vi.fn().mockResolvedValue(true), pauseDownload: vi.fn().mockResolvedValue(true), resumeDownload: vi.fn().mockResolvedValue(true), cancelDownload: vi.fn().mockResolvedValue(true), + checkInstalled: vi.fn().mockResolvedValue(true), + getStatus: vi.fn().mockResolvedValue('running'), + getDownloadState: vi.fn().mockReturnValue({ status: 'downloading', progress: 50 }), } as unknown as ModuleService; } @@ -107,6 +113,31 @@ describe('ModulePlatformService', () => { }); }); + describe('getReleaseDownloadOptions', () => { + it('returns null when app has no release source', async () => { + await expect( + service.getReleaseDownloadOptions(createApp({ dlType: 'archive' })), + ).resolves.toBeNull(); + await expect( + service.getReleaseDownloadOptions(createApp({ repoUrl: '', dlType: 'release' })), + ).resolves.toBeNull(); + }); + + it('loads release options for release downloads', async () => { + const result = await service.getReleaseDownloadOptions( + createApp({ dlType: 'release' }), + ); + + expect(moduleService.getReleaseDownloadOptions).toHaveBeenCalledWith( + 'test-module', + 'https://repo.com/module.zip', + ); + expect(result).toEqual({ + releases: [{ tag_name: 'v1.0.0', name: '1.0.0', assets: [] }], + }); + }); + }); + describe('delete', () => { it('should delete a module successfully', async () => { const app = createApp(); @@ -132,6 +163,16 @@ describe('ModulePlatformService', () => { expect(moduleService.deleteModule).not.toHaveBeenCalled(); }); + it('throws AI engine command errors', async () => { + mocks.deleteEngine.mockReturnValue('delete-engine-promise'); + mocks.invokeSafe.mockResolvedValue({ + status: 'error', + error: { message: 'engine busy' }, + }); + + await expect(service.delete(createApp(), 'ai_image')).rejects.toThrow('engine busy'); + }); + it('should skip deleting externally managed AI engines', async () => { const app = createApp({ id: 'external-engine', @@ -202,6 +243,39 @@ describe('ModulePlatformService', () => { expect(aiBridge.stopProvider).toHaveBeenCalled(); expect(moduleService.control).not.toHaveBeenCalled(); }); + + it('should skip stopping externally managed local modules', async () => { + const app = createApp({ + id: 'external', + type: 'local', + managedExternally: true, + }); + (aiBridge.getState as ReturnType).mockReturnValue({ + activeProviderId: undefined, + isRunning: false, + }); + + const result = await service.stop(app); + + expect(result).toBe(true); + expect(moduleService.control).not.toHaveBeenCalled(); + }); + + it('should stop AI text and image engine slots by category', async () => { + (aiBridge.getState as ReturnType).mockReturnValue({ + activeProviderId: undefined, + isRunning: false, + }); + + await expect(service.stop(createApp({ id: 'llamacpp' }), 'ai_text')).resolves.toBe( + true, + ); + await expect(service.stop(createApp({ id: 'sdcpp' }), 'ai_image')).resolves.toBe(true); + + expect(aiBridge.stopEngineSlot).toHaveBeenNthCalledWith(1, 'text'); + expect(aiBridge.stopEngineSlot).toHaveBeenNthCalledWith(2, 'image'); + expect(moduleService.control).not.toHaveBeenCalled(); + }); }); describe('cancelDownload', () => { @@ -228,6 +302,59 @@ describe('ModulePlatformService', () => { }); }); + describe('status and download state', () => { + it('checks local installation through module service', async () => { + await expect(service.checkInstalled('llamacpp')).resolves.toBe(true); + + expect(moduleService.checkInstalled).toHaveBeenCalledWith('llamacpp'); + }); + + it('reports active API provider as running', async () => { + await expect(service.getStatus(createApp({ type: 'api' }))).resolves.toBe('running'); + }); + + it('reports inactive API provider as stopped without backend calls', async () => { + (aiBridge.getState as ReturnType).mockReturnValue({ + activeProviderId: 'other', + isRunning: true, + }); + + await expect(service.getStatus(createApp({ type: 'api' }))).resolves.toBe('stopped'); + + expect(moduleService.getStatus).not.toHaveBeenCalled(); + }); + + it('uses app status for externally managed modules', async () => { + (aiBridge.getState as ReturnType).mockReturnValue({ + activeProviderId: undefined, + isRunning: false, + }); + + await expect( + service.getStatus(createApp({ managedExternally: true, status: 'running' })), + ).resolves.toBe('running'); + await expect( + service.getStatus(createApp({ managedExternally: true, status: 'stopped' })), + ).resolves.toBe('stopped'); + }); + + it('delegates local module status and download state to module service', async () => { + (aiBridge.getState as ReturnType).mockReturnValue({ + activeProviderId: undefined, + isRunning: false, + }); + + await expect(service.getStatus(createApp({ id: 'ollama' }))).resolves.toBe('running'); + expect(service.getDownloadState('ollama')).toEqual({ + status: 'downloading', + progress: 50, + }); + + expect(moduleService.getStatus).toHaveBeenCalledWith('ollama'); + expect(moduleService.getDownloadState).toHaveBeenCalledWith('ollama'); + }); + }); + describe('isApiModule', () => { it('should return true for type "api"', () => { expect(service.isApiModule(createApp({ type: 'api' }))).toBe(true); diff --git a/src/shared/services/StateManager.test.ts b/src/shared/services/StateManager.test.ts index 976f240f..a22ef3d4 100644 --- a/src/shared/services/StateManager.test.ts +++ b/src/shared/services/StateManager.test.ts @@ -19,6 +19,41 @@ describe('StateManager', () => { beforeEach(() => { vi.clearAllMocks(); + document.body.innerHTML = ''; + }); + + it('registers, unregisters, and skips empty saves', async () => { + const manager = new StateManager(tracer); + const target = createTarget('cache'); + + await manager.saveAllAsync(); + await manager.saveAllImmediate(); + manager.register(target); + manager.unregister('cache'); + await manager.saveAllAsync(); + await manager.saveAllImmediate(); + + expect(target.saveAsync).not.toHaveBeenCalled(); + expect(target.saveImmediate).not.toHaveBeenCalled(); + expect(tracer.debug).toHaveBeenCalledWith('[StateManager] Registered: cache'); + }); + + it('saves all async targets and logs failures', async () => { + const manager = new StateManager(tracer); + const ok = createTarget('ok'); + const failing = createTarget('failing'); + vi.mocked(failing.saveAsync).mockRejectedValueOnce(new Error('disk full')); + manager.register(ok); + manager.register(failing); + + await manager.saveAllAsync(); + + expect(ok.saveAsync).toHaveBeenCalledOnce(); + expect(failing.saveAsync).toHaveBeenCalledOnce(); + expect(tracer.warn).toHaveBeenCalledWith( + '[StateManager] Failed to save failing: Error: disk full', + ); + expect(tracer.info).toHaveBeenCalledWith('[StateManager] Save complete: 1 ok, 1 failed'); }); it('should flush registered targets before destroy disables saving', async () => { @@ -59,14 +94,74 @@ describe('StateManager', () => { expect(settled).toBe(true); }); + it('logs immediate save failures without rejecting', async () => { + const manager = new StateManager(tracer); + const target = createTarget('window'); + vi.mocked(target.saveImmediate).mockRejectedValueOnce('locked'); + manager.register(target); + + await expect(manager.saveAllImmediate()).resolves.toBeUndefined(); + + expect(tracer.warn).toHaveBeenCalledWith( + '[StateManager] Immediate save failed for window: locked', + ); + }); + + it('saves on visibility hidden and beforeunload', async () => { + const manager = new StateManager(tracer); + const target = createTarget(); + manager.register(target); + manager.init(); + + Object.defineProperty(document, 'hidden', { + configurable: true, + value: true, + }); + document.dispatchEvent(new Event('visibilitychange')); + await Promise.resolve(); + await Promise.resolve(); + globalThis.dispatchEvent(new Event('beforeunload')); + await Promise.resolve(); + await Promise.resolve(); + + expect(target.saveAsync).toHaveBeenCalledOnce(); + expect(target.saveImmediate).toHaveBeenCalledOnce(); + + await manager.destroy(); + }); + + it('does not save on visibility visible and removes listeners on destroy', async () => { + const manager = new StateManager(tracer); + const target = createTarget(); + manager.register(target); + manager.init(); + + Object.defineProperty(document, 'hidden', { + configurable: true, + value: false, + }); + document.dispatchEvent(new Event('visibilitychange')); + await Promise.resolve(); + await manager.destroy(); + globalThis.dispatchEvent(new Event('beforeunload')); + document.dispatchEvent(new Event('visibilitychange')); + await Promise.resolve(); + + expect(target.saveAsync).not.toHaveBeenCalled(); + expect(target.saveImmediate).toHaveBeenCalledOnce(); + await expect(manager.destroy()).resolves.toBeUndefined(); + }); + it('should not save targets registered after destroy', async () => { const manager = new StateManager(tracer); await manager.destroy(); const target = createTarget(); manager.register(target); + await manager.saveAllAsync(); await manager.saveAllImmediate(); + expect(target.saveAsync).not.toHaveBeenCalled(); expect(target.saveImmediate).not.toHaveBeenCalled(); }); }); diff --git a/src/vite.config.ts b/src/vite.config.ts index c9e700d1..b1a2bb88 100644 --- a/src/vite.config.ts +++ b/src/vite.config.ts @@ -166,6 +166,10 @@ export default defineConfig({ exclude: [ // Pure TypeScript interface — no executable lines, always 0% '**/shared/types/IBridge.ts', + // Generated Specta bindings. Coverage belongs to the generator contract, not app tests. + '**/shared/types/bindings.ts', + '**/*.d.ts', + '**/coverage/**', ], thresholds: { 'src/features/**/services/*.ts': { From 9b4ca1171c786db84a906460082495579dd84044 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Tue, 5 May 2026 12:39:09 +0300 Subject: [PATCH 068/126] test: cover backend api helpers --- src-tauri/src/api/settings/mod.rs | 14 + src-tauri/src/api/settings/window_settings.rs | 79 ++++++ src-tauri/src/api/system/health.rs | 14 + src-tauri/src/api/system/logs.rs | 260 ++++++++++++++++++ src-tauri/src/domain/system/config_service.rs | 204 ++++++++++++++ 5 files changed, 571 insertions(+) diff --git a/src-tauri/src/api/settings/mod.rs b/src-tauri/src/api/settings/mod.rs index 816f3436..77f902aa 100644 --- a/src-tauri/src/api/settings/mod.rs +++ b/src-tauri/src/api/settings/mod.rs @@ -76,3 +76,17 @@ pub async fn save_module_settings( pub fn get_system_language() -> Result { Ok(settings::get_language_sync()) } + +#[cfg(test)] +mod tests { + use super::get_system_language; + + #[test] + fn get_system_language_returns_supported_code() { + let result = get_system_language(); + assert!(result.is_ok()); + let lang = result.ok().unwrap_or_default(); + + assert!(matches!(lang.as_str(), "en" | "ru" | "zh")); + } +} diff --git a/src-tauri/src/api/settings/window_settings.rs b/src-tauri/src/api/settings/window_settings.rs index e8199b3c..cb9d77bb 100644 --- a/src-tauri/src/api/settings/window_settings.rs +++ b/src-tauri/src/api/settings/window_settings.rs @@ -230,3 +230,82 @@ pub async fn get_window_policy( effective_h, )) } + +#[cfg(test)] +mod tests { + use super::get_window_config; + use super::resolve_zoom; + use crate::infrastructure::config::window_settings::{ + BP_COMPACT, BP_LARGE, BP_MEDIUM, SCALING_MAX_ZOOM, SCALING_MIN_ZOOM, + THRESHOLD_SMALL_SCREEN_HEIGHT, THRESHOLD_SMALL_SCREEN_WIDTH, THRESHOLD_WARNING_HEIGHT, + THRESHOLD_WARNING_WIDTH, + }; + use crate::models::UIState; + + fn assert_zoom_eq(actual: f64, expected: f64) { + assert!( + (actual - expected).abs() < f64::EPSILON, + "expected zoom {expected}, got {actual}" + ); + } + + #[test] + fn resolve_zoom_prefers_saved_resolution_zoom() { + let mut state = UIState { + zoom_level: 1.15, + ..UIState::default() + }; + state.resolution_zoom.insert("1920x1080".to_string(), 1.35); + + assert_zoom_eq(resolve_zoom(&state, "1920x1080"), 1.35); + } + + #[test] + fn resolve_zoom_falls_back_to_global_zoom_then_default() { + let global = UIState { + zoom_level: 1.2, + ..UIState::default() + }; + let invalid_global = UIState { + zoom_level: 0.0, + ..UIState::default() + }; + + assert_zoom_eq(resolve_zoom(&global, "missing"), 1.2); + assert_zoom_eq(resolve_zoom(&invalid_global, "missing"), 1.0); + } + + #[test] + fn resolve_zoom_ignores_non_positive_resolution_zoom_and_clamps_bounds() { + let mut state = UIState { + zoom_level: 1.1, + ..UIState::default() + }; + state.resolution_zoom.insert("invalid".to_string(), -2.0); + state.resolution_zoom.insert("too-low".to_string(), 0.01); + state.resolution_zoom.insert("too-high".to_string(), 99.0); + + assert_zoom_eq(resolve_zoom(&state, "invalid"), 1.1); + assert_zoom_eq(resolve_zoom(&state, "too-low"), SCALING_MIN_ZOOM); + assert_zoom_eq(resolve_zoom(&state, "too-high"), SCALING_MAX_ZOOM); + } + + #[test] + fn get_window_config_exposes_scaling_bounds() { + let config = get_window_config(); + + assert_eq!(config.breakpoints.compact, BP_COMPACT); + assert_eq!(config.breakpoints.medium, BP_MEDIUM); + assert_eq!(config.breakpoints.large, BP_LARGE); + assert_eq!(config.thresholds.warning_width, THRESHOLD_WARNING_WIDTH); + assert_eq!(config.thresholds.warning_height, THRESHOLD_WARNING_HEIGHT); + assert_eq!( + config.thresholds.small_screen_width, + THRESHOLD_SMALL_SCREEN_WIDTH + ); + assert_eq!( + config.thresholds.small_screen_height, + THRESHOLD_SMALL_SCREEN_HEIGHT + ); + } +} diff --git a/src-tauri/src/api/system/health.rs b/src-tauri/src/api/system/health.rs index cd5fddcd..de8f5a03 100644 --- a/src-tauri/src/api/system/health.rs +++ b/src-tauri/src/api/system/health.rs @@ -7,3 +7,17 @@ use crate::errors::AppError; pub fn get_health() -> Result { Ok(services::health::check()) } + +#[cfg(test)] +mod tests { + use super::get_health; + + #[test] + fn get_health_returns_ok_status() { + let result = get_health(); + assert!(result.is_ok()); + let status = result.ok().unwrap_or_default(); + + assert_eq!(status, "ok"); + } +} diff --git a/src-tauri/src/api/system/logs.rs b/src-tauri/src/api/system/logs.rs index e6685085..8800a9c8 100644 --- a/src-tauri/src/api/system/logs.rs +++ b/src-tauri/src/api/system/logs.rs @@ -604,3 +604,263 @@ impl ConsoleLabelFormatter { } } } + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::{ + ConsoleLabelFormatter, ConsoleOverviewBuilder, ConsoleRuntimeStatus, + canonical_console_view_id, canonical_engine_id, clear_all_console_log_files, + clear_console_log_target, resolve_console_log_target, + }; + use crate::domain::engine::types::{Capability, EngineState, EngineStatus, SlotStatus}; + use crate::infrastructure::logging::LogEntry; + use crate::models::{SelectedModule, UIState}; + use std::collections::HashMap; + use std::fs; + + fn selected_module(id: &str, name: &str, type_: &str) -> SelectedModule { + SelectedModule { + id: id.to_string(), + name: name.to_string(), + name_key: None, + icon: "box".to_string(), + type_: type_.to_string(), + desc_key: None, + desc: String::new(), + } + } + + fn log_entry(module_id: Option<&str>) -> LogEntry { + LogEntry { + timestamp: 1.0, + source: "test".to_string(), + level: "info".to_string(), + message: "message".to_string(), + module_id: module_id.map(str::to_string), + display_time: None, + normalized_level: None, + scope: None, + summary_message: None, + source_label: None, + source_class: None, + page: None, + action: None, + expected: None, + } + } + + #[test] + fn canonicalizes_engine_ids_and_console_view_ids() { + assert_eq!(canonical_engine_id(" Stable_Diffusion.cpp "), "sdcpp"); + assert_eq!(canonical_engine_id("llama cpp"), "llama-cpp"); + assert_eq!( + canonical_console_view_id("engine:Stable Diffusion.cpp"), + "engine:sdcpp" + ); + assert_eq!( + canonical_console_view_id(" module:example "), + "module:example" + ); + } + + #[test] + fn formats_console_labels_and_capabilities() { + assert_eq!( + ConsoleLabelFormatter::format_module_label("axelate-open-webui"), + "Open Webui" + ); + assert_eq!(ConsoleLabelFormatter::format_module_label("--"), ""); + assert_eq!( + ConsoleLabelFormatter::format_capability(Capability::Text), + "text" + ); + assert_eq!( + ConsoleLabelFormatter::format_capability(Capability::Image), + "image" + ); + assert_eq!( + ConsoleLabelFormatter::format_capability(Capability::Vision), + "vision" + ); + } + + #[test] + fn resolves_console_log_targets_by_view_kind() { + let engine_target = resolve_console_log_target("engine:Stable Diffusion.cpp"); + let module_target = resolve_console_log_target("module:comfyui"); + let general_target = resolve_console_log_target("general"); + + assert!(engine_target.ends_with("sdcpp")); + assert!(module_target.ends_with("comfyui")); + assert_ne!(general_target, module_target); + } + + #[test] + fn clears_general_and_nested_console_logs_only() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + let general = root.join("axelate.log"); + let nested_dir = root.join("nested"); + let nested_log = nested_dir.join("module.log"); + let nested_txt = nested_dir.join("keep.txt"); + fs::create_dir_all(&nested_dir).unwrap(); + fs::write(&general, "general").unwrap(); + fs::write(&nested_log, "module").unwrap(); + fs::write(&nested_txt, "text").unwrap(); + + clear_console_log_target("general", root).unwrap(); + assert_eq!(fs::read_to_string(&general).unwrap(), ""); + assert_eq!(fs::read_to_string(&nested_log).unwrap(), "module"); + + clear_all_console_log_files(root).unwrap(); + assert_eq!(fs::read_to_string(&nested_log).unwrap(), ""); + assert_eq!(fs::read_to_string(&nested_txt).unwrap(), "text"); + } + + #[test] + fn clear_console_log_target_ignores_missing_targets_and_non_log_files() { + let temp = tempfile::tempdir().unwrap(); + let missing = temp.path().join("missing"); + let target = temp.path().join("target"); + let text_file = target.join("keep.txt"); + fs::create_dir_all(&target).unwrap(); + fs::write(&text_file, "keep").unwrap(); + + clear_console_log_target("module:missing", &missing).unwrap(); + clear_console_log_target("module:target", &target).unwrap(); + + assert_eq!(fs::read_to_string(text_file).unwrap(), "keep"); + } + + #[tokio::test] + async fn console_overview_deduplicates_views_and_reports_engine_states() { + let mut ui_state = UIState::default(); + ui_state.selected_modules.insert( + "services".to_string(), + selected_module("comfyui", "ComfyUI", "service"), + ); + ui_state.selected_modules.insert( + "ai_text".to_string(), + selected_module("stable diffusion.cpp", "Stable Diffusion.cpp", "local"), + ); + ui_state.selected_modules.insert( + "ai_image".to_string(), + selected_module("cloud", "Cloud", "api"), + ); + let logs = vec![log_entry(Some("comfyui")), log_entry(Some("unknown"))]; + let engine = EngineStatus { + id: "stable_diffusion.cpp".to_string(), + name: "Stable Diffusion.cpp".to_string(), + capabilities: vec![Capability::Image], + endpoint: "http://127.0.0.1:7860".to_string(), + healthy: true, + }; + let state = EngineState::Ready { + slots: vec![ + SlotStatus { + capability: Capability::Image, + engine: engine.clone(), + }, + SlotStatus { + capability: Capability::Vision, + engine, + }, + ], + }; + + let overview = ConsoleOverviewBuilder::build(&state, &ui_state, &logs).await; + let views = overview + .views + .iter() + .map(|view| view.id.as_str()) + .collect::>(); + let engine_status = overview + .status_items + .iter() + .find(|item| item.id == "engine:sdcpp") + .unwrap(); + + assert_eq!(views, vec!["general", "engine:sdcpp", "module:comfyui"]); + assert!(matches!( + engine_status.status, + ConsoleRuntimeStatus::Running + )); + assert_eq!(engine_status.detail, "image, vision"); + } + + #[tokio::test] + async fn console_overview_builds_status_rows_for_non_ready_states() { + let cases = [ + ( + EngineState::Idle, + "engine:idle", + ConsoleRuntimeStatus::Stopped, + "No active engines", + ), + ( + EngineState::Starting { + engine_id: "llama-cpp".to_string(), + }, + "engine:llama-cpp", + ConsoleRuntimeStatus::Starting, + "Starting…", + ), + ( + EngineState::Swapping { + from: "old".to_string(), + to: "new".to_string(), + }, + "engine:new", + ConsoleRuntimeStatus::Starting, + "Switching from old", + ), + ( + EngineState::Error { + engine_id: "bad".to_string(), + message: "boom".to_string(), + }, + "engine:bad", + ConsoleRuntimeStatus::Failed, + "boom", + ), + ]; + + for (state, expected_id, expected_status, expected_detail) in cases { + let overview = + ConsoleOverviewBuilder::build(&state, &UIState::default(), &Vec::::new()) + .await; + let item = overview.status_items.first().unwrap(); + assert_eq!(item.id, expected_id); + assert!( + std::mem::discriminant(&item.status) == std::mem::discriminant(&expected_status) + ); + assert_eq!(item.detail, expected_detail); + } + } + + #[test] + fn module_label_collection_excludes_api_modules() { + let mut modules = HashMap::new(); + modules.insert( + "services".to_string(), + selected_module("service-module", "Service Module", "service"), + ); + modules.insert( + "ai_text".to_string(), + selected_module("local-engine", "Local Engine", "local"), + ); + modules.insert( + "ai_image".to_string(), + selected_module("api-engine", "API Engine", "api"), + ); + + let module_labels = ConsoleOverviewBuilder::collect_module_labels(&modules); + let engine_labels = ConsoleOverviewBuilder::collect_selected_engine_labels(&modules); + + assert!(module_labels.contains_key("service-module")); + assert!(engine_labels.contains_key("local-engine")); + assert!(!engine_labels.contains_key("api-engine")); + } +} diff --git a/src-tauri/src/domain/system/config_service.rs b/src-tauri/src/domain/system/config_service.rs index cfd1ca0a..cbb59c28 100644 --- a/src-tauri/src/domain/system/config_service.rs +++ b/src-tauri/src/domain/system/config_service.rs @@ -127,3 +127,207 @@ impl ConfigService { self.repo.load_custom_models() } } + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::ConfigService; + use crate::domain::system::config_repository::ConfigRepository; + use crate::errors::AppError; + use crate::models::config::{ApiProvider, AppMeta, ConfigCatalog, ModuleItem, ProviderType}; + use crate::models::custom_models::{CustomModel, CustomModelConfig}; + use serde_json::json; + + #[derive(Debug)] + struct FakeConfigRepository { + providers: Vec, + local_modules: Vec, + custom_models: CustomModelConfig, + } + + impl ConfigRepository for FakeConfigRepository { + fn load_app_meta(&self) -> Result { + Ok(AppMeta { + version: "9.9.9".to_string(), + }) + } + + fn load_api_providers(&self) -> Result, AppError> { + Ok(self.providers.clone()) + } + + fn load_local_modules(&self) -> Result, AppError> { + Ok(self.local_modules.clone()) + } + + fn load_custom_models(&self) -> Result { + Ok(self.custom_models.clone()) + } + } + + fn provider( + id: &str, + provider_type: Option, + base_url: Option<&str>, + capabilities: Option>, + ) -> ApiProvider { + ApiProvider { + id: id.to_string(), + name: format!("{id} Provider"), + desc_key: None, + description: None, + icon: None, + provider_type, + base_url: base_url.map(str::to_string), + api_key_env: None, + models: None, + capabilities: capabilities.map(|items| items.into_iter().map(str::to_string).collect()), + model_aliases: None, + } + } + + fn module(value: serde_json::Value) -> ModuleItem { + serde_json::from_value(value).unwrap() + } + + fn service(repo: FakeConfigRepository) -> ConfigService { + ConfigService::new(Box::new(repo)) + } + + #[test] + fn load_full_config_hydrates_api_provider_modules() { + let service = service(FakeConfigRepository { + providers: vec![ + provider( + "openai-compatible", + Some(ProviderType::OpenaiCompatible), + Some("https://example.test/v1"), + None, + ), + provider( + "image-api", + Some(ProviderType::Api), + None, + Some(vec!["image"]), + ), + ], + local_modules: Vec::new(), + custom_models: CustomModelConfig::default(), + }); + + let config = service.load_full_config().unwrap(); + let compatible = config + .catalog + .ai + .iter() + .find(|item| item.id == "openai-compatible") + .unwrap(); + let image_api = config + .catalog + .ai + .iter() + .find(|item| item.id == "image-api") + .unwrap(); + let schema = compatible.config_schema.as_ref().unwrap(); + + assert_eq!(config.version, "9.9.9"); + assert!(compatible.installed); + assert_eq!(compatible.capabilities, vec!["text"]); + assert!(schema.contains_key("apiKey")); + assert_eq!( + schema + .get("endpoint") + .and_then(|field| field.default.as_ref()), + Some(&json!("https://example.test/v1")) + ); + assert_eq!(image_api.capabilities, vec!["image"]); + assert!( + image_api + .config_schema + .as_ref() + .unwrap() + .contains_key("apiKey") + ); + assert!( + !image_api + .config_schema + .as_ref() + .unwrap() + .contains_key("endpoint") + ); + } + + #[test] + fn load_full_config_routes_local_modules_by_type() { + let service = service(FakeConfigRepository { + providers: Vec::new(), + local_modules: vec![ + module(json!({ + "id": "local-ai", + "nameKey": "ui.module.local_ai", + "descKey": "ui.module.local_ai.desc", + "name": "Local AI", + "desc": "Local model", + "icon": "cpu", + "type": "local", + "repoUrl": null, + "expectedHash": null + })), + module(json!({ + "id": "service-module", + "nameKey": "ui.module.service", + "descKey": "ui.module.service.desc", + "name": "Service", + "desc": "Service module", + "icon": "plug", + "type": "service", + "repoUrl": null, + "expectedHash": null + })), + ], + custom_models: CustomModelConfig::default(), + }); + + let ConfigCatalog { + ai, + services, + stars, + } = service.load_full_config().unwrap().catalog; + + assert_eq!(ai.len(), 1); + assert_eq!(ai.first().map(|item| item.id.as_str()), Some("local-ai")); + assert_eq!(services.len(), 1); + assert_eq!( + services.first().map(|item| item.id.as_str()), + Some("service-module") + ); + assert!(stars.is_empty()); + } + + #[test] + fn load_custom_models_delegates_to_repository() { + let custom_models = CustomModelConfig { + models: vec![CustomModel { + id: "custom".to_string(), + name: "Custom".to_string(), + provider_id: "gpt".to_string(), + base_model_id: "base".to_string(), + created_at: 123.0, + }], + }; + let service = service(FakeConfigRepository { + providers: Vec::new(), + local_modules: Vec::new(), + custom_models, + }); + + let loaded = service.load_custom_models().unwrap(); + + assert_eq!(loaded.models.len(), 1); + assert_eq!( + loaded.models.first().map(|model| model.id.as_str()), + Some("custom") + ); + } +} From 283eb63b929c0d4f2f93350717ddec6f0f7ee870 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Tue, 5 May 2026 12:53:19 +0300 Subject: [PATCH 069/126] test: cover module preview helpers --- .../src/domain/modules/controller/mod.rs | 215 ++++++++++++++++++ 1 file changed, 215 insertions(+) diff --git a/src-tauri/src/domain/modules/controller/mod.rs b/src-tauri/src/domain/modules/controller/mod.rs index e0e714b6..ba75f0b3 100644 --- a/src-tauri/src/domain/modules/controller/mod.rs +++ b/src-tauri/src/domain/modules/controller/mod.rs @@ -431,3 +431,218 @@ pub async fn control( }), } } + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::{ + ModuleAction, apply_localized_module_preview, load_preview_translations, + preview_image_mime, read_module_preview_image, resolve_module_preview, + }; + use crate::models::modules::ModulePreview; + use std::str::FromStr; + + #[test] + fn module_action_parses_supported_actions_case_insensitively() { + assert_eq!( + ModuleAction::from_str("start").unwrap(), + ModuleAction::Start + ); + assert_eq!(ModuleAction::from_str("STOP").unwrap(), ModuleAction::Stop); + assert_eq!( + ModuleAction::from_str("Restart").unwrap(), + ModuleAction::Restart + ); + assert_eq!( + ModuleAction::from_str("install").unwrap(), + ModuleAction::Install + ); + assert_eq!( + ModuleAction::from_str("uninstall").unwrap(), + ModuleAction::Uninstall + ); + assert_eq!( + ModuleAction::from_str("update").unwrap(), + ModuleAction::Update + ); + assert!(ModuleAction::from_str("delete").is_err()); + } + + #[test] + fn preview_image_mime_accepts_supported_extensions() { + assert_eq!( + preview_image_mime(std::path::Path::new("card.PNG")).unwrap(), + "image/png" + ); + assert_eq!( + preview_image_mime(std::path::Path::new("card.jpg")).unwrap(), + "image/jpeg" + ); + assert_eq!( + preview_image_mime(std::path::Path::new("card.jpeg")).unwrap(), + "image/jpeg" + ); + assert_eq!( + preview_image_mime(std::path::Path::new("card.webp")).unwrap(), + "image/webp" + ); + assert_eq!( + preview_image_mime(std::path::Path::new("card.gif")).unwrap(), + "image/gif" + ); + assert_eq!( + preview_image_mime(std::path::Path::new("card.svg")).unwrap(), + "image/svg+xml" + ); + assert!(preview_image_mime(std::path::Path::new("card.txt")).is_err()); + } + + #[tokio::test] + async fn read_module_preview_image_returns_data_url_and_rejects_unsafe_paths() { + let temp = tempfile::tempdir().unwrap(); + let image_path = temp.path().join("preview.png"); + tokio::fs::write(&image_path, b"png-bytes").await.unwrap(); + + let data_url = read_module_preview_image(temp.path(), "preview.png") + .await + .unwrap(); + + assert_eq!(data_url, "data:image/png;base64,cG5nLWJ5dGVz"); + assert!( + read_module_preview_image(temp.path(), "../preview.png") + .await + .is_err() + ); + assert!( + read_module_preview_image(temp.path(), "missing.png") + .await + .is_err() + ); + assert!( + read_module_preview_image(temp.path(), "preview.txt") + .await + .is_err() + ); + } + + #[tokio::test] + async fn read_module_preview_image_rejects_large_files_and_directories() { + let temp = tempfile::tempdir().unwrap(); + let large_path = temp.path().join("large.png"); + let dir_path = temp.path().join("dir.png"); + tokio::fs::write(&large_path, vec![0_u8; 2 * 1024 * 1024 + 1]) + .await + .unwrap(); + tokio::fs::create_dir(&dir_path).await.unwrap(); + + assert!( + read_module_preview_image(temp.path(), "large.png") + .await + .is_err() + ); + assert!( + read_module_preview_image(temp.path(), "dir.png") + .await + .is_err() + ); + } + + #[test] + fn load_preview_translations_accepts_supported_language_prefixes() { + let temp = tempfile::tempdir().unwrap(); + let i18n = temp.path().join("i18n"); + std::fs::create_dir(&i18n).unwrap(); + std::fs::write( + i18n.join("ru.json"), + r#"{"preview.title":"Ru title","preview.description":"Ru description"}"#, + ) + .unwrap(); + + let translations = + load_preview_translations(temp.path(), std::path::Path::new("i18n"), "ru-BY").unwrap(); + + assert_eq!( + translations + .get("preview.title") + .and_then(serde_json::Value::as_str), + Some("Ru title") + ); + assert!( + load_preview_translations(temp.path(), std::path::Path::new("i18n"), "fr").is_none() + ); + } + + #[test] + fn apply_localized_module_preview_ignores_unsafe_i18n_paths() { + let temp = tempfile::tempdir().unwrap(); + let mut traversal = ModulePreview { + title: Some("Original".to_string()), + i18n: Some("../i18n".to_string()), + ..ModulePreview::default() + }; + let mut absolute = ModulePreview { + title: Some("Original".to_string()), + i18n: Some(temp.path().to_string_lossy().to_string()), + ..ModulePreview::default() + }; + + apply_localized_module_preview(temp.path(), &mut traversal); + apply_localized_module_preview(temp.path(), &mut absolute); + + assert_eq!(traversal.title.as_deref(), Some("Original")); + assert_eq!(absolute.title.as_deref(), Some("Original")); + } + + #[tokio::test] + async fn resolve_module_preview_keeps_external_images_and_embeds_local_images() { + let temp = tempfile::tempdir().unwrap(); + let i18n = temp.path().join("i18n"); + std::fs::create_dir(&i18n).unwrap(); + std::fs::write( + i18n.join("en.json"), + r#"{"preview.title":"Localized","preview.description":"Localized description"}"#, + ) + .unwrap(); + tokio::fs::write(temp.path().join("preview.svg"), b"") + .await + .unwrap(); + + let embedded = resolve_module_preview( + temp.path(), + Some(ModulePreview { + title: Some("Original".to_string()), + description: None, + image: Some("preview.svg".to_string()), + i18n: Some("i18n".to_string()), + ..ModulePreview::default() + }), + ) + .await + .unwrap(); + let external = resolve_module_preview( + temp.path(), + Some(ModulePreview { + image: Some("https://example.test/card.png".to_string()), + ..ModulePreview::default() + }), + ) + .await + .unwrap(); + + assert_eq!(embedded.title.as_deref(), Some("Localized")); + assert_eq!( + embedded.description.as_deref(), + Some("Localized description") + ); + assert_eq!( + embedded.image.as_deref(), + Some("data:image/svg+xml;base64,PHN2Zy8+") + ); + assert_eq!( + external.image.as_deref(), + Some("https://example.test/card.png") + ); + assert!(resolve_module_preview(temp.path(), None).await.is_none()); + } +} From 6cc240a2143dd292032bedfccae21a4a47dc69e5 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Tue, 5 May 2026 13:00:27 +0300 Subject: [PATCH 070/126] test: cover engine api helpers --- src-tauri/src/api/engine/mod.rs | 186 ++++++++++++++++++++++++++++---- 1 file changed, 165 insertions(+), 21 deletions(-) diff --git a/src-tauri/src/api/engine/mod.rs b/src-tauri/src/api/engine/mod.rs index c60e7ecc..fea8596e 100644 --- a/src-tauri/src/api/engine/mod.rs +++ b/src-tauri/src/api/engine/mod.rs @@ -14,7 +14,7 @@ use crate::domain::engine::types::{ }; use crate::errors::AppError; use crate::infrastructure::config::engine_settings::{ - load_engine_config_map, save_engine_config_map, + EngineConfigMap, load_engine_config_map, save_engine_config_map, }; use tauri::State; @@ -25,6 +25,36 @@ pub struct EngineSettingsPayload { pub config: EngineConfig, } +fn engine_config_for_definition(def: &EngineDefinition, saved: &EngineConfigMap) -> EngineConfig { + saved.get(&def.id).map_or_else( + || build_default_engine_config(def), + |config| merge_user_engine_config(def, config), + ) +} + +fn engine_settings_payload_for_definition( + def: &EngineDefinition, + saved: &EngineConfigMap, +) -> EngineSettingsPayload { + EngineSettingsPayload { + config: engine_config_for_definition(def, saved), + } +} + +fn normalize_config_for_save(def: &EngineDefinition, mut config: EngineConfig) -> EngineConfig { + config.engine_id = canonical_engine_id(&config.engine_id); + merge_user_engine_config(def, &normalize_engine_config(config)) +} + +fn mark_engine_definitions_installed( + defs: &mut [EngineDefinition], + mut is_installed: impl FnMut(&EngineDefinition) -> bool, +) { + for def in defs { + def.installed = def.managed_externally || is_installed(def); + } +} + #[tauri::command] #[specta::specta] /// Starts a local engine. Hot-swaps if another engine is active. @@ -95,13 +125,9 @@ pub async fn get_engine_definitions( ) -> Result, AppError> { let mut defs = engine_manager.list_definitions().await; // Populate `installed` at request time — no extra round-trip needed from frontend - for def in &mut defs { - def.installed = if def.managed_externally { - true - } else { - crate::domain::engine::detector::is_engine_installed(&def.id, def.binary.as_deref()) - }; - } + mark_engine_definitions_installed(&mut defs, |def| { + crate::domain::engine::detector::is_engine_installed(&def.id, def.binary.as_deref()) + }); Ok(defs) } @@ -119,11 +145,7 @@ pub async fn get_engine_config( .ok_or_else(|| AppError::Config(format!("Unknown engine: {engine_id}")))?; let saved = load_engine_config_map().await?; - if let Some(config) = saved.get(&engine_id) { - return Ok(merge_user_engine_config(&def, config)); - } - - Ok(build_default_engine_config(&def)) + Ok(engine_config_for_definition(&def, &saved)) } #[tauri::command] @@ -140,13 +162,7 @@ pub async fn get_engine_settings_payload( .ok_or_else(|| AppError::Config(format!("Unknown engine: {engine_id}")))?; let saved = load_engine_config_map().await?; - let config = if let Some(config) = saved.get(&engine_id) { - merge_user_engine_config(&def, config) - } else { - build_default_engine_config(&def) - }; - - Ok(EngineSettingsPayload { config }) + Ok(engine_settings_payload_for_definition(&def, &saved)) } #[tauri::command] @@ -164,7 +180,135 @@ pub async fn set_engine_config( .ok_or_else(|| AppError::Config(format!("Unknown engine: {}", config.engine_id)))?; let mut map = load_engine_config_map().await?; - let normalized = merge_user_engine_config(&def, &normalize_engine_config(config)); + let normalized = normalize_config_for_save(&def, config); map.insert(normalized.engine_id.clone(), normalized); save_engine_config_map(&map).await } + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::*; + use crate::domain::engine::types::EngineComputeMode; + + fn sample_definition(id: &str) -> EngineDefinition { + EngineDefinition { + id: id.to_string(), + name: format!("{id} engine"), + desc: String::new(), + icon: String::new(), + capabilities: vec![Capability::Text], + binary: Some(format!("{id}-server")), + repo_url: None, + version: "1.0.0".to_string(), + default_port: 8081, + default_context_size: 8192, + config_schema: None, + installed: false, + managed_externally: false, + } + } + + fn saved_config(engine_id: &str) -> EngineConfig { + EngineConfig { + engine_id: engine_id.to_string(), + compute_mode: EngineComputeMode::Cpu, + context_size: 2048, + model_path: Some("C:/models/model.gguf".to_string()), + extra_args: vec!["--threads".to_string(), "8".to_string()], + } + } + + #[test] + fn engine_config_for_definition_uses_defaults_when_no_saved_config_exists() { + let def = sample_definition("sdcpp"); + let config = engine_config_for_definition(&def, &EngineConfigMap::default()); + + assert_eq!(config.engine_id, "sdcpp"); + assert_eq!(config.compute_mode, EngineComputeMode::Gpu); + assert_eq!(config.context_size, 8192); + assert_eq!(config.model_path, None); + assert!(config.extra_args.is_empty()); + } + + #[test] + fn engine_config_for_definition_merges_saved_config_and_normalizes_llamacpp() { + let def = sample_definition("llamacpp"); + let mut saved = EngineConfigMap::default(); + saved.insert(def.id.clone(), saved_config("llamacpp")); + + let config = engine_config_for_definition(&def, &saved); + + assert_eq!(config.compute_mode, EngineComputeMode::Cpu); + assert_eq!(config.context_size, 4096); + assert_eq!(config.model_path.as_deref(), Some("C:/models/model.gguf")); + assert_eq!(config.extra_args, vec!["--threads", "8"]); + } + + #[test] + fn engine_settings_payload_wraps_the_resolved_config() { + let def = sample_definition("sdcpp"); + let mut saved = EngineConfigMap::default(); + saved.insert(def.id.clone(), saved_config("sdcpp")); + + let payload = engine_settings_payload_for_definition(&def, &saved); + + assert_eq!(payload.config.engine_id, "sdcpp"); + assert_eq!(payload.config.compute_mode, EngineComputeMode::Cpu); + } + + #[test] + fn normalize_config_for_save_canonicalizes_aliases_before_persisting() { + let def = sample_definition("sdcpp"); + let normalized = normalize_config_for_save(&def, saved_config("stable-diffusion")); + + assert_eq!(normalized.engine_id, "sdcpp"); + assert_eq!(normalized.compute_mode, EngineComputeMode::Cpu); + assert_eq!(normalized.context_size, 2048); + } + + #[test] + fn mark_engine_definitions_installed_keeps_external_engines_available() { + let external = EngineDefinition { + managed_externally: true, + binary: None, + ..sample_definition("external") + }; + let mut defs = vec![sample_definition("missing"), external]; + + mark_engine_definitions_installed(&mut defs, |def| def.id == "missing"); + + assert!(defs.iter().all(|def| def.installed)); + } + + #[test] + fn mark_engine_definitions_installed_marks_missing_local_engines_uninstalled() { + let mut defs = vec![sample_definition("missing")]; + + mark_engine_definitions_installed(&mut defs, |_| false); + + assert!(defs.iter().all(|def| !def.installed)); + } + + #[test] + fn engine_settings_payload_serializes_as_expected() { + let payload = EngineSettingsPayload { + config: EngineConfig { + engine_id: "cloud".to_string(), + compute_mode: EngineComputeMode::Gpu, + context_size: 4096, + model_path: None, + extra_args: vec![], + }, + }; + let json = serde_json::to_value(&payload).unwrap(); + + assert_eq!( + json.get("config") + .and_then(|config| config.get("engine_id")) + .and_then(serde_json::Value::as_str), + Some("cloud") + ); + } +} From 69ca9d3ffd82084a4fbd0d7c399e7532bc2b4141 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Tue, 5 May 2026 13:07:37 +0300 Subject: [PATCH 071/126] test: cover module downloader api helpers --- src-tauri/src/api/modules/downloader.rs | 117 ++++++++++++++---- src-tauri/src/domain/modules/downloader.rs | 4 +- .../src/domain/modules/downloader_service.rs | 6 + 3 files changed, 104 insertions(+), 23 deletions(-) diff --git a/src-tauri/src/api/modules/downloader.rs b/src-tauri/src/api/modules/downloader.rs index 96571043..c795a747 100644 --- a/src-tauri/src/api/modules/downloader.rs +++ b/src-tauri/src/api/modules/downloader.rs @@ -1,7 +1,37 @@ use crate::domain::modules::downloader; +use crate::domain::modules::downloader::DownloadRequest; use crate::errors::AppError; +use std::path::Path; use tauri::AppHandle; +fn resume_request_for_module( + module_id: &str, + request: Option, +) -> Result { + request + .ok_or_else(|| AppError::NotFound(format!("No paused download metadata for {module_id}"))) +} + +fn list_regular_file_names(path: &Path) -> Result, AppError> { + if !path.exists() { + return Err(AppError::NotFound( + "Module directory does not exist".to_string(), + )); + } + + let entries = std::fs::read_dir(path)?; + let mut files = Vec::new(); + for entry in entries { + let entry = entry?; + if entry.file_type()?.is_file() { + files.push(entry.file_name().to_string_lossy().to_string()); + } + } + files.sort(); + + Ok(files) +} + #[tauri::command] #[specta::specta] /// Downloads and verifies a module from a Git repository @@ -44,9 +74,7 @@ pub async fn resume_download( downloader: tauri::State<'_, downloader::DownloaderService>, module_id: String, ) -> Result { - let request = downloader.get_request(&module_id).ok_or_else(|| { - AppError::NotFound(format!("No paused download metadata for {module_id}")) - })?; + let request = resume_request_for_module(&module_id, downloader.get_request(&module_id))?; downloader::download_module( app, @@ -92,24 +120,7 @@ pub async fn list_module_files(module_id: &str) -> Result, AppError> downloader::validate_module_id(module_id)?; let path = downloader::get_module_path(module_id); - if !path.exists() { - return Err(AppError::NotFound( - "Module directory does not exist".to_string(), - )); - } - - let entries = std::fs::read_dir(path)?; - - let mut files = Vec::new(); - for entry in entries.flatten() { - if let Ok(file_type) = entry.file_type() - && file_type.is_file() - { - files.push(entry.file_name().to_string_lossy().to_string()); - } - } - - Ok(files) + list_regular_file_names(&path) } #[tauri::command] @@ -145,3 +156,67 @@ pub fn pause_download( ) -> bool { downloader.pause(&module_id) } + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::*; + use crate::domain::modules::github_releases::{ReleaseComputeTarget, ReleaseDownloadSelection}; + + fn request() -> DownloadRequest { + DownloadRequest { + repo_url: "https://github.com/example/module".to_string(), + expected_hash: Some("sha256".to_string()), + dl_type: Some("release".to_string()), + release_selection: Some(ReleaseDownloadSelection { + tag_name: Some("v1.0.0".to_string()), + compute_target: ReleaseComputeTarget::Cpu, + }), + } + } + + #[test] + fn resume_request_for_module_returns_stored_request() { + let request = request(); + let resolved = resume_request_for_module("llamacpp", Some(request.clone())).unwrap(); + + assert_eq!(resolved.repo_url, request.repo_url); + assert_eq!(resolved.expected_hash, request.expected_hash); + assert_eq!(resolved.dl_type, request.dl_type); + assert_eq!( + resolved + .release_selection + .map(|selection| selection.tag_name), + Some(Some("v1.0.0".to_string())) + ); + } + + #[test] + fn resume_request_for_module_reports_missing_metadata() { + let error = resume_request_for_module("llamacpp", None).unwrap_err(); + + assert!(matches!(error, AppError::NotFound(_))); + assert!(error.to_string().contains("llamacpp")); + } + + #[test] + fn list_regular_file_names_returns_sorted_files_only() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write(temp.path().join("zeta.toml"), "z").unwrap(); + std::fs::write(temp.path().join("alpha.toml"), "a").unwrap(); + std::fs::create_dir(temp.path().join("nested")).unwrap(); + + let files = list_regular_file_names(temp.path()).unwrap(); + + assert_eq!(files, vec!["alpha.toml", "zeta.toml"]); + } + + #[test] + fn list_regular_file_names_reports_missing_directory() { + let temp = tempfile::tempdir().unwrap(); + let error = list_regular_file_names(&temp.path().join("missing")).unwrap_err(); + + assert!(matches!(error, AppError::NotFound(_))); + } +} diff --git a/src-tauri/src/domain/modules/downloader.rs b/src-tauri/src/domain/modules/downloader.rs index 38b4a795..da990c93 100644 --- a/src-tauri/src/domain/modules/downloader.rs +++ b/src-tauri/src/domain/modules/downloader.rs @@ -4,7 +4,7 @@ use super::downloader_progress::{ AggregateDownloadContext, DownloadInterruption, ProgressEvent, ProgressSnapshot, compute_progress, emit_progress, }; -use super::downloader_service::{DownloadRequest, resolve_existing_module_path}; +use super::downloader_service::resolve_existing_module_path; use super::downloader_support::{package_install_dir, remove_partial_metadata}; use super::downloader_transfer::{ DownloadTask, ReleaseDownloadAsset, build_client, build_public_client, clone_repository_into, @@ -15,7 +15,7 @@ use crate::errors::AppError; use std::path::{Path, PathBuf}; use tauri::AppHandle; -pub use super::downloader_service::DownloaderService; +pub use super::downloader_service::{DownloadRequest, DownloaderService}; /// Validates module ID to prevent directory traversal and injection attacks pub fn validate_module_id(module_id: &str) -> Result<(), AppError> { diff --git a/src-tauri/src/domain/modules/downloader_service.rs b/src-tauri/src/domain/modules/downloader_service.rs index 1fb1b54e..72a5893a 100644 --- a/src-tauri/src/domain/modules/downloader_service.rs +++ b/src-tauri/src/domain/modules/downloader_service.rs @@ -36,11 +36,17 @@ pub struct DownloaderService { requests: Arc>>, } +/// Backend-owned request metadata retained so interrupted downloads can resume without frontend +/// resending stale package details. #[derive(Clone, Debug)] pub struct DownloadRequest { + /// Source repository or package URL. pub repo_url: String, + /// Optional expected content hash for verification. pub expected_hash: Option, + /// Optional download type hint, for example a release bundle. pub dl_type: Option, + /// Optional explicit GitHub release and compute-target selection. pub release_selection: Option, } From b813a10fa6a1c358c9e5904eee1b995abb35e109 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Tue, 5 May 2026 13:14:26 +0300 Subject: [PATCH 072/126] test: cover chat dispatch model resolution --- src-tauri/src/domain/ai/ai_dispatch.rs | 233 ++++++++++++++++++++++--- 1 file changed, 207 insertions(+), 26 deletions(-) diff --git a/src-tauri/src/domain/ai/ai_dispatch.rs b/src-tauri/src/domain/ai/ai_dispatch.rs index d664a3dd..2c02a028 100644 --- a/src-tauri/src/domain/ai/ai_dispatch.rs +++ b/src-tauri/src/domain/ai/ai_dispatch.rs @@ -21,6 +21,12 @@ struct LocalEngineResolution { messages_context: Vec, } +struct CloudProviderResolution { + base_url: String, + effective_model: String, + model_max_tokens: Option, +} + pub(super) fn normalize_session_id(value: Option<&str>) -> Option<&str> { value .map(str::trim) @@ -353,46 +359,75 @@ fn resolve_cloud_provider_request( .iter() .find(|provider| provider.id == request.provider) { - if let Some(url) = &provider.base_url { - base_url.clone_from(url); - } + let custom_models = config_service.load_custom_models().ok(); + let resolution = resolve_cloud_provider_values( + &request.provider, + &request.model, + "https://openrouter.ai/api/v1", + provider, + custom_models.as_ref(), + ); + base_url.clone_from(&resolution.base_url); + effective_model.clone_from(&resolution.effective_model); + *model_max_tokens = resolution.model_max_tokens; + } +} - if let Some(target) = provider - .model_aliases - .as_ref() - .and_then(|aliases| aliases.get(&request.model)) - { - tracing::info!("Resolved model alias: {} -> {}", request.model, target); - effective_model.clone_from(target); - } +fn resolve_cloud_provider_values( + provider_id: &str, + request_model: &str, + default_base_url: &str, + provider: &crate::models::config::ApiProvider, + custom_models: Option<&crate::models::custom_models::CustomModelConfig>, +) -> CloudProviderResolution { + let base_url = provider + .base_url + .clone() + .unwrap_or_else(|| default_base_url.to_string()); + let mut effective_model = request_model.to_string(); + let mut model_max_tokens = None; + + if let Some(target) = provider + .model_aliases + .as_ref() + .and_then(|aliases| aliases.get(request_model)) + { + tracing::info!("Resolved model alias: {request_model} -> {target}"); + effective_model.clone_from(target); + } - if let Some(models) = &provider.models - && let Some(definition) = models.iter().find(|model| model.id == *effective_model) + if let Some(models) = &provider.models + && let Some(definition) = models.iter().find(|model| model.id == effective_model) + { + model_max_tokens = definition.max_output_tokens; + if let Some(api_model) = definition + .api_models + .as_ref() + .and_then(|models| models.text.as_ref()) { - *model_max_tokens = definition.max_output_tokens; - if let Some(api_model) = definition - .api_models - .as_ref() - .and_then(|models| models.text.as_ref()) - { - tracing::info!("Resolved API model ID: {effective_model} -> {api_model}"); - *effective_model = api_model.clone(); - } + tracing::info!("Resolved API model ID: {effective_model} -> {api_model}"); + effective_model = api_model.clone(); } } - if let Ok(custom_models) = config_service.load_custom_models() + if let Some(custom_models) = custom_models && let Some(custom) = custom_models .models .iter() - .find(|model| model.id == *effective_model && model.provider_id == request.provider) + .find(|model| model.id == effective_model && model.provider_id == provider_id) { tracing::info!( "Resolved Custom Model: {} -> {}", effective_model, custom.base_model_id ); - *effective_model = custom.base_model_id.clone(); + effective_model = custom.base_model_id.clone(); + } + + CloudProviderResolution { + base_url, + effective_model, + model_max_tokens, } } @@ -406,7 +441,58 @@ fn clamp_max_tokens(request_limit: Option, model_limit: Option) -> Opt #[cfg(test)] mod tests { - use super::normalize_session_id; + #![allow(clippy::unwrap_used)] + + use super::{ + clamp_max_tokens, normalize_session_id, resolve_cloud_provider_values, + resolve_local_text_model_id, + }; + use crate::models::config::{ + AiModel, ApiModelConfig, ApiProvider, ModelStats, ModelTier, ProviderType, + }; + use crate::models::custom_models::{CustomModel, CustomModelConfig}; + use std::collections::HashMap; + + fn provider() -> ApiProvider { + ApiProvider { + id: "gpt".to_string(), + name: "GPT".to_string(), + desc_key: None, + description: None, + icon: None, + provider_type: Some(ProviderType::Openai), + base_url: Some("https://api.example.test/v1".to_string()), + api_key_env: None, + models: Some(vec![AiModel { + id: "catalog-model".to_string(), + desc_key: String::new(), + name: "Catalog Model".to_string(), + desc: String::new(), + tier: ModelTier::Strong, + model_size: None, + release_date: None, + context_window: Some(128_000), + max_output_tokens: Some(16_384), + deprecated: None, + pricing: None, + stats: ModelStats { + speed: 8, + logic: 9, + creative: 7, + }, + capabilities: None, + api_models: Some(ApiModelConfig { + text: Some("provider-text-model".to_string()), + image: None, + }), + }]), + capabilities: Some(vec!["text".to_string()]), + model_aliases: Some(HashMap::from([( + "ui-model".to_string(), + "catalog-model".to_string(), + )])), + } + } #[test] fn normalize_session_id_rejects_blank_values() { @@ -419,4 +505,99 @@ mod tests { fn normalize_session_id_trims_valid_values() { assert_eq!(normalize_session_id(Some(" session-1 ")), Some("session-1")); } + + #[test] + fn resolve_local_text_model_id_prefers_explicit_model() { + assert_eq!( + resolve_local_text_model_id( + "custom-model.gguf", + Some("C:/models/default.gguf"), + "llamacpp" + ), + "custom-model.gguf" + ); + } + + #[test] + fn resolve_local_text_model_id_uses_model_file_name_for_default_request() { + assert_eq!( + resolve_local_text_model_id("default", Some("C:/models/chat-model.gguf"), "llamacpp"), + "chat-model.gguf" + ); + } + + #[test] + fn resolve_local_text_model_id_falls_back_to_provider() { + assert_eq!( + resolve_local_text_model_id("default", None, "llamacpp"), + "llamacpp" + ); + assert_eq!(resolve_local_text_model_id(" ", None, "sdcpp"), "sdcpp"); + } + + #[test] + fn clamp_max_tokens_respects_model_limit() { + assert_eq!(clamp_max_tokens(Some(4_000), Some(2_000)), Some(2_000)); + assert_eq!(clamp_max_tokens(Some(1_000), Some(2_000)), Some(1_000)); + assert_eq!(clamp_max_tokens(None, Some(2_000)), Some(2_000)); + assert_eq!(clamp_max_tokens(Some(1_000), None), Some(1_000)); + assert_eq!(clamp_max_tokens(None, None), None); + } + + #[test] + fn resolve_cloud_provider_values_applies_alias_api_model_and_limit() { + let resolution = resolve_cloud_provider_values( + "gpt", + "ui-model", + "https://fallback.test/v1", + &provider(), + None, + ); + + assert_eq!(resolution.base_url, "https://api.example.test/v1"); + assert_eq!(resolution.effective_model, "provider-text-model"); + assert_eq!(resolution.model_max_tokens, Some(16_384)); + } + + #[test] + fn resolve_cloud_provider_values_keeps_default_base_url_without_provider_url() { + let mut provider = provider(); + provider.base_url = None; + + let resolution = resolve_cloud_provider_values( + "gpt", + "raw-model", + "https://fallback.test/v1", + &provider, + None, + ); + + assert_eq!(resolution.base_url, "https://fallback.test/v1"); + assert_eq!(resolution.effective_model, "raw-model"); + assert_eq!(resolution.model_max_tokens, None); + } + + #[test] + fn resolve_cloud_provider_values_applies_custom_model_after_catalog_mapping() { + let custom_models = CustomModelConfig { + models: vec![CustomModel { + id: "provider-text-model".to_string(), + name: "Custom".to_string(), + provider_id: "gpt".to_string(), + base_model_id: "ft:gpt:custom".to_string(), + created_at: 1.0, + }], + }; + + let resolution = resolve_cloud_provider_values( + "gpt", + "ui-model", + "https://fallback.test/v1", + &provider(), + Some(&custom_models), + ); + + assert_eq!(resolution.effective_model, "ft:gpt:custom"); + assert_eq!(resolution.model_max_tokens, Some(16_384)); + } } From 14d154fc9dde87f917227ac3a50663105b48ee63 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Tue, 5 May 2026 13:20:25 +0300 Subject: [PATCH 073/126] ci: run rust cache action on node 24 --- .github/workflows/ci.yml | 5 +++-- .github/workflows/release.yml | 5 ++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c62f68c..b39fc7ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,7 @@ permissions: env: CARGO_TERM_COLOR: always + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: check-frontend: @@ -38,7 +39,7 @@ jobs: toolchain: 1.94.1 - name: Rust Cache - uses: Swatinem/rust-cache@ad397744b0d591a723ab90405b7247fac0e6b8db # v2 + uses: Swatinem/rust-cache@919333daf4640a6fca9dd8b87468dc776e46b44b # node24 with: workspaces: "src-tauri -> target" @@ -88,7 +89,7 @@ jobs: components: clippy, llvm-tools-preview - name: Rust Cache - uses: Swatinem/rust-cache@ad397744b0d591a723ab90405b7247fac0e6b8db # v2 + uses: Swatinem/rust-cache@919333daf4640a6fca9dd8b87468dc776e46b44b # node24 with: workspaces: "src-tauri -> target" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ae6af6e7..b2eb99fe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,6 +17,9 @@ concurrency: group: release-${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || inputs.tag }} cancel-in-progress: false +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: build-and-release: runs-on: windows-latest @@ -68,7 +71,7 @@ jobs: toolchain: 1.94.1 - name: Rust Cache - uses: Swatinem/rust-cache@ad397744b0d591a723ab90405b7247fac0e6b8db # v2 + uses: Swatinem/rust-cache@919333daf4640a6fca9dd8b87468dc776e46b44b # node24 with: workspaces: "src-tauri -> target" From 53f55d58b2f533b7d710310714fd8e501d219ce1 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Tue, 5 May 2026 13:26:10 +0300 Subject: [PATCH 074/126] test: cover integration api routing helpers --- src-tauri/src/domain/integration_api.rs | 127 +++++++++++++++++++++++- 1 file changed, 123 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/domain/integration_api.rs b/src-tauri/src/domain/integration_api.rs index a1a9ef77..927c46a3 100644 --- a/src-tauri/src/domain/integration_api.rs +++ b/src-tauri/src/domain/integration_api.rs @@ -1053,12 +1053,17 @@ mod tests { #![allow(clippy::expect_used)] use super::{ - IntegrationTextRequest, backend_provider_id, find_header_end, is_authorized, model_api_id, - parse_header_line, parse_header_lines, parse_json_body, read_http_request, - status_for_app_error, status_text, tier_rank, + IntegrationTextRequest, backend_provider_id, find_header_end, is_authorized, + is_loopback_peer, json_error, json_response, model_api_id, parse_header_line, + parse_header_lines, parse_json_body, parse_module_action, read_http_request, + selected_module_from_api_provider, selected_module_from_catalog_item, status_for_app_error, + status_text, tier_rank, }; + use crate::domain::modules::controller::ModuleAction; use crate::errors::AppError; - use crate::models::{AiModel, ApiModelConfig, ModelStats, ModelTier}; + use crate::models::{ + AiModel, ApiModelConfig, ModelStats, ModelTier, ModuleItem, ProviderType, SelectedModule, + }; use std::collections::HashMap; use std::io::Write; use std::net::{Shutdown, TcpListener, TcpStream}; @@ -1191,6 +1196,120 @@ mod tests { assert_eq!(status_text(404), "Not Found"); } + #[test] + fn module_action_parser_accepts_integration_routes_only() { + assert_eq!( + parse_module_action("start").expect("start"), + ModuleAction::Start + ); + assert_eq!( + parse_module_action("stop").expect("stop"), + ModuleAction::Stop + ); + assert_eq!( + parse_module_action("restart").expect("restart"), + ModuleAction::Restart + ); + assert!(matches!( + parse_module_action("install"), + Err(AppError::Validation(_)) + )); + } + + #[test] + fn loopback_guard_rejects_missing_or_remote_peers() { + assert!(is_loopback_peer(Some( + "127.0.0.1:3000".parse().expect("loopback socket") + ))); + assert!(is_loopback_peer(Some( + "[::1]:3000".parse().expect("ipv6 loopback socket") + ))); + assert!(!is_loopback_peer(None)); + assert!(!is_loopback_peer(Some( + "192.168.1.10:3000".parse().expect("remote socket") + ))); + } + + #[test] + fn json_response_helpers_preserve_status_and_error_shape() { + let ok = json_response(200, serde_json::json!({ "ok": true })); + let error = json_error(401, "denied"); + + assert_eq!(ok.status, 200); + assert_eq!( + ok.body.get("ok").and_then(serde_json::Value::as_bool), + Some(true) + ); + assert_eq!(error.status, 401); + assert_eq!( + error.body.get("error").and_then(serde_json::Value::as_str), + Some("denied") + ); + } + + #[test] + fn selected_module_from_catalog_preserves_localized_metadata() { + let module = ModuleItem { + id: "llamacpp".to_string(), + name_key: "ui.module.llamacpp".to_string(), + desc_key: "ui.module.llamacpp.desc".to_string(), + name: "llama.cpp".to_string(), + desc: "Local text engine".to_string(), + icon: "cpu".to_string(), + preview: None, + type_name: "local".to_string(), + dl_type: None, + capabilities: vec!["text".to_string()], + binary: Some("llama-server".to_string()), + repo_url: None, + expected_hash: None, + coming_soon: false, + managed_externally: false, + version: "1.0.0".to_string(), + installed: true, + raw_config_schema: None, + config_schema: None, + }; + + let selected = selected_module_from_catalog_item(&module); + + assert_eq!(selected.id, "llamacpp"); + assert_eq!(selected.name_key.as_deref(), Some("ui.module.llamacpp")); + assert_eq!( + selected.desc_key.as_deref(), + Some("ui.module.llamacpp.desc") + ); + assert_eq!(selected.type_, "local"); + } + + #[test] + fn selected_module_from_api_provider_maps_provider_type() { + let provider = crate::models::ApiProvider { + id: "cloud".to_string(), + name: "Cloud".to_string(), + desc_key: Some("ui.module.cloud.desc".to_string()), + description: Some("Cloud provider".to_string()), + icon: Some("cloud".to_string()), + provider_type: Some(ProviderType::Openai), + base_url: Some("https://api.example.test/v1".to_string()), + api_key_env: None, + models: None, + capabilities: None, + model_aliases: None, + }; + let local = crate::models::ApiProvider { + provider_type: Some(ProviderType::Local), + ..provider.clone() + }; + + let selected_cloud: SelectedModule = selected_module_from_api_provider(&provider); + let selected_local = selected_module_from_api_provider(&local); + + assert_eq!(selected_cloud.type_, "api"); + assert_eq!(selected_cloud.desc, "Cloud provider"); + assert_eq!(selected_local.type_, "local"); + } + #[test] fn maps_app_errors_to_http_status_codes() { assert_eq!( From d7bcbdbf4e1cc2a60ec1f7c7e06170b9b0349914 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Tue, 5 May 2026 13:53:03 +0300 Subject: [PATCH 075/126] fix: address release review feedback --- .github/scripts/workflow.mjs | 39 ++++++++++--------- .prettierrc | 6 +++ .../src/domain/modules/github_releases.rs | 22 +++++++++++ 3 files changed, 48 insertions(+), 19 deletions(-) create mode 100644 .prettierrc diff --git a/.github/scripts/workflow.mjs b/.github/scripts/workflow.mjs index 84143990..7fdd5660 100644 --- a/.github/scripts/workflow.mjs +++ b/.github/scripts/workflow.mjs @@ -105,7 +105,9 @@ function removePath(targetPath) { } const delayMs = attempt * 250; - log(`retry remove ${path.relative(repoRoot, targetPath) || '.'} in ${String(delayMs)}ms`); + log( + `retry remove ${path.relative(repoRoot, targetPath) || '.'} in ${String(delayMs)}ms`, + ); sleep(delayMs); } } @@ -185,21 +187,21 @@ function withEnvOverrides(overrides = {}) { function ensureCargoLlvmCov() { if (commandExists('cargo-llvm-cov', toolEnv())) { - ensureLlvmToolsPreview(); + ensureLlvmTools(); return; } log('cargo-llvm-cov not found; installing with cargo install --locked'); run('cargo', ['install', 'cargo-llvm-cov', '--locked']); - ensureLlvmToolsPreview(); + ensureLlvmTools(); } -function ensureLlvmToolsPreview() { +function ensureLlvmTools() { if (!commandExists('rustup', toolEnv())) { return; } - run('rustup', ['component', 'add', 'llvm-tools-preview']); + run('rustup', ['component', 'add', 'llvm-tools']); } function runRustCoverage(args = []) { @@ -443,13 +445,7 @@ function runDoctor() { if (isWindows) { results.push(checkWindowsWebView2Runtime(env)); - results.push( - checkAvailableCommand( - 'MSVC compiler', - 'cl', - env, - ), - ); + results.push(checkAvailableCommand('MSVC compiler', 'cl', env)); results.push(checkAvailableCommand('Windows SDK rc.exe', 'rc', env)); } @@ -576,7 +572,10 @@ function verifyReleaseHardening() { const targets = Array.isArray(tauriConfig.bundle?.targets) ? tauriConfig.bundle.targets : []; assertCondition(releaseProfile.get('lto') === 'true', 'Cargo release profile enables LTO'); - assertCondition(releaseProfile.get('panic') === '"abort"', 'Cargo release profile aborts on panic'); + assertCondition( + releaseProfile.get('panic') === '"abort"', + 'Cargo release profile aborts on panic', + ); assertCondition(releaseProfile.get('strip') === 'true', 'Cargo release profile strips symbols'); assertCondition( releaseProfile.get('overflow-checks') === 'true', @@ -664,13 +663,15 @@ function stopRunningApp() { const workspaceExecutables = new Set( workspaceAxelateExecutablePaths().map((candidate) => normalizeWindowsPath(candidate)), ); - const runningProcesses = getRunningWindowsProcesses('Axelate.exe', env).filter((processInfo) => { - if (!processInfo?.ExecutablePath) { - return false; - } + const runningProcesses = getRunningWindowsProcesses('Axelate.exe', env).filter( + (processInfo) => { + if (!processInfo?.ExecutablePath) { + return false; + } - return workspaceExecutables.has(normalizeWindowsPath(processInfo.ExecutablePath)); - }); + return workspaceExecutables.has(normalizeWindowsPath(processInfo.ExecutablePath)); + }, + ); for (const processInfo of runningProcesses) { stopWindowsProcessTree(processInfo, 'Axelate.exe'); diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..80c0b19d --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "singleQuote": true, + "tabWidth": 4, + "printWidth": 100, + "endOfLine": "lf" +} diff --git a/src-tauri/src/domain/modules/github_releases.rs b/src-tauri/src/domain/modules/github_releases.rs index d9e0cf62..5429a83e 100644 --- a/src-tauri/src/domain/modules/github_releases.rs +++ b/src-tauri/src/domain/modules/github_releases.rs @@ -524,6 +524,13 @@ fn parse_repo(repo_url: &str) -> Result { { return Err(invalid_repo_url(repo_url)); } + let matched_github_prefix = trimmed.starts_with("https://github.com/") + || trimmed.starts_with("http://github.com/") + || trimmed.starts_with("https://www.github.com/") + || trimmed.starts_with("http://www.github.com/") + || trimmed.starts_with("github.com/") + || trimmed.starts_with("www.github.com/") + || trimmed.starts_with("git@github.com:"); let path = trimmed .strip_prefix("https://github.com/") .or_else(|| trimmed.strip_prefix("http://github.com/")) @@ -534,6 +541,14 @@ fn parse_repo(repo_url: &str) -> Result { .or_else(|| trimmed.strip_prefix("git@github.com:")) .unwrap_or(trimmed); let parts: Vec<&str> = path.split('/').filter(|part| !part.is_empty()).collect(); + if !matched_github_prefix + && (parts.len() != 2 + || parts.first().is_some_and(|owner| { + owner.contains('.') || owner.contains('@') || owner.contains(':') + })) + { + return Err(invalid_repo_url(repo_url)); + } let owner = parts .first() @@ -596,6 +611,13 @@ mod tests { assert!(parsed.is_err()); } + #[test] + fn parse_repo_rejects_non_github_host_like_shorthands() { + assert!(parse_repo("gitlab.com/org/repo").is_err()); + assert!(parse_repo("git@gitlab.com:org/repo.git").is_err()); + assert!(parse_repo("www.gitlab.com/org/repo").is_err()); + } + #[test] fn windows_selection_does_not_treat_darwin_assets_as_windows() { let platform = Platform { From 9b2e4eb6f728812caf79538c2bc2d3f720c8dfbb Mon Sep 17 00:00:00 2001 From: F0RLE Date: Tue, 5 May 2026 14:05:41 +0300 Subject: [PATCH 076/126] test: fail loudly for event click selectors --- src/app/events.test.ts | 40 ++++++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/src/app/events.test.ts b/src/app/events.test.ts index 11951484..ded79787 100644 --- a/src/app/events.test.ts +++ b/src/app/events.test.ts @@ -8,6 +8,14 @@ async function flushAsyncNavigation(): Promise { await new Promise((resolve) => setTimeout(resolve, 0)); } +function clickRequiredElement(selector: string): void { + const element = document.querySelector(selector); + if (element === null) { + throw new Error(`Required test element not found: ${selector}`); + } + element.click(); +} + function createCoreEvents(): ICoreEvents { return { appUI: { @@ -109,8 +117,8 @@ describe('EventHandler', () => { const handler = new EventHandler(core, runtime); handler.init(); - document.querySelector('#current-lang-trigger')?.click(); - document.querySelector('.lang-btn')?.click(); + clickRequiredElement('#current-lang-trigger'); + clickRequiredElement('.lang-btn'); await flushAsyncNavigation(); expect(core.i18nUI.toggleMenu).toHaveBeenCalledOnce(); @@ -128,9 +136,9 @@ describe('EventHandler', () => { const handler = new EventHandler(core, runtime); handler.init(); - document.querySelector('#ai-module-add-btn')?.click(); - document.querySelector('#services-module-card')?.click(); - document.querySelector('.module-settings-btn')?.click(); + clickRequiredElement('#ai-module-add-btn'); + clickRequiredElement('#services-module-card'); + clickRequiredElement('.module-settings-btn'); await flushAsyncNavigation(); expect(core.appUI.openAppSelection).toHaveBeenNthCalledWith(1, 'ai'); @@ -154,12 +162,12 @@ describe('EventHandler', () => { const handler = new EventHandler(core, runtime); handler.init(); - document.querySelector('#clear-chat-btn')?.click(); - document.querySelector('[data-chat-attach-action="file"]')?.click(); - document.querySelector('[data-chat-attach-action="image"]')?.click(); - document.querySelector('#chat-attach-btn')?.click(); - document.querySelector('#chat-voice-btn')?.click(); - document.querySelector('#chat-send-btn')?.click(); + clickRequiredElement('#clear-chat-btn'); + clickRequiredElement('[data-chat-attach-action="file"]'); + clickRequiredElement('[data-chat-attach-action="image"]'); + clickRequiredElement('#chat-attach-btn'); + clickRequiredElement('#chat-voice-btn'); + clickRequiredElement('#chat-send-btn'); await flushAsyncNavigation(); expect(core.chatController.clearChat).toHaveBeenCalledOnce(); @@ -192,11 +200,11 @@ describe('EventHandler', () => { const handler = new EventHandler(core, localRuntime); handler.init(); - document.querySelector('#close-app-selection-btn')?.click(); - document.querySelector('#close-app-selection-btn-alt')?.click(); - document.querySelector('.lang-modal-btn')?.click(); - document.querySelector('#confirm-lang-btn')?.click(); - document.querySelector('#close-module-settings-btn')?.click(); + clickRequiredElement('#close-app-selection-btn'); + clickRequiredElement('#close-app-selection-btn-alt'); + clickRequiredElement('.lang-modal-btn'); + clickRequiredElement('#confirm-lang-btn'); + clickRequiredElement('#close-module-settings-btn'); await flushAsyncNavigation(); expect(core.appUI.closeAppSelection).toHaveBeenCalledTimes(2); From bb8c1efa9964e3d8f9b1e99a3ce1c2275ba9daa2 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Tue, 5 May 2026 14:32:33 +0300 Subject: [PATCH 077/126] fix: harden settings persistence contracts --- .../config/config_repository.rs | 61 +++++++++++++++---- .../ai/ui/AISettingsKeyController.test.ts | 44 +++++++++++++ src/features/ai/ui/AISettingsKeyController.ts | 26 ++++---- src/features/chat/ui/ChatImageController.ts | 60 ++++++++++++------ src/features/chat/ui/ChatUI.test.ts | 6 +- 5 files changed, 150 insertions(+), 47 deletions(-) diff --git a/src-tauri/src/infrastructure/config/config_repository.rs b/src-tauri/src/infrastructure/config/config_repository.rs index 9ac7db3e..99580378 100644 --- a/src-tauri/src/infrastructure/config/config_repository.rs +++ b/src-tauri/src/infrastructure/config/config_repository.rs @@ -1,6 +1,7 @@ use crate::domain::system::config_repository::ConfigRepository; use crate::errors::AppError; use crate::models::config::{AiModel, ApiProvider, AppMeta, ModuleItem}; +use crate::models::custom_models::CustomModelConfig; use std::path::{Path, PathBuf}; use tauri::AppHandle; @@ -71,6 +72,26 @@ impl FileConfigRepository { .map_err(|e| AppError::Config(format!("Failed to parse {filename}: {e}"))) } + fn load_custom_models_from_path(path: &Path) -> Result { + if !path.exists() { + return Ok(CustomModelConfig::default()); + } + + let content = std::fs::read_to_string(path).map_err(|error| { + AppError::Io(format!( + "Failed to read custom models config at {}: {error}", + path.display() + )) + })?; + + serde_json::from_str(&content).map_err(|error| { + AppError::Serialization(format!( + "Failed to parse custom models config at {}: {error}", + path.display() + )) + }) + } + fn parse_api_providers(content: &str) -> Result, AppError> { let raw: serde_json::Value = serde_json::from_str(content) .map_err(|e| AppError::Config(format!("Failed to parse api_providers.json: {e}")))?; @@ -231,18 +252,9 @@ impl ConfigRepository for FileConfigRepository { ) } - fn load_custom_models( - &self, - ) -> Result { + fn load_custom_models(&self) -> Result { let custom_path = crate::utils::paths::CONFIG_DIR.join("custom_models.json"); - if custom_path.exists() { - if let Ok(content) = std::fs::read_to_string(&custom_path) { - if let Ok(config) = serde_json::from_str(&content) { - return Ok(config); - } - } - } - Ok(crate::models::custom_models::CustomModelConfig::default()) + Self::load_custom_models_from_path(&custom_path) } } @@ -251,6 +263,7 @@ mod tests { #![allow(clippy::expect_used)] use super::FileConfigRepository; + use crate::errors::AppError; use std::path::PathBuf; #[test] @@ -386,4 +399,30 @@ mod tests { }) })); } + + #[test] + fn custom_models_loader_defaults_only_when_file_is_missing() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let missing_path = temp_dir.path().join("custom_models.json"); + + let config = FileConfigRepository::load_custom_models_from_path(&missing_path) + .expect("missing custom models should default"); + + assert!(config.models.is_empty()); + } + + #[test] + fn custom_models_loader_reports_invalid_json() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let path = temp_dir.path().join("custom_models.json"); + std::fs::write(&path, "{broken").expect("broken custom models fixture"); + + let error = FileConfigRepository::load_custom_models_from_path(&path) + .expect_err("invalid custom models should not default"); + + assert!(matches!( + error, + AppError::Serialization(message) if message.contains("custom_models.json") + )); + } } diff --git a/src/features/ai/ui/AISettingsKeyController.test.ts b/src/features/ai/ui/AISettingsKeyController.test.ts index cb6a0222..22cb641c 100644 --- a/src/features/ai/ui/AISettingsKeyController.test.ts +++ b/src/features/ai/ui/AISettingsKeyController.test.ts @@ -78,6 +78,50 @@ describe('AISettingsKeyController', () => { expect(button.innerHTML).toBe('Check'); }); + it('should not mask typed keys as stored when settings service disappears before save', async () => { + const input = document.createElement('input'); + const button = document.createElement('button'); + button.innerHTML = 'Check'; + document.body.append(input, button); + const validateOnlyService = { + ...settingsService, + validateApiKey: vi.fn().mockResolvedValue(true), + saveSecureKey: vi.fn(), + }; + const getSettingsService = vi + .fn() + .mockReturnValueOnce(validateOnlyService) + .mockReturnValue(null); + const controllerWithDisappearingSettings = new AISettingsKeyController({ + getSettingsService, + getTranslator, + scheduleButtonReset, + showToast, + icons: { + visible: '', + hidden: '', + check: '', + x: '', + spinner: '', + }, + tracer, + }); + + input.value = 'typed-key'; + input.dataset['keyDirty'] = 'true'; + + await controllerWithDisappearingSettings.checkKey(input, button, 'openrouter'); + + expect(validateOnlyService.validateApiKey).toHaveBeenCalledWith('openrouter', 'typed-key'); + expect(validateOnlyService.saveSecureKey).not.toHaveBeenCalled(); + expect(input.dataset['storedMasked']).toBeUndefined(); + expect(input.value).toBe('typed-key'); + expect(showToast).toHaveBeenCalledWith( + 'ui.settings.key_check_error:Key check error', + 'error', + ); + }); + it('should remove stored keys when a dirty key input is cleared', async () => { const input = document.createElement('input'); const button = document.createElement('button'); diff --git a/src/features/ai/ui/AISettingsKeyController.ts b/src/features/ai/ui/AISettingsKeyController.ts index 484388b4..2b5c1244 100644 --- a/src/features/ai/ui/AISettingsKeyController.ts +++ b/src/features/ai/ui/AISettingsKeyController.ts @@ -53,10 +53,7 @@ export class AISettingsKeyController { input.dataset['keyRemoveInFlight'] = 'true'; try { - const settingsService = this._options.getSettingsService(); - if (settingsService === null) { - throw new Error(); - } + const settingsService = this._requireSettingsService(); await settingsService.removeSecureKey(providerId); this.clearStoredKeyMask(input); this._showToast( @@ -162,11 +159,7 @@ export class AISettingsKeyController { let isValid = false; if (shouldRemoveStoredKey) { - const settingsService = this._options.getSettingsService(); - if (settingsService === null) { - throw new Error(); - } - await settingsService.removeSecureKey(providerId); + await this._requireSettingsService().removeSecureKey(providerId); this.clearStoredKeyMask(input); this.updateButtonState(button, 'success', this._options.icons.check); this._showToast(t('ui.settings.key_removed', 'API key removed'), 'success'); @@ -174,14 +167,12 @@ export class AISettingsKeyController { } else if (shouldValidateTypedKey) { isValid = await this._validateKey(providerId, key); } else if (shouldValidateStoredKey) { - isValid = Boolean( - await this._options.getSettingsService()?.validateStoredApiKey(providerId), - ); + isValid = await this._requireSettingsService().validateStoredApiKey(providerId); } if (isValid) { if (shouldValidateTypedKey && key !== '') { - await this._options.getSettingsService()?.saveSecureKey(providerId, key); + await this._requireSettingsService().saveSecureKey(providerId, key); this.applyStoredKeyMask(input, key.length); } this.updateButtonState(button, 'success', this._options.icons.check); @@ -260,6 +251,15 @@ export class AISettingsKeyController { } } + private _requireSettingsService(): SettingsService { + const settingsService = this._options.getSettingsService(); + if (settingsService === null) { + throw new Error('Settings service is unavailable'); + } + + return settingsService; + } + private _showToast(message: string, type: string): void { this._options.showToast(message, type as 'success' | 'error' | 'warning' | 'info'); } diff --git a/src/features/chat/ui/ChatImageController.ts b/src/features/chat/ui/ChatImageController.ts index 7c703f0d..9e5f227c 100644 --- a/src/features/chat/ui/ChatImageController.ts +++ b/src/features/chat/ui/ChatImageController.ts @@ -1,7 +1,8 @@ import DOMPurify from 'dompurify'; -import { invoke } from '@tauri-apps/api/core'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; +import { invokeSafe } from '@/shared/api/invoke'; +import { commands, type SavedChatImage as SavedChatImageResult } from '@/shared/types/bindings'; import type { TTranslateFunction } from '@/shared/types/global_bridge_types'; type ChatImageLogger = Pick; @@ -161,23 +162,20 @@ export class ChatImageController { } private async _performSaveImage(b64: string, mime: string): Promise { - const result = await invoke<{ file_path: string; folder_path: string }>( - 'save_chat_image_default', - { - base64Data: b64, - mimeType: mime, - }, + const saved = await this._invokeCommand( + commands.saveChatImageDefault(b64, mime), + 'saveChatImageDefault', ); if ( - typeof result.file_path === 'string' && - result.file_path.length > 0 && - typeof result.folder_path === 'string' && - result.folder_path.length > 0 + typeof saved.file_path === 'string' && + saved.file_path.length > 0 && + typeof saved.folder_path === 'string' && + saved.folder_path.length > 0 ) { return { - filePath: result.file_path, - folderPath: result.folder_path, + filePath: saved.file_path, + folderPath: saved.folder_path, }; } @@ -498,7 +496,10 @@ export class ChatImageController { const animationDelay = new Promise((resolve) => { globalThis.setTimeout(resolve, ChatImageController._imageResetDelayMs); }); - const deleteRequest = invoke('delete_chat_image', { filePath }); + const deleteRequest = this._invokeCommand( + commands.deleteChatImage(filePath), + 'deleteChatImage', + ); try { await Promise.all([deleteRequest, animationDelay]); @@ -521,11 +522,6 @@ export class ChatImageController { ): Promise { const handleMissing = async (): Promise => { this._restoreFolderButtonToSave(saveBtn); - try { - await invoke('open_chat_image_location', { filePath: folderPath, folderPath }); - } catch { - /* ignore fallback folder-open errors */ - } this._deps.showToast( this._deps.translate( 'ui.chat.image_missing_resave', @@ -533,10 +529,15 @@ export class ChatImageController { ), 'warning', ); + try { + await this._openSavedImageLocation(folderPath, folderPath); + } catch { + /* ignore fallback folder-open errors */ + } }; try { - await invoke('open_chat_image_location', { filePath, folderPath }); + await this._openSavedImageLocation(filePath, folderPath); } catch (error) { const message = this._deps.extractErrorMessage(error); const normalized = message.toLowerCase(); @@ -560,4 +561,23 @@ export class ChatImageController { ); } } + + private async _openSavedImageLocation(filePath: string, folderPath: string): Promise { + await this._invokeCommand( + commands.openChatImageLocation(filePath, folderPath), + 'openChatImageLocation', + ); + } + + private async _invokeCommand( + command: Promise<{ status: 'ok'; data: T } | { status: 'error'; error: unknown }>, + label: string, + ): Promise { + const result = await invokeSafe(command); + if (result.status === 'ok') { + return result.data; + } + + throw new Error(`${label} failed: ${result.error.message}`); + } } diff --git a/src/features/chat/ui/ChatUI.test.ts b/src/features/chat/ui/ChatUI.test.ts index 698577e7..0efde56b 100644 --- a/src/features/chat/ui/ChatUI.test.ts +++ b/src/features/chat/ui/ChatUI.test.ts @@ -122,7 +122,7 @@ async function saveGeneratedImage(saveButton: HTMLButtonElement): Promise folder_path: savedImageFolderPath, }); saveButton.click(); - await flushPromises(2); + await flushPromises(3); } async function convertSaveButtonToFolderAction(saveButton: HTMLButtonElement): Promise { @@ -707,7 +707,7 @@ describe('ChatUI lifecycle', () => { vi.mocked(invoke).mockRejectedValueOnce(new TypeError('Saved image does not exist')); saveButton.click(); - await flushPromises(2); + await flushPromises(6); expect(saveButton.classList.contains('chat-save-image-btn')).toBe(true); expect(saveButton.classList.contains('chat-open-image-folder-btn')).toBe(false); @@ -738,7 +738,7 @@ describe('ChatUI lifecycle', () => { expect(saveButton.classList.contains('is-trash-state')).toBe(true); vi.advanceTimersByTime(300); - await flushPromises(2); + await flushPromises(4); expect(invoke).toHaveBeenLastCalledWith('delete_chat_image', { filePath: savedImageFilePath, From 49cd48928ea4f7bc4014a807822162f45d4cea3b Mon Sep 17 00:00:00 2001 From: F0RLE Date: Tue, 5 May 2026 15:17:16 +0300 Subject: [PATCH 078/126] refactor: align frontend runtime contracts --- src/features/ai/services/AIChatTransport.ts | 18 ++---- .../monitoring/types/monitoringTypes.ts | 58 +------------------ 2 files changed, 8 insertions(+), 68 deletions(-) diff --git a/src/features/ai/services/AIChatTransport.ts b/src/features/ai/services/AIChatTransport.ts index ef19525a..77867135 100644 --- a/src/features/ai/services/AIChatTransport.ts +++ b/src/features/ai/services/AIChatTransport.ts @@ -7,6 +7,7 @@ import type { IImageGenerationResponse, } from '../types/aiTypes'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; +import type { StreamChunkPayload } from '@/shared/types/bindings'; import type { AITransportContext } from './AIBridgeContext'; import { isCloudProviderId } from '@/shared/utils/providerSupport'; @@ -32,13 +33,6 @@ function extractError(error: unknown): string { return JSON.stringify(error); } -interface IStreamChunkEnvelope { - request_id: string; - message_id: string; - kind: 'chat_chunk' | 'thought_chunk' | 'done'; - content: string; -} - export interface IChatTransport { init(): Promise; send(request: IChatRequest): Promise; @@ -112,7 +106,7 @@ export class AIChatTransport implements IChatTransport { resolve(); }; }); - const chatChannel = new Channel(); + const chatChannel = new Channel(); chatChannel.onmessage = (payload) => { if (!this._isPayloadForRequest(payload, requestId)) { return; @@ -130,7 +124,7 @@ export class AIChatTransport implements IChatTransport { this._emitListeners(this._streamListeners, payload.content); }; - const thoughtChannel = new Channel(); + const thoughtChannel = new Channel(); thoughtChannel.onmessage = (payload) => { if (!this._isPayloadForRequest(payload, requestId)) { return; @@ -196,8 +190,8 @@ export class AIChatTransport implements IChatTransport { request_id: requestId, }; this._activeChatRequestId = requestId; - const chatChannel = new Channel(); - const thoughtChannel = new Channel(); + const chatChannel = new Channel(); + const thoughtChannel = new Channel(); try { const response = await this._runWithTimeout( @@ -438,7 +432,7 @@ export class AIChatTransport implements IChatTransport { }); } - private _isPayloadForRequest(payload: IStreamChunkEnvelope, requestId: string): boolean { + private _isPayloadForRequest(payload: StreamChunkPayload, requestId: string): boolean { return payload.request_id === requestId; } diff --git a/src/features/monitoring/types/monitoringTypes.ts b/src/features/monitoring/types/monitoringTypes.ts index de30abfd..b4638baf 100644 --- a/src/features/monitoring/types/monitoringTypes.ts +++ b/src/features/monitoring/types/monitoringTypes.ts @@ -1,58 +1,4 @@ -export interface ICpuStats { - percent: number; - cores: number; - name: string; -} - -export interface IRamStats { - percent: number; - usedGb: number; - totalGb: number; - availableGb: number; -} - -export interface IGpuStats { - usage: number; - memoryUsed: number; - memoryTotal: number; - temp: number; - name: string; -} - -export interface IVramStats { - percent: number; - usedGb: number; - totalGb: number; -} - -export interface IDiskStats { - readRate: number; - writeRate: number; - utilization: number; - totalGb: number; - usedGb: number; - activityPercent: number; -} - -export interface INetworkStats { - downloadRate: number; - uploadRate: number; - totalReceived: number; - totalSent: number; - utilization: number; - activityPercent: number; -} - -export interface ISystemStats { - cpu: ICpuStats; - ram: IRamStats; - gpu: IGpuStats | null; - vram: IVramStats | null; - disk: IDiskStats; - network: INetworkStats; - pid: number; - appCpu: number; - appMemory: number; -} +import type { SystemStats } from '@/shared/types/bindings'; +export type ISystemStats = SystemStats; export type StatsCallback = (stats: ISystemStats) => void; From 574a57cf3b030943c048da05ef7ba3dd986cf78a Mon Sep 17 00:00:00 2001 From: F0RLE Date: Tue, 5 May 2026 15:40:30 +0300 Subject: [PATCH 079/126] fix: address final release review feedback --- .github/scripts/workflow.mjs | 7 +- .../src/domain/modules/github_releases.rs | 97 +++++++++++++++---- .../config/config_repository.rs | 22 +++-- 3 files changed, 95 insertions(+), 31 deletions(-) diff --git a/.github/scripts/workflow.mjs b/.github/scripts/workflow.mjs index 7fdd5660..4b8f3c88 100644 --- a/.github/scripts/workflow.mjs +++ b/.github/scripts/workflow.mjs @@ -32,6 +32,7 @@ const passthroughArgs = rawArgs.filter((arg, index) => { let cachedEnv; const webView2RuntimeClientId = '{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}'; +const cargoLlvmCovVersion = '0.8.5'; const sleepSignal = new Int32Array(new SharedArrayBuffer(4)); function cleanupTargets() { @@ -191,8 +192,10 @@ function ensureCargoLlvmCov() { return; } - log('cargo-llvm-cov not found; installing with cargo install --locked'); - run('cargo', ['install', 'cargo-llvm-cov', '--locked']); + log( + `cargo-llvm-cov not found; installing version ${cargoLlvmCovVersion} with cargo install --locked`, + ); + run('cargo', ['install', 'cargo-llvm-cov', '--locked', '--version', cargoLlvmCovVersion]); ensureLlvmTools(); } diff --git a/src-tauri/src/domain/modules/github_releases.rs b/src-tauri/src/domain/modules/github_releases.rs index 5429a83e..7cbbea4b 100644 --- a/src-tauri/src/domain/modules/github_releases.rs +++ b/src-tauri/src/domain/modules/github_releases.rs @@ -375,11 +375,7 @@ fn release_download_version( return None; } - let recommended = if gpu.is_some() && has_real_gpu_accelerator(hardware) { - ReleaseComputeTarget::Gpu - } else { - ReleaseComputeTarget::Cpu - }; + let recommended = recommended_release_target(cpu.as_ref(), gpu.as_ref(), hardware); Some(ReleaseDownloadVersion { tag_name: release.tag_name, @@ -425,6 +421,23 @@ fn release_download_variant( }) } +const fn recommended_release_target( + cpu: Option<&ReleaseDownloadVariant>, + gpu: Option<&ReleaseDownloadVariant>, + hardware: HardwareProfile, +) -> ReleaseComputeTarget { + if gpu.is_some() && has_real_gpu_accelerator(hardware) { + return ReleaseComputeTarget::Gpu; + } + if cpu.is_some() { + return ReleaseComputeTarget::Cpu; + } + if gpu.is_some() { + return ReleaseComputeTarget::Gpu; + } + ReleaseComputeTarget::Cpu +} + fn release_assets_match_target(assets: &[ReleaseAsset], target: ReleaseComputeTarget) -> bool { match target { ReleaseComputeTarget::Auto => true, @@ -449,24 +462,34 @@ fn is_gpu_asset_name(name: &str) -> bool { } fn is_gpu_asset_name_lower(lower: &str) -> bool { - lower.contains("cuda") - || lower.contains("cu12") - || lower.contains("cu13") - || lower.contains("vulkan") - || lower.contains("hip") - || lower.contains("rocm") - || lower.contains("sycl") - || lower.contains("openvino") - || lower.contains("nvidia") - || lower.contains("amd") + release_asset_tokens(lower).any(|token| { + token == "cuda" + || token.starts_with("cuda12") + || token.starts_with("cuda13") + || token.starts_with("cu12") + || token.starts_with("cu13") + || token == "vulkan" + || token == "hip" + || token == "rocm" + || token == "sycl" + || token == "openvino" + || token == "nvidia" + || token == "amd" + || token == "radeon" + }) } fn is_cpu_asset_name(name: &str) -> bool { let lower = name.to_ascii_lowercase(); - lower.contains("cpu") - || lower.contains("avx") - || lower.contains("noavx") - || !is_gpu_asset_name_lower(&lower) + let has_explicit_cpu_token = release_asset_tokens(&lower).any(|token| { + token == "cpu" || token == "avx" || token == "avx2" || token == "avx512" || token == "noavx" + }); + has_explicit_cpu_token || !is_gpu_asset_name_lower(&lower) +} + +fn release_asset_tokens(name: &str) -> impl Iterator { + name.split(|character: char| !character.is_ascii_alphanumeric()) + .filter(|token| !token.is_empty()) } const fn has_real_gpu_accelerator(hardware: HardwareProfile) -> bool { @@ -618,6 +641,13 @@ mod tests { assert!(parse_repo("www.gitlab.com/org/repo").is_err()); } + #[test] + fn asset_classification_does_not_treat_amd64_as_amd_gpu() { + assert!(!is_gpu_asset_name("llama-b8981-bin-win-amd64.zip")); + assert!(is_cpu_asset_name("llama-b8981-bin-win-amd64.zip")); + assert!(is_gpu_asset_name("llama-b8981-bin-win-hip-radeon-x64.zip")); + } + #[test] fn windows_selection_does_not_treat_darwin_assets_as_windows() { let platform = Platform { @@ -964,6 +994,35 @@ mod tests { ); } + #[test] + fn release_download_version_recommends_available_gpu_when_cpu_variant_is_missing() { + let platform = Platform { + os: PlatformOs::Windows, + arch: PlatformArch::X64, + }; + let hardware = HardwareProfile { + accelerator: AcceleratorClass::Unknown, + cpu_tier: CpuInstructionTier::Avx2, + cuda_driver_major: None, + cuda_driver_minor: None, + }; + let releases = vec![Release { + tag_name: "gpu-only".to_string(), + published_at: Some("2026-04-29T10:00:00Z".to_string()), + draft: false, + prerelease: false, + assets: vec![asset("llama-gpu-only-bin-win-vulkan-x64.zip")], + }]; + + let versions = release_download_versions("llamacpp", platform, hardware, releases); + + assert_eq!(versions.len(), 1); + let version = versions.first().expect("expected gpu-only option"); + assert!(version.cpu.is_none()); + assert!(version.gpu.is_some()); + assert_eq!(version.recommended, ReleaseComputeTarget::Gpu); + } + #[test] fn prefers_cuda12_when_cuda_driver_version_is_unknown() { let platform = Platform { diff --git a/src-tauri/src/infrastructure/config/config_repository.rs b/src-tauri/src/infrastructure/config/config_repository.rs index 99580378..9f68442b 100644 --- a/src-tauri/src/infrastructure/config/config_repository.rs +++ b/src-tauri/src/infrastructure/config/config_repository.rs @@ -73,16 +73,18 @@ impl FileConfigRepository { } fn load_custom_models_from_path(path: &Path) -> Result { - if !path.exists() { - return Ok(CustomModelConfig::default()); - } - - let content = std::fs::read_to_string(path).map_err(|error| { - AppError::Io(format!( - "Failed to read custom models config at {}: {error}", - path.display() - )) - })?; + let content = match std::fs::read_to_string(path) { + Ok(content) => content, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(CustomModelConfig::default()); + } + Err(error) => { + return Err(AppError::Io(format!( + "Failed to read custom models config at {}: {error}", + path.display() + ))); + } + }; serde_json::from_str(&content).map_err(|error| { AppError::Serialization(format!( From 15c9f35be4f94c025bbea65ab90f7abab56e966a Mon Sep 17 00:00:00 2001 From: F0RLE Date: Tue, 5 May 2026 23:42:18 +0300 Subject: [PATCH 080/126] fix: coordinate monitoring pause lifecycle --- src/shared/services/WindowService.test.ts | 39 +++++++++++++++++++++ src/shared/services/WindowService.ts | 26 +++++++++++++- src/shared/services/WindowServiceActions.ts | 6 ++-- src/shared/shell/SidebarUI.test.ts | 11 ++++-- src/shared/shell/SidebarUI.ts | 2 +- src/shared/shell/WindowUI.test.ts | 7 ++-- src/shared/shell/WindowUI.ts | 2 +- 7 files changed, 82 insertions(+), 11 deletions(-) diff --git a/src/shared/services/WindowService.test.ts b/src/shared/services/WindowService.test.ts index fca2ef85..cad0e74b 100644 --- a/src/shared/services/WindowService.test.ts +++ b/src/shared/services/WindowService.test.ts @@ -476,6 +476,45 @@ describe('WindowService', () => { await service.setMonitoringPaused(true); expect(mockBridge.invoke).not.toHaveBeenCalled(); }); + + it('should skip duplicate monitoring pause states', async () => { + await service.setMonitoringPauseReason('window-inactive', true); + await service.setMonitoringPauseReason('window-inactive', true); + + expect(mockBridge.invoke).toHaveBeenCalledTimes(1); + expect(mockBridge.invoke).toHaveBeenCalledWith('set_monitoring_paused', { + paused: true, + }); + }); + + it('should aggregate monitoring pause reasons before resuming', async () => { + await service.setMonitoringPauseReason('window-inactive', true); + await service.setMonitoringPauseReason('monitor-hidden', true); + await service.setMonitoringPauseReason('window-inactive', false); + await service.setMonitoringPauseReason('monitor-hidden', false); + + expect(mockBridge.invoke).toHaveBeenCalledTimes(2); + expect(mockBridge.invoke).toHaveBeenNthCalledWith(1, 'set_monitoring_paused', { + paused: true, + }); + expect(mockBridge.invoke).toHaveBeenNthCalledWith(2, 'set_monitoring_paused', { + paused: false, + }); + }); + + it('should retry the same monitoring state after a failed apply', async () => { + mockBridge.invoke + .mockRejectedValueOnce(new Error('temporary failure')) + .mockResolvedValueOnce(undefined); + + await service.setMonitoringPauseReason('window-inactive', true); + await service.setMonitoringPauseReason('window-inactive', true); + + expect(mockBridge.invoke).toHaveBeenCalledTimes(2); + expect(mockTracer.error).toHaveBeenCalledWith( + '[WindowService] Failed to set monitoring state', + ); + }); }); // ---------------------------------------------------------- checkPolicy diff --git a/src/shared/services/WindowService.ts b/src/shared/services/WindowService.ts index 295cb16c..ec471f41 100644 --- a/src/shared/services/WindowService.ts +++ b/src/shared/services/WindowService.ts @@ -64,6 +64,8 @@ type WindowRuntime = { setAppZoomCss: (zoom: string) => void; }; +type MonitoringPauseReason = 'window-inactive' | 'monitor-hidden' | 'manual'; + function createDefaultWindowRuntime(): WindowRuntime { return { addEventListener: globalThis.addEventListener.bind(globalThis), @@ -115,6 +117,8 @@ export class WindowService { private readonly _policyService: WindowServicePolicy; private readonly _zoomService: WindowServiceZoom; private _activePageId = 'home'; + private readonly _monitoringPauseReasons = new Map(); + private _lastAppliedMonitoringPaused: boolean | null = null; private readonly _boundWindowResize = () => { this._persistence.scheduleSave(); }; @@ -338,7 +342,27 @@ export class WindowService { * Notifies the backend of a change in system monitoring state. */ public async setMonitoringPaused(paused: boolean): Promise { - await this._actions.setMonitoringPaused(paused); + await this.setMonitoringPauseReason('manual', paused); + } + + public async setMonitoringPauseReason( + reason: MonitoringPauseReason, + paused: boolean, + ): Promise { + const previousReasonPaused = this._monitoringPauseReasons.get(reason); + if (previousReasonPaused !== paused) { + this._monitoringPauseReasons.set(reason, paused); + } + + const aggregatePaused = Array.from(this._monitoringPauseReasons.values()).some(Boolean); + if (this._lastAppliedMonitoringPaused === aggregatePaused) { + return; + } + + const applied = await this._actions.setMonitoringPaused(aggregatePaused); + if (applied) { + this._lastAppliedMonitoringPaused = aggregatePaused; + } } // --- Small Screen Helpers --- diff --git a/src/shared/services/WindowServiceActions.ts b/src/shared/services/WindowServiceActions.ts index 08d6294c..976f834c 100644 --- a/src/shared/services/WindowServiceActions.ts +++ b/src/shared/services/WindowServiceActions.ts @@ -84,15 +84,17 @@ export class WindowServiceActions { ); } - public async setMonitoringPaused(paused: boolean): Promise { + public async setMonitoringPaused(paused: boolean): Promise { if (!this._deps.bridge.isTauri()) { - return; + return true; } try { await this._deps.bridge.invoke('set_monitoring_paused', { paused }); + return true; } catch { this._deps.tracer.error('[WindowService] Failed to set monitoring state'); + return false; } } } diff --git a/src/shared/shell/SidebarUI.test.ts b/src/shared/shell/SidebarUI.test.ts index 15e285ed..81e003f7 100644 --- a/src/shared/shell/SidebarUI.test.ts +++ b/src/shared/shell/SidebarUI.test.ts @@ -37,6 +37,7 @@ describe('SidebarUI', () => { getZoom: vi.fn(() => 1), getMaxSafeZoom: vi.fn(() => Number.POSITIVE_INFINITY), setMonitoringPaused: vi.fn().mockResolvedValue(undefined), + setMonitoringPauseReason: vi.fn().mockResolvedValue(undefined), }; const tracer = { info: vi.fn(), @@ -88,6 +89,7 @@ describe('SidebarUI', () => { windowService.getZoom.mockReturnValue(1); windowService.getMaxSafeZoom.mockReturnValue(Number.POSITIVE_INFINITY); windowService.setMonitoringPaused.mockResolvedValue(undefined); + windowService.setMonitoringPauseReason.mockResolvedValue(undefined); vi.clearAllMocks(); }); @@ -183,7 +185,7 @@ describe('SidebarUI', () => { document.getElementById('system-monitor')?.classList.contains('adaptive-hidden'), ).toBe(true); expect(sidebar.classList.contains('monitor-hidden')).toBe(true); - expect(windowService.setMonitoringPaused).toHaveBeenCalledWith(true); + expect(windowService.setMonitoringPauseReason).toHaveBeenCalledWith('monitor-hidden', true); }); it('resumes monitoring when adaptive monitor is visible', async () => { @@ -199,7 +201,10 @@ describe('SidebarUI', () => { expect( document.getElementById('system-monitor')?.classList.contains('adaptive-hidden'), ).toBe(false); - expect(windowService.setMonitoringPaused).toHaveBeenCalledWith(false); + expect(windowService.setMonitoringPauseReason).toHaveBeenCalledWith( + 'monitor-hidden', + false, + ); }); it('enables auto compact when zoom threshold is reached', async () => { @@ -338,7 +343,7 @@ describe('SidebarUI', () => { const monitor = document.getElementById('system-monitor') as HTMLElement; expect(monitor.classList.contains('adaptive-hidden')).toBe(true); - expect(windowService.setMonitoringPaused).toHaveBeenCalledWith(true); + expect(windowService.setMonitoringPauseReason).toHaveBeenCalledWith('monitor-hidden', true); expect(sidebar.classList.contains('auto-compact')).toBe(false); expect(sidebar.style.width).toBe('280px'); diff --git a/src/shared/shell/SidebarUI.ts b/src/shared/shell/SidebarUI.ts index 6fae0f71..7075164d 100644 --- a/src/shared/shell/SidebarUI.ts +++ b/src/shared/shell/SidebarUI.ts @@ -311,7 +311,7 @@ export class SidebarUI extends BaseComponent { return; } const isMonitorVisible = this._monitorVisibilityController.update(elements); - void this._windowService?.setMonitoringPaused(!isMonitorVisible); + void this._windowService?.setMonitoringPauseReason('monitor-hidden', !isMonitorVisible); } private async _findSidebar(): Promise { diff --git a/src/shared/shell/WindowUI.test.ts b/src/shared/shell/WindowUI.test.ts index eb732957..a942b983 100644 --- a/src/shared/shell/WindowUI.test.ts +++ b/src/shared/shell/WindowUI.test.ts @@ -55,6 +55,7 @@ describe('WindowUI lifecycle', () => { const service = { checkPolicy: vi.fn().mockResolvedValue({ isSmallScreen: false, showWarning: false }), setMonitoringPaused: vi.fn().mockResolvedValue(undefined), + setMonitoringPauseReason: vi.fn().mockResolvedValue(undefined), checkResolutionChange: vi.fn(), isMaximized: vi.fn().mockResolvedValue(false), toggleMaximize: vi.fn().mockResolvedValue(undefined), @@ -388,7 +389,7 @@ describe('WindowUI lifecycle', () => { ui = createWindowUI(); const service = (ui as unknown as { _service: WindowService })._service as unknown as { - setMonitoringPaused: ReturnType; + setMonitoringPauseReason: ReturnType; changeZoom: ReturnType; persistZoom: ReturnType; }; @@ -415,12 +416,12 @@ describe('WindowUI lifecycle', () => { const focusSpy = vi.spyOn(document, 'hasFocus').mockReturnValue(false); document.dispatchEvent(new Event('visibilitychange')); globalThis.dispatchEvent(new Event('blur')); - expect(service.setMonitoringPaused).toHaveBeenCalledWith(true); + expect(service.setMonitoringPauseReason).toHaveBeenCalledWith('window-inactive', true); focusSpy.mockReturnValue(true); Object.defineProperty(document, 'hidden', { configurable: true, value: false }); globalThis.dispatchEvent(new Event('focus')); - expect(service.setMonitoringPaused).toHaveBeenCalledWith(false); + expect(service.setMonitoringPauseReason).toHaveBeenCalledWith('window-inactive', false); document.dispatchEvent( new WheelEvent('wheel', { diff --git a/src/shared/shell/WindowUI.ts b/src/shared/shell/WindowUI.ts index 92fc3703..fd22b2aa 100644 --- a/src/shared/shell/WindowUI.ts +++ b/src/shared/shell/WindowUI.ts @@ -77,7 +77,7 @@ export class WindowUI { await this._service.persistZoom(); }, setMonitoringPaused: async (paused) => { - await this._service.setMonitoringPaused(paused); + await this._service.setMonitoringPauseReason('window-inactive', paused); }, hasOpenDialog: () => this._hasOpenDialog(), isInGracePeriod: () => this._isInGracePeriod, From cd87a7065054f012a98f0318690ee454d149aff8 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Tue, 5 May 2026 23:43:36 +0300 Subject: [PATCH 081/126] fix: reuse particles motion query --- src/shared/shell/Particles.test.ts | 17 +++++++++++++++++ src/shared/shell/Particles.ts | 15 ++++++++++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/shared/shell/Particles.test.ts b/src/shared/shell/Particles.test.ts index f461ee78..cd79c798 100644 --- a/src/shared/shell/Particles.test.ts +++ b/src/shared/shell/Particles.test.ts @@ -151,4 +151,21 @@ describe('Particles', () => { particles.destroy(); }); + + it('reuses the reduced-motion media query across focus restores', () => { + const particles = new Particles(); + const matchMediaSpy = globalThis.matchMedia as unknown as ReturnType; + + expect(matchMediaSpy).toHaveBeenCalledTimes(1); + + Object.defineProperty(document, 'hidden', { configurable: true, value: true }); + document.dispatchEvent(new Event('visibilitychange')); + Object.defineProperty(document, 'hidden', { configurable: true, value: false }); + document.dispatchEvent(new Event('visibilitychange')); + globalThis.dispatchEvent(new Event('focus')); + globalThis.dispatchEvent(new Event('focus')); + + expect(matchMediaSpy).toHaveBeenCalledTimes(1); + particles.destroy(); + }); }); diff --git a/src/shared/shell/Particles.ts b/src/shared/shell/Particles.ts index cc6dcf8b..7aed97a4 100644 --- a/src/shared/shell/Particles.ts +++ b/src/shared/shell/Particles.ts @@ -81,6 +81,7 @@ export class Particles { private _animationFrameId: number | null = null; private _animationTimerId: ReturnType | null = null; private _resizeFrameId: number | null = null; + private _motionQuery: MediaQueryList | null = null; private readonly _cleanupAbort: AbortController = new AbortController(); constructor(private readonly _runtime: ParticlesRuntime = createDefaultParticlesRuntime()) { @@ -195,6 +196,7 @@ export class Particles { this._canvas.remove(); this._particles = []; this._particlesByColor = {}; + this._motionQuery = null; } private _init(): void { @@ -268,7 +270,7 @@ export class Particles { { signal }, ); - const motionQuery = this._runtime.matchMedia('(prefers-reduced-motion: reduce)'); + const motionQuery = this._getMotionQuery(); const handleMotion = (): void => { if (motionQuery.matches) this.stop(); else this.start(); @@ -287,15 +289,17 @@ export class Particles { } private _checkReducedMotionAndStart(): void { - const motionQuery = this._runtime.matchMedia('(prefers-reduced-motion: reduce)'); + const motionQuery = this._getMotionQuery(); if (!motionQuery.matches) { this.start(); } } public start(): void { + if (!this._isTauriRuntime) return; + if (!this._isRunning) { - const motionQuery = this._runtime.matchMedia('(prefers-reduced-motion: reduce)'); + const motionQuery = this._getMotionQuery(); if (motionQuery.matches) return; this._isRunning = true; @@ -304,6 +308,11 @@ export class Particles { } } + private _getMotionQuery(): MediaQueryList { + this._motionQuery ??= this._runtime.matchMedia('(prefers-reduced-motion: reduce)'); + return this._motionQuery; + } + public stop(): void { this._isRunning = false; this._cancelAnimationFrame(); From d558c9368c9c3dbcea88bec8904a329b632ea1e3 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Tue, 5 May 2026 23:44:57 +0300 Subject: [PATCH 082/126] fix: guard repeated lifecycle init --- src/app/events.test.ts | 4 +++ src/app/events.ts | 11 ++++++++ src/shared/services/StateManager.test.ts | 35 ++++++++++++++++++++++++ src/shared/services/StateManager.ts | 7 +++++ 4 files changed, 57 insertions(+) diff --git a/src/app/events.test.ts b/src/app/events.test.ts index ded79787..48712cde 100644 --- a/src/app/events.test.ts +++ b/src/app/events.test.ts @@ -96,6 +96,7 @@ describe('EventHandler', () => { const core = createCoreEvents(); const handler = new EventHandler(core, runtime); handler.init(); + handler.init(); const navButton = document.querySelector('[data-page]'); if (navButton === null) { @@ -105,7 +106,10 @@ describe('EventHandler', () => { await flushAsyncNavigation(); expect(core.navigationUI.showPage).toHaveBeenCalledOnce(); + expect(runtime.addWindowListener).toHaveBeenCalledOnce(); + handler.destroy(); handler.destroy(); + expect(runtime.removeWindowListener).toHaveBeenCalledOnce(); }); it('delegates language menu and language selection clicks', async () => { diff --git a/src/app/events.ts b/src/app/events.ts index 495da099..09b635ef 100644 --- a/src/app/events.ts +++ b/src/app/events.ts @@ -46,6 +46,7 @@ function createDefaultEventHandlerRuntime(): EventHandlerRuntime { export class EventHandler { private readonly _core: ICoreEvents; private _unsubscribers: (() => void)[] = []; + private _initialized = false; constructor( core: ICoreEvents, @@ -58,6 +59,11 @@ export class EventHandler { * Initializes all global event listeners. */ public init(): void { + if (this._initialized) { + return; + } + this._initialized = true; + this._initGlobalDelegation(); this._initWindowControls(); @@ -191,10 +197,15 @@ export class EventHandler { * Cleans up all event listeners. */ public destroy(): void { + if (!this._initialized) { + return; + } + this._unsubscribers.forEach((fn) => { fn(); }); this._unsubscribers = []; + this._initialized = false; this._core.tracer.debug('[EventHandler] Destroyed and listeners removed.'); } diff --git a/src/shared/services/StateManager.test.ts b/src/shared/services/StateManager.test.ts index a22ef3d4..dd37bd88 100644 --- a/src/shared/services/StateManager.test.ts +++ b/src/shared/services/StateManager.test.ts @@ -130,6 +130,41 @@ describe('StateManager', () => { await manager.destroy(); }); + it('does not register duplicate global listeners on repeated init', async () => { + const manager = new StateManager(tracer); + const target = createTarget(); + manager.register(target); + manager.init(); + manager.init(); + + Object.defineProperty(document, 'hidden', { + configurable: true, + value: true, + }); + document.dispatchEvent(new Event('visibilitychange')); + await Promise.resolve(); + await Promise.resolve(); + + expect(target.saveAsync).toHaveBeenCalledOnce(); + expect(tracer.debug).toHaveBeenCalledWith('[StateManager] Global listeners registered'); + expect(tracer.debug).toHaveBeenCalledTimes(2); + + await manager.destroy(); + }); + + it('does not bind global listeners after destroy', async () => { + const manager = new StateManager(tracer); + const target = createTarget(); + manager.register(target); + await manager.destroy(); + + manager.init(); + globalThis.dispatchEvent(new Event('beforeunload')); + await Promise.resolve(); + + expect(target.saveImmediate).toHaveBeenCalledOnce(); + }); + it('does not save on visibility visible and removes listeners on destroy', async () => { const manager = new StateManager(tracer); const target = createTarget(); diff --git a/src/shared/services/StateManager.ts b/src/shared/services/StateManager.ts index 148be005..2fdbaf79 100644 --- a/src/shared/services/StateManager.ts +++ b/src/shared/services/StateManager.ts @@ -27,6 +27,7 @@ export interface StatePersistenceTarget { export class StateManager { private readonly _targets = new Map(); + private _isInitialized = false; private _isDestroyed = false; constructor(private readonly _tracer: StateManagerLogger) {} @@ -126,6 +127,11 @@ export class StateManager { * Call once during app bootstrap. */ init(): void { + if (this._isDestroyed || this._isInitialized) { + return; + } + + this._isInitialized = true; document.addEventListener('visibilitychange', this._boundVisibilityChange); globalThis.addEventListener('beforeunload', this._boundBeforeUnload); this._tracer.debug('[StateManager] Global listeners registered'); @@ -145,6 +151,7 @@ export class StateManager { globalThis.removeEventListener('beforeunload', this._boundBeforeUnload); this._targets.clear(); + this._isInitialized = false; this._tracer.info('[StateManager] Destroyed'); } } From e15cad9de427f7f730b37303920dd850da9b1de4 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Tue, 5 May 2026 23:49:08 +0300 Subject: [PATCH 083/126] feat: expose integration sdk endpoints --- docs/en/LAUNCHER_SDK.md | 62 +++++++- src-tauri/src/domain/integration_api.rs | 203 +++++++++++++++++++++++- 2 files changed, 259 insertions(+), 6 deletions(-) diff --git a/docs/en/LAUNCHER_SDK.md b/docs/en/LAUNCHER_SDK.md index f02bcde1..5e1d41ef 100644 --- a/docs/en/LAUNCHER_SDK.md +++ b/docs/en/LAUNCHER_SDK.md @@ -12,10 +12,13 @@ server is available only on the local machine and requires a per-process token. Launcher-managed integration processes receive these environment variables: +- `AXELATE_SDK_VERSION`: local launcher integration API version, currently `1` - `AXELATE_HTTP_API_BASE`: local base URL, for example `http://127.0.0.1:3000` - `AXELATE_HTTP_API_TOKEN`: bearer token for the current launcher process - `AXELATE_RUNTIME_DIR`: shared launcher runtime directory +- `AXELATE_MODULE_DIR`: read-only integration installation directory - `AXELATE_MODULE_RUNTIME_DIR`: writable runtime directory reserved for the integration +- `AXELATE_MODULE_LOG_DIR`: writable log directory reserved for the integration - `AXELATE_MODULE_ID`: current integration id External tools that are not launched by Axelate need the same two values from @@ -58,6 +61,10 @@ Prefer `Authorization: Bearer ...` for new clients. - Do not store the token permanently. It changes between launcher processes. - Send and receive JSON. - Use `/v1` endpoints only; unversioned endpoints are not public API. +- Read and save integration settings through `/v1/modules/{moduleId}/settings`. + Do not read or write Axelate's internal `module_settings.json` directly. +- Write temporary files, caches, and generated state to `AXELATE_MODULE_RUNTIME_DIR`. + Write logs to `AXELATE_MODULE_LOG_DIR`. - If a request specifies an AI `provider`, the launcher updates the matching UI card selection before running the request. @@ -95,6 +102,7 @@ Invoke-RestMethod ` ```js const baseUrl = process.env.AXELATE_HTTP_API_BASE; const token = process.env.AXELATE_HTTP_API_TOKEN; +const moduleId = process.env.AXELATE_MODULE_ID; const response = await fetch(`${baseUrl}/v1/ai/text`, { method: "POST", @@ -109,6 +117,11 @@ const response = await fetch(`${baseUrl}/v1/ai/text`, { }); const result = await response.json(); + +const settingsResponse = await fetch(`${baseUrl}/v1/modules/${moduleId}/settings`, { + headers: { Authorization: `Bearer ${token}` }, +}); +const { settings } = await settingsResponse.json(); ``` ### Python @@ -119,10 +132,12 @@ import requests base_url = os.environ["AXELATE_HTTP_API_BASE"] token = os.environ["AXELATE_HTTP_API_TOKEN"] +module_id = os.environ["AXELATE_MODULE_ID"] +headers = {"Authorization": f"Bearer {token}"} response = requests.post( f"{base_url}/v1/ai/text", - headers={"Authorization": f"Bearer {token}"}, + headers=headers, json={ "provider": "llamacpp", "prompt": "Write a short status update", @@ -130,6 +145,12 @@ response = requests.post( timeout=120, ) result = response.json() + +settings = requests.get( + f"{base_url}/v1/modules/{module_id}/settings", + headers=headers, + timeout=30, +).json()["settings"] ``` ## Endpoints @@ -151,6 +172,45 @@ state, and metadata. Returns one integration status. +`GET /v1/modules/{moduleId}/context` + +Returns the stable runtime context for an installed integration. + +```json +{ + "ok": true, + "apiVersion": "1", + "moduleId": "my-integration", + "moduleDir": "C:\\Users\\...\\AxelateData\\System\\Integrations\\my-integration", + "runtimeDir": "C:\\Users\\...\\AxelateData\\System\\Runtime", + "moduleRuntimeDir": "C:\\Users\\...\\AxelateData\\System\\Runtime\\Integrations\\my-integration", + "moduleLogDir": "C:\\Users\\...\\AxelateData\\System\\Logs\\Integrations\\my-integration", + "httpApiBase": "http://127.0.0.1:3000" +} +``` + +`moduleDir` is for reading shipped integration files. Runtime output belongs in +`moduleRuntimeDir`, not in the integration folder. + +`GET /v1/modules/{moduleId}/settings` + +Returns the JSON settings object owned by the integration. + +`PUT /v1/modules/{moduleId}/settings` + +Replaces the integration settings object. + +```json +{ + "chatId": "12345", + "enabled": true +} +``` + +`PATCH /v1/modules/{moduleId}/settings` + +Merges the request JSON object into the existing integration settings object. + `POST /v1/modules/{moduleId}/stage` Reports the current user-visible stage of a running integration. The launcher diff --git a/src-tauri/src/domain/integration_api.rs b/src-tauri/src/domain/integration_api.rs index 927c46a3..2cc7f2e4 100644 --- a/src-tauri/src/domain/integration_api.rs +++ b/src-tauri/src/domain/integration_api.rs @@ -11,6 +11,7 @@ use crate::domain::ai::types::{ use crate::domain::ai::{ChatSessionManager, ImageGenerationState}; use crate::domain::engine::manager::EngineManager; use crate::domain::modules::controller::{self as module_controller, ModuleAction}; +use crate::domain::modules::paths as module_paths; use crate::domain::system::config_service::ConfigService; use crate::domain::system::ports::{LAUNCHER_LOCAL_PORT_RANGE, LocalPortPurpose}; use crate::errors::AppError; @@ -28,6 +29,7 @@ use std::time::Duration; use tauri::{AppHandle, Emitter}; const DEFAULT_API_BASE_URL: &str = "http://127.0.0.1:3000"; +const SDK_API_VERSION: &str = "1"; const MAX_REQUEST_BYTES: usize = 1024 * 1024; const CUSTOM_TEXT_PROVIDER_ID: &str = "openrouter-custom-text"; const CUSTOM_IMAGE_PROVIDER_ID: &str = "openrouter-custom-image"; @@ -241,6 +243,19 @@ struct ImageApiResponse { response: ImageGenerationResponse, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ModuleContextApiResponse { + ok: bool, + api_version: &'static str, + module_id: String, + module_dir: String, + runtime_dir: String, + module_runtime_dir: String, + module_log_dir: String, + http_api_base: String, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] struct SelectedModuleChangedEvent { @@ -492,6 +507,18 @@ async fn route_authorized_request( json!({ "ok": true, "moduleId": module_id, "status": status }), )) } + ("GET", ["v1", "modules", module_id, "context"]) => { + handle_module_context_request(module_id) + } + ("GET", ["v1", "modules", module_id, "settings"]) => { + handle_get_module_settings_request(&context, module_id).await + } + ("PUT", ["v1", "modules", module_id, "settings"]) => { + handle_put_module_settings_request(request, &context, module_id).await + } + ("PATCH", ["v1", "modules", module_id, "settings"]) => { + handle_patch_module_settings_request(request, &context, module_id).await + } ("POST", ["v1", "modules", module_id, "stage"]) => { handle_module_stage_request(request, &context, module_id) } @@ -510,6 +537,95 @@ async fn route_authorized_request( } } +fn handle_module_context_request(module_id: &str) -> Result { + ensure_installed_module_id(module_id)?; + let module_dir = crate::domain::modules::downloader::get_module_path(module_id); + let module_runtime_dir = module_paths::runtime_root(module_id); + let module_log_dir = module_paths::log_dir(module_id); + + Ok(json_response( + 200, + json!(ModuleContextApiResponse { + ok: true, + api_version: SDK_API_VERSION, + module_id: module_id.to_string(), + module_dir: module_dir.display().to_string(), + runtime_dir: crate::utils::paths::RUNTIME_DIR.display().to_string(), + module_runtime_dir: module_runtime_dir.display().to_string(), + module_log_dir: module_log_dir.display().to_string(), + http_api_base: api_base_url().to_string(), + }), + )) +} + +async fn handle_get_module_settings_request( + context: &LauncherHttpApiContext, + module_id: &str, +) -> Result { + ensure_installed_module_id(module_id)?; + let settings = context + .settings_service + .get_module_settings(module_id) + .await?; + + Ok(json_response( + 200, + json!({ "ok": true, "moduleId": module_id, "settings": settings }), + )) +} + +async fn handle_put_module_settings_request( + request: &HttpRequest, + context: &LauncherHttpApiContext, + module_id: &str, +) -> Result { + ensure_installed_module_id(module_id)?; + let settings: HashMap = parse_json_body(request)?; + context + .settings_service + .save_module_settings(module_id, &settings) + .await?; + + Ok(json_response( + 200, + json!({ "ok": true, "moduleId": module_id, "settings": settings }), + )) +} + +async fn handle_patch_module_settings_request( + request: &HttpRequest, + context: &LauncherHttpApiContext, + module_id: &str, +) -> Result { + ensure_installed_module_id(module_id)?; + let updates: HashMap = parse_json_body(request)?; + let mut settings = context + .settings_service + .get_module_settings(module_id) + .await?; + settings.extend(updates); + context + .settings_service + .save_module_settings(module_id, &settings) + .await?; + + Ok(json_response( + 200, + json!({ "ok": true, "moduleId": module_id, "settings": settings }), + )) +} + +fn ensure_installed_module_id(module_id: &str) -> Result<(), AppError> { + crate::domain::modules::downloader::validate_module_id(module_id)?; + if crate::domain::modules::downloader::is_module_installed(module_id) { + Ok(()) + } else { + Err(AppError::NotFound(format!( + "Module {module_id} is not installed" + ))) + } +} + fn handle_module_stage_request( request: &HttpRequest, context: &LauncherHttpApiContext, @@ -1053,11 +1169,11 @@ mod tests { #![allow(clippy::expect_used)] use super::{ - IntegrationTextRequest, backend_provider_id, find_header_end, is_authorized, - is_loopback_peer, json_error, json_response, model_api_id, parse_header_line, - parse_header_lines, parse_json_body, parse_module_action, read_http_request, - selected_module_from_api_provider, selected_module_from_catalog_item, status_for_app_error, - status_text, tier_rank, + IntegrationTextRequest, ModuleContextApiResponse, backend_provider_id, find_header_end, + is_authorized, is_loopback_peer, json_error, json_response, model_api_id, + parse_header_line, parse_header_lines, parse_json_body, parse_module_action, + read_http_request, selected_module_from_api_provider, selected_module_from_catalog_item, + status_for_app_error, status_text, tier_rank, }; use crate::domain::modules::controller::ModuleAction; use crate::errors::AppError; @@ -1190,6 +1306,83 @@ mod tests { assert_eq!(payload.messages.expect("messages").len(), 1); } + #[test] + fn parses_module_settings_request_as_json_object() { + let request = super::HttpRequest { + method: "PUT".to_string(), + path: "/v1/modules/sample/settings".to_string(), + headers: HashMap::new(), + body: br#"{"enabled":true,"threshold":3}"#.to_vec(), + }; + + let settings: HashMap = + parse_json_body(&request).expect("settings object"); + + assert_eq!( + settings.get("enabled").and_then(serde_json::Value::as_bool), + Some(true) + ); + assert_eq!( + settings + .get("threshold") + .and_then(serde_json::Value::as_i64), + Some(3) + ); + } + + #[test] + fn rejects_module_settings_request_when_body_is_not_object() { + let request = super::HttpRequest { + method: "PUT".to_string(), + path: "/v1/modules/sample/settings".to_string(), + headers: HashMap::new(), + body: br#"["not","an","object"]"#.to_vec(), + }; + + let error = + parse_json_body::>(&request).expect_err("array"); + + assert!(matches!(error, AppError::Validation(_))); + } + + #[test] + fn module_context_response_uses_public_camel_case_contract() { + let response = serde_json::to_value(ModuleContextApiResponse { + ok: true, + api_version: "1", + module_id: "sample".to_string(), + module_dir: "module".to_string(), + runtime_dir: "runtime".to_string(), + module_runtime_dir: "module-runtime".to_string(), + module_log_dir: "logs".to_string(), + http_api_base: "http://127.0.0.1:3000".to_string(), + }) + .expect("context response"); + + assert_eq!( + response + .get("apiVersion") + .and_then(serde_json::Value::as_str), + Some("1") + ); + assert_eq!( + response.get("moduleId").and_then(serde_json::Value::as_str), + Some("sample") + ); + assert_eq!( + response + .get("moduleRuntimeDir") + .and_then(serde_json::Value::as_str), + Some("module-runtime") + ); + assert_eq!( + response + .get("httpApiBase") + .and_then(serde_json::Value::as_str), + Some("http://127.0.0.1:3000") + ); + } + #[test] fn ranks_model_tiers_for_default_selection() { assert!(tier_rank(&ModelTier::Strong) > tier_rank(&ModelTier::Medium)); From 96145500812b29ead543a0247f1cd8dd199993a9 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Tue, 5 May 2026 23:50:24 +0300 Subject: [PATCH 084/126] feat: import custom integrations --- src-tauri/Cargo.lock | 98 ++++- src-tauri/Cargo.toml | 1 + src-tauri/src/api/modules/downloader.rs | 32 ++ src-tauri/src/domain/modules/downloader.rs | 394 ++++++++++++++++++ .../src/domain/modules/integration_watcher.rs | 89 ++++ src-tauri/src/domain/modules/mod.rs | 2 + src-tauri/src/lib.rs | 5 + src/shared/types/bindings.ts | 8 + 8 files changed, 619 insertions(+), 10 deletions(-) create mode 100644 src-tauri/src/domain/modules/integration_watcher.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 99a71bde..b4061965 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -314,6 +314,7 @@ dependencies = [ "hex", "log", "machine-uid", + "notify", "num_cpus", "nvml-wrapper", "once_cell", @@ -1121,7 +1122,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1313,7 +1314,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1502,6 +1503,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futf" version = "0.1.5" @@ -2381,6 +2391,26 @@ dependencies = [ "cfb", ] +[[package]] +name = "inotify" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" +dependencies = [ + "bitflags 2.11.1", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "inout" version = "0.1.4" @@ -2563,6 +2593,26 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7b65860415f949f23fa882e669f2dbd4a0f0eeb1acdd56790b30494afd7da2f" +dependencies = [ + "bitflags 2.11.1", + "libc", +] + [[package]] name = "kuchikiki" version = "0.8.8-speedreader" @@ -2808,6 +2858,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", + "log", "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] @@ -2911,6 +2962,33 @@ dependencies = [ "memchr", ] +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.11.1", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.11.1", +] + [[package]] name = "ntapi" version = "0.4.3" @@ -2926,7 +3004,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3225,7 +3303,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.61.2", ] [[package]] @@ -4042,7 +4120,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4532,7 +4610,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5250,10 +5328,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5770,7 +5848,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6292,7 +6370,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c4aa99b9..ed75cea5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -100,6 +100,7 @@ async-trait = "0.1.89" flate2 = "1.1.9" tar = "0.4.45" sevenz-rust2 = "0.21.0" +notify = "8.2.0" [target.'cfg(windows)'.dependencies] windows = { version = "0.62.2", features = [ diff --git a/src-tauri/src/api/modules/downloader.rs b/src-tauri/src/api/modules/downloader.rs index c795a747..b28f5e5c 100644 --- a/src-tauri/src/api/modules/downloader.rs +++ b/src-tauri/src/api/modules/downloader.rs @@ -66,6 +66,38 @@ pub async fn get_release_download_options( downloader::get_release_download_options(&module_id, &repo_url).await } +#[tauri::command] +#[specta::specta] +/// Imports an integration from a local folder containing `axelate-module.toml`. +pub async fn import_integration_folder(path: String) -> Result { + downloader::import_integration_folder(&std::path::PathBuf::from(path)) +} + +#[tauri::command] +#[specta::specta] +/// Imports an integration from a local `.zip`, `.tar.gz`, `.tgz`, or `.7z` archive. +pub async fn import_integration_archive(app: AppHandle, path: String) -> Result { + downloader::import_integration_archive(app, std::path::PathBuf::from(path)).await +} + +#[tauri::command] +#[specta::specta] +/// Imports an integration from a local folder or archive, auto-detected by path type. +pub async fn import_integration_path(app: AppHandle, path: String) -> Result { + downloader::import_integration_path(app, std::path::PathBuf::from(path)).await +} + +#[tauri::command] +#[specta::specta] +/// Downloads and imports an integration from a repository or archive URL. +pub async fn import_integration_url( + app: AppHandle, + downloader: tauri::State<'_, downloader::DownloaderService>, + source_url: String, +) -> Result { + downloader::import_integration_url(app, &downloader, source_url).await +} + #[tauri::command] #[specta::specta] /// Resumes a paused module download using backend-owned request metadata. diff --git a/src-tauri/src/domain/modules/downloader.rs b/src-tauri/src/domain/modules/downloader.rs index da990c93..cd59536a 100644 --- a/src-tauri/src/domain/modules/downloader.rs +++ b/src-tauri/src/domain/modules/downloader.rs @@ -11,10 +11,15 @@ use super::downloader_transfer::{ download_file, resolve_download_url, }; use super::github_releases::ReleaseDownloadSelection; +use super::lifecycle::{ManifestLoader, ModuleManifest}; use crate::errors::AppError; +use crate::utils::paths::{INTEGRATIONS_DIR, TEMP_DIR}; use std::path::{Path, PathBuf}; use tauri::AppHandle; +const IMPORT_FILE_COUNT_LIMIT: usize = 20_000; +const IMPORT_TOTAL_SIZE_LIMIT: u64 = 3 * 1024 * 1024 * 1024; + pub use super::downloader_service::{DownloadRequest, DownloaderService}; /// Validates module ID to prevent directory traversal and injection attacks @@ -84,6 +89,115 @@ pub async fn delete_module(module_id: &str) -> Result<(), AppError> { } } +/// Imports an integration from an existing local folder. +pub fn import_integration_folder(path: &Path) -> Result { + ensure_source_directory(path)?; + let manifest = ManifestLoader::load(path)?; + let module_id = validate_integration_manifest(&manifest)?; + let staging_path = ArchiveExtractor::prepare_staging(&module_id)?; + + let result = (|| { + copy_directory_contents_secure(path, &staging_path)?; + finalize_imported_integration(&staging_path, Some("local-folder")) + })(); + + cleanup_staging_on_error(&result, &staging_path); + result +} + +/// Imports an integration from a local path, auto-detecting folder or archive sources. +pub async fn import_integration_path(app: AppHandle, path: PathBuf) -> Result { + let metadata = std::fs::metadata(&path).map_err(|error| { + AppError::Io(format!( + "Failed to read integration source '{}': {error}", + path.display() + )) + })?; + + if metadata.is_dir() { + return import_integration_folder(&path); + } + + if metadata.is_file() { + return import_integration_archive(app, path).await; + } + + Err(AppError::Validation( + "Selected integration source must be a folder or archive file".to_string(), + )) +} + +/// Imports an integration from a local archive file. +pub async fn import_integration_archive(app: AppHandle, path: PathBuf) -> Result { + ensure_source_file(&path)?; + let import_id = build_import_id(); + let staging_path = ArchiveExtractor::prepare_staging(&import_id)?; + + let result = async { + ArchiveExtractor::extract_into(&app, &path, &import_id, &staging_path, None).await?; + finalize_imported_integration(&staging_path, Some("local-archive")) + } + .await; + + cleanup_staging_on_error(&result, &staging_path); + result +} + +/// Downloads and imports an integration from a repository or archive URL. +pub async fn import_integration_url( + app: AppHandle, + downloader: &DownloaderService, + source_url: String, +) -> Result { + let source_url = validate_import_url(&source_url)?; + let import_id = build_import_id(); + let staging_path = ArchiveExtractor::prepare_staging(&import_id)?; + let archive_path = build_import_archive_path(&import_id, &source_url); + let control = downloader.request_control(&import_id); + + let result = async { + let client = build_public_client()?; + let final_url = resolve_download_url(&client, &source_url).await?; + let download_result = download_file( + DownloadTask { + app: &app, + downloader, + client: &client, + url: &final_url, + dest_path: &archive_path, + module_id: &import_id, + control: &control, + }, + None, + ) + .await?; + + if let Some(interruption) = download_result.interruption { + return Err(AppError::External { + request_id: None, + message: interruption.as_error_message().to_string(), + }); + } + + ArchiveExtractor::extract_into( + &app, + &archive_path, + &import_id, + &staging_path, + Some(download_result.snapshot), + ) + .await?; + + finalize_imported_integration(&staging_path, Some(&source_url)) + } + .await; + + downloader.remove_control(&import_id); + cleanup_staging_on_error(&result, &staging_path); + cleanup_import_archive(&archive_path).await; + result +} + /// Downloads and extracts a module from a remote repository pub async fn download_module( app: AppHandle, @@ -363,6 +477,286 @@ pub async fn download_module( Ok("completed".to_string()) } +fn validate_integration_manifest(manifest: &ModuleManifest) -> Result { + validate_module_id(&manifest.id)?; + + if let Some(category) = manifest.category.as_deref() { + let normalized = category.trim().to_ascii_lowercase(); + if !matches!( + normalized.as_str(), + "service" | "services" | "integration" | "integrations" + ) { + return Err(AppError::Validation( + "Custom integration manifest must use type = \"service\"".to_string(), + )); + } + } + + Ok(manifest.id.clone()) +} + +fn finalize_imported_integration( + extraction_path: &Path, + source: Option<&str>, +) -> Result { + let manifest = ManifestLoader::load(extraction_path)?; + let module_id = validate_integration_manifest(&manifest)?; + let final_path = INTEGRATIONS_DIR.join(&module_id); + + std::fs::create_dir_all(&*INTEGRATIONS_DIR).map_err(|error| { + AppError::Io(format!( + "Failed to create integrations directory '{}': {error}", + INTEGRATIONS_DIR.display() + )) + })?; + + let metadata = serde_json::json!({ + "module_id": module_id, + "installed_at": chrono::Local::now().to_rfc3339(), + "archive_hash": null, + "status": "complete", + "version": manifest.version, + "source": source, + }); + let metadata_path = extraction_path.join("metadata.json"); + let metadata_file = std::fs::File::create(&metadata_path).map_err(|error| { + AppError::Io(format!( + "Failed to create install metadata {}: {error}", + metadata_path.display() + )) + })?; + serde_json::to_writer_pretty(metadata_file, &metadata).map_err(|error| { + AppError::Serialization(format!( + "Failed to write install metadata {}: {error}", + metadata_path.display() + )) + })?; + + let backup_path = TEMP_DIR.join(format!( + "{}_integration_backup_{}", + module_id, + uuid::Uuid::new_v4().simple() + )); + + if final_path.exists() { + std::fs::rename(&final_path, &backup_path).map_err(|error| { + AppError::Io(format!( + "Failed to move old integration version to backup: {error}" + )) + })?; + } + + if let Err(error) = std::fs::rename(extraction_path, &final_path) { + if backup_path.exists() + && let Err(restore_error) = std::fs::rename(&backup_path, &final_path) + { + tracing::error!( + module_id, + backup = %backup_path.display(), + target = %final_path.display(), + "Failed to restore previous integration after import failure: {restore_error}" + ); + } + + return Err(AppError::Io(format!( + "Atomic integration import failed during move: {error}" + ))); + } + + if backup_path.exists() { + std::fs::remove_dir_all(&backup_path).map_err(|error| { + AppError::Io(format!("Failed to remove old integration backup: {error}")) + })?; + } + + crate::infrastructure::logging::logger::add_log( + &format!("Integration {module_id} imported successfully"), + "Downloader", + "info", + ); + + Ok(module_id) +} + +fn copy_directory_contents_secure(source: &Path, destination: &Path) -> Result<(), AppError> { + let mut state = ImportCopyState::default(); + copy_directory_contents_secure_inner(source, destination, &mut state) +} + +#[derive(Default)] +struct ImportCopyState { + file_count: usize, + total_size: u64, +} + +fn copy_directory_contents_secure_inner( + source: &Path, + destination: &Path, + state: &mut ImportCopyState, +) -> Result<(), AppError> { + for entry in std::fs::read_dir(source)? { + let entry = entry?; + let source_path = entry.path(); + let metadata = std::fs::symlink_metadata(&source_path)?; + if metadata.file_type().is_symlink() { + return Err(AppError::Validation(format!( + "Symlinks are not supported in integration imports: {}", + source_path.display() + ))); + } + + let destination_path = destination.join(entry.file_name()); + if metadata.is_dir() { + std::fs::create_dir_all(&destination_path)?; + copy_directory_contents_secure_inner(&source_path, &destination_path, state)?; + continue; + } + + if !metadata.is_file() { + return Err(AppError::Validation(format!( + "Unsupported filesystem entry in integration import: {}", + source_path.display() + ))); + } + + state.file_count += 1; + if state.file_count > IMPORT_FILE_COUNT_LIMIT { + return Err(AppError::Validation(format!( + "Integration folder contains too many files. Limit is {IMPORT_FILE_COUNT_LIMIT}." + ))); + } + + state.total_size = state + .total_size + .checked_add(metadata.len()) + .ok_or_else(|| AppError::Validation("Integration folder size overflow".to_string()))?; + if state.total_size > IMPORT_TOTAL_SIZE_LIMIT { + return Err(AppError::Validation( + "Integration folder is too large to import".to_string(), + )); + } + + if let Some(parent) = destination_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::copy(&source_path, &destination_path).map_err(|error| { + AppError::Io(format!( + "Failed to copy '{}' to '{}': {error}", + source_path.display(), + destination_path.display() + )) + })?; + } + + Ok(()) +} + +fn ensure_source_directory(path: &Path) -> Result<(), AppError> { + let metadata = std::fs::metadata(path).map_err(|error| { + AppError::Io(format!( + "Failed to read integration folder '{}': {error}", + path.display() + )) + })?; + if !metadata.is_dir() { + return Err(AppError::Validation( + "Selected integration source is not a folder".to_string(), + )); + } + + Ok(()) +} + +fn ensure_source_file(path: &Path) -> Result<(), AppError> { + let metadata = std::fs::metadata(path).map_err(|error| { + AppError::Io(format!( + "Failed to read integration archive '{}': {error}", + path.display() + )) + })?; + if !metadata.is_file() { + return Err(AppError::Validation( + "Selected integration source is not an archive file".to_string(), + )); + } + + Ok(()) +} + +fn validate_import_url(source_url: &str) -> Result { + let source_url = source_url.trim(); + if source_url.is_empty() { + return Err(AppError::Validation( + "Integration URL cannot be empty".to_string(), + )); + } + if !source_url.starts_with("https://") && !source_url.starts_with("http://") { + return Err(AppError::Validation( + "Integration URL must start with http:// or https://".to_string(), + )); + } + + Ok(source_url.to_string()) +} + +fn build_import_id() -> String { + format!("integration-import-{}", uuid::Uuid::new_v4().simple()) +} + +fn build_import_archive_path(import_id: &str, source_url: &str) -> PathBuf { + let normalized = source_url + .split(['?', '#']) + .next() + .unwrap_or(source_url) + .to_ascii_lowercase(); + let extension = if normalized.ends_with(".tar.gz") { + "tar.gz" + } else { + match Path::new(&normalized) + .extension() + .and_then(|extension| extension.to_str()) + { + Some(extension) if extension.eq_ignore_ascii_case("tgz") => "tgz", + Some(extension) if extension.eq_ignore_ascii_case("7z") => "7z", + _ => "zip", + } + }; + + TEMP_DIR.join(format!("{import_id}.{extension}")) +} + +fn cleanup_staging_on_error(result: &Result, staging_path: &Path) { + if result.is_ok() || !staging_path.exists() { + return; + } + + if let Err(error) = std::fs::remove_dir_all(staging_path) { + tracing::warn!( + path = %staging_path.display(), + "Failed to clean integration import staging directory: {error}" + ); + } +} + +async fn cleanup_import_archive(archive_path: &Path) { + if let Err(error) = remove_partial_metadata(archive_path).await { + tracing::warn!( + path = %archive_path.display(), + "Failed to remove integration import partial metadata: {error}" + ); + } + match tokio::fs::remove_file(archive_path).await { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + tracing::warn!( + path = %archive_path.display(), + "Failed to remove temporary integration archive: {error}" + ); + } + } +} + fn is_github_repo_url(repo_url: &str) -> bool { repo_url.trim().to_ascii_lowercase().contains("github.com/") } diff --git a/src-tauri/src/domain/modules/integration_watcher.rs b/src-tauri/src/domain/modules/integration_watcher.rs new file mode 100644 index 00000000..7ce55977 --- /dev/null +++ b/src-tauri/src/domain/modules/integration_watcher.rs @@ -0,0 +1,89 @@ +//! Filesystem watcher for externally changed integration folders. + +use crate::utils::paths::INTEGRATIONS_DIR; +use notify::{EventKind, RecursiveMode, Watcher}; +use serde::Serialize; +use std::sync::mpsc; +use std::time::{Duration, Instant}; +use tauri::Emitter; + +const INTEGRATIONS_CHANGED_EVENT: &str = "integrations_changed"; +const EVENT_DEBOUNCE: Duration = Duration::from_millis(350); + +/// Payload emitted when the integrations folder changes on disk. +#[derive(Debug, Clone, Serialize)] +pub struct IntegrationsChangedPayload { + /// Absolute path of the watched integrations directory. + pub path: String, +} + +/// Starts a background watcher for integration folder changes. +pub fn start(app: tauri::AppHandle) { + let path = INTEGRATIONS_DIR.clone(); + + if let Err(error) = std::fs::create_dir_all(&path) { + tracing::warn!( + path = %path.display(), + "Failed to create integrations directory for watcher: {error}" + ); + return; + } + + std::thread::Builder::new() + .name("integration-folder-watcher".to_string()) + .spawn(move || { + let (tx, rx) = mpsc::channel(); + let mut watcher = match notify::recommended_watcher(tx) { + Ok(watcher) => watcher, + Err(error) => { + tracing::warn!("Failed to start integrations watcher: {error}"); + return; + } + }; + + if let Err(error) = watcher.watch(&path, RecursiveMode::Recursive) { + tracing::warn!( + path = %path.display(), + "Failed to watch integrations directory: {error}" + ); + return; + } + + let mut last_emit = Instant::now() + .checked_sub(EVENT_DEBOUNCE) + .unwrap_or_else(Instant::now); + while let Ok(event) = rx.recv() { + match event { + Ok(event) if is_integration_change(event.kind) => { + if last_emit.elapsed() < EVENT_DEBOUNCE { + continue; + } + last_emit = Instant::now(); + if let Err(error) = app.emit( + INTEGRATIONS_CHANGED_EVENT, + IntegrationsChangedPayload { + path: path.to_string_lossy().to_string(), + }, + ) { + tracing::warn!("Failed to emit integrations change event: {error}"); + } + } + Ok(_) => {} + Err(error) => { + tracing::warn!("Integrations watcher error: {error}"); + } + } + } + }) + .map_err(|error| { + tracing::warn!("Failed to spawn integrations watcher thread: {error}"); + }) + .ok(); +} + +const fn is_integration_change(kind: EventKind) -> bool { + matches!( + kind, + EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) + ) +} diff --git a/src-tauri/src/domain/modules/mod.rs b/src-tauri/src/domain/modules/mod.rs index dbbe782c..387575d5 100644 --- a/src-tauri/src/domain/modules/mod.rs +++ b/src-tauri/src/domain/modules/mod.rs @@ -11,6 +11,8 @@ mod downloader_transfer; mod github_release_selection; /// Open-Source engine GitHub releases parsing pub mod github_releases; +/// Filesystem watcher for externally changed integrations. +pub mod integration_watcher; /// Module lifecycle management pub mod lifecycle; /// Module-scoped filesystem paths diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3123ea56..921fe046 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -110,6 +110,10 @@ pub fn create_specta_builder() -> Builder { logs::log_batch, downloader::download_module, downloader::get_release_download_options, + downloader::import_integration_folder, + downloader::import_integration_archive, + downloader::import_integration_path, + downloader::import_integration_url, downloader::resume_download, downloader::check_module_installed, downloader::get_module_path, @@ -317,6 +321,7 @@ fn setup_dependencies(app: &tauri::App) -> Result<(), Box ), ); monitor_service.start_monitoring(monitor_emitter, DEFAULT_MONITORING_INTERVAL_MS); + crate::domain::modules::integration_watcher::start(app.handle().clone()); #[cfg(desktop)] setup_global_shortcut(app)?; diff --git a/src/shared/types/bindings.ts b/src/shared/types/bindings.ts index 327a346d..552eaee1 100644 --- a/src/shared/types/bindings.ts +++ b/src/shared/types/bindings.ts @@ -54,6 +54,14 @@ export const commands = { } | null) => typedError(__TAURI_INVOKE("download_module", { moduleId, repoUrl, expectedHash, dlType, releaseSelection })), // Lists compatible release versions and CPU/GPU package choices for a module. getReleaseDownloadOptions: (moduleId: string, repoUrl: string) => typedError(__TAURI_INVOKE("get_release_download_options", { moduleId, repoUrl })), + // Imports an integration from a local folder containing `axelate-module.toml`. + importIntegrationFolder: (path: string) => typedError(__TAURI_INVOKE("import_integration_folder", { path })), + // Imports an integration from a local `.zip`, `.tar.gz`, `.tgz`, or `.7z` archive. + importIntegrationArchive: (path: string) => typedError(__TAURI_INVOKE("import_integration_archive", { path })), + // Imports an integration from a local folder or archive, auto-detected by path type. + importIntegrationPath: (path: string) => typedError(__TAURI_INVOKE("import_integration_path", { path })), + // Downloads and imports an integration from a repository or archive URL. + importIntegrationUrl: (sourceUrl: string) => typedError(__TAURI_INVOKE("import_integration_url", { sourceUrl })), // Resumes a paused module download using backend-owned request metadata. resumeDownload: (moduleId: string) => typedError(__TAURI_INVOKE("resume_download", { moduleId })), // Checks if a module is already installed locally From 1226c8f090474024a0844fd7cd22ef73277b66ac Mon Sep 17 00:00:00 2001 From: F0RLE Date: Tue, 5 May 2026 23:51:55 +0300 Subject: [PATCH 085/126] feat: pass integration runtime env --- .../src/domain/modules/controller/script_runtime.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/domain/modules/controller/script_runtime.rs b/src-tauri/src/domain/modules/controller/script_runtime.rs index ff139f17..30f9bc7c 100644 --- a/src-tauri/src/domain/modules/controller/script_runtime.rs +++ b/src-tauri/src/domain/modules/controller/script_runtime.rs @@ -138,7 +138,7 @@ async fn spawn_python_process( .env("PYTHONUNBUFFERED", "1") .env("PYTHONUTF8", "1"); - spawn_runtime_command(module_id, &runtime_root, command, "Python").await + spawn_runtime_command(module_id, module_path, &runtime_root, command, "Python").await } async fn spawn_node_process( @@ -175,7 +175,7 @@ async fn spawn_node_process( .env("NODE_PATH", env_dir.join("node_modules")) .env("AXELATE_NODE_ENV_DIR", env_dir); - spawn_runtime_command(module_id, &runtime_root, command, "Node").await + spawn_runtime_command(module_id, module_path, &runtime_root, command, "Node").await } async fn spawn_bun_process( @@ -211,11 +211,12 @@ async fn spawn_bun_process( .env("NODE_PATH", env_dir.join("node_modules")) .env("AXELATE_BUN_ENV_DIR", env_dir); - spawn_runtime_command(module_id, &runtime_root, command, "Bun").await + spawn_runtime_command(module_id, module_path, &runtime_root, command, "Bun").await } async fn spawn_runtime_command( module_id: &str, + module_path: &Path, language_runtime_root: &Path, mut command: Command, runtime_name: &str, @@ -239,6 +240,7 @@ async fn spawn_runtime_command( command .env("BOT_CONFIG_DIR", CONFIG_DIR.as_os_str()) + .env("AXELATE_SDK_VERSION", "1") .env("AXELATE_CONFIG_DIR", CONFIG_DIR.as_os_str()) .env("AXELATE_RUNTIME_DIR", RUNTIME_DIR.as_os_str()) .env( @@ -249,6 +251,8 @@ async fn spawn_runtime_command( "AXELATE_MODULE_RUNTIME_DIR", module_runtime_root.as_os_str(), ) + .env("AXELATE_MODULE_DIR", module_path.as_os_str()) + .env("AXELATE_MODULE_LOG_DIR", module_log_dir.as_os_str()) .env("AXELATE_MODULE_ID", module_id) .stdout(Stdio::from(log_file.try_clone().map_err(|e| { AppError::Io(format!("Failed to clone runtime log file: {e}")) From 29255cbd8fdba372d1947f754a0524b9a5f96c0d Mon Sep 17 00:00:00 2001 From: F0RLE Date: Tue, 5 May 2026 23:53:14 +0300 Subject: [PATCH 086/126] feat: add integration import UI --- docs/en/CUSTOM_INTEGRATIONS.md | 71 ++++++++ src-tauri/resources/locales/en.json | 19 +- src-tauri/resources/locales/ru.json | 19 +- src-tauri/resources/locales/zh.json | 19 +- src/app/CoreUiFactory.ts | 8 +- src/assets/fonts/Cubic_11.zh-subset.woff2 | Bin 29412 -> 29720 bytes src/shared/services/CatalogService.test.ts | 56 ++++++ src/shared/services/CatalogService.ts | 34 ++++ .../services/ModulePlatformService.test.ts | 46 +++++ src/shared/services/ModulePlatformService.ts | 20 ++ src/shared/services/ModuleService.test.ts | 96 ++++++++++ src/shared/services/ModuleService.ts | 52 ++++++ src/shared/shell/AppUI.test.ts | 67 +++++++ src/shared/shell/AppUI.ts | 62 +++++++ src/shared/shell/ui/AppUiLifecycleBindings.ts | 6 + src/shared/shell/ui/AppUiModuleFlow.test.ts | 155 ++++++++++++++++ src/shared/shell/ui/AppUiModuleFlow.ts | 139 ++++++++++++++ .../shell/ui/IntegrationImportDialog.test.ts | 141 ++++++++++++++ .../shell/ui/IntegrationImportDialog.ts | 136 ++++++++++++++ src/shared/shell/ui/ModalManager.test.ts | 70 ++++++- src/shared/shell/ui/ModalManager.ts | 9 +- src/shared/shell/ui/ModalManagerSupport.ts | 118 +++++++++++- .../features/module-selection-modal.css | 172 +++++++++++++++++- src/styles/layouts/modal-shell.css | 12 ++ 24 files changed, 1514 insertions(+), 13 deletions(-) create mode 100644 docs/en/CUSTOM_INTEGRATIONS.md create mode 100644 src/shared/shell/ui/IntegrationImportDialog.test.ts create mode 100644 src/shared/shell/ui/IntegrationImportDialog.ts diff --git a/docs/en/CUSTOM_INTEGRATIONS.md b/docs/en/CUSTOM_INTEGRATIONS.md new file mode 100644 index 00000000..ca1c0e5f --- /dev/null +++ b/docs/en/CUSTOM_INTEGRATIONS.md @@ -0,0 +1,71 @@ +# Custom Integrations + +Axelate integrations are folders with an `axelate-module.toml` manifest. The +launcher can import a folder, a local archive, or a GitHub repository/archive URL. +Archives may be `.zip`, `.tar.gz`, `.tgz`, or `.7z`. + +## Minimal Layout + +```text +my-integration/ + axelate-module.toml + README.md + requirements.txt + src/ + main.py + settings-ui/ + index.html +``` + +## Manifest + +```toml +api_version = "1" +id = "my-integration" +name = "My Integration" +version = "0.1.0" +description = "Connects my product to Axelate." +author = "Your Name" +type = "service" +icon = "⚙" +readme = "README.md" +settings_ui = "settings-ui/index.html" + +[runtime] +kind = "python" +version = "3.14" +entry = "src/main.py" +dependencies = "requirements.txt" +``` + +Rules: + +- `id` may contain only letters, numbers, `-`, and `_`. +- `type` should be `service` for launcher integrations. +- `runtime.entry` and `runtime.dependencies` are paths relative to the module + folder. +- Do not ship `.venv`, `node_modules`, caches, logs, or downloaded runtimes. + +## Launcher API + +Launcher-managed integrations receive: + +- `AXELATE_SDK_VERSION` +- `AXELATE_HTTP_API_BASE` +- `AXELATE_HTTP_API_TOKEN` +- `AXELATE_RUNTIME_DIR` +- `AXELATE_MODULE_DIR` +- `AXELATE_MODULE_RUNTIME_DIR` +- `AXELATE_MODULE_LOG_DIR` +- `AXELATE_MODULE_ID` + +Use the local HTTP API from [LAUNCHER_SDK.md](./LAUNCHER_SDK.md) to call AI, +read and save integration settings, report stages, and control integration +status. Store integration-owned runtime files under `AXELATE_MODULE_RUNTIME_DIR` +and logs under `AXELATE_MODULE_LOG_DIR`; do not write generated files into the +imported integration folder. + +## Example + +Use [Axelate Telegram Parser](https://github.com/F0RLE/Axelate-telegram-parser) +as a working integration structure. diff --git a/src-tauri/resources/locales/en.json b/src-tauri/resources/locales/en.json index 7b40fb54..ac777dd5 100644 --- a/src-tauri/resources/locales/en.json +++ b/src-tauri/resources/locales/en.json @@ -168,6 +168,23 @@ "ui.launcher.models.ai_desc": "AI runtimes and providers for text, image, and code generation.", "ui.launcher.models.services": "Integrations", "ui.launcher.models.services_desc": "Local tools, automation flows, and external project integrations.", + "ui.launcher.integrations.import.add": "Add", + "ui.launcher.integrations.import.archive": "Archive", + "ui.launcher.integrations.import.archive_title": "Choose integration archive", + "ui.launcher.integrations.import.card_desc": "Import a custom integration from a folder, archive, or repository link.", + "ui.launcher.integrations.import.card_title": "Add integration", + "ui.launcher.integrations.import.error": "Integration import failed", + "ui.launcher.integrations.import.folder": "Folder", + "ui.launcher.integrations.import.folder_title": "Choose integration folder", + "ui.launcher.integrations.import.guide_short": "Create an axelate-module.toml, runtime entry, optional settings UI, then import it here.", + "ui.launcher.integrations.import.guide_title": "Integration guide", + "ui.launcher.integrations.import.open": "Open", + "ui.launcher.integrations.import.open_title": "Choose integration folder or archive", + "ui.launcher.integrations.import.success": "Integration added", + "ui.launcher.integrations.import.url": "Link", + "ui.launcher.integrations.import.url_desc": "Paste a GitHub repository or direct archive URL.", + "ui.launcher.integrations.import.url_placeholder": "Repository or archive URL", + "ui.launcher.integrations.import.url_title": "Add integration URL", "ui.launcher.models_subtitle": "Choose an engine or integration to start work", "ui.launcher.module.delete": "Delete", "ui.launcher.module.download": "Download", @@ -365,7 +382,7 @@ "ui.settings.thinking.off": "Off", "ui.settings.toggle_visibility": "Toggle password visibility", "ui.launcher.modules.modal.btn_remove": "Hide", - "ui.launcher.modules.modal.btn_select": "Select", + "ui.launcher.modules.modal.btn_select": "Launch", "ui.launcher.engine.llamacpp.name": "llama.cpp", "ui.launcher.engine.llamacpp.desc": "Universal LLM engine. CUDA, Vulkan, CPU.", "ui.launcher.engine.sdcpp.name": "Stable Diffusion.cpp", diff --git a/src-tauri/resources/locales/ru.json b/src-tauri/resources/locales/ru.json index 31e70bff..7adc91a3 100644 --- a/src-tauri/resources/locales/ru.json +++ b/src-tauri/resources/locales/ru.json @@ -172,6 +172,23 @@ "ui.launcher.models.ai_desc": "ИИ-рантаймы и провайдеры для генерации текста, изображений и кода.", "ui.launcher.models.services": "Интеграции", "ui.launcher.models.services_desc": "Локальные инструменты, автоматизация и внешние интеграции проекта.", + "ui.launcher.integrations.import.add": "Добавить", + "ui.launcher.integrations.import.archive": "Архив", + "ui.launcher.integrations.import.archive_title": "Выберите архив интеграции", + "ui.launcher.integrations.import.card_desc": "Импортируйте свою интеграцию из папки, архива или ссылки на репозиторий.", + "ui.launcher.integrations.import.card_title": "Добавить интеграцию", + "ui.launcher.integrations.import.error": "Не удалось импортировать интеграцию", + "ui.launcher.integrations.import.folder": "Папка", + "ui.launcher.integrations.import.folder_title": "Выберите папку интеграции", + "ui.launcher.integrations.import.guide_short": "Создайте axelate-module.toml, runtime entry, опциональный settings UI и импортируйте модуль здесь.", + "ui.launcher.integrations.import.guide_title": "Гайд по интеграциям", + "ui.launcher.integrations.import.open": "Открыть", + "ui.launcher.integrations.import.open_title": "Выберите папку или архив интеграции", + "ui.launcher.integrations.import.success": "Интеграция добавлена", + "ui.launcher.integrations.import.url": "Ссылка", + "ui.launcher.integrations.import.url_desc": "Вставьте ссылку на GitHub-репозиторий или прямой URL архива.", + "ui.launcher.integrations.import.url_placeholder": "URL репозитория или архива", + "ui.launcher.integrations.import.url_title": "Добавить интеграцию по ссылке", "ui.launcher.models_subtitle": "Выберите движок или интеграцию для работы", "ui.launcher.module.delete": "Удалить", "ui.launcher.module.download": "Скачать", @@ -366,7 +383,7 @@ "ui.settings.thinking.off": "Выкл", "ui.settings.toggle_visibility": "Показать/скрыть пароль", "ui.launcher.modules.modal.btn_remove": "Убрать", - "ui.launcher.modules.modal.btn_select": "Выбрать", + "ui.launcher.modules.modal.btn_select": "Запустить", "ui.launcher.engine.llamacpp.name": "llama.cpp", "ui.launcher.engine.llamacpp.desc": "Универсальный LLM движок. CUDA, Vulkan, CPU.", "ui.launcher.engine.sdcpp.name": "Stable Diffusion.cpp", diff --git a/src-tauri/resources/locales/zh.json b/src-tauri/resources/locales/zh.json index 8479dc39..21f2eee3 100644 --- a/src-tauri/resources/locales/zh.json +++ b/src-tauri/resources/locales/zh.json @@ -168,6 +168,23 @@ "ui.launcher.models.ai_desc": "用于文本、图像和代码生成的 AI 运行时与提供商。", "ui.launcher.models.services": "集成", "ui.launcher.models.services_desc": "本地工具、自动化流程和项目外部集成。", + "ui.launcher.integrations.import.add": "添加", + "ui.launcher.integrations.import.archive": "归档", + "ui.launcher.integrations.import.archive_title": "选择集成归档", + "ui.launcher.integrations.import.card_desc": "从文件夹、归档或仓库链接导入自定义集成。", + "ui.launcher.integrations.import.card_title": "添加集成", + "ui.launcher.integrations.import.error": "集成导入失败", + "ui.launcher.integrations.import.folder": "文件夹", + "ui.launcher.integrations.import.folder_title": "选择集成文件夹", + "ui.launcher.integrations.import.guide_short": "创建 axelate-module.toml、runtime entry、可选 settings UI,然后在此导入。", + "ui.launcher.integrations.import.guide_title": "集成指南", + "ui.launcher.integrations.import.open": "打开", + "ui.launcher.integrations.import.open_title": "选择集成文件夹或归档", + "ui.launcher.integrations.import.success": "集成已添加", + "ui.launcher.integrations.import.url": "链接", + "ui.launcher.integrations.import.url_desc": "粘贴 GitHub 仓库或直接归档 URL。", + "ui.launcher.integrations.import.url_placeholder": "仓库或归档 URL", + "ui.launcher.integrations.import.url_title": "通过 URL 添加集成", "ui.launcher.models_subtitle": "选择引擎或集成以开始工作", "ui.launcher.module.delete": "删除", "ui.launcher.module.download": "下载", @@ -362,7 +379,7 @@ "ui.settings.thinking.off": "关闭", "ui.settings.toggle_visibility": "切换密码显示", "ui.launcher.modules.modal.btn_remove": "收起", - "ui.launcher.modules.modal.btn_select": "选择", + "ui.launcher.modules.modal.btn_select": "启动", "ui.launcher.engine.llamacpp.name": "llama.cpp", "ui.launcher.engine.llamacpp.desc": "通用LLM引擎。CUDA、Vulkan、CPU。", "ui.launcher.engine.sdcpp.name": "Stable Diffusion.cpp", diff --git a/src/app/CoreUiFactory.ts b/src/app/CoreUiFactory.ts index ea7a2eda..153c2e8a 100644 --- a/src/app/CoreUiFactory.ts +++ b/src/app/CoreUiFactory.ts @@ -29,7 +29,7 @@ import type { ModuleSettingsUiController, } from './CoreUiContracts'; import { LazyMonitoringUiAdapter } from './LazyUiAdapters'; -import { createModuleSettingsGateway } from './CoreUiBridgeHelpers'; +import { createExternalUrlOpener, createModuleSettingsGateway } from './CoreUiBridgeHelpers'; import { createConsoleUI, createModuleSettingsUI, createSettingsUI } from './CoreDeferredUiFactory'; import { createChatController } from './CoreChatFactory'; @@ -63,6 +63,7 @@ type CreateAppUIDeps = { openModuleSettings: (app: IApp) => Promise; }; aiBridge: AIBridge; + tauriProvider: TauriProvider; }; type CreateCoreUiBundleDeps = { @@ -126,6 +127,10 @@ export function createAppUI(deps: CreateAppUIDeps): AppUI { stopAiProvider: () => { deps.aiBridge.stopProvider(); }, + reloadCatalog: async () => { + await deps.catalog.loadCatalog(); + }, + openExternalUrl: createExternalUrlOpener(deps.tauriProvider), }, ); } @@ -164,6 +169,7 @@ export function createCoreUiBundle(deps: CreateCoreUiBundleDeps): CoreUiBundle { bridge: deps.bridge, moduleSettingsUI: moduleSettingsGateway, aiBridge: deps.aiBridge, + tauriProvider: deps.tauriProvider, }); const windowUI = new WindowUI( deps.windowService, diff --git a/src/assets/fonts/Cubic_11.zh-subset.woff2 b/src/assets/fonts/Cubic_11.zh-subset.woff2 index 5c7340e7ec72c0b0b39097a26adbba038a10bea7..ee4bcdf4804ddca4a866b44a568c20dbad85f0bb 100644 GIT binary patch literal 29720 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 literal 29412 zcmV)BK*PUxPew8T0RR910CMC23IG5A0%VK;0CJT80RR9100000000000000000000 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�Z38g) z(`U0KOu%6(2%#BR)G{`V9tW_1!C$bkCy0A*O3rdsA|RjxB|t*$w0)D$+Q|Z>fhJ^t z_H=>7D@{R?5ujvV`p71IC2%-ML&iE&jl>g3h%_TDG$V1yFr*~~ygJSISzC@STymAb zfH5+tXWjmC;g=EN6G$NZIC9&3J+WrW3Ia1)34-Frok$!aM}{E-64flEg#iVFRjb;N zhB@beFH-yV@Xz2O*b%D9G*ZVkttLfoxxxvTVhw zB7sOEQ>ZjLgK5L2E!%eN+GBILJib6E5=&%c<>VCcqyAC>a(XEGG zee@e(P@IG$DQPmYQufbIe;{(Gts6ShdEw4K{7D zZHHZZ>^tDl5ywtAb;h|1E?sf$hFf>sd*IO%&t7=-#=8$beK9s+YR24xr4?%%ws!0t zNF6yjb8+S7&clYq{?Ptc5D(qL=u@orO_Eo7MsI`czl6SWMXPsw&Qxf ze*S)mho_gz+lNqQxw#FFkf^lr^)uFaAb>;xjR6)1JOM-!$P_?S(CA<=!D55M1&zidk2nAoL#uOarfZq z#oLFkAO8phL_k7DK}AD1Oe|~%7Z0C+kcbjxDpaXar$LhzZ8~&&fRu3|l{VJ-5G7er zHQg{RJLOVa9}X@aen3b>oRE@{Q&3ja&i{~;lHWK`jVwx9Txkq|{SynN=px+VgB(CA zo#l!gOEINl%SBfrNvq#5u8B}faJxDXO=Y<*$A}(BqDd`WiH*pIZTO zLbC=ZnPxqLJ37HS){iCE0g3TNq{{dR_7DKHW%^th^pqPN+GbqZrmMl5^v zORWzO;zgA}t}2Dm8j%f%pbmGPcz1no04Q$BDY;eB-U>(E8P6AlUkM?I$zB0kWzFl! z8RgozTp2JFCu_vDG)_1_7FNOGn5pz-OL9u+V0asntfM{H>UcX14kDL;m+zJFdKuK0 zyVG&qwB20H6fAC`V!!0KhX)8l@&WL0Pb zOdo}Fyv=AQ=hSB9vP55hX4nd!?ikVKWQOl~YT5OXF}YOKPN(b7I4=RfKr2S6GJYo_ zdNp1qIVHy9WMCD>&P6gWIzK~33#-TIO#cRA9gM^hWRDlS6u@k=79#D)@y>QJ%Jwvt z<@*v}dJga~oQq#lqAa9px*KY97x~q0P*Oy~GM-0edG zKrloWiRQ1vbp7_M6`6dshFGQtzMDk#>;yXR)w07#+zVYM;4n7 vpk@uA@w|p>EXf(t#0 { true, ); }); + + it('should reload catalog when backend reports integration folder changes', async () => { + const config = createMockAppConfig({ + catalog: { + ai: [], + services: [{ id: 'catalog-anchor', name: 'Catalog Anchor', type: 'local' }], + stars: [], + }, + }); + const firstModules = [ + { + id: 'parser', + name: 'Parser', + description: 'Parser integration', + version: '1.0.0', + icon: '🤖', + } as unknown as IModule, + ]; + const secondModules: IModule[] = []; + const listener = { integrationsChanged: null as null | (() => void) }; + let moduleListCalls = 0; + + mockBridge.isTauri.mockReturnValue(true); + mockBridge.listen.mockImplementation((event: string, callback: () => void) => { + if (event === 'integrations_changed') { + listener.integrationsChanged = callback; + } + return Promise.resolve(() => {}); + }); + mockBridge.invoke.mockImplementation((cmd: string) => { + if (cmd === 'get_config') return Promise.resolve(config); + if (cmd === 'get_engine_definitions') return Promise.resolve([]); + if (cmd === 'get_modules') { + moduleListCalls += 1; + return Promise.resolve(moduleListCalls === 1 ? firstModules : secondModules); + } + return Promise.resolve(undefined); + }); + + await service.loadCatalog(); + await Promise.resolve(); + expect(service.getAppById('parser')).toBeDefined(); + + if (listener.integrationsChanged === null) { + throw new Error('integrations_changed listener was not registered'); + } + listener.integrationsChanged(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => globalThis.setTimeout(resolve, 0)); + + expect(moduleListCalls).toBe(2); + expect(service.getAppById('parser')).toBeUndefined(); + expect(mockBridge.listen).toHaveBeenCalledTimes(1); + }); }); describe('_initGlobalExposures DEV branch (L29)', () => { diff --git a/src/shared/services/CatalogService.ts b/src/shared/services/CatalogService.ts index ff93f442..d396a7af 100644 --- a/src/shared/services/CatalogService.ts +++ b/src/shared/services/CatalogService.ts @@ -14,6 +14,8 @@ type CatalogLogger = Pick; export class CatalogService { private readonly _appData: ICatalogData = { ai: [], services: [] }; + private _integrationWatcherUnlisten: (() => void) | null = null; + private _integrationWatcherBinding = false; constructor( private readonly _bridge: IBridge, @@ -24,6 +26,7 @@ export class CatalogService { * Asynchronously loads the application catalog from the Tauri backend. */ public async loadCatalog(): Promise { + this._bindIntegrationWatcher(); const snapshot = await this._loadSnapshot(); try { @@ -48,6 +51,37 @@ export class CatalogService { } } + public destroy(): void { + this._integrationWatcherUnlisten?.(); + this._integrationWatcherUnlisten = null; + this._integrationWatcherBinding = false; + } + + private _bindIntegrationWatcher(): void { + if ( + this._integrationWatcherBinding || + this._integrationWatcherUnlisten !== null || + !this._bridge.isTauri() + ) { + return; + } + + this._integrationWatcherBinding = true; + void this._bridge + .listen('integrations_changed', () => { + void this.loadCatalog(); + }) + .then((unlisten) => { + this._integrationWatcherUnlisten = unlisten; + }) + .catch((error: unknown) => { + this._integrationWatcherBinding = false; + this._tracer.warn( + `[CatalogService] Failed to subscribe to integrations watcher: ${String(error)}`, + ); + }); + } + private async _loadSnapshot(): Promise { const [config, installedModules, engineDefs] = await Promise.all([ this._loadConfig(), diff --git a/src/shared/services/ModulePlatformService.test.ts b/src/shared/services/ModulePlatformService.test.ts index bb05c814..418b0e76 100644 --- a/src/shared/services/ModulePlatformService.test.ts +++ b/src/shared/services/ModulePlatformService.test.ts @@ -34,6 +34,10 @@ function createMockModuleService(): ModuleService { checkInstalled: vi.fn().mockResolvedValue(true), getStatus: vi.fn().mockResolvedValue('running'), getDownloadState: vi.fn().mockReturnValue({ status: 'downloading', progress: 50 }), + importIntegrationFolder: vi.fn().mockResolvedValue('folder-module'), + importIntegrationArchive: vi.fn().mockResolvedValue('archive-module'), + importIntegrationPath: vi.fn().mockResolvedValue('path-module'), + importIntegrationUrl: vi.fn().mockResolvedValue('url-module'), } as unknown as ModuleService; } @@ -187,6 +191,48 @@ describe('ModulePlatformService', () => { }); }); + describe('integration imports', () => { + it('delegates folder imports to module service', async () => { + await expect(service.importIntegrationFolder('C:\\Integrations\\Parser')).resolves.toBe( + 'folder-module', + ); + + expect(moduleService.importIntegrationFolder).toHaveBeenCalledWith( + 'C:\\Integrations\\Parser', + ); + }); + + it('delegates archive imports to module service', async () => { + await expect( + service.importIntegrationArchive('C:\\Downloads\\Parser.zip'), + ).resolves.toBe('archive-module'); + + expect(moduleService.importIntegrationArchive).toHaveBeenCalledWith( + 'C:\\Downloads\\Parser.zip', + ); + }); + + it('delegates auto-detected path imports to module service', async () => { + await expect(service.importIntegrationPath('C:\\Downloads\\Parser')).resolves.toBe( + 'path-module', + ); + + expect(moduleService.importIntegrationPath).toHaveBeenCalledWith( + 'C:\\Downloads\\Parser', + ); + }); + + it('delegates URL imports to module service', async () => { + await expect( + service.importIntegrationUrl('https://github.com/F0RLE/Axelate-telegram-parser'), + ).resolves.toBe('url-module'); + + expect(moduleService.importIntegrationUrl).toHaveBeenCalledWith( + 'https://github.com/F0RLE/Axelate-telegram-parser', + ); + }); + }); + describe('stop', () => { it('should stop API provider for API modules', async () => { const app = createApp({ type: 'api' }); diff --git a/src/shared/services/ModulePlatformService.ts b/src/shared/services/ModulePlatformService.ts index c379434f..7db4e865 100644 --- a/src/shared/services/ModulePlatformService.ts +++ b/src/shared/services/ModulePlatformService.ts @@ -63,6 +63,26 @@ export class ModulePlatformService { return await this._moduleService.getReleaseDownloadOptions(app.id, app.repoUrl); } + public async importIntegrationFolder(path: string): Promise { + this._tracer.info(`[ModulePlatformService] Importing integration folder: ${path}`); + return await this._moduleService.importIntegrationFolder(path); + } + + public async importIntegrationArchive(path: string): Promise { + this._tracer.info(`[ModulePlatformService] Importing integration archive: ${path}`); + return await this._moduleService.importIntegrationArchive(path); + } + + public async importIntegrationPath(path: string): Promise { + this._tracer.info(`[ModulePlatformService] Importing integration path: ${path}`); + return await this._moduleService.importIntegrationPath(path); + } + + public async importIntegrationUrl(sourceUrl: string): Promise { + this._tracer.info(`[ModulePlatformService] Importing integration URL: ${sourceUrl}`); + return await this._moduleService.importIntegrationUrl(sourceUrl); + } + /** * Deletes a module. * @param app The module to delete. diff --git a/src/shared/services/ModuleService.test.ts b/src/shared/services/ModuleService.test.ts index a935f318..bb1b99b2 100644 --- a/src/shared/services/ModuleService.test.ts +++ b/src/shared/services/ModuleService.test.ts @@ -20,6 +20,10 @@ const mocks = vi.hoisted(() => { pauseDownload: vi.fn().mockResolvedValue(true), resumeDownload: vi.fn(), cancelDownload: vi.fn().mockResolvedValue(true), + importIntegrationFolder: vi.fn(), + importIntegrationArchive: vi.fn(), + importIntegrationPath: vi.fn(), + importIntegrationUrl: vi.fn(), }, tauriProvider: { isTauri: vi.fn().mockReturnValue(true), @@ -68,6 +72,10 @@ describe('ModuleService', () => { mocks.commands.getModuleStatus.mockResolvedValue({ status: 'ok', data: 'running' }); mocks.commands.downloadModule.mockResolvedValue({ status: 'ok', data: null }); mocks.commands.deleteModule.mockResolvedValue({ status: 'ok', data: null }); + mocks.commands.importIntegrationFolder.mockReturnValue('import-folder-promise'); + mocks.commands.importIntegrationArchive.mockReturnValue('import-archive-promise'); + mocks.commands.importIntegrationPath.mockReturnValue('import-path-promise'); + mocks.commands.importIntegrationUrl.mockReturnValue('import-url-promise'); // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument moduleService = new ModuleService(mocks.tauriProvider as any, mocks.tracer); @@ -283,6 +291,94 @@ describe('ModuleService', () => { }); }); + describe('integration imports', () => { + it('should invoke integration folder import command', async () => { + mocks.invokeSafe.mockResolvedValueOnce({ status: 'ok', data: 'folder-module' }); + + await expect( + moduleService.importIntegrationFolder('C:\\Integrations\\Parser'), + ).resolves.toBe('folder-module'); + + expect(mocks.commands.importIntegrationFolder).toHaveBeenCalledWith( + 'C:\\Integrations\\Parser', + ); + expect(mocks.invokeSafe).toHaveBeenCalledWith('import-folder-promise'); + }); + + it('should invoke integration archive import command', async () => { + mocks.invokeSafe.mockResolvedValueOnce({ status: 'ok', data: 'archive-module' }); + + await expect( + moduleService.importIntegrationArchive('C:\\Downloads\\Parser.zip'), + ).resolves.toBe('archive-module'); + + expect(mocks.commands.importIntegrationArchive).toHaveBeenCalledWith( + 'C:\\Downloads\\Parser.zip', + ); + expect(mocks.invokeSafe).toHaveBeenCalledWith('import-archive-promise'); + }); + + it('should invoke auto-detected integration path import command', async () => { + mocks.invokeSafe.mockResolvedValueOnce({ status: 'ok', data: 'path-module' }); + + await expect( + moduleService.importIntegrationPath('C:\\Downloads\\Parser'), + ).resolves.toBe('path-module'); + + expect(mocks.commands.importIntegrationPath).toHaveBeenCalledWith( + 'C:\\Downloads\\Parser', + ); + expect(mocks.invokeSafe).toHaveBeenCalledWith('import-path-promise'); + }); + + it('should invoke integration URL import command', async () => { + mocks.invokeSafe.mockResolvedValueOnce({ status: 'ok', data: 'url-module' }); + + await expect( + moduleService.importIntegrationUrl( + 'https://github.com/F0RLE/Axelate-telegram-parser', + ), + ).resolves.toBe('url-module'); + + expect(mocks.commands.importIntegrationUrl).toHaveBeenCalledWith( + 'https://github.com/F0RLE/Axelate-telegram-parser', + ); + expect(mocks.invokeSafe).toHaveBeenCalledWith('import-url-promise'); + }); + + it('should throw integration import errors', async () => { + mocks.invokeSafe.mockResolvedValueOnce({ + status: 'error', + error: { message: 'bad integration' }, + }); + + await expect(moduleService.importIntegrationPath('C:\\Broken')).rejects.toThrow( + 'bad integration', + ); + }); + + it('should throw integration imports in web mode', async () => { + mocks.tauriProvider.isTauri.mockReturnValueOnce(false); + + await expect( + moduleService.importIntegrationUrl('https://example.com/mod.zip'), + ).rejects.toThrow('Import available only in desktop app'); + }); + + it('should clear deleted-module cache after importing the same module id', async () => { + mocks.invokeSafe.mockResolvedValueOnce({ status: 'ok', data: null }); + await moduleService.deleteModule('restored-module'); + + mocks.invokeSafe.mockResolvedValueOnce({ status: 'ok', data: 'restored-module' }); + await moduleService.importIntegrationPath('C:\\Restored'); + + mocks.invokeSafe.mockResolvedValueOnce({ status: 'ok', data: true }); + await expect(moduleService.checkInstalled('restored-module')).resolves.toBe(true); + + expect(mocks.commands.checkModuleInstalled).toHaveBeenCalledWith('restored-module'); + }); + }); + describe('control', () => { it('should invoke control_module command', async () => { mocks.invokeSafe.mockResolvedValueOnce({ diff --git a/src/shared/services/ModuleService.ts b/src/shared/services/ModuleService.ts index 5169f4f1..87bd82f5 100644 --- a/src/shared/services/ModuleService.ts +++ b/src/shared/services/ModuleService.ts @@ -197,6 +197,58 @@ export class ModuleService { } } + public async importIntegrationFolder(path: string): Promise { + if (!this._bridge.isTauri()) { + throw new Error('Import available only in desktop app'); + } + + const result = await invokeSafe(commands.importIntegrationFolder(path)); + if (result.status === 'error') { + throw new Error(result.error.message); + } + this._deletedModules.delete(result.data); + return result.data; + } + + public async importIntegrationArchive(path: string): Promise { + if (!this._bridge.isTauri()) { + throw new Error('Import available only in desktop app'); + } + + const result = await invokeSafe(commands.importIntegrationArchive(path)); + if (result.status === 'error') { + throw new Error(result.error.message); + } + this._deletedModules.delete(result.data); + return result.data; + } + + public async importIntegrationPath(path: string): Promise { + if (!this._bridge.isTauri()) { + throw new Error('Import available only in desktop app'); + } + + const result = await invokeSafe(commands.importIntegrationPath(path)); + if (result.status === 'error') { + throw new Error(result.error.message); + } + this._deletedModules.delete(result.data); + return result.data; + } + + public async importIntegrationUrl(sourceUrl: string): Promise { + if (!this._bridge.isTauri()) { + throw new Error('Import available only in desktop app'); + } + + const result = await invokeSafe(commands.importIntegrationUrl(sourceUrl)); + if (result.status === 'error') { + throw new Error(result.error.message); + } + this._deletedModules.delete(result.data); + return result.data; + } + private _normalizeDownloadOutcome(value: unknown): DownloadModuleOutcome { if (value === 'paused' || value === 'cancelled' || value === 'completed') { return value; diff --git a/src/shared/shell/AppUI.test.ts b/src/shared/shell/AppUI.test.ts index 67404f6e..ff1306f2 100644 --- a/src/shared/shell/AppUI.test.ts +++ b/src/shared/shell/AppUI.test.ts @@ -6,6 +6,7 @@ import type { NavigationService } from '@/infrastructure/navigation/NavigationSe import type { LoggerService } from '@/infrastructure/logging/LoggerService'; import type { IApp } from '../types/coreTypes'; import { CUSTOM_IMAGE_PROVIDER_ID, CUSTOM_TEXT_PROVIDER_ID } from '../utils/customProviderSupport'; +import { openIntegrationUrlDialog } from './ui/IntegrationImportDialog'; describe('AppUI lifecycle', () => { let appUI: AppUI | null = null; @@ -17,6 +18,7 @@ describe('AppUI lifecycle', () => { let launchAppMock: ReturnType; let openModuleSettingsMock: ReturnType; let stopAiProviderMock: ReturnType; + let reloadCatalogMock: ReturnType Promise>>; let getCatalogCategoryMock: ReturnType; let tracerMock: LoggerService; let platformServiceMock: { @@ -41,6 +43,7 @@ describe('AppUI lifecycle', () => { launchAppMock = vi.fn().mockResolvedValue(undefined); openModuleSettingsMock = vi.fn(); stopAiProviderMock = vi.fn(); + reloadCatalogMock = vi.fn<() => Promise>().mockResolvedValue(undefined); getCatalogCategoryMock = vi.fn().mockReturnValue([]); tracerMock = { info: vi.fn(), @@ -114,6 +117,10 @@ describe('AppUI lifecycle', () => { stopAiProvider: () => { (stopAiProviderMock as () => void)(); }, + reloadCatalog: async () => { + await reloadCatalogMock(); + }, + openExternalUrl: vi.fn().mockResolvedValue(undefined), }, ); } @@ -176,6 +183,20 @@ describe('AppUI lifecycle', () => { expect(refreshSpy).toHaveBeenCalledTimes(1); }); + it('should close transient integration dialogs on page change', async () => { + appUI = createAppUI(); + const result = openIntegrationUrlDialog({ + translate: (_key, fallback) => fallback, + }); + + expect(document.querySelector('.integration-import-dialog-view')).not.toBeNull(); + + testEventBus.emit('page:change', { pageId: 'settings' }); + + await expect(result).resolves.toBeNull(); + expect(document.querySelector('.integration-import-dialog-view')).toBeNull(); + }); + it('should cancel pending AI card wheel switch on destroy', () => { vi.useFakeTimers(); appUI = createAppUI(); @@ -796,6 +817,52 @@ describe('AppUI lifecycle', () => { expect(refreshSpy).toHaveBeenCalledWith(refreshedApps, null); }); + it('should refresh an open integrations modal after catalog reload events', () => { + appUI = createAppUI(); + const refreshedApps: IApp[] = []; + getCatalogCategoryMock.mockReturnValue(refreshedApps); + + const privateAppUI = appUI as unknown as { + _modalManager: { + isViewingCategory: (category: string) => boolean; + refreshCurrentSelection: (apps?: IApp[], selectedId?: string | null) => void; + }; + }; + vi.spyOn(privateAppUI._modalManager, 'isViewingCategory').mockReturnValue(true); + const refreshSpy = vi.spyOn(privateAppUI._modalManager, 'refreshCurrentSelection'); + + globalThis.dispatchEvent(new Event('catalog-loaded')); + + expect(refreshSpy).toHaveBeenCalledWith(refreshedApps, null); + }); + + it('should clear selected integration without stopping it when it disappears from catalog', () => { + appUI = createAppUI(); + document.body.innerHTML = ` +
    +
    +
    +
    +
    + `; + const missingApp = { + id: 'axelate-telegram-parser', + name: 'Parser', + installed: true, + type: 'local', + } as IApp; + appUI.updateModuleCard('services', missingApp); + getCatalogCategoryMock.mockReturnValue([]); + + globalThis.dispatchEvent(new Event('catalog-loaded')); + + expect(uiStateMocks.removeSelectedModule).toHaveBeenCalledWith('services'); + expect(platformServiceMock.stop).not.toHaveBeenCalledWith(missingApp, 'services'); + expect(document.getElementById('services-module-card')?.classList.contains('empty')).toBe( + true, + ); + }); + it('should resolve app by id from injected catalog resolver', () => { appUI = createAppUI(); getCatalogCategoryMock.mockImplementation((category: string) => diff --git a/src/shared/shell/AppUI.ts b/src/shared/shell/AppUI.ts index 52af788c..ae7d7b37 100644 --- a/src/shared/shell/AppUI.ts +++ b/src/shared/shell/AppUI.ts @@ -14,6 +14,7 @@ import { AppUiLifecycleBindings } from './ui/AppUiLifecycleBindings'; import { AppUiModuleFlow } from './ui/AppUiModuleFlow'; import { AppUiModuleLifecycle } from './ui/AppUiModuleLifecycle'; import { AppUiSelectionFlow } from './ui/AppUiSelectionFlow'; +import { closeIntegrationImportDialogs } from './ui/IntegrationImportDialog'; import { ModalManager } from './ui/ModalManager'; import { ModuleCardRenderer } from './ui/ModuleCardRenderer'; import { SkeletonManager } from './ui/SkeletonManager'; @@ -32,6 +33,8 @@ type AppUIDeps = { launchApp: (category: string, app: IApp) => Promise; openModuleSettings: (app: IApp) => void; stopAiProvider: () => void; + reloadCatalog: () => Promise; + openExternalUrl: (url: string) => Promise; }; /** @@ -123,6 +126,9 @@ export class AppUI { async (app) => { await this._platformService.resumeDownload(app.id); }, + (action) => { + void this._moduleFlow.handleIntegrationImport(action); + }, ); this._moduleFlow = new AppUiModuleFlow({ platformService: this._platformService, @@ -135,6 +141,12 @@ export class AppUI { this._dashboardSupport.markSlotCardAsInstalled(card, app), showToast: (message, type = 'info') => this.showToast(message, type), translate: this._translate, + reloadCatalog: async () => { + await this._deps.reloadCatalog(); + }, + openExternalUrl: async (url) => { + await this._deps.openExternalUrl(url); + }, }); this._cardActionFlow = new AppUiCardActionFlow({ platformService: this._platformService, @@ -192,11 +204,16 @@ export class AppUI { }); this._lifecycleBindings = new AppUiLifecycleBindings({ eventBus: this._eventBus, + onCatalogLoaded: () => { + this._reconcileSelectionsWithCatalog(); + this._refreshOpenServicesSelection(); + }, onLanguageChanged: () => { this._modalManager.refreshCurrentSelection(); }, onPageChange: ({ pageId }) => { if (pageId !== 'modules' && pageId !== 'page-modules') { + closeIntegrationImportDialogs(); this.closeAppSelection(); } }, @@ -423,6 +440,51 @@ export class AppUI { return this._getCatalogApps(this._dashboardSupport.resolveCatalogCategory(category)); } + private _refreshOpenServicesSelection(): void { + if (!this._modalManager.isViewingCategory('services')) { + return; + } + + this._modalManager.refreshCurrentSelection( + this._getCatalogApps('services'), + this._selectionState.get('services')?.id ?? null, + ); + } + + private _reconcileSelectionsWithCatalog(): void { + this._clearMissingSelection(CategoryKey.SERVICES); + } + + private _clearMissingSelection(category: string): void { + const selectedApp = this._selectionState.get(category); + if (selectedApp === undefined) { + return; + } + + const catalogCategory = this._dashboardSupport.resolveCatalogCategory(category); + const stillExists = this._getCatalogApps(catalogCategory).some((app) => { + return app.id === selectedApp.id; + }); + if (stillExists) { + return; + } + + this._deps.tracer.warn( + `[AppUI] Selected module disappeared from catalog, clearing ${category}: ${selectedApp.id}`, + ); + const card = this._dashboardSupport.getDashboardCard(category); + this._dashboardSupport.cancelPendingSwitch(); + this._moduleLifecycle.bumpLaunchSelectionVersion(category); + this._selectionState.delete(category); + if (card instanceof HTMLElement) { + this._dashboardSupport.resetCardToEmpty(card); + } + this._deps.uiState.removeSelectedModule(category); + if (this._modalManager.isViewingCategory(catalogCategory)) { + this._modalManager.updateSelection(null); + } + } + public getPreferredAiCategory(): 'ai_text' | 'ai_image' { const card = this._dashboardSupport.getDashboardCard(CategoryKey.AI_TEXT); if (card instanceof HTMLElement) { diff --git a/src/shared/shell/ui/AppUiLifecycleBindings.ts b/src/shared/shell/ui/AppUiLifecycleBindings.ts index e9954e2b..bbb1af9a 100644 --- a/src/shared/shell/ui/AppUiLifecycleBindings.ts +++ b/src/shared/shell/ui/AppUiLifecycleBindings.ts @@ -2,6 +2,7 @@ import type { EventBus } from '@/shared/services/EventBus'; type AppUiLifecycleBindingsDeps = { eventBus: EventBus; + onCatalogLoaded: () => void; onLanguageChanged: () => void; onPageChange: (payload: { pageId: string }) => void; }; @@ -10,11 +11,15 @@ export class AppUiLifecycleBindings { private readonly _boundLanguageChanged = () => { this._deps.onLanguageChanged(); }; + private readonly _boundCatalogLoaded = () => { + this._deps.onCatalogLoaded(); + }; private readonly _pageChangeUnsub: () => void; public constructor(private readonly _deps: AppUiLifecycleBindingsDeps) { globalThis.addEventListener('language-changed', this._boundLanguageChanged); + globalThis.addEventListener('catalog-loaded', this._boundCatalogLoaded); this._pageChangeUnsub = this._deps.eventBus.on('page:change', (payload) => { this._deps.onPageChange(payload); }); @@ -22,6 +27,7 @@ export class AppUiLifecycleBindings { public destroy(): void { globalThis.removeEventListener('language-changed', this._boundLanguageChanged); + globalThis.removeEventListener('catalog-loaded', this._boundCatalogLoaded); this._pageChangeUnsub(); } } diff --git a/src/shared/shell/ui/AppUiModuleFlow.test.ts b/src/shared/shell/ui/AppUiModuleFlow.test.ts index dc9fbf8f..2de4612d 100644 --- a/src/shared/shell/ui/AppUiModuleFlow.test.ts +++ b/src/shared/shell/ui/AppUiModuleFlow.test.ts @@ -2,14 +2,36 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { IApp } from '../../types/coreTypes'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; import { AppUiModuleFlow } from './AppUiModuleFlow'; +import { open } from '@tauri-apps/plugin-dialog'; +import { downloadDir } from '@tauri-apps/api/path'; + +const integrationDialogMocks = vi.hoisted(() => ({ + openIntegrationUrlDialog: vi.fn(), +})); + +vi.mock('./IntegrationImportDialog', () => ({ + openIntegrationUrlDialog: integrationDialogMocks.openIntegrationUrlDialog, +})); + +vi.mock('@tauri-apps/plugin-dialog', () => ({ + open: vi.fn(), +})); + +vi.mock('@tauri-apps/api/path', () => ({ + downloadDir: vi.fn(), +})); describe('AppUiModuleFlow', () => { const platformService = { delete: vi.fn(), download: vi.fn(), + importIntegrationPath: vi.fn(), + importIntegrationUrl: vi.fn(), } as unknown as { delete: ReturnType; download: ReturnType; + importIntegrationPath: ReturnType; + importIntegrationUrl: ReturnType; }; const modalManager = { @@ -28,11 +50,14 @@ describe('AppUiModuleFlow', () => { const markSlotCardAsInstalled = vi.fn(); const showToast = vi.fn(); const translate = vi.fn((_key: string, fallback: string) => fallback); + const reloadCatalog = vi.fn().mockResolvedValue(undefined); + const openExternalUrl = vi.fn().mockResolvedValue(undefined); let flow: AppUiModuleFlow; beforeEach(() => { vi.clearAllMocks(); + localStorage.clear(); document.body.innerHTML = ''; flow = new AppUiModuleFlow({ platformService: platformService as never, @@ -49,6 +74,8 @@ describe('AppUiModuleFlow', () => { markSlotCardAsInstalled, showToast, translate, + reloadCatalog, + openExternalUrl, }); }); @@ -62,6 +89,7 @@ describe('AppUiModuleFlow', () => { await flow.handleDeleteModule(app, 'services'); expect(platformService.delete).toHaveBeenCalledWith(app, 'services'); + expect(reloadCatalog).toHaveBeenCalledOnce(); expect(app.installed).toBe(false); expect(clearModuleCard).toHaveBeenCalledWith('services'); expect(modalManager.refreshCurrentSelection).toHaveBeenCalledWith( @@ -79,6 +107,7 @@ describe('AppUiModuleFlow', () => { await flow.handleDeleteModule(app, 'services'); + expect(reloadCatalog).toHaveBeenCalledOnce(); expect(clearModuleCard).not.toHaveBeenCalled(); expect(modalManager.refreshCurrentSelection).toHaveBeenCalledWith( [{ id: 'svc', installed: false }], @@ -179,4 +208,130 @@ describe('AppUiModuleFlow', () => { expect(btn.querySelector('.download-pct')?.style.display).toBe('none'); expect(btn.querySelector('.download-label')?.textContent).toBe('Download'); }); + + it('opens integration folder picker in downloads by default and remembers selected folder', async () => { + vi.mocked(downloadDir).mockResolvedValue('C:\\Users\\FORLE\\Downloads'); + vi.mocked(open).mockResolvedValue('C:\\Users\\FORLE\\Downloads\\Parser'); + platformService.importIntegrationPath.mockResolvedValue('telegram-parser'); + modalManager.isViewingCategory.mockReturnValue(false); + + await flow.handleIntegrationImport('local'); + + expect(open).toHaveBeenCalledWith( + expect.objectContaining({ + directory: true, + defaultPath: 'C:\\Users\\FORLE\\Downloads', + }), + ); + expect(platformService.importIntegrationPath).toHaveBeenCalledWith( + 'C:\\Users\\FORLE\\Downloads\\Parser', + ); + expect(localStorage.getItem('axelate.integrationImport.lastDirectory')).toBe( + 'C:\\Users\\FORLE\\Downloads\\Parser', + ); + + vi.mocked(open).mockResolvedValue(null); + await flow.handleIntegrationImport('local'); + + expect(open).toHaveBeenLastCalledWith( + expect.objectContaining({ + defaultPath: 'C:\\Users\\FORLE\\Downloads\\Parser', + }), + ); + }); + + it('does not import when local integration picking is cancelled', async () => { + vi.mocked(downloadDir).mockResolvedValue('C:\\Users\\FORLE\\Downloads'); + vi.mocked(open).mockResolvedValue(null); + + await flow.handleIntegrationImport('local'); + + expect(platformService.importIntegrationPath).not.toHaveBeenCalled(); + expect(reloadCatalog).not.toHaveBeenCalled(); + expect(showToast).not.toHaveBeenCalled(); + }); + + it('refreshes the open integrations modal after local integration import', async () => { + vi.mocked(downloadDir).mockResolvedValue('C:\\Users\\FORLE\\Downloads'); + vi.mocked(open).mockResolvedValue('C:\\Users\\FORLE\\Downloads\\Parser'); + platformService.importIntegrationPath.mockResolvedValue('telegram-parser'); + modalManager.isViewingCategory.mockReturnValue(true); + getCatalogApps.mockReturnValue([{ id: 'telegram-parser', installed: true }]); + getSelectedAppId.mockReturnValue('telegram-parser'); + + await flow.handleIntegrationImport('local'); + + expect(reloadCatalog).toHaveBeenCalledOnce(); + expect(modalManager.refreshCurrentSelection).toHaveBeenCalledWith( + [{ id: 'telegram-parser', installed: true }], + 'telegram-parser', + ); + expect(showToast).toHaveBeenCalledWith('Integration added', 'success'); + }); + + it('opens the custom integration guide without importing anything', async () => { + await flow.handleIntegrationImport('guide'); + + expect(openExternalUrl).toHaveBeenCalledWith( + 'https://github.com/F0RLE/Axelate/blob/nightly/docs/en/CUSTOM_INTEGRATIONS.md', + ); + expect(platformService.importIntegrationPath).not.toHaveBeenCalled(); + expect(platformService.importIntegrationUrl).not.toHaveBeenCalled(); + expect(reloadCatalog).not.toHaveBeenCalled(); + }); + + it('imports integration URLs while suspending and resuming the selection modal', async () => { + modalManager.isAppSelectionOpen.mockReturnValue(true); + modalManager.suspendAppSelection.mockReturnValue(true); + modalManager.isViewingCategory.mockReturnValue(true); + integrationDialogMocks.openIntegrationUrlDialog.mockResolvedValue( + 'https://github.com/F0RLE/Axelate-telegram-parser', + ); + platformService.importIntegrationUrl.mockResolvedValue('axelate-telegram-parser'); + getCatalogApps.mockReturnValue([{ id: 'axelate-telegram-parser', installed: true }]); + getSelectedAppId.mockReturnValue(null); + + await flow.handleIntegrationImport('url'); + + expect(modalManager.suspendAppSelection).toHaveBeenCalledOnce(); + expect(integrationDialogMocks.openIntegrationUrlDialog).toHaveBeenCalledWith({ + translate, + }); + expect(platformService.importIntegrationUrl).toHaveBeenCalledWith( + 'https://github.com/F0RLE/Axelate-telegram-parser', + ); + expect(modalManager.resumeAppSelection).toHaveBeenCalledOnce(); + expect(reloadCatalog).toHaveBeenCalledOnce(); + expect(modalManager.refreshCurrentSelection).toHaveBeenCalledWith( + [{ id: 'axelate-telegram-parser', installed: true }], + null, + ); + expect(showToast).toHaveBeenCalledWith('Integration added', 'success'); + }); + + it('does not import URLs when the URL dialog is cancelled', async () => { + modalManager.isAppSelectionOpen.mockReturnValue(true); + modalManager.suspendAppSelection.mockReturnValue(true); + integrationDialogMocks.openIntegrationUrlDialog.mockResolvedValue(null); + + await flow.handleIntegrationImport('url'); + + expect(platformService.importIntegrationUrl).not.toHaveBeenCalled(); + expect(reloadCatalog).not.toHaveBeenCalled(); + expect(showToast).not.toHaveBeenCalled(); + expect(modalManager.resumeAppSelection).toHaveBeenCalledOnce(); + }); + + it('shows localized import errors without refreshing catalog', async () => { + vi.mocked(downloadDir).mockResolvedValue('C:\\Users\\FORLE\\Downloads'); + vi.mocked(open).mockResolvedValue('C:\\Users\\FORLE\\Downloads\\Broken'); + platformService.importIntegrationPath.mockRejectedValue( + new Error('ui.launcher.integrations.import.error'), + ); + + await flow.handleIntegrationImport('local'); + + expect(reloadCatalog).not.toHaveBeenCalled(); + expect(showToast).toHaveBeenCalledWith('Integration import failed', 'error'); + }); }); diff --git a/src/shared/shell/ui/AppUiModuleFlow.ts b/src/shared/shell/ui/AppUiModuleFlow.ts index 6cbfd194..f6286c62 100644 --- a/src/shared/shell/ui/AppUiModuleFlow.ts +++ b/src/shared/shell/ui/AppUiModuleFlow.ts @@ -3,6 +3,14 @@ import { resolveCatalogCategory } from '../../utils/moduleCategoryPolicy'; import type { ModulePlatformService } from '../../services/ModulePlatformService'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; import { openDownloadSelectionDialog } from './DownloadSelectionDialog'; +import { openIntegrationUrlDialog } from './IntegrationImportDialog'; +import type { IntegrationImportAction } from './ModalManagerSupport'; +import { open } from '@tauri-apps/plugin-dialog'; +import { downloadDir } from '@tauri-apps/api/path'; + +const CUSTOM_INTEGRATION_GUIDE_URL = + 'https://github.com/F0RLE/Axelate/blob/nightly/docs/en/CUSTOM_INTEGRATIONS.md'; +const INTEGRATION_IMPORT_LAST_DIR_KEY = 'axelate.integrationImport.lastDirectory'; type ModalBridge = { isAppSelectionOpen(): boolean; @@ -24,6 +32,8 @@ type AppUiModuleFlowDeps = { markSlotCardAsInstalled: (card: HTMLElement, app: IApp) => void; showToast: (message: string, type?: string) => void; translate: (key: string, fallback: string) => string; + reloadCatalog: () => Promise; + openExternalUrl: (url: string) => Promise; }; export class AppUiModuleFlow { @@ -33,6 +43,7 @@ export class AppUiModuleFlow { this._deps.tracer.info('[AppUI] Remove module clicked:', app.id); try { await this._deps.platformService.delete(app, category); + await this._deps.reloadCatalog(); app.installed = false; const wasSelected = this._deps.getSelectedAppId(category) === app.id; @@ -195,6 +206,107 @@ export class AppUiModuleFlow { ); } + public async handleIntegrationImport(action: IntegrationImportAction): Promise { + if (action === 'guide') { + await this._deps.openExternalUrl(CUSTOM_INTEGRATION_GUIDE_URL); + return; + } + + try { + const moduleId = await this._runIntegrationImportAction(action); + if (moduleId === null) { + return; + } + + await this._deps.reloadCatalog(); + if (this._deps.modalManager.isViewingCategory('services')) { + this._deps.modalManager.refreshCurrentSelection( + this._deps.getCatalogApps('services'), + this._deps.getSelectedAppId('services'), + ); + } + + this._deps.showToast( + this._deps.translate( + 'ui.launcher.integrations.import.success', + 'Integration added', + ), + 'success', + ); + } catch (error) { + this._deps.tracer.error('[AppUI] Integration import error:', error); + this._deps.showToast( + this._getLocalizedError( + error, + 'ui.launcher.integrations.import.error', + 'Integration import failed', + ), + 'error', + ); + } + } + + private async _runIntegrationImportAction( + action: Exclude, + ): Promise { + if (action === 'local') { + const path = await this._openLocalIntegrationSource(); + return path === null + ? null + : await this._deps.platformService.importIntegrationPath(path); + } + + const url = await this._openIntegrationUrlSource(); + return url === null ? null : await this._deps.platformService.importIntegrationUrl(url); + } + + private async _openIntegrationUrlSource(): Promise { + const shouldRestoreSelection = this._deps.modalManager.isAppSelectionOpen(); + const suspendedSelection = shouldRestoreSelection + ? this._deps.modalManager.suspendAppSelection() + : false; + + try { + return await openIntegrationUrlDialog({ translate: this._deps.translate }); + } finally { + if (suspendedSelection) { + this._deps.modalManager.resumeAppSelection(); + } + } + } + + private async _openLocalIntegrationSource(): Promise { + const selectedPath = normalizeDialogPath( + await open({ + directory: true, + defaultPath: await this._getIntegrationImportDefaultPath(), + multiple: false, + recursive: true, + title: this._deps.translate( + 'ui.launcher.integrations.import.folder_title', + 'Choose integration folder', + ), + }), + ); + if (selectedPath !== null) { + saveIntegrationImportLastDirectory(selectedPath); + } + return selectedPath; + } + + private async _getIntegrationImportDefaultPath(): Promise { + const savedPath = loadIntegrationImportLastDirectory(); + if (savedPath !== null) { + return savedPath; + } + + try { + return await downloadDir(); + } catch { + return undefined; + } + } + private _getLocalizedError(err: unknown, fallbackKey: string, fallbackText: string): string { const message = err instanceof Error ? err.message : typeof err === 'string' ? err : ''; const msg = message.startsWith('ui.') ? message : fallbackKey; @@ -224,3 +336,30 @@ export class AppUiModuleFlow { return null; } } + +function normalizeDialogPath(value: string | string[] | null): string | null { + if (typeof value === 'string') { + return value; + } + if (Array.isArray(value)) { + return value[0] ?? null; + } + return null; +} + +function loadIntegrationImportLastDirectory(): string | null { + try { + const value = localStorage.getItem(INTEGRATION_IMPORT_LAST_DIR_KEY); + return value !== null && value.trim().length > 0 ? value : null; + } catch { + return null; + } +} + +function saveIntegrationImportLastDirectory(path: string): void { + try { + localStorage.setItem(INTEGRATION_IMPORT_LAST_DIR_KEY, path); + } catch { + // Dialog still works if storage is unavailable. + } +} diff --git a/src/shared/shell/ui/IntegrationImportDialog.test.ts b/src/shared/shell/ui/IntegrationImportDialog.test.ts new file mode 100644 index 00000000..5340172f --- /dev/null +++ b/src/shared/shell/ui/IntegrationImportDialog.test.ts @@ -0,0 +1,141 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { I18nUI } from '@/infrastructure/i18n/I18nUI'; +import type { I18nService } from '@/infrastructure/i18n/I18nService'; +import { closeIntegrationImportDialogs, openIntegrationUrlDialog } from './IntegrationImportDialog'; + +describe('IntegrationImportDialog', () => { + afterEach(() => { + closeIntegrationImportDialogs(); + document.body.innerHTML = ''; + }); + + it('closes when transient dialogs are dismissed globally', async () => { + const result = openIntegrationUrlDialog({ + translate: (_key, fallback) => fallback, + }); + + expect(document.querySelector('.integration-import-dialog-view')).not.toBeNull(); + + closeIntegrationImportDialogs(); + + await expect(result).resolves.toBeNull(); + expect(document.querySelector('.integration-import-dialog-view')).toBeNull(); + expect(document.body.classList.contains('integration-import-open')).toBe(false); + }); + + it('returns the trimmed URL on confirm', async () => { + const result = openIntegrationUrlDialog({ + translate: (_key, fallback) => fallback, + }); + const input = document.querySelector('.integration-import-url-input'); + const confirm = document.querySelector( + '.integration-import-dialog-confirm', + ); + + if (input === null || confirm === null) { + throw new Error('dialog controls missing'); + } + + input.value = ' https://github.com/F0RLE/Axelate-telegram-parser '; + confirm.click(); + + await expect(result).resolves.toBe('https://github.com/F0RLE/Axelate-telegram-parser'); + expect(document.querySelector('.integration-import-dialog-view')).toBeNull(); + }); + + it('keeps the dialog open when confirm is clicked with an empty URL', async () => { + const result = openIntegrationUrlDialog({ + translate: (_key, fallback) => fallback, + }); + const input = document.querySelector('.integration-import-url-input'); + const confirm = document.querySelector( + '.integration-import-dialog-confirm', + ); + + if (input === null || confirm === null) { + throw new Error('dialog controls missing'); + } + + confirm.click(); + + expect(document.querySelector('.integration-import-dialog-view')).not.toBeNull(); + expect(document.activeElement).toBe(input); + + closeIntegrationImportDialogs(); + await result; + }); + + it('submits with Enter and cancels with Escape', async () => { + const submitted = openIntegrationUrlDialog({ + translate: (_key, fallback) => fallback, + }); + const input = document.querySelector('.integration-import-url-input'); + if (input === null) { + throw new Error('dialog input missing'); + } + + input.value = 'https://example.com/integration.zip'; + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + + await expect(submitted).resolves.toBe('https://example.com/integration.zip'); + + const cancelled = openIntegrationUrlDialog({ + translate: (_key, fallback) => fallback, + }); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + + await expect(cancelled).resolves.toBeNull(); + }); + + it('cancels from the cancel button and backdrop', async () => { + const fromButton = openIntegrationUrlDialog({ + translate: (_key, fallback) => fallback, + }); + document.querySelector('.integration-import-dialog-cancel')?.click(); + + await expect(fromButton).resolves.toBeNull(); + + const fromBackdrop = openIntegrationUrlDialog({ + translate: (_key, fallback) => fallback, + }); + const overlay = document.querySelector('.integration-import-dialog-view'); + if (overlay === null) { + throw new Error('dialog overlay missing'); + } + overlay.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + await expect(fromBackdrop).resolves.toBeNull(); + }); + + it('updates visible copy when global translations are reapplied', async () => { + const result = openIntegrationUrlDialog({ + translate: (_key, fallback) => fallback, + }); + const i18nUI = new I18nUI({ + t: (key: string) => `translated:${key}`, + getCurrentLang: () => 'ru', + } as I18nService); + + i18nUI.applyTranslations(); + + expect(document.querySelector('h3')?.textContent).toBe( + 'translated:ui.launcher.integrations.import.url_title', + ); + expect(document.querySelector('p')?.textContent).toBe( + 'translated:ui.launcher.integrations.import.url_desc', + ); + expect(document.querySelector('input')?.placeholder).toBe( + 'translated:ui.launcher.integrations.import.url_placeholder', + ); + expect(document.querySelector('.integration-import-dialog-cancel')?.textContent).toBe( + 'translated:ui.launcher.button.cancel', + ); + expect(document.querySelector('.integration-import-dialog-confirm')?.textContent).toBe( + 'translated:ui.launcher.integrations.import.add', + ); + + i18nUI.destroy(); + closeIntegrationImportDialogs(); + await result; + }); +}); diff --git a/src/shared/shell/ui/IntegrationImportDialog.ts b/src/shared/shell/ui/IntegrationImportDialog.ts new file mode 100644 index 00000000..7b665d11 --- /dev/null +++ b/src/shared/shell/ui/IntegrationImportDialog.ts @@ -0,0 +1,136 @@ +type TranslateFunc = (key: string, fallback: string) => string; + +type DialogElements = { + overlay: HTMLDivElement; + input: HTMLInputElement; + confirm: HTMLButtonElement; + cancel: HTMLButtonElement; +}; + +const CLOSE_INTEGRATION_IMPORT_DIALOG_EVENT = 'integration-import-dialog:close'; + +export function closeIntegrationImportDialogs(): void { + globalThis.dispatchEvent(new Event(CLOSE_INTEGRATION_IMPORT_DIALOG_EVENT)); +} + +export async function openIntegrationUrlDialog(options: { + translate: TranslateFunc; +}): Promise { + return await new Promise((resolve) => { + const elements = createDialogElements(options.translate); + let settled = false; + + const close = (value: string | null) => { + if (settled) return; + settled = true; + document.removeEventListener('keydown', onKeyDown); + globalThis.removeEventListener(CLOSE_INTEGRATION_IMPORT_DIALOG_EVENT, onExternalClose); + document.body.classList.remove('integration-import-open'); + elements.overlay.remove(); + resolve(value); + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape') { + return; + } + event.preventDefault(); + close(null); + }; + + const onExternalClose = () => { + close(null); + }; + + elements.cancel.addEventListener('click', () => { + close(null); + }); + elements.overlay.addEventListener('click', (event) => { + if (event.target === elements.overlay) { + close(null); + } + }); + elements.confirm.addEventListener('click', () => { + const value = elements.input.value.trim(); + if (value === '') { + elements.input.focus(); + return; + } + close(value); + }); + elements.input.addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + elements.confirm.click(); + } + }); + + document.body.appendChild(elements.overlay); + document.body.classList.add('integration-import-open'); + document.addEventListener('keydown', onKeyDown); + globalThis.addEventListener(CLOSE_INTEGRATION_IMPORT_DIALOG_EVENT, onExternalClose); + requestAnimationFrame(() => { + elements.input.focus(); + }); + }); +} + +function createDialogElements(translate: TranslateFunc): DialogElements { + const overlay = document.createElement('div'); + overlay.className = 'integration-import-dialog-view'; + overlay.setAttribute('role', 'dialog'); + overlay.setAttribute('aria-modal', 'true'); + + const panel = document.createElement('div'); + panel.className = 'integration-import-dialog-panel'; + + const title = document.createElement('h3'); + title.dataset['i18n'] = 'ui.launcher.integrations.import.url_title'; + title.textContent = translate( + 'ui.launcher.integrations.import.url_title', + 'Add integration URL', + ); + + const description = document.createElement('p'); + description.dataset['i18n'] = 'ui.launcher.integrations.import.url_desc'; + description.textContent = translate( + 'ui.launcher.integrations.import.url_desc', + 'Paste a GitHub repository or direct archive URL.', + ); + + const input = document.createElement('input'); + input.type = 'url'; + input.className = 'integration-import-url-input'; + input.dataset['i18nPlaceholder'] = 'ui.launcher.integrations.import.url_placeholder'; + input.placeholder = translate( + 'ui.launcher.integrations.import.url_placeholder', + 'Repository or archive URL', + ); + input.value = ''; + input.autocomplete = 'new-password'; + input.setAttribute('autocapitalize', 'none'); + input.setAttribute('data-lpignore', 'true'); + input.setAttribute('data-form-type', 'other'); + input.spellcheck = false; + + const actions = document.createElement('div'); + actions.className = 'integration-import-dialog-actions'; + + const cancel = document.createElement('button'); + cancel.type = 'button'; + cancel.className = 'integration-import-dialog-cancel'; + cancel.dataset['i18n'] = 'ui.launcher.button.cancel'; + cancel.textContent = translate('ui.launcher.button.cancel', 'Cancel'); + + const confirm = document.createElement('button'); + confirm.type = 'button'; + confirm.className = 'integration-import-dialog-confirm'; + confirm.dataset['i18n'] = 'ui.launcher.integrations.import.add'; + confirm.textContent = translate('ui.launcher.integrations.import.add', 'Add'); + + actions.append(cancel, confirm); + panel.append(title, description, input, actions); + overlay.appendChild(panel); + + return { overlay, input, confirm, cancel }; +} diff --git a/src/shared/shell/ui/ModalManager.test.ts b/src/shared/shell/ui/ModalManager.test.ts index 16186625..d74005ec 100644 --- a/src/shared/shell/ui/ModalManager.test.ts +++ b/src/shared/shell/ui/ModalManager.test.ts @@ -84,7 +84,10 @@ describe('ModalManager lifecycle', () => { vi.useRealTimers(); }); - function createManager(onFilterChange?: (capability: 'text' | 'image') => string | null) { + function createManager( + onFilterChange?: (capability: 'text' | 'image') => string | null, + onIntegrationImport?: (action: 'local' | 'url' | 'guide') => void, + ) { return new ModalManager( new ModuleCardRenderer({ translate: (_key, fallback) => fallback, tracer }), interactionSpy as unknown as (e: MouseEvent, app: IApp, category: string) => void, @@ -94,6 +97,9 @@ describe('ModalManager lifecycle', () => { (_key, fallback) => fallback, tracer, navigation, + undefined, + undefined, + onIntegrationImport, ); } @@ -113,6 +119,56 @@ describe('ModalManager lifecycle', () => { expect(closeSpy).toHaveBeenCalledTimes(1); }); + it('should render integration import actions for empty services lists', () => { + const importSpy = vi.fn(); + modalManager = createManager(undefined, importSpy); + + modalManager.openAppSelection('services', []); + + const importCard = document.querySelector('.integration-import-card'); + expect(importCard).not.toBeNull(); + if (importCard === null) throw new Error('integration import card missing'); + expect(document.querySelector('.app-modal-empty-state')).toBeNull(); + + const actionButtons = document.querySelectorAll( + '.integration-import-action-btn', + ); + expect(actionButtons).toHaveLength(2); + const [openButton, linkButton] = Array.from(actionButtons); + if (openButton === undefined || linkButton === undefined) { + throw new Error('integration import buttons missing'); + } + + openButton.click(); + linkButton.click(); + const helpBadge = document.querySelector('.integration-help-badge'); + expect(helpBadge).not.toBeNull(); + if (helpBadge === null) throw new Error('integration help badge missing'); + expect(helpBadge.querySelector('.badge-icon')?.textContent).toBe('?'); + helpBadge.click(); + importCard.click(); + + expect(importSpy).toHaveBeenNthCalledWith(1, 'local'); + expect(importSpy).toHaveBeenNthCalledWith(2, 'url'); + expect(importSpy).toHaveBeenNthCalledWith(3, 'guide'); + expect(importSpy).toHaveBeenNthCalledWith(4, 'local'); + }); + + it('should rerender services selection when refresh receives an empty app list', () => { + modalManager = createManager(); + + modalManager.openAppSelection('services', [ + { id: 'parser', name: 'Parser', installed: true } as IApp, + ]); + expect(document.querySelector('[data-app-id="parser"]')).not.toBeNull(); + + modalManager.refreshCurrentSelection([], null); + + expect(document.querySelector('[data-app-id="parser"]')).toBeNull(); + expect(document.querySelector('.integration-import-card')).not.toBeNull(); + expect(document.querySelector('.app-modal-empty-state')).toBeNull(); + }); + it('should switch filters without leaving transient list styles', () => { vi.useFakeTimers(); @@ -194,11 +250,15 @@ describe('ModalManager lifecycle', () => { 'svc-b', ); - expect(document.querySelectorAll('#app-modal-list .app-card')).toHaveLength(1); expect( - (document.querySelector('#app-modal-list .app-card') as HTMLElement | null)?.dataset[ - 'appId' - ], + document.querySelectorAll('#app-modal-list .app-card:not(.integration-import-card)'), + ).toHaveLength(1); + expect( + ( + document.querySelector( + '#app-modal-list .app-card:not(.integration-import-card)', + ) as HTMLElement | null + )?.dataset['appId'], ).toBe('svc-b'); expect(document.querySelector('#app-modal-list .modal-btn')?.textContent).toBe('Remove'); }); diff --git a/src/shared/shell/ui/ModalManager.ts b/src/shared/shell/ui/ModalManager.ts index 664f94ba..b80ddaba 100644 --- a/src/shared/shell/ui/ModalManager.ts +++ b/src/shared/shell/ui/ModalManager.ts @@ -8,6 +8,7 @@ import { ModalFilterTransitionController } from './ModalFilterTransitionControll import { cancelModalDownload, createModalDownloadProgressHandler, + type IntegrationImportAction, populateModalAppList, transitionSelectionButton, updateModalSidebarWidth, @@ -82,6 +83,7 @@ export class ModalManager { private readonly _onCancelDownloadRequest: (app: IApp) => Promise; private readonly _onPauseDownloadRequest: (app: IApp) => Promise; private readonly _onResumeDownloadRequest: (app: IApp) => Promise; + private readonly _onIntegrationImport: (action: IntegrationImportAction) => void; private readonly _translate: (key: string, fallback: string) => string; private readonly _tracer: LoggerService; @@ -100,6 +102,7 @@ export class ModalManager { private readonly _navigation: NavigationService, onPauseDownloadRequest?: (app: IApp) => Promise, onResumeDownloadRequest?: (app: IApp) => Promise, + onIntegrationImport?: (action: IntegrationImportAction) => void, ) { this._cardRenderer = cardRenderer; this._onAppInteraction = onAppInteraction; @@ -108,6 +111,7 @@ export class ModalManager { this._onCancelDownloadRequest = onCancelDownloadRequest; this._onPauseDownloadRequest = onPauseDownloadRequest ?? (() => Promise.resolve()); this._onResumeDownloadRequest = onResumeDownloadRequest ?? (() => Promise.resolve()); + this._onIntegrationImport = onIntegrationImport ?? (() => {}); this._translate = translate; this._tracer = tracer; @@ -269,7 +273,7 @@ export class ModalManager { this._currentSelectedAppId = selectedAppId; } - if (this._currentCategory === null || this._currentApps.length === 0) { + if (this._currentCategory === null) { return; } @@ -367,6 +371,9 @@ export class ModalManager { cardRenderer: this._cardRenderer, onAppInteraction: this._onAppInteraction, onDownload: (app, action) => this._handleDownload(app, action), + onIntegrationImport: (action) => { + this._onIntegrationImport(action); + }, translate: this._translate, }); } diff --git a/src/shared/shell/ui/ModalManagerSupport.ts b/src/shared/shell/ui/ModalManagerSupport.ts index 43d46149..9a8dfd50 100644 --- a/src/shared/shell/ui/ModalManagerSupport.ts +++ b/src/shared/shell/ui/ModalManagerSupport.ts @@ -15,6 +15,7 @@ type AppInteractionHandler = (event: MouseEvent, app: IApp, category: string) => type DownloadHandler = (app: IApp, action: ModuleCardDownloadAction) => void; type ProgressEventHandler = (event: Event) => void; type TranslateFunc = (key: string, fallback: string) => string; +export type IntegrationImportAction = 'local' | 'url' | 'guide'; export async function cancelModalDownload(options: { app: IApp; @@ -91,6 +92,7 @@ export function populateModalAppList(options: { cardRenderer: ModuleCardRenderer; onAppInteraction: AppInteractionHandler; onDownload: DownloadHandler; + onIntegrationImport?: (action: IntegrationImportAction) => void; translate: TranslateFunc; }): void { const visibleApps = options.selectionPolicy.getVisibleApps( @@ -99,9 +101,14 @@ export function populateModalAppList(options: { options.currentFilter, ); + const shouldShowIntegrationImport = options.category === 'services'; + options.listElement.innerHTML = ''; - options.listElement.classList.toggle('app-grid-empty', visibleApps.length === 0); - if (visibleApps.length === 0) { + options.listElement.classList.toggle( + 'app-grid-empty', + visibleApps.length === 0 && !shouldShowIntegrationImport, + ); + if (visibleApps.length === 0 && !shouldShowIntegrationImport) { renderModalEmptyState(options.listElement, options.translate); return; } @@ -121,6 +128,12 @@ export function populateModalAppList(options: { ); options.listElement.appendChild(card); }); + + if (shouldShowIntegrationImport) { + options.listElement.appendChild( + createIntegrationImportCard(options.translate, options.onIntegrationImport), + ); + } } export function transitionSelectionButton(options: { @@ -222,3 +235,104 @@ function resetModalDownloadButton(button: HTMLButtonElement, translate: Translat label.textContent = translate('ui.launcher.module.download', 'Download'); } } + +function createIntegrationImportCard( + translate: TranslateFunc, + onIntegrationImport?: (action: IntegrationImportAction) => void, +): HTMLElement { + const card = document.createElement('div'); + card.className = 'app-card module-selection-card integration-import-card'; + card.tabIndex = 0; + card.setAttribute( + 'aria-label', + translate('ui.launcher.integrations.import.card_title', 'Add integration'), + ); + card.addEventListener('click', () => { + onIntegrationImport?.('local'); + }); + card.addEventListener('keydown', (event) => { + if (event.key !== 'Enter' && event.key !== ' ') { + return; + } + + event.preventDefault(); + onIntegrationImport?.('local'); + }); + + const help = createIntegrationHelpBadge(translate, onIntegrationImport); + const icon = document.createElement('div'); + icon.className = 'module-selection-card-icon integration-import-icon'; + icon.innerHTML = ''; + + const title = document.createElement('div'); + title.className = 'module-selection-card-title'; + title.textContent = translate('ui.launcher.integrations.import.card_title', 'Add integration'); + + const desc = document.createElement('div'); + desc.className = 'module-selection-card-description'; + desc.textContent = translate( + 'ui.launcher.integrations.import.card_desc', + 'Import a custom integration from a folder, archive, or repository link.', + ); + + const actions = document.createElement('div'); + actions.className = 'module-selection-card-actions integration-import-actions'; + actions.append( + createIntegrationImportButton( + 'local', + translate('ui.launcher.integrations.import.open', 'Open'), + onIntegrationImport, + ), + createIntegrationImportButton( + 'url', + translate('ui.launcher.integrations.import.url', 'Link'), + onIntegrationImport, + ), + ); + + card.append(help, icon, title, desc, actions); + return card; +} + +function createIntegrationHelpBadge( + translate: TranslateFunc, + onIntegrationImport?: (action: IntegrationImportAction) => void, +): HTMLButtonElement { + const badge = document.createElement('button'); + badge.type = 'button'; + badge.className = 'module-action-badge right integration-help-badge'; + badge.title = translate('ui.launcher.integrations.import.guide_title', 'Integration guide'); + badge.setAttribute('aria-label', badge.title); + const icon = document.createElement('span'); + icon.className = 'badge-icon'; + icon.setAttribute('aria-hidden', 'true'); + icon.textContent = '?'; + badge.append(icon); + badge.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + onIntegrationImport?.('guide'); + }); + + return badge; +} + +function createIntegrationImportButton( + action: Exclude, + label: string, + onIntegrationImport?: (action: IntegrationImportAction) => void, +): HTMLButtonElement { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'modal-btn modal-btn-primary integration-import-action-btn'; + button.title = label; + button.setAttribute('aria-label', label); + button.textContent = label; + button.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + onIntegrationImport?.(action); + }); + + return button; +} diff --git a/src/styles/features/module-selection-modal.css b/src/styles/features/module-selection-modal.css index a59b67c9..e087eb1e 100644 --- a/src/styles/features/module-selection-modal.css +++ b/src/styles/features/module-selection-modal.css @@ -683,6 +683,175 @@ body.snapping .modal-backdrop { filter: none !important; } +.integration-import-card { + cursor: pointer !important; +} + +.integration-import-card::before { + background: rgba(var(--primary-raw), 0.22); +} + +.integration-import-icon svg { + width: 42px; + height: 42px; + color: var(--text-primary); +} + +.integration-import-card .module-selection-card-description { + max-width: 22ch; +} + +.integration-import-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.55rem; + height: 48px; + bottom: 24px; +} + +.integration-import-actions .integration-import-action-btn { + width: 100%; + max-width: none; + min-width: 0; + min-height: 44px; + padding: 0.6rem 0.85rem !important; + font-size: 0.95rem !important; + line-height: 1; + background: var(--premium-purple-bg) !important; + border: 1px solid var(--premium-purple-border) !important; + color: var(--premium-purple-text) !important; + box-shadow: var(--premium-purple-shadow) !important; +} + +.integration-import-actions .integration-import-action-btn:hover { + background: var(--premium-purple-bg-hover) !important; + border-color: var(--premium-purple-border-strong) !important; + color: var(--premium-purple-text) !important; + box-shadow: var(--premium-purple-shadow) !important; +} + +.integration-help-badge { + border: 0; + background: transparent; + color: #ff4d4d; + z-index: 45; +} + +.integration-help-badge .badge-icon { + display: inline-flex; + align-items: center; + justify-content: center; + color: #ff4d4d; + font-family: var(--app-font-family); + font-size: 18px; + font-weight: 800; + line-height: 1; + text-shadow: 0 2px 8px rgba(255, 64, 64, 0.28); +} + +.integration-help-badge .badge-text { + display: none !important; +} + +.integration-help-badge:hover, +.integration-help-badge:focus-visible { + color: #ff6868; + background: transparent; + border-color: transparent; + min-width: 32px; +} + +.integration-import-dialog-view { + position: fixed; + top: var(--header-height, 60px); + right: 0; + bottom: 0; + left: var(--sidebar-width, 280px); + z-index: 1900; + display: flex; + align-items: center; + justify-content: center; + padding: 1.25rem; + background: transparent; + color: var(--text-primary); + pointer-events: auto; +} + +.integration-import-dialog-view:focus, +.integration-import-dialog-view:focus-visible { + outline: none; +} + +.integration-import-dialog-panel { + width: min(480px, calc(100% - 32px)); + display: grid; + gap: 0.8rem; + padding: 1rem; + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 16px; + background: rgb(18, 17, 24); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.06), + 0 20px 54px rgba(0, 0, 0, 0.42); +} + +.integration-import-dialog-panel h3, +.integration-import-dialog-panel p { + margin: 0; +} + +.integration-import-dialog-panel h3 { + font-size: 1rem; +} + +.integration-import-dialog-panel p { + color: var(--text-secondary); + font-size: 0.8rem; + line-height: 1.45; +} + +.integration-import-url-input { + min-height: 46px; + padding: 0 0.85rem; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + background: rgba(255, 255, 255, 0.045); + color: var(--text-primary); + font: 0.86rem var(--app-font-family); +} + +.integration-import-url-input:focus, +.integration-import-url-input:focus-visible { + outline: none; + border-color: rgba(255, 255, 255, 0.08); + box-shadow: none; +} + +.integration-import-dialog-actions { + display: grid; + grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr); + gap: 0.55rem; +} + +.integration-import-dialog-actions button { + min-height: 44px; + border-radius: var(--module-button-radius); + color: var(--module-button-text); + font-family: var(--app-font-family); + font-weight: 700; + cursor: pointer; +} + +.integration-import-dialog-cancel { + border: 1px solid rgba(255, 255, 255, 0.05); + background: rgba(255, 255, 255, 0.04); +} + +.integration-import-dialog-confirm { + border: 1px solid var(--premium-purple-border); + background: var(--premium-purple-bg); +} + .module-selection-card-actions .modal-btn-secondary { background: linear-gradient( 90deg, @@ -818,7 +987,8 @@ body.snapping .modal-backdrop { body.app-selection-open #main-area, body.settings-modal-open #main-area, -body.download-selection-open #main-area { +body.download-selection-open #main-area, +body.integration-import-open #main-area { visibility: hidden !important; opacity: 0 !important; pointer-events: none !important; diff --git a/src/styles/layouts/modal-shell.css b/src/styles/layouts/modal-shell.css index 32adbe27..6bd7514f 100644 --- a/src/styles/layouts/modal-shell.css +++ b/src/styles/layouts/modal-shell.css @@ -13,6 +13,18 @@ --modal-shell-padding-block-start: 24px; --modal-shell-padding-block-end: 24px; --modal-shell-radius: 24px; + outline: none !important; + box-shadow: none; +} + +.modal-backdrop:focus, +.modal-backdrop:focus-visible, +#app-selection-modal:focus, +#app-selection-modal:focus-visible, +#module-settings-modal:focus, +#module-settings-modal:focus-visible { + outline: none !important; + box-shadow: none !important; } /* Keep app selection on the shared modal geometry used by module settings. */ From c75baaa035e9ffdf4220763a722061ae0e7606c6 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Tue, 5 May 2026 23:55:15 +0300 Subject: [PATCH 087/126] fix: allow reload with dialogs open --- src/shared/shell/WindowUI.test.ts | 4 ++-- src/shared/shell/WindowUiInteractionController.ts | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/shared/shell/WindowUI.test.ts b/src/shared/shell/WindowUI.test.ts index a942b983..ad70805f 100644 --- a/src/shared/shell/WindowUI.test.ts +++ b/src/shared/shell/WindowUI.test.ts @@ -339,7 +339,7 @@ describe('WindowUI lifecycle', () => { expect(dblClick.defaultPrevented).toBe(true); }); - it('should block window-level shortcuts while a dialog is open', () => { + it('should block window-level shortcuts while a dialog is open but still allow reload', () => { document.body.innerHTML = ` @@ -373,7 +373,7 @@ describe('WindowUI lifecycle', () => { }); document.dispatchEvent(refreshEvent); expect(refreshEvent.defaultPrevented).toBe(true); - expect(reloadSpy).not.toHaveBeenCalled(); + expect(reloadSpy).toHaveBeenCalled(); }); it('should manage monitoring, wheel zoom, tooltip suppression and maximize icon rebuild', async () => { diff --git a/src/shared/shell/WindowUiInteractionController.ts b/src/shared/shell/WindowUiInteractionController.ts index ed2561e9..66c7a8df 100644 --- a/src/shared/shell/WindowUiInteractionController.ts +++ b/src/shared/shell/WindowUiInteractionController.ts @@ -176,6 +176,12 @@ export class WindowUiInteractionController { return; } + if (this._isReloadShortcut(e)) { + e.preventDefault(); + this._deps.runtime.reload(); + return; + } + if (this._deps.hasOpenDialog() && this._isWindowShortcut(e)) { e.preventDefault(); e.stopPropagation(); @@ -190,12 +196,6 @@ export class WindowUiInteractionController { return; } - if (this._isReloadShortcut(e)) { - e.preventDefault(); - this._deps.runtime.reload(); - return; - } - if (e.ctrlKey && BLOCKED_CTRL_KEYS.includes(e.key.toLowerCase() as never)) { e.preventDefault(); e.stopPropagation(); From f8fcb768a54c4b6f7113bc515000c5e2108c9b6f Mon Sep 17 00:00:00 2001 From: F0RLE Date: Tue, 5 May 2026 23:56:30 +0300 Subject: [PATCH 088/126] fix: label module cards as launch --- src/shared/shell/ui/ModalSelectionPolicy.test.ts | 2 +- src/shared/shell/ui/ModalSelectionPolicy.ts | 2 +- src/shared/shell/ui/ModuleCardActions.ts | 2 +- src/shared/shell/ui/ModuleCardRenderer.test.ts | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/shared/shell/ui/ModalSelectionPolicy.test.ts b/src/shared/shell/ui/ModalSelectionPolicy.test.ts index 203c0971..fd9bcc40 100644 --- a/src/shared/shell/ui/ModalSelectionPolicy.test.ts +++ b/src/shared/shell/ui/ModalSelectionPolicy.test.ts @@ -34,7 +34,7 @@ describe('ModalSelectionPolicy', () => { expect(policy.getButtonState(card, false)).toEqual({ className: 'modal-btn modal-btn-primary', key: 'ui.launcher.modules.modal.btn_select', - defaultLabel: 'Select', + defaultLabel: 'Launch', }); card.classList.add('engine-starting'); diff --git a/src/shared/shell/ui/ModalSelectionPolicy.ts b/src/shared/shell/ui/ModalSelectionPolicy.ts index 0a2857bb..529953e3 100644 --- a/src/shared/shell/ui/ModalSelectionPolicy.ts +++ b/src/shared/shell/ui/ModalSelectionPolicy.ts @@ -45,7 +45,7 @@ export class ModalSelectionPolicy { return { className: 'modal-btn modal-btn-primary', key: 'ui.launcher.modules.modal.btn_select', - defaultLabel: 'Select', + defaultLabel: 'Launch', }; } diff --git a/src/shared/shell/ui/ModuleCardActions.ts b/src/shared/shell/ui/ModuleCardActions.ts index fd337e57..a4f1f54b 100644 --- a/src/shared/shell/ui/ModuleCardActions.ts +++ b/src/shared/shell/ui/ModuleCardActions.ts @@ -105,7 +105,7 @@ export function buildModuleCardActionButton( actionBtn.className = 'modal-btn modal-btn-primary'; const i18nKey = 'ui.launcher.modules.modal.btn_select'; actionBtn.dataset['i18n'] = i18nKey; - actionBtn.textContent = translate(i18nKey, 'Select'); + actionBtn.textContent = translate(i18nKey, 'Launch'); } actionBtn.onclick = (event) => { diff --git a/src/shared/shell/ui/ModuleCardRenderer.test.ts b/src/shared/shell/ui/ModuleCardRenderer.test.ts index 25536c65..4507370c 100644 --- a/src/shared/shell/ui/ModuleCardRenderer.test.ts +++ b/src/shared/shell/ui/ModuleCardRenderer.test.ts @@ -161,7 +161,7 @@ describe('ModuleCardRenderer', () => { false, onClick, ); - expect(selectCard.textContent).toContain('Select'); + expect(selectCard.textContent).toContain('Launch'); (selectCard.querySelector('.modal-btn-primary') as HTMLButtonElement).click(); expect(onClick).toHaveBeenCalled(); @@ -234,7 +234,7 @@ describe('ModuleCardRenderer', () => { ); expect(card.querySelector('.download-btn')).toBeNull(); - expect(card.querySelector('.modal-btn-primary')?.textContent).toContain('Select'); + expect(card.querySelector('.modal-btn-primary')?.textContent).toContain('Launch'); }); it('renders delete badge emoji for installed local modules', () => { From 34d1b1a00d58048e4814308b8fcf74cd2f212bbd Mon Sep 17 00:00:00 2001 From: F0RLE Date: Wed, 6 May 2026 00:04:51 +0300 Subject: [PATCH 089/126] fix: address release review nits --- .github/scripts/workflow.mjs | 1 + .../src/domain/modules/github_releases.rs | 22 +++++++++++++------ .../config/config_repository.rs | 22 +++++++++++++++++++ 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/.github/scripts/workflow.mjs b/.github/scripts/workflow.mjs index 4b8f3c88..baa7dbbb 100644 --- a/.github/scripts/workflow.mjs +++ b/.github/scripts/workflow.mjs @@ -792,6 +792,7 @@ Tasks: test:coverage:all Run frontend and Rust coverage rust:test:coverage Run Rust tests with coverage summary rust:test:coverage:lcov Generate Rust LCOV report at src-tauri/lcov.info + Note: Rust coverage passthrough args go directly to cargo-llvm-cov; include explicit "--" before cargo test filters. test:watch Run frontend tests in watch mode typecheck Run frontend type checks verify Run the full local verification pipeline diff --git a/src-tauri/src/domain/modules/github_releases.rs b/src-tauri/src/domain/modules/github_releases.rs index 7cbbea4b..ef6f18f0 100644 --- a/src-tauri/src/domain/modules/github_releases.rs +++ b/src-tauri/src/domain/modules/github_releases.rs @@ -91,7 +91,8 @@ pub struct ReleaseDownloadVariant { const RELEASES_PER_PAGE: u8 = 100; const MAX_RELEASE_DOWNLOAD_OPTIONS: usize = 50; -const GITHUB_API_USER_AGENT: &str = "Axelate"; +const MAX_RELEASE_PAGES: u32 = 10; +const GITHUB_API_USER_AGENT: &str = concat!("Axelate/", env!("CARGO_PKG_VERSION")); #[derive(Clone, Debug, Deserialize)] struct Release { @@ -160,7 +161,7 @@ pub async fn fetch_release_bundle( ); let mut page = 1_u32; - loop { + while page <= MAX_RELEASE_PAGES { let releases = fetch_release_page(client, &repo_ref, module_id, page).await?; if releases.is_empty() { break; @@ -202,7 +203,7 @@ pub async fn fetch_release_download_options( let mut page = 1_u32; let mut versions = Vec::new(); - loop { + while page <= MAX_RELEASE_PAGES { let releases = fetch_release_page(client, &repo_ref, module_id, page).await?; if releases.is_empty() { break; @@ -481,10 +482,9 @@ fn is_gpu_asset_name_lower(lower: &str) -> bool { fn is_cpu_asset_name(name: &str) -> bool { let lower = name.to_ascii_lowercase(); - let has_explicit_cpu_token = release_asset_tokens(&lower).any(|token| { + release_asset_tokens(&lower).any(|token| { token == "cpu" || token == "avx" || token == "avx2" || token == "avx512" || token == "noavx" - }); - has_explicit_cpu_token || !is_gpu_asset_name_lower(&lower) + }) } fn release_asset_tokens(name: &str) -> impl Iterator { @@ -644,10 +644,18 @@ mod tests { #[test] fn asset_classification_does_not_treat_amd64_as_amd_gpu() { assert!(!is_gpu_asset_name("llama-b8981-bin-win-amd64.zip")); - assert!(is_cpu_asset_name("llama-b8981-bin-win-amd64.zip")); + assert!(!is_cpu_asset_name("llama-b8981-bin-win-amd64.zip")); assert!(is_gpu_asset_name("llama-b8981-bin-win-hip-radeon-x64.zip")); } + #[test] + fn unknown_accelerator_tokens_are_not_classified_as_cpu() { + assert!(!is_cpu_asset_name("llama-b8981-bin-win-metal-x64.zip")); + assert!(!is_cpu_asset_name("llama-b8981-bin-win-npu-x64.zip")); + assert!(!is_cpu_asset_name("llama-b8981-bin-win-xpu-x64.zip")); + assert!(!is_cpu_asset_name("llama-b8981-bin-win-rtx5090-x64.zip")); + } + #[test] fn windows_selection_does_not_treat_darwin_assets_as_windows() { let platform = Platform { diff --git a/src-tauri/src/infrastructure/config/config_repository.rs b/src-tauri/src/infrastructure/config/config_repository.rs index 9f68442b..cb35d6a5 100644 --- a/src-tauri/src/infrastructure/config/config_repository.rs +++ b/src-tauri/src/infrastructure/config/config_repository.rs @@ -413,6 +413,28 @@ mod tests { assert!(config.models.is_empty()); } + #[test] + fn custom_models_loader_parses_valid_json() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let path = temp_dir.path().join("custom_models.json"); + std::fs::write( + &path, + r#"{"models":[{"id":"custom-1","name":"Custom One","provider_id":"gpt","base_model_id":"gpt-4.1","created_at":1712345678.0}]}"#, + ) + .expect("valid custom models fixture"); + + let config = FileConfigRepository::load_custom_models_from_path(&path) + .expect("valid custom models should parse"); + + let model = config.models.first().expect("written custom model"); + assert_eq!(config.models.len(), 1); + assert_eq!(model.id, "custom-1"); + assert_eq!(model.name, "Custom One"); + assert_eq!(model.provider_id, "gpt"); + assert_eq!(model.base_model_id, "gpt-4.1"); + assert!((model.created_at - 1_712_345_678.0).abs() < f64::EPSILON); + } + #[test] fn custom_models_loader_reports_invalid_json() { let temp_dir = tempfile::tempdir().expect("temp dir"); From 83b7672ba72b2d33ee7ad771d2144f5c8fd90ee0 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Wed, 6 May 2026 08:57:43 +0300 Subject: [PATCH 090/126] fix: preserve direct integration asset urls --- .../src/domain/modules/downloader_transfer.rs | 56 +++++++++++++++++-- 1 file changed, 51 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/domain/modules/downloader_transfer.rs b/src-tauri/src/domain/modules/downloader_transfer.rs index b38238e0..8a7ba7b3 100644 --- a/src-tauri/src/domain/modules/downloader_transfer.rs +++ b/src-tauri/src/domain/modules/downloader_transfer.rs @@ -23,11 +23,7 @@ pub(super) async fn resolve_download_url( client: &reqwest::Client, download_url: &str, ) -> Result { - if download_url.contains("github.com") - && !Path::new(download_url) - .extension() - .is_some_and(|ext| ext.eq_ignore_ascii_case("zip")) - { + if is_github_repository_reference(download_url) { let base_url = download_url.trim_end_matches(".git").trim_end_matches('/'); let main_url = format!("{base_url}/archive/refs/heads/main.zip"); let master_url = format!("{base_url}/archive/refs/heads/master.zip"); @@ -71,6 +67,28 @@ pub(super) async fn resolve_download_url( Ok(download_url.to_string()) } +fn is_github_repository_reference(download_url: &str) -> bool { + let Ok(url) = reqwest::Url::parse(download_url.trim()) else { + return false; + }; + if !matches!(url.host_str(), Some("github.com" | "www.github.com")) { + return false; + } + + let Some(segments) = url.path_segments() else { + return false; + }; + let segments = segments + .filter(|segment| !segment.is_empty()) + .collect::>(); + let [owner, repo] = segments.as_slice() else { + return false; + }; + + let repo = repo.trim_end_matches(".git"); + !owner.is_empty() && !repo.is_empty() +} + pub(super) async fn clone_repository_into( app: &AppHandle, module_id: &str, @@ -410,3 +428,31 @@ pub(super) async fn download_file( interruption: None, }) } + +#[cfg(test)] +mod tests { + use super::is_github_repository_reference; + + #[test] + fn github_repository_reference_accepts_repo_roots_only() { + assert!(is_github_repository_reference( + "https://github.com/F0RLE/Axelate-telegram-parser" + )); + assert!(is_github_repository_reference( + "https://github.com/F0RLE/Axelate-telegram-parser.git" + )); + } + + #[test] + fn github_repository_reference_rejects_direct_assets_and_archive_urls() { + for url in [ + "https://github.com/F0RLE/Axelate-telegram-parser/archive/refs/heads/main.zip", + "https://github.com/F0RLE/Axelate-telegram-parser/releases/download/v1/parser.7z", + "https://github.com/F0RLE/Axelate-telegram-parser/releases/download/v1/parser.tar.gz", + "https://github.com/F0RLE/Axelate-telegram-parser/raw/main/parser.zip", + "https://example.com/F0RLE/Axelate-telegram-parser", + ] { + assert!(!is_github_repository_reference(url), "{url}"); + } + } +} From 6dd09d236229573757bee0bd4a43b2be04522902 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Wed, 6 May 2026 09:14:41 +0300 Subject: [PATCH 091/126] fix: address integration API review feedback --- .github/scripts/workflow.mjs | 39 +++-- docs/en/LAUNCHER_SDK.md | 80 +++++----- src-tauri/src/api/modules/downloader.rs | 2 +- src-tauri/src/domain/integration_api.rs | 147 +++++++++++++++--- .../modules/controller/script_runtime.rs | 7 +- src-tauri/src/domain/modules/downloader.rs | 73 +++++++-- .../src/domain/modules/github_releases.rs | 42 ++++- 7 files changed, 299 insertions(+), 91 deletions(-) diff --git a/.github/scripts/workflow.mjs b/.github/scripts/workflow.mjs index baa7dbbb..85d59671 100644 --- a/.github/scripts/workflow.mjs +++ b/.github/scripts/workflow.mjs @@ -188,14 +188,35 @@ function withEnvOverrides(overrides = {}) { function ensureCargoLlvmCov() { if (commandExists('cargo-llvm-cov', toolEnv())) { - ensureLlvmTools(); - return; + const invocation = buildCommandInvocation('cargo', ['llvm-cov', '--version'], toolEnv()); + const result = spawnSync(invocation.command, invocation.args, { + cwd: tauriDir, + env: toolEnv(), + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + shell: false, + }); + const versionOutput = result.stdout?.trim() || result.stderr?.trim() || ''; + const installedVersion = versionOutput.match(/\d+\.\d+\.\d+/u)?.[0]; + if (!result.error && result.status === 0 && installedVersion === cargoLlvmCovVersion) { + ensureLlvmTools(); + return; + } + + log( + `cargo-llvm-cov version mismatch (installed: ${installedVersion ?? 'unknown'}, expected: ${cargoLlvmCovVersion}); reinstalling`, + ); } - log( - `cargo-llvm-cov not found; installing version ${cargoLlvmCovVersion} with cargo install --locked`, - ); - run('cargo', ['install', 'cargo-llvm-cov', '--locked', '--version', cargoLlvmCovVersion]); + log(`installing cargo-llvm-cov version ${cargoLlvmCovVersion} with cargo install --locked`); + run('cargo', [ + 'install', + 'cargo-llvm-cov', + '--locked', + '--version', + cargoLlvmCovVersion, + '--force', + ]); ensureLlvmTools(); } @@ -792,7 +813,7 @@ Tasks: test:coverage:all Run frontend and Rust coverage rust:test:coverage Run Rust tests with coverage summary rust:test:coverage:lcov Generate Rust LCOV report at src-tauri/lcov.info - Note: Rust coverage passthrough args go directly to cargo-llvm-cov; include explicit "--" before cargo test filters. + Note: Rust coverage passthrough args go directly to cargo-llvm-cov; bare "--" is stripped, so cargo test filters cannot be forwarded here. test:watch Run frontend tests in watch mode typecheck Run frontend type checks verify Run the full local verification pipeline @@ -893,9 +914,7 @@ Tasks: tasks['rust:test:coverage'](); }, 'rust:test:coverage'() { - runRustCoverage( - withDirectPassthroughArgs(['--workspace', '--all-features', '--summary-only']), - ); + runRustCoverage(withDirectPassthroughArgs(['--workspace', '--all-features'])); }, 'rust:test:coverage:lcov'() { runRustCoverage( diff --git a/docs/en/LAUNCHER_SDK.md b/docs/en/LAUNCHER_SDK.md index 5e1d41ef..fbc595f1 100644 --- a/docs/en/LAUNCHER_SDK.md +++ b/docs/en/LAUNCHER_SDK.md @@ -105,21 +105,21 @@ const token = process.env.AXELATE_HTTP_API_TOKEN; const moduleId = process.env.AXELATE_MODULE_ID; const response = await fetch(`${baseUrl}/v1/ai/text`, { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - provider: "llamacpp", - prompt: "Write a short status update", - }), + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + provider: 'llamacpp', + prompt: 'Write a short status update', + }), }); const result = await response.json(); const settingsResponse = await fetch(`${baseUrl}/v1/modules/${moduleId}/settings`, { - headers: { Authorization: `Bearer ${token}` }, + headers: { Authorization: `Bearer ${token}` }, }); const { settings } = await settingsResponse.json(); ``` @@ -178,19 +178,21 @@ Returns the stable runtime context for an installed integration. ```json { - "ok": true, - "apiVersion": "1", - "moduleId": "my-integration", - "moduleDir": "C:\\Users\\...\\AxelateData\\System\\Integrations\\my-integration", - "runtimeDir": "C:\\Users\\...\\AxelateData\\System\\Runtime", - "moduleRuntimeDir": "C:\\Users\\...\\AxelateData\\System\\Runtime\\Integrations\\my-integration", - "moduleLogDir": "C:\\Users\\...\\AxelateData\\System\\Logs\\Integrations\\my-integration", - "httpApiBase": "http://127.0.0.1:3000" + "ok": true, + "apiVersion": "1", + "moduleId": "my-integration", + "moduleDir": "{AXELATE_DATA_DIR}/System/Integrations/my-integration", + "runtimeDir": "{AXELATE_DATA_DIR}/System/Runtime", + "moduleRuntimeDir": "{AXELATE_DATA_DIR}/System/Runtime/Integrations/my-integration", + "moduleLogDir": "{AXELATE_DATA_DIR}/System/Logs/Integrations/my-integration", + "httpApiBase": "http://127.0.0.1:3000" } ``` `moduleDir` is for reading shipped integration files. Runtime output belongs in -`moduleRuntimeDir`, not in the integration folder. +`moduleRuntimeDir`, not in the integration folder. Treat these paths as +platform-specific strings and use path utilities such as `path.join` and +`path.sep` instead of hardcoded separators. `GET /v1/modules/{moduleId}/settings` @@ -202,8 +204,8 @@ Replaces the integration settings object. ```json { - "chatId": "12345", - "enabled": true + "chatId": "12345", + "enabled": true } ``` @@ -218,10 +220,10 @@ emits `module-stage-changed` for UI surfaces and writes the stage to logs. ```json { - "stage": "parser.fetch", - "label": "Fetching external data", - "details": { "topics": 3 }, - "progress": 0.35 + "stage": "parser.fetch", + "label": "Fetching external data", + "details": { "topics": 3 }, + "progress": 0.35 } ``` @@ -250,14 +252,14 @@ launcher chat. ```json { - "prompt": "Summarize this message", - "sessionId": "sample-integration", - "provider": "openai", - "model": "gpt-5.5", - "messages": [{ "role": "user", "content": "Optional chat history" }], - "thinkingLevel": "medium", - "maxTokens": 1024, - "webSearch": { "enabled": false } + "prompt": "Summarize this message", + "sessionId": "sample-integration", + "provider": "openai", + "model": "gpt-5.5", + "messages": [{ "role": "user", "content": "Optional chat history" }], + "thinkingLevel": "medium", + "maxTokens": 1024, + "webSearch": { "enabled": false } } ``` @@ -275,12 +277,12 @@ Runs image generation through the selected or requested image AI provider. ```json { - "prompt": "Pixel art launcher icon", - "provider": "openai", - "model": "image-model-id", - "width": 1024, - "height": 1024, - "steps": 30 + "prompt": "Pixel art launcher icon", + "provider": "openai", + "model": "image-model-id", + "width": 1024, + "height": 1024, + "steps": 30 } ``` diff --git a/src-tauri/src/api/modules/downloader.rs b/src-tauri/src/api/modules/downloader.rs index b28f5e5c..35a57be8 100644 --- a/src-tauri/src/api/modules/downloader.rs +++ b/src-tauri/src/api/modules/downloader.rs @@ -70,7 +70,7 @@ pub async fn get_release_download_options( #[specta::specta] /// Imports an integration from a local folder containing `axelate-module.toml`. pub async fn import_integration_folder(path: String) -> Result { - downloader::import_integration_folder(&std::path::PathBuf::from(path)) + downloader::import_integration_folder(&std::path::PathBuf::from(path)).await } #[tauri::command] diff --git a/src-tauri/src/domain/integration_api.rs b/src-tauri/src/domain/integration_api.rs index 2cc7f2e4..90ba2812 100644 --- a/src-tauri/src/domain/integration_api.rs +++ b/src-tauri/src/domain/integration_api.rs @@ -29,7 +29,8 @@ use std::time::Duration; use tauri::{AppHandle, Emitter}; const DEFAULT_API_BASE_URL: &str = "http://127.0.0.1:3000"; -const SDK_API_VERSION: &str = "1"; +/// Public launcher integration SDK contract version exposed to module runtimes. +pub const SDK_API_VERSION: &str = "1"; const MAX_REQUEST_BYTES: usize = 1024 * 1024; const CUSTOM_TEXT_PROVIDER_ID: &str = "openrouter-custom-text"; const CUSTOM_IMAGE_PROVIDER_ID: &str = "openrouter-custom-image"; @@ -74,7 +75,8 @@ pub fn api_token() -> &'static str { pub fn apply_process_env(command: &mut tokio::process::Command) { command .env("AXELATE_HTTP_API_BASE", api_base_url()) - .env("AXELATE_HTTP_API_TOKEN", api_token()); + .env("AXELATE_HTTP_API_TOKEN", api_token()) + .env("AXELATE_SDK_VERSION", SDK_API_VERSION); } /// Starts the local launcher HTTP API server. @@ -278,11 +280,29 @@ struct ModuleStageChangedEvent { fn serve_launcher_http_api(listener: &TcpListener, context: &LauncherHttpApiContext) { for incoming in listener.incoming() { match incoming { - Ok(stream) => { + Ok(mut stream) => { let request_context = context.clone(); + let peer_addr = stream.peer_addr().ok(); + let request = match read_http_request(&mut stream) { + Ok(request) => request, + Err(error) => { + let response = HttpResponse { + status: 400, + body: json!({ "ok": false, "error": error }), + }; + write_response_or_log(&mut stream, &response); + continue; + } + }; + if let Some(response) = preflight_http_request(&request, peer_addr) { + write_response_or_log(&mut stream, &response); + continue; + } if let Err(error) = std::thread::Builder::new() .name("axelate-local-http-request".to_string()) - .spawn(move || handle_stream(stream, request_context)) + .spawn(move || { + handle_validated_request(stream, request, request_context, peer_addr); + }) { tracing::warn!("Failed to spawn launcher HTTP API request handler: {error}"); } @@ -294,19 +314,42 @@ fn serve_launcher_http_api(listener: &TcpListener, context: &LauncherHttpApiCont } } -fn handle_stream(mut stream: TcpStream, context: LauncherHttpApiContext) { - let peer_addr = stream.peer_addr().ok(); - let response = match read_http_request(&mut stream) { - Ok(request) => { - tauri::async_runtime::block_on(dispatch_http_request(request, context, peer_addr)) - } - Err(error) => HttpResponse { - status: 400, - body: json!({ "ok": false, "error": error }), - }, - }; +fn preflight_http_request( + request: &HttpRequest, + peer_addr: Option, +) -> Option { + if !is_loopback_peer(peer_addr) { + return Some(json_error( + 403, + "Launcher API only accepts loopback clients", + )); + } + + let path = request_path(request); + if request.method == "GET" && path == "/v1/health" { + return None; + } - if let Err(error) = write_http_response(&mut stream, &response) { + if !is_authorized(&request.headers) { + return Some(json_error(401, "Missing or invalid launcher API token")); + } + + None +} + +fn handle_validated_request( + mut stream: TcpStream, + request: HttpRequest, + context: LauncherHttpApiContext, + peer_addr: Option, +) { + let response = + tauri::async_runtime::block_on(dispatch_http_request(request, context, peer_addr)); + write_response_or_log(&mut stream, &response); +} + +fn write_response_or_log(stream: &mut TcpStream, response: &HttpResponse) { + if let Err(error) = write_http_response(stream, response) { tracing::warn!("Failed to write launcher HTTP API response: {error}"); } } @@ -453,7 +496,7 @@ async fn dispatch_http_request( return json_error(403, "Launcher API only accepts loopback clients"); } - let path = request.path.split('?').next().unwrap_or(&request.path); + let path = request_path(&request); if request.method == "GET" && path == "/v1/health" { return json_response(200, json!({ "ok": true, "service": "axelate-launcher" })); } @@ -468,6 +511,10 @@ async fn dispatch_http_request( } } +fn request_path(request: &HttpRequest) -> &str { + request.path.split('?').next().unwrap_or(&request.path) +} + const fn status_for_app_error(error: &AppError) -> u16 { match error { AppError::Validation(_) | AppError::Config(_) => 400, @@ -603,7 +650,7 @@ async fn handle_patch_module_settings_request( .settings_service .get_module_settings(module_id) .await?; - settings.extend(updates); + merge_json_settings(&mut settings, updates); context .settings_service .save_module_settings(module_id, &settings) @@ -615,6 +662,38 @@ async fn handle_patch_module_settings_request( )) } +fn merge_json_settings( + settings: &mut HashMap, + updates: HashMap, +) { + for (key, update) in updates { + match settings.get_mut(&key) { + Some(existing) => merge_json_value(existing, update), + None => { + settings.insert(key, update); + } + } + } +} + +fn merge_json_value(target: &mut serde_json::Value, update: serde_json::Value) { + match (target, update) { + (serde_json::Value::Object(target), serde_json::Value::Object(update)) => { + for (key, value) in update { + match target.get_mut(&key) { + Some(existing) => merge_json_value(existing, value), + None => { + target.insert(key, value); + } + } + } + } + (target, update) => { + *target = update; + } + } +} + fn ensure_installed_module_id(module_id: &str) -> Result<(), AppError> { crate::domain::modules::downloader::validate_module_id(module_id)?; if crate::domain::modules::downloader::is_module_installed(module_id) { @@ -1345,6 +1424,38 @@ mod tests { assert!(matches!(error, AppError::Validation(_))); } + #[test] + fn patch_settings_merge_nested_objects_without_dropping_existing_keys() { + let mut settings = HashMap::from([ + ( + "notifications".to_string(), + serde_json::json!({ + "enabled": true, + "channels": { "chat": true, "logs": true } + }), + ), + ("theme".to_string(), serde_json::json!("dark")), + ]); + let updates = HashMap::from([ + ( + "notifications".to_string(), + serde_json::json!({ "channels": { "logs": false } }), + ), + ("theme".to_string(), serde_json::json!("light")), + ]); + + super::merge_json_settings(&mut settings, updates); + + assert_eq!( + settings.get("notifications"), + Some(&serde_json::json!({ + "enabled": true, + "channels": { "chat": true, "logs": false } + })) + ); + assert_eq!(settings.get("theme"), Some(&serde_json::json!("light"))); + } + #[test] fn module_context_response_uses_public_camel_case_contract() { let response = serde_json::to_value(ModuleContextApiResponse { diff --git a/src-tauri/src/domain/modules/controller/script_runtime.rs b/src-tauri/src/domain/modules/controller/script_runtime.rs index 30f9bc7c..eed82fea 100644 --- a/src-tauri/src/domain/modules/controller/script_runtime.rs +++ b/src-tauri/src/domain/modules/controller/script_runtime.rs @@ -1,3 +1,4 @@ +use crate::domain::integration_api::SDK_API_VERSION; use crate::domain::modules::lifecycle::{ModuleManifest, ModuleRuntimeKind}; use crate::domain::modules::paths as module_paths; use crate::errors::AppError; @@ -134,7 +135,6 @@ async fn spawn_python_process( let mut command = Command::new(&python_path); command .arg(&entry_path) - .current_dir(module_path) .env("PYTHONUNBUFFERED", "1") .env("PYTHONUTF8", "1"); @@ -171,7 +171,6 @@ async fn spawn_node_process( let mut command = Command::new(node_executable); command .arg(entry_path) - .current_dir(module_path) .env("NODE_PATH", env_dir.join("node_modules")) .env("AXELATE_NODE_ENV_DIR", env_dir); @@ -207,7 +206,6 @@ async fn spawn_bun_process( let mut command = Command::new(bun_executable); command .arg(entry_path) - .current_dir(module_path) .env("NODE_PATH", env_dir.join("node_modules")) .env("AXELATE_BUN_ENV_DIR", env_dir); @@ -239,8 +237,9 @@ async fn spawn_runtime_command( .map_err(|e| AppError::Io(format!("Failed to open runtime log: {e}")))?; command + .current_dir(module_path) .env("BOT_CONFIG_DIR", CONFIG_DIR.as_os_str()) - .env("AXELATE_SDK_VERSION", "1") + .env("AXELATE_SDK_VERSION", SDK_API_VERSION) .env("AXELATE_CONFIG_DIR", CONFIG_DIR.as_os_str()) .env("AXELATE_RUNTIME_DIR", RUNTIME_DIR.as_os_str()) .env( diff --git a/src-tauri/src/domain/modules/downloader.rs b/src-tauri/src/domain/modules/downloader.rs index cd59536a..c737089d 100644 --- a/src-tauri/src/domain/modules/downloader.rs +++ b/src-tauri/src/domain/modules/downloader.rs @@ -90,16 +90,23 @@ pub async fn delete_module(module_id: &str) -> Result<(), AppError> { } /// Imports an integration from an existing local folder. -pub fn import_integration_folder(path: &Path) -> Result { +pub async fn import_integration_folder(path: &Path) -> Result { ensure_source_directory(path)?; let manifest = ManifestLoader::load(path)?; let module_id = validate_integration_manifest(&manifest)?; let staging_path = ArchiveExtractor::prepare_staging(&module_id)?; - - let result = (|| { - copy_directory_contents_secure(path, &staging_path)?; - finalize_imported_integration(&staging_path, Some("local-folder")) - })(); + let source_path = path.to_path_buf(); + let blocking_staging_path = staging_path.clone(); + + let result = tokio::task::spawn_blocking(move || { + copy_directory_contents_secure(&source_path, &blocking_staging_path)?; + finalize_imported_integration(&blocking_staging_path, Some("local-folder")) + }) + .await + .map_err(|error| AppError::Internal { + request_id: None, + message: format!("Integration folder import worker failed: {error}"), + })?; cleanup_staging_on_error(&result, &staging_path); result @@ -115,7 +122,7 @@ pub async fn import_integration_path(app: AppHandle, path: PathBuf) -> Result Result { "Integration URL cannot be empty".to_string(), )); } - if !source_url.starts_with("https://") && !source_url.starts_with("http://") { - return Err(AppError::Validation( - "Integration URL must start with http:// or https://".to_string(), - )); + + let url = reqwest::Url::parse(source_url) + .map_err(|error| AppError::Validation(format!("Integration URL is invalid: {error}")))?; + match url.scheme() { + "https" => {} + "http" if is_local_import_host(url.host_str()) => {} + "http" => { + return Err(AppError::Validation( + "Integration URL must use https:// unless it targets localhost development" + .to_string(), + )); + } + _ => { + return Err(AppError::Validation( + "Integration URL must start with https://".to_string(), + )); + } } Ok(source_url.to_string()) } +fn is_local_import_host(host: Option<&str>) -> bool { + matches!(host, Some("localhost" | "127.0.0.1" | "::1")) +} + fn build_import_id() -> String { format!("integration-import-{}", uuid::Uuid::new_v4().simple()) } @@ -798,6 +826,7 @@ mod tests { load_partial_metadata, normalize_archive_relative_path, parse_content_range_total, store_partial_metadata, }; + use crate::errors::AppError; use sevenz_rust2::{ArchiveReader, Password}; use std::path::{Path, PathBuf}; @@ -823,6 +852,20 @@ mod tests { assert!(error.to_string().contains("Download cancelled")); } + #[test] + fn validate_import_url_rejects_plain_http_except_localhost() { + assert!( + super::validate_import_url("https://github.com/F0RLE/demo/archive/main.zip").is_ok() + ); + assert!(super::validate_import_url("http://localhost:4000/integration.zip").is_ok()); + assert!(super::validate_import_url("http://127.0.0.1:4000/integration.zip").is_ok()); + + let error = super::validate_import_url("http://example.com/integration.zip") + .expect_err("plain remote http should be rejected"); + + assert!(matches!(error, AppError::Validation(_))); + } + #[test] fn normalize_archive_relative_path_rejects_traversal() { let error = normalize_archive_relative_path(Path::new("../escape/file.txt")) diff --git a/src-tauri/src/domain/modules/github_releases.rs b/src-tauri/src/domain/modules/github_releases.rs index ef6f18f0..7a7e1bb2 100644 --- a/src-tauri/src/domain/modules/github_releases.rs +++ b/src-tauri/src/domain/modules/github_releases.rs @@ -482,9 +482,43 @@ fn is_gpu_asset_name_lower(lower: &str) -> bool { fn is_cpu_asset_name(name: &str) -> bool { let lower = name.to_ascii_lowercase(); - release_asset_tokens(&lower).any(|token| { - token == "cpu" || token == "avx" || token == "avx2" || token == "avx512" || token == "noavx" - }) + let mut has_cpu_token = false; + let mut has_os_or_arch_token = false; + let mut has_unknown_accelerator_token = false; + for token in release_asset_tokens(&lower) { + if token == "cpu" + || token == "avx" + || token == "avx2" + || token == "avx512" + || token == "noavx" + { + has_cpu_token = true; + } + if matches!( + token, + "linux" + | "windows" + | "win" + | "darwin" + | "macos" + | "osx" + | "x86" + | "x86_64" + | "x64" + | "amd64" + | "arm64" + | "aarch64" + ) { + has_os_or_arch_token = true; + } + if token == "metal" || token == "npu" || token == "xpu" || token.starts_with("rtx") { + has_unknown_accelerator_token = true; + } + } + + (has_cpu_token || has_os_or_arch_token) + && !has_unknown_accelerator_token + && !is_gpu_asset_name_lower(&lower) } fn release_asset_tokens(name: &str) -> impl Iterator { @@ -644,7 +678,7 @@ mod tests { #[test] fn asset_classification_does_not_treat_amd64_as_amd_gpu() { assert!(!is_gpu_asset_name("llama-b8981-bin-win-amd64.zip")); - assert!(!is_cpu_asset_name("llama-b8981-bin-win-amd64.zip")); + assert!(is_cpu_asset_name("llama-b8981-bin-win-amd64.zip")); assert!(is_gpu_asset_name("llama-b8981-bin-win-hip-radeon-x64.zip")); } From 9815f427f6dbd52f197bf6399de91ac2e5ef1e27 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Wed, 6 May 2026 09:24:43 +0300 Subject: [PATCH 092/126] fix: scope integration api requests --- src-tauri/src/domain/integration_api.rs | 180 +++++++++++++++--- .../domain/modules/controller/lifecycle.rs | 3 +- .../modules/controller/script_runtime.rs | 2 +- 3 files changed, 158 insertions(+), 27 deletions(-) diff --git a/src-tauri/src/domain/integration_api.rs b/src-tauri/src/domain/integration_api.rs index 90ba2812..75a6e8f1 100644 --- a/src-tauri/src/domain/integration_api.rs +++ b/src-tauri/src/domain/integration_api.rs @@ -21,10 +21,11 @@ use crate::models::{AiModel, ApiProvider, ModelTier, ModuleItem, ProviderType, S use once_cell::sync::{Lazy, OnceCell}; use serde::{Deserialize, Serialize}; use serde_json::json; +use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::io::{Read, Write}; use std::net::{SocketAddr, TcpListener, TcpStream}; -use std::sync::Arc; +use std::sync::{Arc, Mutex, mpsc}; use std::time::Duration; use tauri::{AppHandle, Emitter}; @@ -32,6 +33,8 @@ const DEFAULT_API_BASE_URL: &str = "http://127.0.0.1:3000"; /// Public launcher integration SDK contract version exposed to module runtimes. pub const SDK_API_VERSION: &str = "1"; const MAX_REQUEST_BYTES: usize = 1024 * 1024; +const MAX_HTTP_API_WORKERS: usize = 8; +const MAX_HTTP_API_QUEUE: usize = 32; const CUSTOM_TEXT_PROVIDER_ID: &str = "openrouter-custom-text"; const CUSTOM_IMAGE_PROVIDER_ID: &str = "openrouter-custom-image"; const CUSTOM_TEXT_BACKEND_PROVIDER_ID: &str = "gpt"; @@ -72,13 +75,18 @@ pub fn api_token() -> &'static str { } /// Adds local launcher API environment variables to a module process. -pub fn apply_process_env(command: &mut tokio::process::Command) { +pub fn apply_process_env(command: &mut tokio::process::Command, module_id: &str) { command .env("AXELATE_HTTP_API_BASE", api_base_url()) - .env("AXELATE_HTTP_API_TOKEN", api_token()) + .env("AXELATE_HTTP_API_TOKEN", module_api_token(module_id)) .env("AXELATE_SDK_VERSION", SDK_API_VERSION); } +fn module_api_token(module_id: &str) -> String { + let digest = Sha256::digest(format!("{}:{module_id}", api_token()).as_bytes()); + format!("{module_id}.{}", hex::encode(digest)) +} + /// Starts the local launcher HTTP API server. pub fn start_launcher_http_api( context: LauncherHttpApiContext, @@ -183,6 +191,21 @@ struct HttpResponse { body: serde_json::Value, } +struct ValidatedHttpRequest { + stream: TcpStream, + request: HttpRequest, + context: LauncherHttpApiContext, + peer_addr: Option, +} + +type HttpWorkerReceiver = Arc>>; + +#[derive(Debug, Clone, PartialEq, Eq)] +enum AuthorizedClient { + Launcher, + Module(String), +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct IntegrationTextRequest { @@ -278,6 +301,18 @@ struct ModuleStageChangedEvent { } fn serve_launcher_http_api(listener: &TcpListener, context: &LauncherHttpApiContext) { + let (sender, receiver) = mpsc::sync_channel(MAX_HTTP_API_QUEUE); + let receiver = Arc::new(Mutex::new(receiver)); + for worker_index in 0..MAX_HTTP_API_WORKERS { + let worker_receiver = Arc::clone(&receiver); + if let Err(error) = std::thread::Builder::new() + .name(format!("axelate-local-http-worker-{worker_index}")) + .spawn(move || run_http_api_worker(&worker_receiver)) + { + tracing::warn!("Failed to spawn launcher HTTP API worker: {error}"); + } + } + for incoming in listener.incoming() { match incoming { Ok(mut stream) => { @@ -298,13 +333,22 @@ fn serve_launcher_http_api(listener: &TcpListener, context: &LauncherHttpApiCont write_response_or_log(&mut stream, &response); continue; } - if let Err(error) = std::thread::Builder::new() - .name("axelate-local-http-request".to_string()) - .spawn(move || { - handle_validated_request(stream, request, request_context, peer_addr); - }) - { - tracing::warn!("Failed to spawn launcher HTTP API request handler: {error}"); + let job = ValidatedHttpRequest { + stream, + request, + context: request_context, + peer_addr, + }; + match sender.try_send(job) { + Ok(()) => {} + Err(mpsc::TrySendError::Full(mut job)) => { + let response = json_error(503, "Launcher API request queue is full"); + write_response_or_log(&mut job.stream, &response); + } + Err(mpsc::TrySendError::Disconnected(mut job)) => { + let response = json_error(500, "Launcher API workers are unavailable"); + write_response_or_log(&mut job.stream, &response); + } } } Err(error) => { @@ -314,6 +358,25 @@ fn serve_launcher_http_api(listener: &TcpListener, context: &LauncherHttpApiCont } } +fn run_http_api_worker(receiver: &HttpWorkerReceiver) { + loop { + let job = { + let Ok(receiver) = receiver.lock() else { + tracing::warn!("Launcher HTTP API worker receiver lock is poisoned"); + return; + }; + receiver.recv() + }; + + match job { + Ok(job) => { + handle_validated_request(job.stream, job.request, job.context, job.peer_addr); + } + Err(_) => return, + } + } +} + fn preflight_http_request( request: &HttpRequest, peer_addr: Option, @@ -501,11 +564,11 @@ async fn dispatch_http_request( return json_response(200, json!({ "ok": true, "service": "axelate-launcher" })); } - if !is_authorized(&request.headers) { + let Some(client) = authorize_request(&request.headers) else { return json_error(401, "Missing or invalid launcher API token"); - } + }; - match route_authorized_request(path, &request, context).await { + match route_authorized_request(path, &request, context, &client).await { Ok(response) => response, Err(error) => json_error(status_for_app_error(&error), &error.to_string()), } @@ -531,6 +594,7 @@ async fn route_authorized_request( path: &str, request: &HttpRequest, context: LauncherHttpApiContext, + client: &AuthorizedClient, ) -> Result { let segments = path .trim_matches('/') @@ -547,6 +611,7 @@ async fn route_authorized_request( )) } ("GET", ["v1", "modules", module_id, "status"]) => { + ensure_module_route_owner(client, module_id)?; crate::domain::modules::downloader::validate_module_id(module_id)?; let status = module_controller::get_module_status(module_id).await; Ok(json_response( @@ -555,21 +620,27 @@ async fn route_authorized_request( )) } ("GET", ["v1", "modules", module_id, "context"]) => { + ensure_module_route_owner(client, module_id)?; handle_module_context_request(module_id) } ("GET", ["v1", "modules", module_id, "settings"]) => { + ensure_module_route_owner(client, module_id)?; handle_get_module_settings_request(&context, module_id).await } ("PUT", ["v1", "modules", module_id, "settings"]) => { + ensure_module_route_owner(client, module_id)?; handle_put_module_settings_request(request, &context, module_id).await } ("PATCH", ["v1", "modules", module_id, "settings"]) => { + ensure_module_route_owner(client, module_id)?; handle_patch_module_settings_request(request, &context, module_id).await } ("POST", ["v1", "modules", module_id, "stage"]) => { + ensure_module_route_owner(client, module_id)?; handle_module_stage_request(request, &context, module_id) } ("POST", ["v1", "modules", module_id, action]) => { + ensure_module_route_owner(client, module_id)?; crate::domain::modules::downloader::validate_module_id(module_id)?; let action = parse_module_action(action)?; let response = module_controller::control(context.app, module_id, action).await?; @@ -705,6 +776,16 @@ fn ensure_installed_module_id(module_id: &str) -> Result<(), AppError> { } } +fn ensure_module_route_owner(client: &AuthorizedClient, module_id: &str) -> Result<(), AppError> { + match client { + AuthorizedClient::Launcher => Ok(()), + AuthorizedClient::Module(owner_id) if owner_id == module_id => Ok(()), + AuthorizedClient::Module(_) => Err(AppError::PermissionDenied( + "Integration token cannot access another integration".to_string(), + )), + } +} + fn handle_module_stage_request( request: &HttpRequest, context: &LauncherHttpApiContext, @@ -1183,24 +1264,44 @@ fn is_loopback_peer(peer_addr: Option) -> bool { } fn is_authorized(headers: &HashMap) -> bool { + authorize_request(headers).is_some() +} + +fn authorize_request(headers: &HashMap) -> Option { headers .get("authorization") - .is_some_and(|value| is_authorized_bearer(value)) - || headers - .get("x-axelate-token") - .is_some_and(|value| value.trim() == api_token()) + .and_then(|value| authorized_bearer_client(value)) + .or_else(|| { + headers + .get("x-axelate-token") + .and_then(|value| authorized_token_client(value.trim())) + }) } -fn is_authorized_bearer(value: &str) -> bool { +fn authorized_bearer_client(value: &str) -> Option { let mut parts = value.split_whitespace(); - let Some(scheme) = parts.next() else { - return false; - }; - let Some(token) = parts.next() else { - return false; - }; + let scheme = parts.next()?; + let token = parts.next()?; + if parts.next().is_some() || !scheme.eq_ignore_ascii_case("bearer") { + return None; + } - parts.next().is_none() && scheme.eq_ignore_ascii_case("bearer") && token == api_token() + authorized_token_client(token) +} + +fn authorized_token_client(token: &str) -> Option { + if token == api_token() { + return Some(AuthorizedClient::Launcher); + } + + let (module_id, digest) = token.split_once('.')?; + crate::domain::modules::downloader::validate_module_id(module_id).ok()?; + let expected = module_api_token(module_id); + if token == expected && !digest.is_empty() { + Some(AuthorizedClient::Module(module_id.to_string())) + } else { + None + } } const fn json_response(status: u16, body: serde_json::Value) -> HttpResponse { @@ -1239,6 +1340,7 @@ const fn status_text(status: u16) -> &'static str { 403 => "Forbidden", 404 => "Not Found", 500 => "Internal Server Error", + 503 => "Service Unavailable", _ => "Unknown", } } @@ -1340,6 +1442,34 @@ mod tests { assert!(is_authorized(&headers)); } + #[test] + fn authorization_maps_module_tokens_to_module_owner() { + let mut headers = HashMap::new(); + headers.insert( + "authorization".to_string(), + format!("Bearer {}", super::module_api_token("sample-module")), + ); + + assert_eq!( + super::authorize_request(&headers), + Some(super::AuthorizedClient::Module("sample-module".to_string())) + ); + assert!( + super::ensure_module_route_owner( + &super::AuthorizedClient::Module("sample-module".to_string()), + "sample-module" + ) + .is_ok() + ); + assert!(matches!( + super::ensure_module_route_owner( + &super::AuthorizedClient::Module("sample-module".to_string()), + "other-module" + ), + Err(AppError::PermissionDenied(_)) + )); + } + #[test] fn authorization_rejects_malformed_bearer_values() { let mut headers = HashMap::new(); diff --git a/src-tauri/src/domain/modules/controller/lifecycle.rs b/src-tauri/src/domain/modules/controller/lifecycle.rs index cba0bca2..f1a5864d 100644 --- a/src-tauri/src/domain/modules/controller/lifecycle.rs +++ b/src-tauri/src/domain/modules/controller/lifecycle.rs @@ -151,13 +151,14 @@ impl<'a> LifecycleExecutor<'a> { let mut builder = build_command(start_cmd); builder .current_dir(self.module_path) + .env("AXELATE_MODULE_ID", &self.module_id) .stdout(Stdio::from( log_file .try_clone() .map_err(|e| AppError::Io(e.to_string()))?, )) .stderr(Stdio::from(log_file)); - crate::domain::integration_api::apply_process_env(&mut builder); + crate::domain::integration_api::apply_process_env(&mut builder, &self.module_id); builder.spawn().map_err(|e| AppError::Internal { request_id: None, diff --git a/src-tauri/src/domain/modules/controller/script_runtime.rs b/src-tauri/src/domain/modules/controller/script_runtime.rs index eed82fea..9c459d70 100644 --- a/src-tauri/src/domain/modules/controller/script_runtime.rs +++ b/src-tauri/src/domain/modules/controller/script_runtime.rs @@ -257,7 +257,7 @@ async fn spawn_runtime_command( AppError::Io(format!("Failed to clone runtime log file: {e}")) })?)) .stderr(Stdio::from(log_file)); - crate::domain::integration_api::apply_process_env(&mut command); + crate::domain::integration_api::apply_process_env(&mut command, module_id); command.spawn().map_err(|e| AppError::Internal { request_id: None, From ae0c23f01a5523782391679badfd7d3e72f236e6 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Wed, 6 May 2026 09:53:42 +0300 Subject: [PATCH 093/126] fix: harden integration api isolation --- src-tauri/src/domain/integration_api.rs | 247 ++++++++++++++++-------- 1 file changed, 167 insertions(+), 80 deletions(-) diff --git a/src-tauri/src/domain/integration_api.rs b/src-tauri/src/domain/integration_api.rs index 75a6e8f1..0cd5dfa2 100644 --- a/src-tauri/src/domain/integration_api.rs +++ b/src-tauri/src/domain/integration_api.rs @@ -17,11 +17,12 @@ use crate::domain::system::ports::{LAUNCHER_LOCAL_PORT_RANGE, LocalPortPurpose}; use crate::errors::AppError; use crate::infrastructure::config::settings::SettingsService; use crate::infrastructure::config::ui_state::UiStateService; -use crate::models::{AiModel, ApiProvider, ModelTier, ModuleItem, ProviderType, SelectedModule}; +use crate::models::{ + AiModel, ApiProvider, ModelTier, Module, ModuleItem, ProviderType, SelectedModule, +}; use once_cell::sync::{Lazy, OnceCell}; use serde::{Deserialize, Serialize}; use serde_json::json; -use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::io::{Read, Write}; use std::net::{SocketAddr, TcpListener, TcpStream}; @@ -48,6 +49,8 @@ static API_TOKEN: Lazy = Lazy::new(|| { uuid::Uuid::new_v4().simple() ) }); +static MODULE_API_TOKENS: Lazy>> = + Lazy::new(|| Mutex::new(HashMap::new())); /// Handle for the running launcher HTTP API server. #[derive(Debug, Clone)] @@ -78,13 +81,18 @@ pub fn api_token() -> &'static str { pub fn apply_process_env(command: &mut tokio::process::Command, module_id: &str) { command .env("AXELATE_HTTP_API_BASE", api_base_url()) - .env("AXELATE_HTTP_API_TOKEN", module_api_token(module_id)) + .env("AXELATE_HTTP_API_TOKEN", issue_module_api_token(module_id)) .env("AXELATE_SDK_VERSION", SDK_API_VERSION); } -fn module_api_token(module_id: &str) -> String { - let digest = Sha256::digest(format!("{}:{module_id}", api_token()).as_bytes()); - format!("{module_id}.{}", hex::encode(digest)) +fn issue_module_api_token(module_id: &str) -> String { + let token = format!("{module_id}.{}", uuid::Uuid::new_v4().simple()); + if let Ok(mut tokens) = MODULE_API_TOKENS.lock() { + tokens.insert(module_id.to_string(), token.clone()); + } else { + tracing::warn!("Failed to register module API token for {module_id}"); + } + token } /// Starts the local launcher HTTP API server. @@ -281,14 +289,6 @@ struct ModuleContextApiResponse { http_api_base: String, } -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct SelectedModuleChangedEvent { - category: String, - module: SelectedModule, - source: &'static str, -} - #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] struct ModuleStageChangedEvent { @@ -318,7 +318,7 @@ fn serve_launcher_http_api(listener: &TcpListener, context: &LauncherHttpApiCont Ok(mut stream) => { let request_context = context.clone(); let peer_addr = stream.peer_addr().ok(); - let request = match read_http_request(&mut stream) { + let request = match read_http_request_head(&mut stream) { Ok(request) => request, Err(error) => { let response = HttpResponse { @@ -406,8 +406,15 @@ fn handle_validated_request( context: LauncherHttpApiContext, peer_addr: Option, ) { - let response = - tauri::async_runtime::block_on(dispatch_http_request(request, context, peer_addr)); + let response = match complete_http_request_body(&mut stream, request) { + Ok(request) => { + tauri::async_runtime::block_on(dispatch_http_request(request, context, peer_addr)) + } + Err(error) => HttpResponse { + status: 400, + body: json!({ "ok": false, "error": error }), + }, + }; write_response_or_log(&mut stream, &response); } @@ -417,7 +424,13 @@ fn write_response_or_log(stream: &mut TcpStream, response: &HttpResponse) { } } +#[cfg(test)] fn read_http_request(stream: &mut TcpStream) -> Result { + let request = read_http_request_head(stream)?; + complete_http_request_body(stream, request) +} + +fn read_http_request_head(stream: &mut TcpStream) -> Result { stream .set_read_timeout(Some(Duration::from_secs(5))) .map_err(|error| format!("Failed to configure read timeout: {error}"))?; @@ -485,32 +498,53 @@ fn read_http_request(stream: &mut TcpStream) -> Result { .checked_add(4) .ok_or_else(|| "Internal HTTP body offset overflowed".to_string())?; let mut body = buffer.get(body_start..).unwrap_or_default().to_vec(); - while body.len() < content_length { + body.truncate(content_length); + + Ok(HttpRequest { + method, + path, + headers, + body, + }) +} + +fn complete_http_request_body( + stream: &mut TcpStream, + mut request: HttpRequest, +) -> Result { + let content_length = request + .headers + .get("content-length") + .map_or(Ok(0_usize), |value| { + value + .parse::() + .map_err(|error| format!("Invalid content-length: {error}")) + })?; + if content_length > MAX_REQUEST_BYTES { + return Err("HTTP request body is too large".to_string()); + } + let mut chunk = [0_u8; 4096]; + while request.body.len() < content_length { let read = stream .read(&mut chunk) .map_err(|error| format!("Failed to read request body: {error}"))?; if read == 0 { return Err(format!( "HTTP request body ended before content-length was reached: expected {content_length} bytes, got {}", - body.len() + request.body.len() )); } let read_chunk = chunk .get(..read) .ok_or_else(|| "Internal HTTP body buffer range is invalid".to_string())?; - body.extend_from_slice(read_chunk); - if body.len() > MAX_REQUEST_BYTES { + request.body.extend_from_slice(read_chunk); + if request.body.len() > MAX_REQUEST_BYTES { return Err("HTTP request body is too large".to_string()); } } - body.truncate(content_length); + request.body.truncate(content_length); - Ok(HttpRequest { - method, - path, - headers, - body, - }) + Ok(request) } fn find_header_end(buffer: &[u8]) -> Option { @@ -604,7 +638,8 @@ async fn route_authorized_request( match (request.method.as_str(), segments.as_slice()) { ("GET", ["v1", "modules"]) => { - let modules = module_controller::get_all_modules().await; + let modules = + modules_visible_to_client(module_controller::get_all_modules().await, client); Ok(json_response( 200, json!({ "ok": true, "modules": modules }), @@ -655,6 +690,13 @@ async fn route_authorized_request( } } +fn modules_visible_to_client(mut modules: Vec, client: &AuthorizedClient) -> Vec { + if let AuthorizedClient::Module(module_id) = client { + modules.retain(|module| module.id == *module_id); + } + modules +} + fn handle_module_context_request(module_id: &str) -> Result { ensure_installed_module_id(module_id)?; let module_dir = crate::domain::modules::downloader::get_module_path(module_id); @@ -851,7 +893,7 @@ async fn handle_text_request( .ok_or_else(|| AppError::Validation("No selected text AI provider".to_string()))?, }; if requested_provider.is_some() { - select_provider_for_category(&context, "ai_text", &ui_provider).await?; + resolve_selected_provider_module(&context.config_service, &ui_provider)?; } let provider = backend_provider_id(&ui_provider).to_string(); let model = resolve_model_id( @@ -940,7 +982,7 @@ async fn handle_image_request( .ok_or_else(|| AppError::Validation("No selected image AI provider".to_string()))?, }; if requested_provider.is_some() { - select_provider_for_category(&context, "ai_image", &ui_provider).await?; + resolve_selected_provider_module(&context.config_service, &ui_provider)?; } let provider = backend_provider_id(&ui_provider).to_string(); let model = resolve_model_id( @@ -1000,42 +1042,6 @@ fn parse_json_body Deserialize<'de>>(request: &HttpRequest) -> Resul .map_err(|error| AppError::Validation(format!("Invalid JSON request body: {error}"))) } -async fn select_provider_for_category( - context: &LauncherHttpApiContext, - category: &str, - provider_id: &str, -) -> Result<(), AppError> { - let selected_module = resolve_selected_provider_module(&context.config_service, provider_id)?; - let mut state = context.ui_state_service.get_ui_state().await?; - let previous_id = state - .selected_modules - .get(category) - .map(|module| module.id.as_str()); - - if previous_id == Some(selected_module.id.as_str()) { - return Ok(()); - } - - state - .selected_modules - .insert(category.to_string(), selected_module.clone()); - context.ui_state_service.save_ui_state(&state).await?; - - let payload = SelectedModuleChangedEvent { - category: category.to_string(), - module: selected_module, - source: "integration-api", - }; - if let Err(error) = context - .app - .emit("ui-state:selected-module-changed", payload) - { - tracing::warn!("Failed to emit selected module change: {error}"); - } - - Ok(()) -} - fn resolve_selected_provider_module( config_service: &ConfigService, provider_id: &str, @@ -1296,12 +1302,15 @@ fn authorized_token_client(token: &str) -> Option { let (module_id, digest) = token.split_once('.')?; crate::domain::modules::downloader::validate_module_id(module_id).ok()?; - let expected = module_api_token(module_id); - if token == expected && !digest.is_empty() { - Some(AuthorizedClient::Module(module_id.to_string())) - } else { - None + if digest.is_empty() { + return None; } + MODULE_API_TOKENS + .lock() + .ok() + .and_then(|tokens| tokens.get(module_id).cloned()) + .filter(|expected| expected == token) + .map(|_| AuthorizedClient::Module(module_id.to_string())) } const fn json_response(status: u16, body: serde_json::Value) -> HttpResponse { @@ -1352,18 +1361,20 @@ mod tests { use super::{ IntegrationTextRequest, ModuleContextApiResponse, backend_provider_id, find_header_end, is_authorized, is_loopback_peer, json_error, json_response, model_api_id, - parse_header_line, parse_header_lines, parse_json_body, parse_module_action, - read_http_request, selected_module_from_api_provider, selected_module_from_catalog_item, - status_for_app_error, status_text, tier_rank, + modules_visible_to_client, parse_header_line, parse_header_lines, parse_json_body, + parse_module_action, read_http_request, selected_module_from_api_provider, + selected_module_from_catalog_item, status_for_app_error, status_text, tier_rank, }; use crate::domain::modules::controller::ModuleAction; use crate::errors::AppError; use crate::models::{ - AiModel, ApiModelConfig, ModelStats, ModelTier, ModuleItem, ProviderType, SelectedModule, + AiModel, ApiModelConfig, ModelStats, ModelTier, Module, ModuleItem, ProviderType, + SelectedModule, }; use std::collections::HashMap; use std::io::Write; use std::net::{Shutdown, TcpListener, TcpStream}; + use std::time::Duration; fn model_with_api_ids() -> AiModel { AiModel { @@ -1444,11 +1455,9 @@ mod tests { #[test] fn authorization_maps_module_tokens_to_module_owner() { + let token = super::issue_module_api_token("sample-module"); let mut headers = HashMap::new(); - headers.insert( - "authorization".to_string(), - format!("Bearer {}", super::module_api_token("sample-module")), - ); + headers.insert("authorization".to_string(), format!("Bearer {token}")); assert_eq!( super::authorize_request(&headers), @@ -1470,6 +1479,24 @@ mod tests { )); } + #[test] + fn issuing_new_module_token_invalidates_previous_token() { + let old_token = super::issue_module_api_token("rotating-module"); + let new_token = super::issue_module_api_token("rotating-module"); + let mut headers = HashMap::new(); + + headers.insert("authorization".to_string(), format!("Bearer {old_token}")); + assert_eq!(super::authorize_request(&headers), None); + + headers.insert("authorization".to_string(), format!("Bearer {new_token}")); + assert_eq!( + super::authorize_request(&headers), + Some(super::AuthorizedClient::Module( + "rotating-module".to_string() + )) + ); + } + #[test] fn authorization_rejects_malformed_bearer_values() { let mut headers = HashMap::new(); @@ -1716,6 +1743,42 @@ mod tests { assert_eq!(selected.type_, "local"); } + #[test] + fn module_tokens_only_see_their_own_module_in_list_route() { + fn module(id: &str) -> Module { + Module { + id: id.to_string(), + name: id.to_string(), + description: String::new(), + version: String::new(), + author: String::new(), + category: "service".to_string(), + icon: String::new(), + preview: None, + path: String::new(), + installed: true, + local: true, + enabled: false, + status: None, + is_deletable: true, + config: HashMap::new(), + config_schema: None, + settings_ui: None, + } + } + + let visible = modules_visible_to_client( + vec![module("owned-module"), module("other-module")], + &super::AuthorizedClient::Module("owned-module".to_string()), + ); + + assert_eq!(visible.len(), 1); + assert_eq!( + visible.first().map(|module| module.id.as_str()), + Some("owned-module") + ); + } + #[test] fn selected_module_from_api_provider_maps_provider_type() { let provider = crate::models::ApiProvider { @@ -1782,6 +1845,30 @@ mod tests { assert_eq!(error, "HTTP request body is too large"); } + #[test] + fn reads_headers_without_waiting_for_full_body() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test listener"); + let addr = listener.local_addr().expect("local addr"); + let client = std::thread::spawn(move || { + let mut stream = TcpStream::connect(addr).expect("connect test listener"); + stream + .write_all(b"POST /v1/ai/text HTTP/1.1\r\nContent-Length: 8\r\n\r\nabc") + .expect("write partial request"); + std::thread::sleep(Duration::from_millis(200)); + stream.shutdown(Shutdown::Write).expect("shutdown write"); + }); + + let (mut stream, _) = listener.accept().expect("accept test client"); + let request = super::read_http_request_head(&mut stream).expect("request head"); + + assert_eq!(request.path, "/v1/ai/text"); + assert_eq!(request.body, b"abc"); + let error = super::complete_http_request_body(&mut stream, request) + .expect_err("remaining body should still be required"); + client.join().expect("client thread"); + assert!(error.contains("expected 8 bytes, got 3")); + } + #[test] fn rejects_http_body_shorter_than_content_length() { let listener = TcpListener::bind("127.0.0.1:0").expect("bind test listener"); From 054eaf6fe94bfe41327d98f6018872c5e999ca75 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Wed, 6 May 2026 10:02:41 +0300 Subject: [PATCH 094/126] fix: address coderabbit integration feedback --- docs/en/LAUNCHER_SDK.md | 5 +- .../src/domain/modules/github_releases.rs | 8 +++ .../src/domain/modules/integration_watcher.rs | 52 ++++++++++++------- 3 files changed, 46 insertions(+), 19 deletions(-) diff --git a/docs/en/LAUNCHER_SDK.md b/docs/en/LAUNCHER_SDK.md index fbc595f1..152e37df 100644 --- a/docs/en/LAUNCHER_SDK.md +++ b/docs/en/LAUNCHER_SDK.md @@ -14,7 +14,10 @@ Launcher-managed integration processes receive these environment variables: - `AXELATE_SDK_VERSION`: local launcher integration API version, currently `1` - `AXELATE_HTTP_API_BASE`: local base URL, for example `http://127.0.0.1:3000` -- `AXELATE_HTTP_API_TOKEN`: bearer token for the current launcher process +- `AXELATE_HTTP_API_TOKEN`: bearer token issued by `apply_process_env` through + `issue_module_api_token` and scoped to this integration. It authorizes shared + endpoints and only this integration's own `/v1/modules/{moduleId}/...` routes; + `ensure_module_route_owner` rejects other module routes with `403`. - `AXELATE_RUNTIME_DIR`: shared launcher runtime directory - `AXELATE_MODULE_DIR`: read-only integration installation directory - `AXELATE_MODULE_RUNTIME_DIR`: writable runtime directory reserved for the integration diff --git a/src-tauri/src/domain/modules/github_releases.rs b/src-tauri/src/domain/modules/github_releases.rs index 7a7e1bb2..f67490d0 100644 --- a/src-tauri/src/domain/modules/github_releases.rs +++ b/src-tauri/src/domain/modules/github_releases.rs @@ -469,6 +469,7 @@ fn is_gpu_asset_name_lower(lower: &str) -> bool { || token.starts_with("cuda13") || token.starts_with("cu12") || token.starts_with("cu13") + || token == "metal" || token == "vulkan" || token == "hip" || token == "rocm" @@ -690,6 +691,13 @@ mod tests { assert!(!is_cpu_asset_name("llama-b8981-bin-win-rtx5090-x64.zip")); } + #[test] + fn metal_assets_are_classified_as_gpu() { + assert!(is_gpu_asset_name_lower( + "llama-b9028-bin-darwin-metal-arm64.zip" + )); + } + #[test] fn windows_selection_does_not_treat_darwin_assets_as_windows() { let platform = Platform { diff --git a/src-tauri/src/domain/modules/integration_watcher.rs b/src-tauri/src/domain/modules/integration_watcher.rs index 7ce55977..900263d3 100644 --- a/src-tauri/src/domain/modules/integration_watcher.rs +++ b/src-tauri/src/domain/modules/integration_watcher.rs @@ -49,29 +49,34 @@ pub fn start(app: tauri::AppHandle) { return; } - let mut last_emit = Instant::now() - .checked_sub(EVENT_DEBOUNCE) - .unwrap_or_else(Instant::now); - while let Ok(event) = rx.recv() { - match event { - Ok(event) if is_integration_change(event.kind) => { - if last_emit.elapsed() < EVENT_DEBOUNCE { + let mut debounce_deadline: Option = None; + loop { + let event = match debounce_deadline { + Some(deadline) => { + let now = Instant::now(); + if now >= deadline { + emit_integrations_changed(&app, &path); + debounce_deadline = None; continue; } - last_emit = Instant::now(); - if let Err(error) = app.emit( - INTEGRATIONS_CHANGED_EVENT, - IntegrationsChangedPayload { - path: path.to_string_lossy().to_string(), - }, - ) { - tracing::warn!("Failed to emit integrations change event: {error}"); - } + rx.recv_timeout(deadline.saturating_duration_since(now)) + } + None => rx.recv().map_err(|_| mpsc::RecvTimeoutError::Disconnected), + }; + + match event { + Ok(Ok(event)) if is_integration_change(event.kind) => { + debounce_deadline = Some(Instant::now() + EVENT_DEBOUNCE); } - Ok(_) => {} - Err(error) => { + Ok(Ok(_)) => {} + Ok(Err(error)) => { tracing::warn!("Integrations watcher error: {error}"); } + Err(mpsc::RecvTimeoutError::Timeout) => { + emit_integrations_changed(&app, &path); + debounce_deadline = None; + } + Err(mpsc::RecvTimeoutError::Disconnected) => break, } } }) @@ -81,6 +86,17 @@ pub fn start(app: tauri::AppHandle) { .ok(); } +fn emit_integrations_changed(app: &tauri::AppHandle, path: &std::path::Path) { + if let Err(error) = app.emit( + INTEGRATIONS_CHANGED_EVENT, + IntegrationsChangedPayload { + path: path.to_string_lossy().to_string(), + }, + ) { + tracing::warn!("Failed to emit integrations change event: {error}"); + } +} + const fn is_integration_change(kind: EventKind) -> bool { matches!( kind, From 18f6eba1c4f57e4acfda56f5705e46021f3850de Mon Sep 17 00:00:00 2001 From: F0RLE Date: Wed, 6 May 2026 14:20:10 +0300 Subject: [PATCH 095/126] feat(integrations): add developer tooling and examples --- .github/scripts/integration/doctor.mjs | 237 +++++++++++++++ .github/scripts/integration/scaffold.mjs | 269 ++++++++++++++++++ .github/scripts/workflow.mjs | 18 ++ .gitignore | 2 + docs/en/CUSTOM_INTEGRATIONS.md | 12 +- docs/en/INTEGRATION_DEVELOPMENT.md | 132 +++++++++ docs/en/LAUNCHER_SDK.md | 44 +-- .../integrations/python-ai-tool/README.md | 22 ++ .../python-ai-tool/axelate-module.toml | 15 + .../settings-ui/axelate-settings-bridge.js | 80 ++++++ .../python-ai-tool/settings-ui/index.html | 75 +++++ .../integrations/python-ai-tool/src/main.py | 56 ++++ .../sdk/browser/axelate-settings-bridge.js | 99 +++++++ .../sdk/javascript/axelate-client.mjs | 55 ++++ docs/examples/sdk/python/axelate_sdk.py | 43 +++ docs/ru/INTEGRATION_DEVELOPMENT.md | 132 +++++++++ docs/zh/INTEGRATION_DEVELOPMENT.md | 128 +++++++++ package.json | 2 + src-tauri/src/domain/integration_api.rs | 44 ++- 19 files changed, 1445 insertions(+), 20 deletions(-) create mode 100644 .github/scripts/integration/doctor.mjs create mode 100644 .github/scripts/integration/scaffold.mjs create mode 100644 docs/en/INTEGRATION_DEVELOPMENT.md create mode 100644 docs/examples/integrations/python-ai-tool/README.md create mode 100644 docs/examples/integrations/python-ai-tool/axelate-module.toml create mode 100644 docs/examples/integrations/python-ai-tool/settings-ui/axelate-settings-bridge.js create mode 100644 docs/examples/integrations/python-ai-tool/settings-ui/index.html create mode 100644 docs/examples/integrations/python-ai-tool/src/main.py create mode 100644 docs/examples/sdk/browser/axelate-settings-bridge.js create mode 100644 docs/examples/sdk/javascript/axelate-client.mjs create mode 100644 docs/examples/sdk/python/axelate_sdk.py create mode 100644 docs/ru/INTEGRATION_DEVELOPMENT.md create mode 100644 docs/zh/INTEGRATION_DEVELOPMENT.md diff --git a/.github/scripts/integration/doctor.mjs b/.github/scripts/integration/doctor.mjs new file mode 100644 index 00000000..c3e729c8 --- /dev/null +++ b/.github/scripts/integration/doctor.mjs @@ -0,0 +1,237 @@ +#!/usr/bin/env node + +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; + +const FORBIDDEN_ENTRIES = new Set([ + '.axelate', + '.git', + '.venv', + '__pycache__', + 'build', + 'dist', + 'node_modules', + 'target', +]); + +const VALID_RUNTIME_KINDS = new Set(['python', 'node', 'bun', 'binary']); +const REQUIRED_TOP_LEVEL_FIELDS = ['api_version', 'id', 'name', 'version', 'type']; + +const targetArg = process.argv.slice(2).find((arg) => !arg.startsWith('--')) ?? '.'; +const root = path.resolve(targetArg); +const manifestPath = path.join(root, 'axelate-module.toml'); +const findings = []; + +function add(level, message) { + findings.push({ level, message }); +} + +function readManifest() { + if (!existsSync(root)) { + add('error', `Integration folder does not exist: ${root}`); + return null; + } + + if (!statSync(root).isDirectory()) { + add('error', `Integration path must be a directory: ${root}`); + return null; + } + + if (!existsSync(manifestPath)) { + add('error', 'Missing axelate-module.toml'); + return null; + } + + return readFileSync(manifestPath, 'utf8'); +} + +function parseManifest(source) { + const values = new Map(); + let section = ''; + + source.split(/\r?\n/u).forEach((rawLine) => { + const line = rawLine.replace(/#.*/u, '').trim(); + if (line.length === 0) { + return; + } + + const sectionMatch = line.match(/^\[([A-Za-z0-9_.-]+)\]$/u); + if (sectionMatch) { + section = sectionMatch[1]; + return; + } + + const fieldMatch = line.match(/^([A-Za-z0-9_.-]+)\s*=\s*(.+)$/u); + if (!fieldMatch) { + return; + } + + const key = section.length > 0 ? `${section}.${fieldMatch[1]}` : fieldMatch[1]; + values.set(key, normalizeTomlScalar(fieldMatch[2])); + }); + + return values; +} + +function normalizeTomlScalar(rawValue) { + const trimmed = rawValue.trim(); + const quoted = trimmed.match(/^"([\s\S]*)"$/u); + if (quoted) { + return quoted[1].replace(/\\"/gu, '"'); + } + + const singleQuoted = trimmed.match(/^'([\s\S]*)'$/u); + if (singleQuoted) { + return singleQuoted[1]; + } + + return trimmed; +} + +function isSafeRelativePath(value) { + if (typeof value !== 'string' || value.trim().length === 0) { + return false; + } + + const normalized = value.replaceAll('\\', '/'); + if (path.isAbsolute(normalized)) { + return false; + } + + return !normalized.split('/').some((part) => part === '..' || part.length === 0); +} + +function checkExistingFile(values, key) { + const value = values.get(key); + if (value === undefined) { + return; + } + + if (!isSafeRelativePath(value)) { + add('error', `${key} must be a safe relative path`); + return; + } + + const resolved = path.resolve(root, value); + if (!resolved.startsWith(`${root}${path.sep}`) && resolved !== root) { + add('error', `${key} resolves outside the integration folder`); + return; + } + + if (!existsSync(resolved) || !statSync(resolved).isFile()) { + add('error', `${key} points to a missing file: ${value}`); + } +} + +function checkSettingsUi(values) { + const value = values.get('settings_ui'); + if (value === undefined) { + add('warn', 'No settings_ui configured; users will not get a custom settings panel.'); + return; + } + + if (!isSafeRelativePath(value)) { + add('error', 'settings_ui must be a safe relative path'); + return; + } + + const resolved = path.resolve(root, value); + if (!resolved.startsWith(`${root}${path.sep}`) && resolved !== root) { + add('error', 'settings_ui resolves outside the integration folder'); + return; + } + + if (!existsSync(resolved)) { + add('error', `settings_ui path does not exist: ${value}`); + return; + } + + const stats = statSync(resolved); + if (stats.isDirectory()) { + const indexPath = path.join(resolved, 'index.html'); + if (!existsSync(indexPath) || !statSync(indexPath).isFile()) { + add('error', `settings_ui directory must contain index.html: ${value}`); + } + return; + } + + if (!stats.isFile() || path.basename(resolved).toLowerCase() !== 'index.html') { + add('warn', 'settings_ui should usually point to an index.html file or directory.'); + } +} + +function checkFilesystemTree(directory = root) { + readdirSync(directory, { withFileTypes: true }).forEach((entry) => { + const entryPath = path.join(directory, entry.name); + const relativePath = path.relative(root, entryPath); + if (FORBIDDEN_ENTRIES.has(entry.name)) { + add('error', `Do not ship generated/runtime directory: ${relativePath}`); + return; + } + + if (entry.isSymbolicLink()) { + add('error', `Symlinks are not supported in integration imports: ${relativePath}`); + return; + } + + if (entry.isDirectory()) { + checkFilesystemTree(entryPath); + } + }); +} + +function checkManifest(values) { + REQUIRED_TOP_LEVEL_FIELDS.forEach((field) => { + if (!values.has(field)) { + add('error', `Missing required manifest field: ${field}`); + } + }); + + const id = values.get('id'); + if (typeof id === 'string' && !/^[A-Za-z0-9_-]+$/u.test(id)) { + add('error', 'id may contain only letters, numbers, "-" and "_"'); + } + + const runtimeKind = values.get('runtime.kind'); + if (!runtimeKind) { + add('error', 'Missing [runtime].kind'); + } else if (!VALID_RUNTIME_KINDS.has(runtimeKind)) { + add('error', `runtime.kind must be one of: ${Array.from(VALID_RUNTIME_KINDS).join(', ')}`); + } + + if (!values.has('runtime.entry')) { + add('error', 'Missing [runtime].entry'); + } else { + checkExistingFile(values, 'runtime.entry'); + } + + checkExistingFile(values, 'runtime.dependencies'); + checkSettingsUi(values); + + if (runtimeKind === 'binary' && !values.has('lifecycle.start.program')) { + add('error', 'binary integrations must define [lifecycle.start].program'); + } +} + +const source = readManifest(); +if (source !== null) { + const values = parseManifest(source); + checkManifest(values); + checkFilesystemTree(); +} + +const errors = findings.filter((finding) => finding.level === 'error'); +const warnings = findings.filter((finding) => finding.level === 'warn'); + +if (findings.length === 0) { + console.log(`[integration:doctor] ok ${root}`); +} else { + findings.forEach((finding) => { + console.log(`[integration:doctor] ${finding.level}: ${finding.message}`); + }); +} + +console.log(`[integration:doctor] ${errors.length} error(s), ${warnings.length} warning(s)`); + +process.exit(errors.length > 0 ? 1 : 0); diff --git a/.github/scripts/integration/scaffold.mjs b/.github/scripts/integration/scaffold.mjs new file mode 100644 index 00000000..b5268e6b --- /dev/null +++ b/.github/scripts/integration/scaffold.mjs @@ -0,0 +1,269 @@ +#!/usr/bin/env node + +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; + +const args = process.argv.slice(2); +const targetArg = args.find((arg) => !arg.startsWith('--')); + +if (!targetArg) { + console.error( + 'Usage: npm run integration:new -- [--id my-id] [--name "My Integration"]', + ); + process.exit(1); +} + +function optionValue(name, fallback) { + const index = args.indexOf(name); + if (index === -1 || index + 1 >= args.length) { + return fallback; + } + + return args[index + 1]; +} + +function slugFromName(value) { + return ( + value + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/gu, '-') + .replace(/^-+|-+$/gu, '') || 'my-integration' + ); +} + +const target = path.resolve(targetArg); +const defaultId = slugFromName(path.basename(target)); +const id = optionValue('--id', defaultId); +const name = optionValue('--name', id.replace(/[-_]+/gu, ' ')); + +if (existsSync(target)) { + console.error(`Target already exists: ${target}`); + process.exit(1); +} + +mkdirSync(path.join(target, 'src'), { recursive: true }); +mkdirSync(path.join(target, 'settings-ui'), { recursive: true }); + +writeFileSync( + path.join(target, 'axelate-module.toml'), + `api_version = "1" +id = "${id}" +name = "${name}" +version = "0.1.0" +description = "Connects ${name} to Axelate AI." +author = "Your Name" +type = "service" +icon = "⚙" +readme = "README.md" +settings_ui = "settings-ui/index.html" + +[runtime] +kind = "python" +version = "3.11" +entry = "src/main.py" +`, +); + +writeFileSync( + path.join(target, 'README.md'), + `# ${name} + +Axelate integration scaffold. + +## Run + +1. Import this folder in Axelate. +2. Open the integration settings and save a prompt. +3. Launch the integration card. + +Use \`npm run integration:doctor -- ${target}\` from the Axelate repository to validate the package. +`, +); + +writeFileSync( + path.join(target, 'src', 'main.py'), + `from __future__ import annotations + +import json +import os +import urllib.error +import urllib.request + + +BASE_URL = os.environ["AXELATE_HTTP_API_BASE"] +TOKEN = os.environ["AXELATE_HTTP_API_TOKEN"] +MODULE_ID = os.environ["AXELATE_MODULE_ID"] + + +def request(method: str, path: str, payload: dict | None = None) -> dict: + data = None if payload is None else json.dumps(payload).encode("utf-8") + req = urllib.request.Request( + f"{BASE_URL}{path}", + data=data, + method=method, + headers={ + "Authorization": f"Bearer {TOKEN}", + "Content-Type": "application/json", + }, + ) + with urllib.request.urlopen(req, timeout=120) as response: + return json.loads(response.read().decode("utf-8")) + + +def main() -> None: + settings = request("GET", f"/v1/modules/{MODULE_ID}/settings").get("settings", {}) + prompt = settings.get("prompt") or "Write a short status update." + request( + "POST", + f"/v1/modules/{MODULE_ID}/stage", + {"stage": "ai.request", "label": "Calling Axelate AI", "progress": 0.5}, + ) + result = request("POST", "/v1/ai/text", {"prompt": prompt, "sessionId": MODULE_ID}) + print(json.dumps(result, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + try: + main() + except urllib.error.HTTPError as error: + print(error.read().decode("utf-8")) + raise +`, +); + +writeFileSync( + path.join(target, 'settings-ui', 'axelate-settings-bridge.js'), + `const CHANNEL = "axelate:module-settings"; + +export class AxelateSettingsBridge { + constructor(target = window.parent) { + this.target = target; + this.pending = new Map(); + this.context = null; + this.settings = {}; + window.addEventListener("message", (event) => this.handleMessage(event)); + } + + ready() { + this.target.postMessage({ channel: CHANNEL, type: "module-ready" }, "*"); + } + + rendered() { + this.target.postMessage({ channel: CHANNEL, type: "module-rendered" }, "*"); + } + + waitForHost() { + return new Promise((resolve) => { + if (this.context !== null) { + resolve({ context: this.context, settings: this.settings }); + return; + } + + this.pending.set("host-ready", { resolve }); + }); + } + + saveSettings(settings) { + return this.request("saveSettings", settings).then((savedSettings) => { + this.settings = savedSettings; + return savedSettings; + }); + } + + request(method, payload) { + const requestId = crypto.randomUUID(); + this.target.postMessage({ channel: CHANNEL, requestId, method, payload }, "*"); + + return new Promise((resolve, reject) => { + this.pending.set(requestId, { resolve, reject }); + }); + } + + handleMessage(event) { + const payload = event.data; + if (payload?.channel !== CHANNEL) { + return; + } + + if (payload.type === "host-ready") { + this.context = payload.context; + this.settings = payload.settings ?? {}; + const waiter = this.pending.get("host-ready"); + if (waiter) { + this.pending.delete("host-ready"); + waiter.resolve({ context: this.context, settings: this.settings }); + } + return; + } + + if (typeof payload.requestId !== "string") { + return; + } + + const pending = this.pending.get(payload.requestId); + if (!pending) { + return; + } + + this.pending.delete(payload.requestId); + if (payload.ok) { + pending.resolve(payload.result); + } else { + pending.reject(new Error(payload.error ?? "Settings bridge request failed.")); + } + } +} +`, +); + +writeFileSync( + path.join(target, 'settings-ui', 'index.html'), + ` + + + + + ${name} Settings + + + + + + + + +`, +); + +console.log(`[integration:new] created ${target}`); +console.log(`[integration:new] validate with: npm run integration:doctor -- ${target}`); diff --git a/.github/scripts/workflow.mjs b/.github/scripts/workflow.mjs index 85d59671..c27bf28e 100644 --- a/.github/scripts/workflow.mjs +++ b/.github/scripts/workflow.mjs @@ -820,6 +820,8 @@ Tasks: doctor Check local development prerequisites setup Validate prerequisites, install frontend deps, and configure hooks install-deps Install frontend dependencies + integration:doctor Validate an Axelate integration folder + integration:new Scaffold a minimal Python integration folder update Update npm and cargo dependencies, then verify prepare Configure Git hooks check-size Print a frontend bundle size report @@ -948,6 +950,22 @@ Tasks: 'install-deps'() { run('npm', ['ci'], { cwd: srcDir }); }, + 'integration:doctor'() { + run( + 'node', + withPassthroughArgs([ + path.join(repoRoot, '.github', 'scripts', 'integration', 'doctor.mjs'), + ]), + ); + }, + 'integration:new'() { + run( + 'node', + withPassthroughArgs([ + path.join(repoRoot, '.github', 'scripts', 'integration', 'scaffold.mjs'), + ]), + ); + }, update() { run('npm', ['update'], { cwd: srcDir }); run('cargo', ['update'], { cwd: tauriDir }); diff --git a/.gitignore b/.gitignore index c1501db3..7887e78b 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,8 @@ /.history/ /.playwright-cli/ /build/ +__pycache__/ +*.py[cod] # Local app/runtime data /AxelateData/ diff --git a/docs/en/CUSTOM_INTEGRATIONS.md b/docs/en/CUSTOM_INTEGRATIONS.md index ca1c0e5f..cafbbcc9 100644 --- a/docs/en/CUSTOM_INTEGRATIONS.md +++ b/docs/en/CUSTOM_INTEGRATIONS.md @@ -4,6 +4,8 @@ Axelate integrations are folders with an `axelate-module.toml` manifest. The launcher can import a folder, a local archive, or a GitHub repository/archive URL. Archives may be `.zip`, `.tar.gz`, `.tgz`, or `.7z`. +For a guided development flow, use [Integration Development](INTEGRATION_DEVELOPMENT.md). + ## Minimal Layout ```text @@ -48,7 +50,7 @@ Rules: ## Launcher API -Launcher-managed integrations receive: +Launcher-managed script-runtime integrations receive: - `AXELATE_SDK_VERSION` - `AXELATE_HTTP_API_BASE` @@ -65,6 +67,14 @@ status. Store integration-owned runtime files under `AXELATE_MODULE_RUNTIME_DIR` and logs under `AXELATE_MODULE_LOG_DIR`; do not write generated files into the imported integration folder. +## Current Trust Limits + +Custom integrations are local code imported by the user. The current launcher can +validate the manifest, isolate settings/runtime/log folders, issue scoped local +API tokens, and remove imported files. It does not yet provide marketplace +signing, verified publisher identity, install-time permission review, or managed +remote execution. Treat manually imported integrations as code you chose to run. + ## Example Use [Axelate Telegram Parser](https://github.com/F0RLE/Axelate-telegram-parser) diff --git a/docs/en/INTEGRATION_DEVELOPMENT.md b/docs/en/INTEGRATION_DEVELOPMENT.md new file mode 100644 index 00000000..b1a5f86c --- /dev/null +++ b/docs/en/INTEGRATION_DEVELOPMENT.md @@ -0,0 +1,132 @@ +# Integration Development + +> Build a product integration that uses Axelate AI, settings, logs, and runtime +> folders without depending on launcher internals. + +## Fast Path + +Create a starter integration: + +```bash +npm run integration:new -- ./my-integration --id my-integration --name "My Integration" +npm run integration:doctor -- ./my-integration +``` + +Then import the folder in the launcher integrations screen and launch it. + +## Repository Helpers + +- `npm run integration:new -- ` creates a minimal Python integration. +- `npm run integration:doctor -- ` validates `axelate-module.toml`, + entry files, settings UI, dependency paths, and common generated folders that + should not be shipped. +- `docs/examples/integrations/python-ai-tool/` is the smallest working example. +- `docs/examples/sdk/python/axelate_sdk.py` and + `docs/examples/sdk/javascript/axelate-client.mjs` are small copyable + client helpers for the local HTTP API. +- `docs/examples/sdk/browser/axelate-settings-bridge.js` is a copyable + helper for custom settings UI iframe messaging. + +These helpers are developer tools. The runtime contract is still the local HTTP +API documented in [Launcher SDK](LAUNCHER_SDK.md). + +## Integration Layout + +```text +my-integration/ + axelate-module.toml + README.md + src/ + main.py + settings-ui/ + index.html +``` + +The manifest must declare a launcher-managed runtime: + +```toml +api_version = "1" +id = "my-integration" +name = "My Integration" +version = "0.1.0" +type = "service" +settings_ui = "settings-ui/index.html" + +[runtime] +kind = "python" +version = "3.11" +entry = "src/main.py" +``` + +Supported runtime kinds are `python`, `node`, `bun`, and `binary`. + +## Runtime Contract + +When Axelate launches a script-runtime integration it sets: + +- `AXELATE_SDK_VERSION` +- `AXELATE_HTTP_API_BASE` +- `AXELATE_HTTP_API_TOKEN` +- `AXELATE_MODULE_ID` +- `AXELATE_MODULE_DIR` +- `AXELATE_RUNTIME_DIR` +- `AXELATE_MODULE_RUNTIME_DIR` +- `AXELATE_MODULE_LOG_DIR` + +Use those values at process start. Do not hardcode ports or data paths. + +## Calling AI + +Minimal Python call: + +```python +from axelate_sdk import AxelateClient + +client = AxelateClient() +settings = client.settings() +reply = client.ai_text(settings.get("prompt", "Write a short status update.")) +print(reply) +``` + +Minimal JavaScript call: + +```js +import { AxelateClient } from './axelate-client.mjs'; + +const client = new AxelateClient(); +const settings = await client.settings(); +const reply = await client.aiText(settings.prompt ?? 'Write a short status update.'); +console.log(reply); +``` + +## Settings UI + +If `settings_ui` points to an HTML file or a directory with `index.html`, the +launcher opens it in a sandboxed settings host. + +The iframe protocol is: + +- post `{ channel: "axelate:module-settings", type: "module-ready" }` +- wait for `host-ready`, which includes `settings` and `context` +- post `module-rendered` when the UI is ready +- save settings with a message whose `method` is `saveSettings` + +Use `docs/examples/integrations/python-ai-tool/settings-ui/index.html` and +`docs/examples/integrations/python-ai-tool/settings-ui/axelate-settings-bridge.js` +as the current reference. + +## Development Loop + +1. Scaffold or copy the example. +2. Run `integration:doctor`. +3. Import the folder in Axelate. +4. Launch the card. +5. Check integration logs from the launcher console/logs UI. +6. Keep generated data in `AXELATE_MODULE_RUNTIME_DIR`. +7. Keep shipped source clean: no `.venv`, `node_modules`, caches, logs, or + downloaded runtimes. + +## Trust Rule + +Imported integrations are local code chosen by the user. They are not reviewed, +signed, or sandboxed packages yet. diff --git a/docs/en/LAUNCHER_SDK.md b/docs/en/LAUNCHER_SDK.md index 152e37df..52fa5ed4 100644 --- a/docs/en/LAUNCHER_SDK.md +++ b/docs/en/LAUNCHER_SDK.md @@ -1,16 +1,21 @@ # Launcher SDK -This guide describes the stable contract external integrations use to control -Axelate. The contract is language-neutral: every integration talks to the +This guide describes the current versioned contract external integrations use to +control Axelate. The contract is language-neutral: every integration talks to the launcher through a local HTTP API. Language SDKs can wrap this contract later, but the HTTP API is the source of truth. +For scaffolding, validation, and examples, start with +[Integration Development](INTEGRATION_DEVELOPMENT.md). + ## Runtime Contract Axelate starts a local API server on `127.0.0.1` when the launcher starts. The -server is available only on the local machine and requires a per-process token. +server is available only on the local machine and requires a launcher-issued +runtime token. -Launcher-managed integration processes receive these environment variables: +Launcher-managed script-runtime integration processes receive these environment +variables: - `AXELATE_SDK_VERSION`: local launcher integration API version, currently `1` - `AXELATE_HTTP_API_BASE`: local base URL, for example `http://127.0.0.1:3000` @@ -24,8 +29,9 @@ Launcher-managed integration processes receive these environment variables: - `AXELATE_MODULE_LOG_DIR`: writable log directory reserved for the integration - `AXELATE_MODULE_ID`: current integration id -External tools that are not launched by Axelate need the same two values from -the user or from their own launcher integration flow. +Standalone tools that are not launched by Axelate are not the primary public +contract yet. They should use a launcher-managed integration flow instead of +persisting or guessing local API credentials. Script integrations declare their runtime in `axelate-module.toml`. Legacy top-level `entry` and `dependencies` fields are not supported. @@ -68,8 +74,9 @@ Prefer `Authorization: Bearer ...` for new clients. Do not read or write Axelate's internal `module_settings.json` directly. - Write temporary files, caches, and generated state to `AXELATE_MODULE_RUNTIME_DIR`. Write logs to `AXELATE_MODULE_LOG_DIR`. -- If a request specifies an AI `provider`, the launcher updates the matching UI - card selection before running the request. +- If a request specifies an AI `provider`, the launcher validates and uses that + provider for the request only. It does not change the user's visual card + selection. Omit `provider` to use the active launcher selection. ## Quick Start @@ -168,8 +175,9 @@ Does not require authentication. Returns whether the local API server is alive. `GET /v1/modules` -Returns known integrations with launcher status, selected state, category, install -state, and metadata. +Returns installed integrations with launcher status, category, install state, and +metadata. A launcher-wide token can see all installed integrations. A +module-scoped token only sees the integration that received the token. `GET /v1/modules/{moduleId}/status` @@ -256,8 +264,8 @@ launcher chat. ```json { "prompt": "Summarize this message", - "sessionId": "sample-integration", - "provider": "openai", + "sessionId": "my-integration", + "provider": "gpt", "model": "gpt-5.5", "messages": [{ "role": "user", "content": "Optional chat history" }], "thinkingLevel": "medium", @@ -269,8 +277,8 @@ launcher chat. `provider` and `model` are optional. When omitted, the launcher uses the active `ai_text` module selection and its selected model. -When `provider` is provided, the launcher also updates the visual `ai_text` -selection card so the UI matches the integration request. +When `provider` is provided, the launcher validates that provider and runs this +request against it without changing the user's active `ai_text` selection. ### AI Image @@ -281,8 +289,8 @@ Runs image generation through the selected or requested image AI provider. ```json { "prompt": "Pixel art launcher icon", - "provider": "openai", - "model": "image-model-id", + "provider": "gpt-image", + "model": "openai/gpt-5-image", "width": 1024, "height": 1024, "steps": 30 @@ -292,5 +300,5 @@ Runs image generation through the selected or requested image AI provider. `provider` and `model` are optional. When omitted, the launcher uses the active `ai_image` module selection and its selected model. -When `provider` is provided, the launcher also updates the visual `ai_image` -selection card so the UI matches the integration request. +When `provider` is provided, the launcher validates that provider and runs this +request against it without changing the user's active `ai_image` selection. diff --git a/docs/examples/integrations/python-ai-tool/README.md b/docs/examples/integrations/python-ai-tool/README.md new file mode 100644 index 00000000..ab475446 --- /dev/null +++ b/docs/examples/integrations/python-ai-tool/README.md @@ -0,0 +1,22 @@ +# Python AI Tool + +Minimal Axelate integration example. + +It demonstrates: + +- `axelate-module.toml` +- launcher-provided environment variables +- `/v1/modules/{moduleId}/settings` +- `/v1/modules/{moduleId}/stage` +- `/v1/ai/text` +- custom settings UI + +## Try It + +From the Axelate repository: + +```bash +npm run integration:doctor -- docs/examples/integrations/python-ai-tool +``` + +Then import this folder in the launcher integrations screen and launch it. diff --git a/docs/examples/integrations/python-ai-tool/axelate-module.toml b/docs/examples/integrations/python-ai-tool/axelate-module.toml new file mode 100644 index 00000000..44d5e448 --- /dev/null +++ b/docs/examples/integrations/python-ai-tool/axelate-module.toml @@ -0,0 +1,15 @@ +api_version = "1" +id = "python-ai-tool" +name = "Python AI Tool" +version = "0.1.0" +description = "Example integration that calls Axelate AI and stores settings." +author = "Axelate" +type = "service" +icon = "⚙" +readme = "README.md" +settings_ui = "settings-ui/index.html" + +[runtime] +kind = "python" +version = "3.11" +entry = "src/main.py" diff --git a/docs/examples/integrations/python-ai-tool/settings-ui/axelate-settings-bridge.js b/docs/examples/integrations/python-ai-tool/settings-ui/axelate-settings-bridge.js new file mode 100644 index 00000000..47bbf1e0 --- /dev/null +++ b/docs/examples/integrations/python-ai-tool/settings-ui/axelate-settings-bridge.js @@ -0,0 +1,80 @@ +const CHANNEL = 'axelate:module-settings'; + +export class AxelateSettingsBridge { + constructor(target = window.parent) { + this.target = target; + this.pending = new Map(); + this.context = null; + this.settings = {}; + window.addEventListener('message', (event) => this.handleMessage(event)); + } + + ready() { + this.target.postMessage({ channel: CHANNEL, type: 'module-ready' }, '*'); + } + + rendered() { + this.target.postMessage({ channel: CHANNEL, type: 'module-rendered' }, '*'); + } + + waitForHost() { + return new Promise((resolve) => { + if (this.context !== null) { + resolve({ context: this.context, settings: this.settings }); + return; + } + + this.pending.set('host-ready', { resolve }); + }); + } + + saveSettings(settings) { + return this.request('saveSettings', settings).then((savedSettings) => { + this.settings = savedSettings; + return savedSettings; + }); + } + + request(method, payload) { + const requestId = crypto.randomUUID(); + this.target.postMessage({ channel: CHANNEL, requestId, method, payload }, '*'); + + return new Promise((resolve, reject) => { + this.pending.set(requestId, { resolve, reject }); + }); + } + + handleMessage(event) { + const payload = event.data; + if (payload?.channel !== CHANNEL) { + return; + } + + if (payload.type === 'host-ready') { + this.context = payload.context; + this.settings = payload.settings ?? {}; + const waiter = this.pending.get('host-ready'); + if (waiter) { + this.pending.delete('host-ready'); + waiter.resolve({ context: this.context, settings: this.settings }); + } + return; + } + + if (typeof payload.requestId !== 'string') { + return; + } + + const pending = this.pending.get(payload.requestId); + if (!pending) { + return; + } + + this.pending.delete(payload.requestId); + if (payload.ok) { + pending.resolve(payload.result); + } else { + pending.reject(new Error(payload.error ?? 'Settings bridge request failed.')); + } + } +} diff --git a/docs/examples/integrations/python-ai-tool/settings-ui/index.html b/docs/examples/integrations/python-ai-tool/settings-ui/index.html new file mode 100644 index 00000000..82e8dbfa --- /dev/null +++ b/docs/examples/integrations/python-ai-tool/settings-ui/index.html @@ -0,0 +1,75 @@ + + + + + + Python AI Tool Settings + + + + + + + + diff --git a/docs/examples/integrations/python-ai-tool/src/main.py b/docs/examples/integrations/python-ai-tool/src/main.py new file mode 100644 index 00000000..aded972c --- /dev/null +++ b/docs/examples/integrations/python-ai-tool/src/main.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import json +import os +import urllib.error +import urllib.request + + +BASE_URL = os.environ["AXELATE_HTTP_API_BASE"] +TOKEN = os.environ["AXELATE_HTTP_API_TOKEN"] +MODULE_ID = os.environ["AXELATE_MODULE_ID"] + + +def request(method: str, path: str, payload: dict | None = None) -> dict: + data = None if payload is None else json.dumps(payload).encode("utf-8") + req = urllib.request.Request( + f"{BASE_URL}{path}", + data=data, + method=method, + headers={ + "Authorization": f"Bearer {TOKEN}", + "Content-Type": "application/json", + }, + ) + with urllib.request.urlopen(req, timeout=120) as response: + return json.loads(response.read().decode("utf-8")) + + +def main() -> None: + settings = request("GET", f"/v1/modules/{MODULE_ID}/settings").get("settings", {}) + prompt = settings.get("prompt") or "Write a short status update." + + request( + "POST", + f"/v1/modules/{MODULE_ID}/stage", + {"stage": "ai.request", "label": "Calling Axelate AI", "progress": 0.5}, + ) + + result = request( + "POST", + "/v1/ai/text", + { + "prompt": prompt, + "sessionId": MODULE_ID, + }, + ) + + print(json.dumps(result, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + try: + main() + except urllib.error.HTTPError as error: + print(error.read().decode("utf-8")) + raise diff --git a/docs/examples/sdk/browser/axelate-settings-bridge.js b/docs/examples/sdk/browser/axelate-settings-bridge.js new file mode 100644 index 00000000..bd2b2dd6 --- /dev/null +++ b/docs/examples/sdk/browser/axelate-settings-bridge.js @@ -0,0 +1,99 @@ +const CHANNEL = 'axelate:module-settings'; + +export class AxelateSettingsBridge { + constructor(target = window.parent) { + this.target = target; + this.pending = new Map(); + this.context = null; + this.settings = {}; + window.addEventListener('message', (event) => this.handleMessage(event)); + } + + ready() { + this.target.postMessage({ channel: CHANNEL, type: 'module-ready' }, '*'); + } + + rendered() { + this.target.postMessage({ channel: CHANNEL, type: 'module-rendered' }, '*'); + } + + waitForHost() { + return new Promise((resolve) => { + if (this.context !== null) { + resolve({ context: this.context, settings: this.settings }); + return; + } + + this.pending.set('host-ready', { resolve }); + }); + } + + getSettings() { + return this.request('getSettings'); + } + + saveSettings(settings) { + return this.request('saveSettings', settings).then((savedSettings) => { + this.settings = savedSettings; + return savedSettings; + }); + } + + notify(payload) { + return this.request('notify', payload); + } + + request(method, payload) { + const requestId = crypto.randomUUID(); + this.target.postMessage( + { + channel: CHANNEL, + requestId, + method, + payload, + }, + '*', + ); + + return new Promise((resolve, reject) => { + this.pending.set(requestId, { resolve, reject }); + }); + } + + handleMessage(event) { + const payload = event.data; + if (payload?.channel !== CHANNEL) { + return; + } + + if (payload.type === 'host-ready') { + this.context = payload.context; + this.settings = payload.settings ?? {}; + const waiter = this.pending.get('host-ready'); + if (waiter) { + this.pending.delete('host-ready'); + waiter.resolve({ + context: this.context, + settings: this.settings, + }); + } + return; + } + + if (typeof payload.requestId !== 'string') { + return; + } + + const pending = this.pending.get(payload.requestId); + if (!pending) { + return; + } + + this.pending.delete(payload.requestId); + if (payload.ok) { + pending.resolve(payload.result); + } else { + pending.reject(new Error(payload.error ?? 'Settings bridge request failed.')); + } + } +} diff --git a/docs/examples/sdk/javascript/axelate-client.mjs b/docs/examples/sdk/javascript/axelate-client.mjs new file mode 100644 index 00000000..2ef21ee7 --- /dev/null +++ b/docs/examples/sdk/javascript/axelate-client.mjs @@ -0,0 +1,55 @@ +export class AxelateClient { + constructor(env = globalThis.process?.env ?? {}) { + this.baseUrl = String(env.AXELATE_HTTP_API_BASE ?? '').replace(/\/$/u, ''); + this.token = String(env.AXELATE_HTTP_API_TOKEN ?? ''); + this.moduleId = String(env.AXELATE_MODULE_ID ?? ''); + + if (!this.baseUrl || !this.token || !this.moduleId) { + throw new Error('Axelate integration environment is missing.'); + } + } + + async request(method, path, payload) { + const response = await fetch(`${this.baseUrl}${path}`, { + method, + headers: { + Authorization: `Bearer ${this.token}`, + 'Content-Type': 'application/json', + }, + body: payload === undefined ? undefined : JSON.stringify(payload), + }); + + const body = await response.json(); + if (!response.ok) { + throw new Error(body.error ?? `Axelate request failed: ${response.status}`); + } + + return body; + } + + settings() { + return this.request('GET', `/v1/modules/${this.moduleId}/settings`).then( + (body) => body.settings ?? {}, + ); + } + + saveSettings(settings) { + return this.request('PUT', `/v1/modules/${this.moduleId}/settings`, settings); + } + + stage(stage, label, progress) { + const payload = { stage, label }; + if (progress !== undefined) { + payload.progress = progress; + } + return this.request('POST', `/v1/modules/${this.moduleId}/stage`, payload); + } + + aiText(prompt, options = {}) { + return this.request('POST', '/v1/ai/text', { + prompt, + sessionId: this.moduleId, + ...options, + }); + } +} diff --git a/docs/examples/sdk/python/axelate_sdk.py b/docs/examples/sdk/python/axelate_sdk.py new file mode 100644 index 00000000..7f768f26 --- /dev/null +++ b/docs/examples/sdk/python/axelate_sdk.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import json +import os +import urllib.request +from typing import Any + + +class AxelateClient: + def __init__(self) -> None: + self.base_url = os.environ["AXELATE_HTTP_API_BASE"].rstrip("/") + self.token = os.environ["AXELATE_HTTP_API_TOKEN"] + self.module_id = os.environ["AXELATE_MODULE_ID"] + + def request(self, method: str, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: + data = None if payload is None else json.dumps(payload).encode("utf-8") + request = urllib.request.Request( + f"{self.base_url}{path}", + data=data, + method=method, + headers={ + "Authorization": f"Bearer {self.token}", + "Content-Type": "application/json", + }, + ) + with urllib.request.urlopen(request, timeout=120) as response: + return json.loads(response.read().decode("utf-8")) + + def settings(self) -> dict[str, Any]: + return self.request("GET", f"/v1/modules/{self.module_id}/settings").get("settings", {}) + + def save_settings(self, settings: dict[str, Any]) -> dict[str, Any]: + return self.request("PUT", f"/v1/modules/{self.module_id}/settings", settings) + + def stage(self, stage: str, label: str, progress: float | None = None) -> dict[str, Any]: + payload: dict[str, Any] = {"stage": stage, "label": label} + if progress is not None: + payload["progress"] = progress + return self.request("POST", f"/v1/modules/{self.module_id}/stage", payload) + + def ai_text(self, prompt: str, **options: Any) -> dict[str, Any]: + payload = {"prompt": prompt, "sessionId": self.module_id, **options} + return self.request("POST", "/v1/ai/text", payload) diff --git a/docs/ru/INTEGRATION_DEVELOPMENT.md b/docs/ru/INTEGRATION_DEVELOPMENT.md new file mode 100644 index 00000000..e5d43bb0 --- /dev/null +++ b/docs/ru/INTEGRATION_DEVELOPMENT.md @@ -0,0 +1,132 @@ +# Разработка интеграций + +> Как подключить свой продукт к Axelate, использовать AI лаунчера, настройки, +> логи и runtime-папки без доступа к внутренним файлам приложения. + +## Быстрый старт + +Создать шаблон интеграции: + +```bash +npm run integration:new -- ./my-integration --id my-integration --name "My Integration" +npm run integration:doctor -- ./my-integration +``` + +После этого импортируй папку на странице интеграций в лаунчере и запусти +карточку. + +## Инструменты в репозитории + +- `npm run integration:new -- ` создает минимальную Python-интеграцию. +- `npm run integration:doctor -- ` проверяет `axelate-module.toml`, + entry-файлы, settings UI, dependency paths и типичные сгенерированные папки, + которые нельзя поставлять. +- `docs/examples/integrations/python-ai-tool/` - минимальный рабочий пример. +- `docs/examples/sdk/python/axelate_sdk.py` и + `docs/examples/sdk/javascript/axelate-client.mjs` - маленькие helper + клиенты, которые можно скопировать в свой проект. +- `docs/examples/sdk/browser/axelate-settings-bridge.js` - helper для + iframe-протокола custom settings UI. + +Главный контракт все равно описан в [Launcher SDK](../en/LAUNCHER_SDK.md): это +локальный HTTP API лаунчера. + +## Структура интеграции + +```text +my-integration/ + axelate-module.toml + README.md + src/ + main.py + settings-ui/ + index.html +``` + +Минимальный manifest: + +```toml +api_version = "1" +id = "my-integration" +name = "My Integration" +version = "0.1.0" +type = "service" +settings_ui = "settings-ui/index.html" + +[runtime] +kind = "python" +version = "3.11" +entry = "src/main.py" +``` + +Поддерживаемые runtime: `python`, `node`, `bun`, `binary`. + +## Runtime-контракт + +Когда Axelate запускает script-runtime интеграцию, он передает: + +- `AXELATE_SDK_VERSION` +- `AXELATE_HTTP_API_BASE` +- `AXELATE_HTTP_API_TOKEN` +- `AXELATE_MODULE_ID` +- `AXELATE_MODULE_DIR` +- `AXELATE_RUNTIME_DIR` +- `AXELATE_MODULE_RUNTIME_DIR` +- `AXELATE_MODULE_LOG_DIR` + +Используй эти значения при старте процесса. Не хардкодь порт и пути. + +## Вызов AI + +Python: + +```python +from axelate_sdk import AxelateClient + +client = AxelateClient() +settings = client.settings() +reply = client.ai_text(settings.get("prompt", "Write a short status update.")) +print(reply) +``` + +JavaScript: + +```js +import { AxelateClient } from './axelate-client.mjs'; + +const client = new AxelateClient(); +const settings = await client.settings(); +const reply = await client.aiText(settings.prompt ?? 'Write a short status update.'); +console.log(reply); +``` + +## Settings UI + +Если `settings_ui` указывает на HTML-файл или папку с `index.html`, лаунчер +открывает его в sandboxed host. + +Протокол iframe: + +- отправить `{ channel: "axelate:module-settings", type: "module-ready" }` +- дождаться `host-ready`, где есть `settings` и `context` +- отправить `module-rendered`, когда интерфейс готов +- сохранить настройки сообщением с `method: "saveSettings"` + +Текущий пример: +`docs/examples/integrations/python-ai-tool/settings-ui/index.html` и +`docs/examples/integrations/python-ai-tool/settings-ui/axelate-settings-bridge.js`. + +## Цикл разработки + +1. Создай шаблон или скопируй пример. +2. Запусти `integration:doctor`. +3. Импортируй папку в Axelate. +4. Запусти карточку. +5. Смотри логи интеграции в лаунчере. +6. Runtime-файлы пиши в `AXELATE_MODULE_RUNTIME_DIR`. +7. Не поставляй `.venv`, `node_modules`, caches, logs и скачанные runtime. + +## Правило доверия + +Импортированные интеграции - это локальный код, который пользователь сам решил +запустить. Сейчас это не reviewed, signed или sandboxed packages. diff --git a/docs/zh/INTEGRATION_DEVELOPMENT.md b/docs/zh/INTEGRATION_DEVELOPMENT.md new file mode 100644 index 00000000..1719b2ff --- /dev/null +++ b/docs/zh/INTEGRATION_DEVELOPMENT.md @@ -0,0 +1,128 @@ +# 集成开发 + +> 将你的产品接入 Axelate,并使用启动器提供的 AI、设置、日志和运行时目录, +> 而不是依赖应用内部文件。 + +## 快速开始 + +创建一个集成模板: + +```bash +npm run integration:new -- ./my-integration --id my-integration --name "My Integration" +npm run integration:doctor -- ./my-integration +``` + +然后在 Axelate 的 Integrations 页面导入这个文件夹并启动卡片。 + +## 仓库工具 + +- `npm run integration:new -- ` 创建一个最小 Python 集成。 +- `npm run integration:doctor -- ` 检查 `axelate-module.toml`、入口文件、 + settings UI、依赖路径,以及不应该随包发布的生成目录。 +- `docs/examples/integrations/python-ai-tool/` 是最小可运行示例。 +- `docs/examples/sdk/python/axelate_sdk.py` 和 + `docs/examples/sdk/javascript/axelate-client.mjs` 是可复制的小型客户端 helper。 +- `docs/examples/sdk/browser/axelate-settings-bridge.js` 是 custom settings + UI iframe 消息协议的可复制 helper。 + +真正的运行时契约仍然是 [Launcher SDK](../en/LAUNCHER_SDK.md) 中描述的本地 +HTTP API。 + +## 集成结构 + +```text +my-integration/ + axelate-module.toml + README.md + src/ + main.py + settings-ui/ + index.html +``` + +最小 manifest: + +```toml +api_version = "1" +id = "my-integration" +name = "My Integration" +version = "0.1.0" +type = "service" +settings_ui = "settings-ui/index.html" + +[runtime] +kind = "python" +version = "3.11" +entry = "src/main.py" +``` + +支持的 runtime: `python`, `node`, `bun`, `binary`。 + +## 运行时契约 + +Axelate 启动 script-runtime 集成时会设置: + +- `AXELATE_SDK_VERSION` +- `AXELATE_HTTP_API_BASE` +- `AXELATE_HTTP_API_TOKEN` +- `AXELATE_MODULE_ID` +- `AXELATE_MODULE_DIR` +- `AXELATE_RUNTIME_DIR` +- `AXELATE_MODULE_RUNTIME_DIR` +- `AXELATE_MODULE_LOG_DIR` + +在进程启动时读取这些值。不要硬编码端口或数据路径。 + +## 调用 AI + +Python: + +```python +from axelate_sdk import AxelateClient + +client = AxelateClient() +settings = client.settings() +reply = client.ai_text(settings.get("prompt", "Write a short status update.")) +print(reply) +``` + +JavaScript: + +```js +import { AxelateClient } from './axelate-client.mjs'; + +const client = new AxelateClient(); +const settings = await client.settings(); +const reply = await client.aiText(settings.prompt ?? 'Write a short status update.'); +console.log(reply); +``` + +## Settings UI + +如果 `settings_ui` 指向 HTML 文件,或指向包含 `index.html` 的目录,启动器会在 +sandboxed settings host 中打开它。 + +iframe 协议: + +- 发送 `{ channel: "axelate:module-settings", type: "module-ready" }` +- 等待包含 `settings` 和 `context` 的 `host-ready` +- UI 准备完成后发送 `module-rendered` +- 使用 `method: "saveSettings"` 的消息保存设置 + +当前参考示例:`docs/examples/integrations/python-ai-tool/settings-ui/index.html` 和 +`docs/examples/integrations/python-ai-tool/settings-ui/axelate-settings-bridge.js`。 + +## 开发循环 + +1. 创建模板或复制示例。 +2. 运行 `integration:doctor`。 +3. 在 Axelate 中导入文件夹。 +4. 启动卡片。 +5. 在启动器的日志界面查看集成日志。 +6. 将运行时文件写入 `AXELATE_MODULE_RUNTIME_DIR`。 +7. 不要发布 `.venv`, `node_modules`, caches, logs 或下载的 runtime。 + +## 信任规则 + +导入的集成是用户选择运行的本地代码。目前它们不是经过 review、签名或 sandbox +隔离的 packages。 diff --git a/package.json b/package.json index e13352fd..712d735e 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,8 @@ "setup": "node .github/scripts/workflow.mjs setup", "verify": "node .github/scripts/workflow.mjs verify", "install-deps": "node .github/scripts/workflow.mjs install-deps", + "integration:doctor": "node .github/scripts/workflow.mjs integration:doctor", + "integration:new": "node .github/scripts/workflow.mjs integration:new", "update": "node .github/scripts/workflow.mjs update", "prepare": "node .github/scripts/workflow.mjs prepare", "check-size": "node .github/scripts/workflow.mjs check-size" diff --git a/src-tauri/src/domain/integration_api.rs b/src-tauri/src/domain/integration_api.rs index 0cd5dfa2..2d5c4523 100644 --- a/src-tauri/src/domain/integration_api.rs +++ b/src-tauri/src/domain/integration_api.rs @@ -79,10 +79,19 @@ pub fn api_token() -> &'static str { /// Adds local launcher API environment variables to a module process. pub fn apply_process_env(command: &mut tokio::process::Command, module_id: &str) { + let module_dir = crate::domain::modules::downloader::get_module_path(module_id); + let module_runtime_dir = crate::domain::modules::paths::runtime_root(module_id); + let module_log_dir = crate::domain::modules::paths::log_dir(module_id); + command .env("AXELATE_HTTP_API_BASE", api_base_url()) .env("AXELATE_HTTP_API_TOKEN", issue_module_api_token(module_id)) - .env("AXELATE_SDK_VERSION", SDK_API_VERSION); + .env("AXELATE_SDK_VERSION", SDK_API_VERSION) + .env("AXELATE_MODULE_ID", module_id) + .env("AXELATE_MODULE_DIR", module_dir) + .env("AXELATE_RUNTIME_DIR", &*crate::utils::paths::RUNTIME_DIR) + .env("AXELATE_MODULE_RUNTIME_DIR", module_runtime_dir) + .env("AXELATE_MODULE_LOG_DIR", module_log_dir); } fn issue_module_api_token(module_id: &str) -> String { @@ -1651,6 +1660,39 @@ mod tests { ); } + #[test] + fn apply_process_env_sets_documented_integration_contract() { + let module_id = "sample"; + let mut command = tokio::process::Command::new("sample-command"); + super::apply_process_env(&mut command, module_id); + + let envs = command + .as_std() + .get_envs() + .filter_map(|(key, value)| { + Some(( + key.to_string_lossy().to_string(), + value?.to_string_lossy().to_string(), + )) + }) + .collect::>(); + + assert_eq!( + envs.get("AXELATE_SDK_VERSION").map(String::as_str), + Some("1") + ); + assert_eq!( + envs.get("AXELATE_MODULE_ID").map(String::as_str), + Some(module_id) + ); + assert!(envs.contains_key("AXELATE_HTTP_API_BASE")); + assert!(envs.contains_key("AXELATE_HTTP_API_TOKEN")); + assert!(envs.contains_key("AXELATE_MODULE_DIR")); + assert!(envs.contains_key("AXELATE_RUNTIME_DIR")); + assert!(envs.contains_key("AXELATE_MODULE_RUNTIME_DIR")); + assert!(envs.contains_key("AXELATE_MODULE_LOG_DIR")); + } + #[test] fn ranks_model_tiers_for_default_selection() { assert!(tier_rank(&ModelTier::Strong) > tier_rank(&ModelTier::Medium)); From 091fb368f7cd76ce1180f6e84076d5629a1b5ddf Mon Sep 17 00:00:00 2001 From: F0RLE Date: Wed, 6 May 2026 14:21:38 +0300 Subject: [PATCH 096/126] docs: refresh project roadmap and guides --- README.md | 4 + docs/en/ARCHITECTURE.md | 109 +++++++++ docs/en/CURRENT_STATE.md | 49 ++-- docs/en/DEVELOPMENT_WORKFLOW.md | 2 + docs/en/GETTING_STARTED.md | 4 +- docs/en/RELEASES.md | 4 +- docs/en/ROADMAP.md | 418 ++++++++++++++++++++++++++------ docs/en/USER_GUIDE.md | 97 ++++++++ docs/en/VISION.md | 373 ++++++++-------------------- 9 files changed, 697 insertions(+), 363 deletions(-) create mode 100644 docs/en/ARCHITECTURE.md create mode 100644 docs/en/USER_GUIDE.md diff --git a/README.md b/README.md index aaa5e3c1..42230307 100644 --- a/README.md +++ b/README.md @@ -100,8 +100,11 @@ For the full release checklist, see [Releases](docs/en/RELEASES.md). Start here: +- [User Guide](docs/en/USER_GUIDE.md) - [Getting Started](docs/en/GETTING_STARTED.md) - [Development Workflow](docs/en/DEVELOPMENT_WORKFLOW.md) +- [Architecture](docs/en/ARCHITECTURE.md) +- [Integration Development](docs/en/INTEGRATION_DEVELOPMENT.md) - [Releases](docs/en/RELEASES.md) - [Current State](docs/en/CURRENT_STATE.md) - [Contributing](CONTRIBUTING.md) @@ -109,6 +112,7 @@ Start here: Current reference: - [Trust Model](docs/en/TRUST_MODEL.md) +- Integration Development: [RU](docs/ru/INTEGRATION_DEVELOPMENT.md) · [ZH](docs/zh/INTEGRATION_DEVELOPMENT.md) Planning only: diff --git a/docs/en/ARCHITECTURE.md b/docs/en/ARCHITECTURE.md new file mode 100644 index 00000000..929970f7 --- /dev/null +++ b/docs/en/ARCHITECTURE.md @@ -0,0 +1,109 @@ +# Axelate Architecture + +> Practical map of the current repository. Use this before changing backend +> contracts, integrations, runtime lifecycle, or cross-platform behavior. + +## Runtime Shape + +Axelate is a Tauri 2 desktop app: + +- `src/` is the TypeScript frontend. +- `src-tauri/` is the Rust backend and Tauri host. +- Rust commands are exported to TypeScript through Specta bindings in + `src/shared/types/bindings.ts`. +- Runtime assets, built-in module manifests, and locales live under + `src-tauri/resources/`. + +The frontend should render state and orchestrate user flow. The backend should +own domain rules, filesystem access, secrets, process lifecycle, downloads, and +persisted state. + +## Frontend + +Important frontend areas: + +- `src/app/`: application bootstrap, shell wiring, and top-level events. +- `src/features/`: user-facing feature modules such as chat, AI catalog, + downloads, console, monitoring, settings, and the home overview placeholder. +- `src/infrastructure/`: adapters for i18n, logging, navigation, and Tauri IPC. +- `src/shared/`: shared API wrappers, shell helpers, UI utilities, config, and + generated backend types. + +Rules for frontend changes: + +- Keep user-facing text in `src-tauri/resources/locales/`. +- Call backend commands through the existing Tauri provider and generated + bindings where available. +- Do not store provider secrets in frontend-owned state. +- Surface provider, engine, module, and download errors through notifications or + status UI, not as assistant chat messages. + +## Backend + +Important backend areas: + +- `src-tauri/src/api/`: Tauri commands and frontend-facing request/response + boundaries. +- `src-tauri/src/domain/`: AI, engine, module, integration API, monitoring, and + system domain logic. +- `src-tauri/src/infrastructure/`: config, filesystem, crypto, logging, + persistence, and platform adapters. +- `src-tauri/src/models/`: shared data structures exported to the frontend. +- `src-tauri/src/app/`: window, tray, startup, and application lifecycle glue. + +Rules for backend changes: + +- Keep frontend-facing contracts stable and typed. +- Regenerate bindings after changing exported commands or types. +- Keep OS-specific behavior behind platform adapters or `cfg(...)` gates. +- Prefer typed errors over string-only failures. +- Avoid adding product gates or activation logic unless the real backend system + exists. + +## Integration Runtime + +Local integrations are installed modules that can be imported from folders, +archives, repositories, or trusted URLs. The launcher owns: + +- install/import flow +- module manifest discovery +- start, stop, status, and cleanup requests +- settings-session tokens for module-owned settings UIs +- local integration API tokens for script-runtime integrations + +Current local integrations are code the user chose to run. They are not the same +as reviewed or signed packages yet. Permission prompts, signing, verified +publisher state, and remote managed execution are future layers documented in +the roadmap and trust model. + +## Contracts + +Use this sequence when changing a frontend-visible backend contract: + +1. Update Rust command/type definitions. +2. Run `npm --prefix src run bindings:sync`. +3. Update TypeScript callers. +4. Run `npm --prefix src run typecheck`. + +If bindings are out of date, `npm --prefix src run bindings:check` should fail. + +## Cross-Platform Rule + +The app is Windows-first today, but new architecture should keep Linux and macOS +viable: + +- avoid hardcoded path separators and drive-letter assumptions +- avoid `.exe` assumptions outside platform-specific code +- model GitHub release parsing by OS, architecture, accelerator, and archive + format +- degrade unavailable platform features in the UI instead of failing late + +## Related Docs + +- [Getting Started](GETTING_STARTED.md) +- [User Guide](USER_GUIDE.md) +- [Development Workflow](DEVELOPMENT_WORKFLOW.md) +- [Launcher SDK](LAUNCHER_SDK.md) +- [Integration Development](INTEGRATION_DEVELOPMENT.md) +- [Custom Integrations](CUSTOM_INTEGRATIONS.md) +- [Trust Model](TRUST_MODEL.md) diff --git a/docs/en/CURRENT_STATE.md b/docs/en/CURRENT_STATE.md index d2be2518..343c730c 100644 --- a/docs/en/CURRENT_STATE.md +++ b/docs/en/CURRENT_STATE.md @@ -1,6 +1,6 @@ # Axelate Current State -> Repository-grounded snapshot as of 2026-04-29. +> Repository-grounded snapshot as of 2026-05-06. > This document describes what exists now, not what the future product aspires to become. For setup and contributor workflow, use [Getting Started](GETTING_STARTED.md) and [Development Workflow](DEVELOPMENT_WORKFLOW.md). @@ -20,11 +20,11 @@ Today the repository is closest to: Today the repository is not yet: -- a real creator marketplace +- a reviewed package distribution layer - a full package distribution platform - a managed runtime platform - a mature MCP-first workstation -- a finished public product with a stable long-term business model +- a finished public product with stable distribution and operations ## Current Stack @@ -68,10 +68,9 @@ Current frontend feature areas: - `chat/` - `console/` - `downloads/` -- `home-overview/` - `monitoring/` - `settings/` -- shared shell and app composition layers +- shared shell, templates, and app composition layers ## Current User-Facing Surfaces @@ -83,13 +82,13 @@ The repository clearly contains code for these surfaces: - downloads - console logs - monitoring -- home overview placeholder +- home page placeholder in the shared shell/templates - shared shell, sidebar, window, modal flow Important nuance: - the shell has carried marketplace ambitions and related wording -- the current frontend feature set is still centered on workstation behavior, not marketplace commerce +- the current frontend feature set is still centered on workstation behavior, not public package distribution - the home overview is still a placeholder surface, not a finished dashboard ## Current AI Layer @@ -173,11 +172,20 @@ Important current interpretation: - it is not a stable cornerstone of the current product definition - it should be treated as placeholder or future integration, not as core value today -### 4. `sample-integration` +### 4. Imported custom integrations -- type: script +- source: user-imported folder, archive, or supported GitHub URL +- manifest: `axelate-module.toml` +- runtime: `python`, `node`, `bun`, or `binary` - role: external workflow integration +Important current interpretation: + +- there is no bundled `sample-integration` entry in the current resource catalog +- imported integrations are discovered from the user's integrations directory +- imported integrations are local code chosen by the user, not reviewed + marketplace packages + ### Confirmed Runtime Responsibilities The backend currently handles real runtime concerns: @@ -206,7 +214,8 @@ Current limitation: - the repository now moves to `Apache-2.0` for the desktop open core - the legal and packaging split between open core and closed platform is still incomplete -- the commercial backend, signing, billing, entitlements, and managed execution layers are not separated in this repository yet +- package signing, ownership sync, verified distribution, and managed execution + layers are not separated in this repository yet ## Current Strengths @@ -251,24 +260,23 @@ OpenRouter is a strong accelerator for the current stage, but it also means: The repository still contains surfaces or ideas that are ahead of the stable product: -- marketplace ambitions without actual commerce backend - home overview placeholder - ComfyUI presence without product-ready positioning - legacy wording and shell assumptions carried from earlier product framing -### 4. Incomplete Commercial Foundation +### 4. Incomplete Package Trust Foundation What does not exist yet as a finished system: - package signing service -- entitlement service -- billing -- payouts -- creator onboarding +- verified package distribution - managed runtime orchestration - trust and review pipeline for third-party packages +- permission prompts for package capabilities +- signed update and rollback flow -Without these, Axelate cannot honestly claim to be a creator marketplace today. +Without these, Axelate cannot honestly claim to be a trusted package platform +today. ### 5. Incomplete Trust Story On The Surface @@ -295,7 +303,8 @@ Based on the repository and current capabilities, Axelate should currently be de - weaker than a finished platform business It already has enough substance to become a serious product if scope stays narrow. -It does not yet have enough commercial infrastructure to expand safely into a public creator marketplace. +It does not yet have enough trust infrastructure to expand safely into public +package distribution. ## What The Project Should Mean Right Now @@ -307,7 +316,7 @@ That is the current truth. The project should not yet describe itself as: -- a mature creator marketplace +- a mature package distribution platform - a fully open ecosystem - a trusted managed execution platform - a finished MCP operating layer @@ -347,6 +356,6 @@ The correct interpretation is: - keep building the workstation core - remove identity confusion -- treat package commerce as phase two +- treat public package distribution as phase two - treat managed execution as phase three - do not reopen scope until the desktop core is reliable and coherent diff --git a/docs/en/DEVELOPMENT_WORKFLOW.md b/docs/en/DEVELOPMENT_WORKFLOW.md index eeb3dc6b..4e305cf9 100644 --- a/docs/en/DEVELOPMENT_WORKFLOW.md +++ b/docs/en/DEVELOPMENT_WORKFLOW.md @@ -168,7 +168,9 @@ The current doctor flow checks WebView2 through the Windows registry and resolve Use these as current truth: +- [User Guide](USER_GUIDE.md) - [Getting Started](GETTING_STARTED.md) +- [Architecture](ARCHITECTURE.md) - [Releases](RELEASES.md) - [Current State](CURRENT_STATE.md) - [Trust Model](TRUST_MODEL.md) diff --git a/docs/en/GETTING_STARTED.md b/docs/en/GETTING_STARTED.md index 5176b252..dc073f6b 100644 --- a/docs/en/GETTING_STARTED.md +++ b/docs/en/GETTING_STARTED.md @@ -134,7 +134,7 @@ That gate includes: - prerequisite check - Rust format, clippy, check, and tests - frontend dependency presence check -- frontend bindings check, format check, typecheck, lint, tests, build, and size budget +- frontend bindings check, format check, typecheck, lint, tests, and bundle build If `verify` is red, the repository is not ready for release work. @@ -164,7 +164,9 @@ npm run clear ## Related Docs +- [User Guide](USER_GUIDE.md) - [Development Workflow](DEVELOPMENT_WORKFLOW.md) +- [Architecture](ARCHITECTURE.md) - [Releases](RELEASES.md) - [Current State](CURRENT_STATE.md) - [Trust Model](TRUST_MODEL.md) diff --git a/docs/en/RELEASES.md b/docs/en/RELEASES.md index 649dad3e..43002805 100644 --- a/docs/en/RELEASES.md +++ b/docs/en/RELEASES.md @@ -22,7 +22,9 @@ - pull requests targeting `nightly` - manual dispatch from GitHub Actions -The CI gate checks frontend linting, formatting, type/build, bundle size, tests, Rust clippy, Rust check, Rust tests, and audit reporting. +The CI gate checks frontend linting, formatting, type/build, tests, Rust clippy, +Rust check, Rust tests, and audit reporting. Frontend size reporting exists as a +local task, but it is not part of the required release gate. Additional release-relevant automation: diff --git a/docs/en/ROADMAP.md b/docs/en/ROADMAP.md index db558730..68218385 100644 --- a/docs/en/ROADMAP.md +++ b/docs/en/ROADMAP.md @@ -1,39 +1,321 @@ # Axelate Roadmap -> Strategic execution roadmap as of 2026-04-23. -> Planning document only. It is not a setup guide and it does not mean every listed feature already exists in the repository today. +> Product execution roadmap as of 2026-05-06. +> Planning document only. It is not a setup guide and it does not mean every +> listed feature already exists in the repository today. ## Mission -Build Axelate into the trusted Windows-first AI workstation and creator distribution platform. +Build Axelate into a trusted Windows-first AI workstation and local integration +runtime. That means: - reliable desktop core first -- curated distribution second -- managed execution third +- local integrations second +- trusted package and managed execution layers later + +Business and operations planning live outside this repository roadmap in the +private Axelate notes. ## Overall Difficulty -Overall product ambition: `9/10`. +Overall product ambition: `8/10`. Phase difficulty: - workstation core: `6/10` -- package system and curated marketplace: `8/10` -- managed runtime and platform operations: `9-10/10` +- local integrations and SDKs: `6/10` +- trusted package layer: `7/10` +- managed or hybrid execution layer: `9/10` ## Core Constraints These constraints are not optional. - Windows-first until the model is proven -- workstation before marketplace -- curated packages before public upload chaos +- workstation before distribution - local and BYOK before managed cloud complexity -- trust and permissions before growth hacks +- trust and permissions before growth - clear product identity before feature expansion +## Market Read + +The market does not need another generic AI chat client. Local AI users already +combine tools such as Ollama, LM Studio, Jan, AnythingLLM, Open WebUI, Pinokio, +ComfyUI, and Dify-like workflow builders. The repeated demand is for a reliable +control plane that makes those pieces usable without manual setup, broken +downloads, hidden GPU usage, unclear logs, unsafe scripts, or provider-specific +lock-in. + +The strongest opening for Axelate is: + +> A desktop AI launcher and runtime for integrations: one app that installs +> engines, manages local and BYOK cloud providers, owns secrets, exposes a +> stable local API, and lets user-added tools run against shared AI capability. + +Axelate should not try to win by being the best chat UI. It should win by being +the dependable desktop layer underneath AI-powered tools. + +## What Users Are Asking For + +Signals from local AI communities and competing products point to these +priorities: + +- simple install, update, resume, stop, and repair flows for local runtimes +- OpenAI-compatible local API support so existing clients can point at the app +- local-first privacy with optional cloud routing for frontier models +- hardware-aware model and engine selection across CUDA, Vulkan, ROCm, Metal, + CPU, x64, arm64, and future cross-platform targets +- reliable idle behavior that does not leak memory or keep GPU/CPU busy after + work completes +- logs, status, and diagnostics that explain failures without forcing users into + terminals +- workflow tools, RAG, MCP, settings, and state management rather than only a + prompt box +- one-click integration install from folders, archives, and trusted URLs, with + clear permissions and uninstall behavior + +These are product requirements, not nice-to-have polish. If they are weak, +users will fall back to the existing toolchain. + +## Competitive Position + +Axelate should learn from existing products without copying their identity: + +- Ollama proves that a simple local model API can become ecosystem plumbing. +- LM Studio proves that a polished desktop model runner can reach non-expert + users and still serve developers through APIs. +- Jan proves that local desktop AI, extensions, local API servers, and MCP can + coexist in one open product. +- AnythingLLM proves demand for private workspaces, RAG, agents, and practical + tools on top of local and cloud providers. +- Pinokio proves demand for one-click local AI app installation, but also shows + why arbitrary script execution needs a stronger trust model. +- Dify-like products prove demand for workflow and agent builders, but Axelate + should stay desktop/runtime-first before trying to become a full web platform. + +The defensible position is not "Axelate replaces all of them." The defensible +position is "Axelate is the desktop runtime and integration layer that makes +AI-powered tools easier to install, run, observe, and trust." + +## Stack Fit + +The current stack fits this direction. + +- Tauri 2 plus Rust is the right foundation for process lifecycle, downloads, + archive extraction, local HTTP APIs, secure storage, hardware probing, logs, + and cross-platform adapters. +- Tokio and reqwest fit long-running async work such as streaming, downloads, + release fetching, and provider calls. +- Specta-generated TypeScript bindings reduce frontend/backend contract drift + and should remain mandatory for Tauri commands. +- Vanilla TypeScript is acceptable for the current desktop UI, but the frontend + needs strict component and controller discipline as settings, permissions, + integrations, and package surfaces grow. +- The backend should remain the source of truth for secrets, runtime state, + provider routing, module lifecycle, and persistent state. + +The main stack risk is not the backend. The main risk is frontend and package +surface complexity growing faster than the product architecture. If the UI starts +carrying runtime truth or ad-hoc module behavior, the project will become hard to +stabilize. + +## Ordered Execution Plan + +This is ordered by the best mix of importance, simplicity, and dependency +sequence. + +### 1. Fix Current-State Truth And Developer Docs + +Difficulty: `1/10` + +Work: + +- keep `CURRENT_STATE.md`, `ROADMAP.md`, `TRUST_MODEL.md`, and + `LAUNCHER_SDK.md` aligned with actual behavior +- remove stale claims when backend behavior changes +- document the exact local API, environment variables, runtime directories, and + settings ownership rules +- keep examples runnable + +Exit criteria: + +- a developer can read the docs and build one integration without asking how + tokens, settings, logs, and runtime folders work + +### 2. Make Runtime Reliability Boring + +Difficulty: `3/10` to `5/10` + +Work: + +- resumable downloads +- deterministic start, stop, cancel, and restart +- health checks and repair actions +- clear release selection for OS, architecture, accelerator, and archive type +- clear errors for missing engines, missing modules, bad archives, and GitHub + release failures +- no memory or GPU growth while idle + +Exit criteria: + +- install, start, stop, restart, delete, and restart-after-app-relaunch work + repeatedly without stale UI state or duplicate backend actions + +### 3. Make Integrations First-Class Locally + +Difficulty: `4/10` to `6/10` + +Work: + +- stable manifest validation +- folder, archive, and GitHub URL import +- predictable uninstall and external-folder-missing behavior +- clear runtime, cache, log, and settings ownership +- integration template generator +- minimal "hello Axelate" integration sample +- practical example integration such as Telegram or Discord summarizer/parser + +Exit criteria: + +- a user can add, configure, run, stop, remove, and re-add an integration without + touching project internals + +### 4. Add An OpenAI-Compatible Gateway + +Difficulty: `5/10` to `7/10` + +Work: + +- `/v1/models` +- `/v1/chat/completions` +- `/v1/responses` +- `/v1/images/generations` +- streaming compatibility where practical +- compatibility tests against common OpenAI clients +- clear mapping from Axelate providers, engines, sessions, and settings to + OpenAI-compatible request fields + +Exit criteria: + +- common OpenAI SDK clients can use Axelate for local and BYOK cloud routes + without a custom adapter + +### 5. Add SDKs For Real Integration Development + +Difficulty: `5/10` to `7/10` + +Work: + +- TypeScript SDK +- Python SDK +- helpers for chat, image, settings, stage reporting, and module control +- typed errors +- examples that match the integration template +- version compatibility checks using `AXELATE_SDK_VERSION` + +Exit criteria: + +- integration authors can build useful tools without hand-writing local HTTP + plumbing + +### 6. Make Trust And Permissions Visible + +Difficulty: `6/10` to `8/10` + +Work: + +- module permissions in the manifest +- local, managed, and hybrid mode labels +- install-time permission review +- verified/signed package state +- visible module token boundaries +- explicit MCP server and tool approvals +- clear warning for manually imported unverified integrations + +Exit criteria: + +- users can see what an integration is allowed to do before running it + +### 7. Add MCP Foundation + +Difficulty: `7/10` to `8/10` + +Work: + +- MCP server registry +- connection state +- tool discovery +- user approval for server and tool access +- failure handling and logs +- no hidden automatic unsafe execution + +Exit criteria: + +- MCP works as a controlled workstation feature, not as an invisible execution + side channel + +### 8. Prepare Package Signing And Update Trust + +Difficulty: `7/10` to `9/10` + +Work: + +- signed package metadata +- verified publisher metadata +- update channels +- rollback metadata +- local verification before install/update +- clear official vs manual package state + +Exit criteria: + +- the desktop can distinguish trusted official packages from manual local + imports + +### 9. Add Trusted Package Discovery And Ownership + +Difficulty: `8/10` to `9/10` + +Work: + +- reviewed package discovery surface +- ownership metadata +- ownership sync contract +- reviewed package install/update flow +- revocation and rollback behavior + +Exit criteria: + +- reviewed packages can be discovered, installed, updated, and revoked + predictably. + +### 10. Build Managed And Hybrid Runtime Support + +Difficulty: `9/10` to `10/10` + +Work: + +- managed runtime API contract +- secure relay +- usage metering +- package ownership enforcement +- revocation +- managed logs and diagnostics +- deployment requirements + +Exit criteria: + +- protected workflows can run without shipping all sensitive logic locally, and + users can understand what runs local vs remote + +### Ordering Rule + +Do not start a later layer if an earlier layer is still failing in normal use. +The product earns the right to add platform complexity only after the desktop +runtime and local integration path are reliable. + ## Phase 0: Product Reset ### Goal @@ -44,8 +326,9 @@ Remove identity confusion and define one honest product direction. - consolidate documentation into English canonical docs - define the product as a Windows AI workstation, not a generic chat client -- define future business logic before adding new platform layers -- remove or demote legacy positioning that implies a marketplace already exists +- define future platform boundaries before adding new layers +- remove or demote legacy positioning that implies distribution features already + exist ### Exit Criteria @@ -94,30 +377,24 @@ Turn the current shell into a reliable daily-use Windows AI workstation. - keep hardware-aware resolution readable and debuggable - keep ComfyUI out of the core promise until it is truly product-ready -### Workstream E: MCP Foundation - -- add real MCP client support behind clean adapters -- make permissions explicit per server and per tool -- expose connection state in the desktop UI -- avoid hidden or automatic unsafe execution - ### Exit Criteria - a new user can install the app and complete a first useful workflow - local and cloud model routing feels coherent - logs, monitoring, and repair tools explain failures - provider settings are understandable -- MCP works as a feature, not as a science experiment +- local integrations can run through the launcher without manual path hacks ### Why This Phase Matters -If this phase fails, the marketplace should not launch. +If this phase fails, later package and platform layers should not launch. -## Phase 2: Package System Foundation +## Phase 2: Integration And Package Foundation ### Goal -Create the technical base for creator-distributed packages without pretending the marketplace is already live. +Create the technical base for user-installed integrations and future reviewed +packages. ### Work @@ -126,7 +403,7 @@ Create the technical base for creator-distributed packages without pretending th - define settings schema model for packages - define install, update, rollback, and uninstall contracts - define signing flow for official builds -- define entitlement sync contract for future commercial packages +- define ownership sync contract for future reviewed packages ### Packaging Modes @@ -146,64 +423,58 @@ The package system must support three modes from the start: ### Why This Phase Matters -Without a real package model, a marketplace is just marketing. +Without a real package model, package discovery is just marketing. -## Phase 3: Curated Marketplace MVP +## Phase 3: Trusted Discovery And Ownership ### Goal -Launch a controlled creator marketplace with real purchases and real entitlements, but only for reviewed packages. +Add a controlled discovery and ownership layer for reviewed packages. Business +and operations planning is intentionally kept outside this repository roadmap. -### Workstream A: Website and Commerce +### Workstream A: Public Product Surface - landing page - download flow -- pricing -- account creation -- billing -- purchase history -- entitlement management +- trust explanation +- package discovery +- account and ownership flow only when needed for verified packages -### Workstream B: Creator Program +### Workstream B: Reviewed Package Intake -- creator onboarding - package submission flow - review rules - screenshots and listing metadata -- pricing controls -- support and refund policy +- quality and support metadata ### Workstream C: Desktop Integration -- account sign-in -- entitlement sync -- marketplace browsing inside desktop -- install from owned entitlements +- ownership sync +- reviewed package browsing inside desktop +- install from owned or claimed packages - update and rollback from official channel ### Trust Rules - no public self-serve upload at first -- no anonymous package publishing - no claims of perfect IP protection for local packages - no unsafe execution path hidden behind one click ### Exit Criteria -- users can buy and install reviewed packages -- creators can submit and update packages -- entitlements sync into the desktop reliably -- refund and payout operations are operationally manageable +- users can install reviewed packages through a trusted flow +- packages can be submitted and updated through a review path +- ownership state syncs into the desktop reliably ### Difficulty `8/10` -## Phase 4: Managed and Hybrid Runtime Platform +## Phase 4: Managed And Hybrid Runtime Support ### Goal -Support creators who need stronger protection and platform-hosted execution. +Support packages that need stronger protection and platform-hosted execution. ### Work @@ -212,8 +483,8 @@ Support creators who need stronger protection and platform-hosted execution. - define usage metering - define revocation and expiration - define managed logs and diagnostics -- define creator-side deployment requirements -- optionally host official managed execution for creators +- define deployment requirements +- optionally host official managed execution ### Operational Requirements @@ -226,24 +497,24 @@ Support creators who need stronger protection and platform-hosted execution. ### Exit Criteria -- managed packages can be sold and enforced -- creator logic does not need to ship locally when not appropriate -- platform can meter and bill usage without trust collapse +- sensitive logic does not need to ship locally when not appropriate +- platform can meter usage without trust collapse - users understand whether a package runs local, remote, or hybrid ### Difficulty `9-10/10` -## Phase 5: Open Core Transition +## Phase 5: Open Core And Platform Boundary ### Goal -Make the desktop core auditable and contribution-friendly while keeping the commercial platform defensible. +Make the desktop core auditable and contribution-friendly while keeping future +platform services clearly separated. ### Work -- split open desktop core from closed platform services +- split open desktop core from platform services - publish package spec and SDKs - choose final open-core license - formalize contribution rules @@ -252,7 +523,7 @@ Make the desktop core auditable and contribution-friendly while keeping the comm ### Exit Criteria - external contributors can work on the desktop core safely -- official commercial backend stays private and operationally controlled +- platform services stay operationally controlled - forks do not confuse official trust guarantees ## What Is Explicitly Not A Priority @@ -273,7 +544,8 @@ Not now: Question: -- is the workstation core reliable enough that people would use it weekly without the marketplace? +- is the workstation core reliable enough that people would use it weekly without + package discovery? If no: @@ -284,21 +556,22 @@ If no: Question: -- do we have a safe package model, a review process, and entitlement sync that is understandable to users? +- do we have a safe package model, a review process, and ownership sync that is + understandable to users? If no: -- do not launch a marketplace +- do not launch package discovery ### Checkpoint 3: Before Phase 4 Question: -- can we run commercial managed infrastructure without burning margin or collapsing trust? +- can we run managed infrastructure without collapsing trust or reliability? If no: -- keep the business focused on local and hybrid packages first +- keep the product focused on local and hybrid packages first ## Success Metrics @@ -310,24 +583,23 @@ If no: - chat success rate - crash-free sessions -### Marketplace Metrics +### Package Metrics -- package conversion rate -- paid package install completion rate -- creator retention -- refund rate -- payout accuracy +- reviewed package install completion rate +- ownership sync reliability +- rollback success rate +- package update success rate -### Managed Platform Metrics +### Managed Runtime Metrics -- gross margin after infra cost -- entitlement verification reliability +- ownership verification reliability - incident frequency - abuse rate - package uptime and latency ## Final Roadmap Rule -Axelate should only earn the right to become a marketplace after it becomes a trusted workstation. +Axelate should only earn the right to become a package platform after it becomes +a trusted workstation. That sequencing is the roadmap. diff --git a/docs/en/USER_GUIDE.md b/docs/en/USER_GUIDE.md new file mode 100644 index 00000000..f01c5272 --- /dev/null +++ b/docs/en/USER_GUIDE.md @@ -0,0 +1,97 @@ +# Axelate User Guide + +> Short guide for using the current desktop app. This is not a developer setup +> guide; for source builds use [Getting Started](GETTING_STARTED.md). + +## What Axelate Does Today + +Axelate is a Windows-first AI workstation. It can: + +- use BYOK cloud AI providers through the launcher UI +- run chat and image requests +- manage local AI engines such as `llamacpp` and `sdcpp` +- import local integrations from folders, archives, or supported URLs +- show downloads, runtime logs, settings, and system monitoring in one shell + +It is not yet a reviewed package store, a managed remote execution platform, or +a finished MCP control layer. + +## First Launch + +On first launch: + +1. Open Settings. +2. Choose the UI language and theme. +3. Add the provider key you want to use. +4. Select an AI provider and model. +5. Optionally install a local engine for text or image generation. + +Provider keys are stored through the backend secure-storage path. The UI should +show whether a key exists without exposing the full secret. + +## Chat And Images + +Use the chat surface for text conversations and image attachments. If a provider +or local engine fails, the error should appear as a notification or status +message, not as a fake assistant reply. + +For image generation, select an image-capable provider or local image engine in +the AI settings surface before sending the request. + +## Local Engines + +The current built-in local engines are: + +- `llamacpp` for text generation +- `sdcpp` for image generation +- `comfyui` as a future/experimental image workflow entry + +Engine downloads and starts are backend-owned. Use the launcher controls to +install, launch, stop, delete, and inspect logs instead of editing runtime files +by hand. + +## Integrations + +Custom integrations are local projects with an `axelate-module.toml` manifest. +The launcher can import: + +- a folder +- a local archive +- a supported GitHub repository or archive URL + +Imported integrations are code you chose to run. They are not reviewed or signed +packages yet. Use the card actions to launch, stop, open, or delete an +integration. + +## Data And Logs + +Axelate keeps runtime data under its application data directory, split by +purpose: + +- integration install folders +- integration runtime folders +- integration logs +- engine runtime folders +- launcher logs and settings + +Prefer launcher actions for deleting engines or integrations. Manual deletion +can leave stale UI state until the launcher refreshes its module list. + +## Troubleshooting + +If something fails: + +- check the notification/status message first +- open the console/logs surface +- stop and launch the engine or integration again +- verify that required provider keys still exist +- delete and reinstall a broken local engine only after checking logs + +For source-development problems, use [Development Workflow](DEVELOPMENT_WORKFLOW.md). + +## Trust Limits + +Current local integrations are not sandboxed packages. Do not import projects you +would not normally run on your machine. + +For the full trust model, see [Trust Model](TRUST_MODEL.md). diff --git a/docs/en/VISION.md b/docs/en/VISION.md index caab243a..4575ebbc 100644 --- a/docs/en/VISION.md +++ b/docs/en/VISION.md @@ -1,36 +1,43 @@ # Axelate Vision -> Strategic direction as of 2026-04-23. -> Planning document only. Use `CURRENT_STATE.md`, `GETTING_STARTED.md`, and `DEVELOPMENT_WORKFLOW.md` for the repository as it works today. +> Product direction as of 2026-05-06. +> Planning document only. Use `CURRENT_STATE.md`, `GETTING_STARTED.md`, and +> `DEVELOPMENT_WORKFLOW.md` for the repository as it works today. ## Product Name - Short name: Axelate - Working full name: Axelate Workstation Platform -- Category: Windows-first AI workstation and creator marketplace -- Product sentence: Axelate is a secure desktop control plane for local AI runtimes, BYOK cloud models, MCP tools, and packaged creator apps. +- Current category: Windows-first AI workstation and local integration runtime +- Future category: trusted desktop control plane for local AI, BYOK cloud models, + MCP tools, and packaged AI integrations -## Why This Product Should Exist +## Product Sentence + +Axelate is a desktop AI workstation that installs and controls local AI engines, +connects BYOK cloud providers, and lets user-installed integrations run against a +shared local AI runtime, settings, logs, and API surface. -The AI desktop market is crowded with chat clients, local model launchers, agent shells, and web dashboards. What is still weak on Windows is the layer that combines all of the following in one consistent product: +## Why This Product Should Exist -- local runtime install, update, start, stop, and health status -- cloud model access through user-owned keys -- MCP tool access with explicit permissions -- creator-distributed AI packages with billing and updates -- backend-owned secrets, signing, entitlements, and managed execution when local delivery is not enough +The AI desktop market already has chat clients, model runners, agent shells, and +web dashboards. What is still weak is the layer that makes practical AI tools +easy to install, run, observe, repair, and trust on a user's machine. Axelate should exist to be that layer. -The product should not compete as "another chat UI". It should compete as the trusted operating surface for practical AI work on Windows. +The product should not compete as "another chat UI". It should compete as the +trusted operating surface for local and hybrid AI work. ## Product Thesis Axelate wins only if it stays narrow and honest: - one desktop shell for local and cloud AI work -- one package system for creator tools and paid workflows -- one trust model for permissions, secrets, updates, and entitlements +- one integration runtime for user-installed AI tools +- one local API surface for chat, image, settings, status, logs, and lifecycle +- one trust model for secrets, permissions, runtime folders, updates, and future + verified packages Axelate loses if it tries to become: @@ -42,313 +49,143 @@ Axelate loses if it tries to become: ## Target Users - Power users who switch between local runtimes and cloud models. -- Creators who want to sell AI-powered tools without building their own launcher, updater, billing system, and entitlement service. -- Small studios that need one desktop surface for text, image, automation, and tool-backed AI workflows. +- Developers who want their tools to use local or BYOK AI without rebuilding + engine setup, provider routing, settings, logs, and runtime management. +- Small studios that need one desktop surface for text, image, automation, and + tool-backed AI workflows. ## Core Product Definition -### 1. Desktop App +### 1. Desktop Workstation The desktop app is the main product. It should provide: -- local engine lifecycle management +- local engine install, update, start, stop, and health status - cloud provider selection and model routing -- MCP server configuration and permission prompts -- package install, update, rollback, and uninstall +- OpenAI-compatible local API support where practical +- integration import from folders, archives, and trusted URLs - backend-owned credential storage -- logs, health checks, and repair actions - -The desktop app should stay Windows-first until the model is proven. - -### 2. Website - -The website is not optional. It is the public business surface. - -It should handle: - -- landing pages and positioning -- download distribution -- pricing -- account creation -- billing and subscriptions -- package discovery -- creator onboarding -- creator payouts -- docs, legal pages, and trust material - -Recommended first public structure: - -- `/` product landing page -- `/download` installer distribution -- `/pricing` plans and marketplace fees -- `/marketplace` searchable package catalog -- `/creators` creator program and publishing rules -- `/account` purchases, entitlements, devices, billing -- `/trust` security model, signing, package review -- `/docs` later, once the public protocol and package spec stabilize - -### 3. Creator Platform - -Creators should be able to ship AI products in three package modes: - -- `Local package` - - shipped to the user machine - - best for tools that can run locally with acceptable IP exposure - - supports versioning, signing, updates, rollback, and local settings schemas -- `Managed package` - - sensitive logic stays on creator or platform infrastructure - - desktop acts as authenticated client and orchestrator - - best for protected commercial workflows and premium automations -- `Hybrid package` - - local UI and setup, remote execution for sensitive steps - - best for mixed local/cloud tools - -This packaging model is the bridge between the desktop shell and the business. - -## Business Logic - -### User Flow - -1. User installs Axelate desktop. -2. User signs in or continues in local-only mode. -3. User adds cloud provider keys or installs local runtimes. -4. User browses packages on the website or inside the desktop marketplace. -5. User purchases or claims a package entitlement. -6. Desktop syncs entitlements and installs the package. -7. Package runs in local, managed, or hybrid mode. -8. Updates, permissions, logs, and billing stay visible in one place. - -### Creator Flow - -1. Creator applies for creator access. -2. Creator creates a package listing with category, screenshots, pricing, support terms, and manifest. -3. Creator chooses `local`, `managed`, or `hybrid`. -4. Creator uploads signed assets or registers the managed runtime endpoint. -5. Platform runs validation, malware checks, schema checks, and policy review. -6. Approved package becomes visible in the marketplace. -7. Purchases create entitlements and payout records. -8. Updates go through version review and staged rollout. - -### Platform Flow - -The platform owned by Axelate should be responsible for: - -- account and identity -- package signing and trust chain -- entitlement issuance -- billing and payouts -- abuse prevention -- marketplace discovery and ranking -- package review -- optional managed runtime orchestration - -The platform should not promise impossible guarantees. - -Local packages are convenient and monetizable, but they are not undecompilable. -Managed packages are the correct answer for creators who need stronger protection. - -## Revenue Model - -### Owner Revenue - -Axelate should have three revenue lines. - -#### 1. Marketplace fee - -- recommended default: `15%` platform fee on creator software revenue -- payout target: creator receives `85%` before payment processor and tax adjustments - -This is simple, legible, and competitive enough for an early curated marketplace. - -#### 2. Pro subscription for end users - -The desktop should remain useful for free local and BYOK usage. +- integration settings, runtime folders, and logs +- downloads, console logs, monitoring, and repair actions -Paid `Axelate Pro` should unlock platform features, not basic trust: +The workstation core should stay Windows-first until the product model is proven. -- encrypted cloud backup of settings and entitlements -- multi-device sync -- advanced package rollback history -- premium diagnostics and recovery tools -- early access to verified package releases +### 2. Integration Runtime -This should be a modest subscription, not the core profit engine. +Integrations should be folders or packages with a manifest and runtime contract. -#### 3. Managed runtime margin +The launcher should provide: -For managed packages, Axelate can charge for platform-hosted orchestration: +- manifest validation +- runtime dependency setup +- scoped local API tokens +- per-integration settings +- per-integration runtime and log directories +- start, stop, restart, status, and stage reporting +- predictable import, update, removal, and external-folder-missing behavior -- entitlement checks -- secure relay -- execution control -- usage metering -- storage and logs +This layer is the product wedge. It turns Axelate from a model launcher into a +workstation platform. -This can be billed as: +### 3. Trust Surface -- pass-through infra cost plus a platform margin -- or a fixed platform fee charged to creators +Local integrations are useful, but they are not automatically trusted. -The product should start with pass-through plus margin. It is easier to explain and less risky. +Axelate should make boundaries visible: -### Creator Revenue +- backend vs frontend +- local vs remote +- installed vs verified +- manual import vs official package +- allowed vs denied permissions +- user-owned secrets vs integration-owned state -Creators should be able to earn through: +The current repository does not yet ship full package signing, publisher +verification, package review, or managed execution. Those belong to later +platform layers. -- one-time purchases -- subscriptions -- paid upgrades -- seat-based licenses later -- managed workflow subscriptions +## Immediate Focus -Creators should control their own list prices. -The platform should only control fee policy, refund windows, and content rules. +The next product work should prioritize: -## Open vs Closed Strategy +1. runtime reliability +2. custom integration import and lifecycle +3. OpenAI-compatible local API +4. TypeScript and Python SDKs +5. integration templates and examples +6. visible trust and permission UX +7. MCP foundation after runtime and permissions are stable -The best long-term model is `open core + closed commercial platform`. - -### What Should Be Open - -- desktop core -- package manifest specification -- public SDKs -- MCP and provider adapters that are part of the core client -- documentation for package and entitlement integration - -Selected license for the open core: `Apache-2.0`. - -Reason: - -- trust matters for a BYOK desktop product -- contributors are more likely to help if the core is auditable -- forks do not destroy the business if the marketplace, signing, billing, and brand stay controlled - -### What Should Stay Closed - -- official marketplace backend -- billing and payout services -- entitlement service -- signing infrastructure -- abuse detection -- managed runtime orchestration -- official ranking and recommendation logic - -### Can Everyone Modify It - -For the open core: - -- yes, anyone can read, fork, modify, and submit changes -- no, modified forks do not automatically become official builds - -For the official platform: - -- no, only the owner and approved maintainers can change the production marketplace and commercial backend - -For creator packages: - -- creators may choose open packages -- creators may choose closed local packages -- creators may choose managed packages where sensitive logic never ships - -This is the practical trust and business split. +Package discovery, account-backed ownership, and managed execution should not +lead the roadmap until the workstation and local integration path are reliable. ## Product Rules -- Do not promise perfect DRM for local packages. - Do not make the chat tab the center of the brand. -- Do not force cloud accounts for local-only usage. -- Do not mix unsafe arbitrary execution with the curated marketplace path. -- Do not build a creator marketplace before the workstation core is stable. +- Do not force cloud accounts for local-only workflows. +- Do not imply that manually imported integrations are verified. +- Do not promise perfect DRM for local packages. +- Do not mix unsafe arbitrary execution with future curated package flows. +- Do not build package distribution before the workstation core is stable. -## What Axelate Should Actually Build +## Phase Direction ### Phase 1: Workstation Core Ship a reliable Windows desktop with: -- strong provider routing -- strong streaming chat and image flows +- stable provider routing +- stable streaming chat and image flows - local runtime management -- MCP client support -- package manifest and install model +- integration import and lifecycle +- local API and SDK foundations - health checks, logs, and repair tools -This phase proves product value to users. +This phase proves product value to users and developers. -### Phase 2: Curated Creator Marketplace +### Phase 2: Trusted Package Layer Ship: -- website -- creator onboarding -- package listing flow -- package signing and review -- entitlement sync into desktop +- package manifest and permission model +- verified package metadata +- signing and update trust +- reviewed package install/update/remove flow +- user-visible execution mode labels -This phase proves creator demand. +This phase proves that Axelate can safely move beyond manual local imports. -### Phase 3: Managed Package Layer +### Phase 3: Platform Layer -Ship: +Only after the workstation and package trust model are stable, add: -- managed package runtime contract -- billing for managed subscriptions -- creator payout automation -- usage metering -- optional owner-hosted execution plane +- package ownership sync +- curated package discovery +- publisher onboarding +- managed or hybrid execution contracts +- operational services around verified packages -This phase proves defensible business value. +This phase proves platform value beyond the desktop app. ## Reality Check This product is real and implementable, but only under these conditions: -- Windows-first, not cross-platform from day one -- curated marketplace, not open upload chaos -- open core for trust, closed platform for monetization -- local plus BYOK first, managed cloud second -- creator distribution and entitlement first, full enterprise later - -If Axelate tries to launch as: - -- chat app -- launcher -- package manager -- agent platform -- public marketplace -- managed cloud - -all at once, it will become diffuse and weak. - -If Axelate launches first as: - -- the trusted Windows AI workstation for running local and paid AI tools - -then the marketplace and managed layer become believable. - -## Execution Difficulty - -This product is feasible, but it is not cheap or simple to execute well. - -Overall ambition difficulty: `9/10`. - -Phase difficulty: - -- workstation core only: `6/10` -- package system and curated marketplace: `8/10` -- managed runtime, billing, payouts, signing, and abuse control: `9-10/10` - -Why the score is high: - -- the desktop product alone requires stable runtime orchestration, provider routing, permissions, logs, recovery, and packaging -- the marketplace requires trust, policy review, entitlement sync, billing, and payouts -- the managed layer requires production backend operations, metering, security, incident handling, and revocation +- workstation before marketplace +- local and BYOK before managed cloud complexity +- trust and permissions before growth +- integration runtime before public package distribution +- open core for desktop trust, controlled platform services later -The product becomes realistic only if it is built in phases and only if each phase proves value before the next one starts. +If Axelate launches first as a reliable desktop AI workstation for running local +engines and user-installed integrations, the later platform layers become +believable. ## Final Product Statement -Axelate should become the Windows-first AI workstation and creator distribution platform: a desktop product where users run local engines, connect cloud models, install verified MCP-enabled packages, and buy creator-built AI tools from one trusted surface; with an open desktop core for trust and a closed commercial platform for billing, signing, entitlements, payouts, and managed execution. +Axelate should become the trusted desktop AI workstation for running local +engines, BYOK cloud models, and user-installed AI integrations from one reliable +surface, with future verified packages and managed workflows added only after +the workstation core earns trust. From 0bb62d8b146ad1746600552bb9cd7495469c9e75 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Wed, 6 May 2026 14:22:53 +0300 Subject: [PATCH 097/126] refactor(core): remove dormant licensing and marketplace surfaces --- src-tauri/resources/locales/en.json | 1 - src-tauri/resources/locales/ru.json | 1 - src-tauri/resources/locales/zh.json | 1 - src-tauri/src/api/license/mod.rs | 42 ------------ src-tauri/src/api/mod.rs | 2 - src-tauri/src/api/secure/mod.rs | 8 +-- src-tauri/src/domain/license/mod.rs | 13 ---- src-tauri/src/domain/license/storage.rs | 27 -------- src-tauri/src/domain/license/types.rs | 30 --------- src-tauri/src/domain/license/verifier.rs | 65 ------------------- src-tauri/src/domain/mod.rs | 2 - .../src/domain/modules/downloader_transfer.rs | 30 +-------- src-tauri/src/lib.rs | 6 +- src-tauri/src/models/license.rs | 12 ---- src-tauri/src/models/mod.rs | 3 - src/app/CoreRuntimeSupport.ts | 1 - src/assets/icons.ts | 7 -- .../ui/GeneralSettingsRenderer.test.ts | 3 +- src/index.html | 1 - src/public/templates/pages/marketplace.html | 9 --- src/shared/config/AppPages.ts | 7 -- src/shared/services/WindowService.ts | 1 - src/shared/services/state/UiStateStore.ts | 2 +- .../shell/SidebarNavigationRenderer.test.ts | 2 +- src/shared/shell/SidebarUI.test.ts | 2 +- src/shared/types/bindings.ts | 29 --------- 26 files changed, 11 insertions(+), 296 deletions(-) delete mode 100644 src-tauri/src/api/license/mod.rs delete mode 100644 src-tauri/src/domain/license/mod.rs delete mode 100644 src-tauri/src/domain/license/storage.rs delete mode 100644 src-tauri/src/domain/license/types.rs delete mode 100644 src-tauri/src/domain/license/verifier.rs delete mode 100644 src-tauri/src/models/license.rs delete mode 100644 src/public/templates/pages/marketplace.html diff --git a/src-tauri/resources/locales/en.json b/src-tauri/resources/locales/en.json index ac777dd5..71af4336 100644 --- a/src-tauri/resources/locales/en.json +++ b/src-tauri/resources/locales/en.json @@ -252,7 +252,6 @@ "ui.launcher.web.information": "Information", "ui.launcher.web.logs_general": "Platform", "ui.launcher.web.main_menu": "Main Menu", - "ui.launcher.web.marketplace": "Market", "ui.connectivity.offline_title": "No internet connection", "ui.connectivity.offline_text": "Local modules still work. Cloud AI, downloads and external pages are unavailable.", "ui.launcher.web.models_title": "AI Engines & Integrations", diff --git a/src-tauri/resources/locales/ru.json b/src-tauri/resources/locales/ru.json index 7adc91a3..463f4bb1 100644 --- a/src-tauri/resources/locales/ru.json +++ b/src-tauri/resources/locales/ru.json @@ -253,7 +253,6 @@ "ui.launcher.web.information": "Информация", "ui.launcher.web.logs_general": "Платформа", "ui.launcher.web.main_menu": "Главное меню", - "ui.launcher.web.marketplace": "Маркет", "ui.connectivity.offline_title": "Нет подключения к интернету", "ui.connectivity.offline_text": "Локальные модули продолжают работать. Облачный ИИ, загрузки и внешние страницы недоступны.", "ui.launcher.web.models_title": "Движки и интеграции", diff --git a/src-tauri/resources/locales/zh.json b/src-tauri/resources/locales/zh.json index 21f2eee3..875a329a 100644 --- a/src-tauri/resources/locales/zh.json +++ b/src-tauri/resources/locales/zh.json @@ -249,7 +249,6 @@ "ui.launcher.web.information": "信息", "ui.launcher.web.logs_general": "平台", "ui.launcher.web.main_menu": "主菜单", - "ui.launcher.web.marketplace": "市场", "ui.connectivity.offline_title": "网络连接不可用", "ui.connectivity.offline_text": "本地模块仍可继续工作。云 AI、下载和外部页面暂时不可用。", "ui.launcher.web.models_title": "AI 引擎与集成", diff --git a/src-tauri/src/api/license/mod.rs b/src-tauri/src/api/license/mod.rs deleted file mode 100644 index 9a3ceecb..00000000 --- a/src-tauri/src/api/license/mod.rs +++ /dev/null @@ -1,42 +0,0 @@ -// use tauri::command; - -use crate::domain::license; -use crate::domain::license::types::LicenseStatus; -use crate::errors::AppError; -use crate::models::LicenseStatusResponse; - -#[tauri::command] -#[specta::specta] -/// Retrieves current license activation status -#[allow(clippy::missing_const_for_fn)] // Wrapper around const verify() function -pub fn get_license_status() -> Result { - let status = license::verify()?; - Ok(LicenseStatusResponse { - status, - email: None, // In real app, load from storage - }) -} - -#[tauri::command] -#[specta::specta] -/// Activates a license key with optional email -pub async fn activate_license( - key: String, - email: Option, -) -> Result { - license::activate(&key, email) -} - -#[tauri::command] -#[specta::specta] -/// Deactivates the current license -pub async fn deactivate_license() -> Result<(), AppError> { - license::deactivate() -} - -#[tauri::command] -#[specta::specta] -/// Checks if a specific feature is enabled by the current license -pub fn check_feature(feature: &str) -> Result { - license::has_feature(feature) -} diff --git a/src-tauri/src/api/mod.rs b/src-tauri/src/api/mod.rs index 72bf82c0..7b2bf8f0 100644 --- a/src-tauri/src/api/mod.rs +++ b/src-tauri/src/api/mod.rs @@ -2,8 +2,6 @@ pub mod ai; /// Engine lifecycle commands (start, stop, status) pub mod engine; -/// License management commands -pub mod license; /// Module management commands (download, control) pub mod modules; /// Secure storage commands diff --git a/src-tauri/src/api/secure/mod.rs b/src-tauri/src/api/secure/mod.rs index ab15140b..a2ce8435 100644 --- a/src-tauri/src/api/secure/mod.rs +++ b/src-tauri/src/api/secure/mod.rs @@ -110,14 +110,14 @@ mod tests { fn frontend_secret_policy_allows_only_expected_service_names() { assert!(is_frontend_managed_secret("openrouter_api_key")); assert!(is_frontend_managed_secret("ai_session_id")); - assert!(!is_frontend_managed_secret("license_data")); + assert!(!is_frontend_managed_secret("internal_service_token")); assert!(is_frontend_readable_secret("ai_session_id")); assert!(is_frontend_readable_secret("openrouter_api_key")); } #[tokio::test] async fn get_secure_key_rejects_non_frontend_secret_reads() { - let err = get_secure_key("license_data".to_string()) + let err = get_secure_key("internal_service_token".to_string()) .await .unwrap_err(); @@ -129,7 +129,7 @@ mod tests { #[tokio::test] async fn has_secure_key_rejects_non_frontend_secret_names() { - let err = has_secure_key("license_data".to_string()) + let err = has_secure_key("internal_service_token".to_string()) .await .unwrap_err(); @@ -141,7 +141,7 @@ mod tests { #[tokio::test] async fn remove_secure_key_rejects_non_frontend_secret_names() { - let err = remove_secure_key("license_data".to_string()) + let err = remove_secure_key("internal_service_token".to_string()) .await .unwrap_err(); diff --git a/src-tauri/src/domain/license/mod.rs b/src-tauri/src/domain/license/mod.rs deleted file mode 100644 index c7d50e40..00000000 --- a/src-tauri/src/domain/license/mod.rs +++ /dev/null @@ -1,13 +0,0 @@ -//! License management module -//! -//! Handles license verification, activation, and feature gating - -/// License storage operations -pub mod storage; -/// License types and status -pub mod types; -/// License verification logic -pub mod verifier; - -pub use types::{LicenseInfo, LicenseStatus}; -pub use verifier::{activate, deactivate, has_feature, verify}; diff --git a/src-tauri/src/domain/license/storage.rs b/src-tauri/src/domain/license/storage.rs deleted file mode 100644 index 01fda0c4..00000000 --- a/src-tauri/src/domain/license/storage.rs +++ /dev/null @@ -1,27 +0,0 @@ -use super::types::LicenseInfo; -use crate::errors::AppError; -use crate::infrastructure::crypto::secure_storage::SecureStorage; - -const LICENSE_KEY: &str = "license_data"; - -/// Loads license from storage -pub fn load_license() -> Result, AppError> { - let Some(json) = SecureStorage::get_key(LICENSE_KEY)? else { - return Ok(None); - }; - - serde_json::from_str(&json) - .map(Some) - .map_err(|e| AppError::Serialization(format!("Failed to parse stored license: {e}"))) -} - -/// Saves license to encrypted storage -pub fn save_license(info: &LicenseInfo) -> Result<(), AppError> { - let json = serde_json::to_string(info).map_err(|e| AppError::Serialization(e.to_string()))?; - SecureStorage::save_key(LICENSE_KEY.to_string(), json) -} - -/// Clears license from storage -pub fn clear_license() -> Result<(), AppError> { - SecureStorage::remove_key(LICENSE_KEY) -} diff --git a/src-tauri/src/domain/license/types.rs b/src-tauri/src/domain/license/types.rs deleted file mode 100644 index f0204ff2..00000000 --- a/src-tauri/src/domain/license/types.rs +++ /dev/null @@ -1,30 +0,0 @@ -use serde::{Deserialize, Serialize}; -use specta::Type; - -/// License tier status -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Type)] -pub enum LicenseStatus { - /// Free tier - Free, - /// Pro tier - Pro, - /// Enterprise tier - Enterprise, - /// Expired license - Expired, - /// Invalid license key - Invalid, -} - -/// License information -#[derive(Debug, Clone, Serialize, Deserialize, Type)] -pub struct LicenseInfo { - /// License key - pub key: String, - /// User email - pub email: Option, - /// License tier - pub tier: LicenseStatus, - /// Expiration timestamp - pub expires_at: Option, -} diff --git a/src-tauri/src/domain/license/verifier.rs b/src-tauri/src/domain/license/verifier.rs deleted file mode 100644 index c0c13d72..00000000 --- a/src-tauri/src/domain/license/verifier.rs +++ /dev/null @@ -1,65 +0,0 @@ -use super::storage; -use super::types::{LicenseInfo, LicenseStatus}; -use crate::errors::AppError; - -/// Verifies current license status -pub fn verify() -> Result { - Ok(match storage::load_license()? { - Some(info) => verify_license_info(&info), - None => LicenseStatus::Free, - }) -} - -/// Verifies a license info object -pub fn verify_license_info(info: &LicenseInfo) -> LicenseStatus { - // Basic verification logic - if info.key.starts_with("PRO-") { - LicenseStatus::Pro - } else if info.key.starts_with("ENT-") { - LicenseStatus::Enterprise - } else { - LicenseStatus::Invalid - } -} - -/// Activates a license key -pub fn activate(key: &str, email: Option) -> Result { - let status = if key.starts_with("PRO-") { - LicenseStatus::Pro - } else if key.starts_with("ENT-") { - LicenseStatus::Enterprise - } else { - return Err(AppError::Validation( - "Invalid license key format".to_string(), - )); - }; - - let info = LicenseInfo { - key: key.to_string(), - email, - tier: status.clone(), - expires_at: None, - }; - - storage::save_license(&info)?; - Ok(status) -} - -/// Deactivates the current license -#[allow(clippy::missing_const_for_fn)] // Calls non-const storage function -pub fn deactivate() -> Result<(), AppError> { - storage::clear_license() -} - -/// Checks if a feature is available in the current license -pub fn has_feature(feature: &str) -> Result { - let status = verify()?; - match status { - LicenseStatus::Enterprise => Ok(true), - LicenseStatus::Pro => { - // Pro features list - Ok(matches!(feature, "advanced_stats" | "custom_themes")) - } - _ => Ok(false), - } -} diff --git a/src-tauri/src/domain/mod.rs b/src-tauri/src/domain/mod.rs index 5b4297e8..93b95709 100644 --- a/src-tauri/src/domain/mod.rs +++ b/src-tauri/src/domain/mod.rs @@ -6,8 +6,6 @@ pub mod engine; pub mod filesystem; /// Local HTTP API for launcher integrations pub mod integration_api; -/// License domain logic -pub mod license; /// Module domain logic pub mod modules; /// Monitoring domain logic diff --git a/src-tauri/src/domain/modules/downloader_transfer.rs b/src-tauri/src/domain/modules/downloader_transfer.rs index 8a7ba7b3..34b3da45 100644 --- a/src-tauri/src/domain/modules/downloader_transfer.rs +++ b/src-tauri/src/domain/modules/downloader_transfer.rs @@ -163,37 +163,11 @@ pub(super) async fn clone_repository_into( } pub(super) fn build_client(module_id: &str) -> Result { - let mut client_builder = reqwest::Client::builder() + let client_builder = reqwest::Client::builder() .user_agent("Axelate/1.0.0 (Tauri; Windows)") .timeout(std::time::Duration::from_secs(600)); - let loaded_license = match crate::domain::license::storage::load_license() { - Ok(license) => license, - Err(error) => { - tracing::warn!( - module_id = module_id, - "Failed to load license for download headers: {error}" - ); - None - } - }; - - if let Some(license) = loaded_license - && !license.key.is_empty() - { - tracing::info!("Injecting license key for module download: {module_id}"); - let mut headers = reqwest::header::HeaderMap::new(); - if let Ok(auth_val) = - reqwest::header::HeaderValue::from_str(&format!("Bearer {}", license.key)) - { - headers.insert(reqwest::header::AUTHORIZATION, auth_val); - } - if let Ok(lic_val) = reqwest::header::HeaderValue::from_str(&license.key) { - headers.insert("X-Axelate-License", lic_val); - } - client_builder = client_builder.default_headers(headers); - } - + tracing::debug!(module_id = module_id, "Building module download client"); client_builder.build().map_err(|error| AppError::External { request_id: None, message: format!("Client error: {error}"), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 921fe046..d8964d1c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -45,7 +45,7 @@ mod tests; // Re-export API modules to match the flat structure expected by collect_commands! use api::{ - ai, engine, license, + ai, engine, modules::{self, downloader}, secure, settings::{self, theme, translations, ui_state, window_settings}, @@ -135,10 +135,6 @@ pub fn create_specta_builder() -> Builder { window::show_window, window::hide_window, translations::get_translations, - license::get_license_status, - license::activate_license, - license::deactivate_license, - license::check_feature, theme::get_theme_colors, window_settings::get_window_settings, window_settings::save_window_size, diff --git a/src-tauri/src/models/license.rs b/src-tauri/src/models/license.rs deleted file mode 100644 index 0731b3c7..00000000 --- a/src-tauri/src/models/license.rs +++ /dev/null @@ -1,12 +0,0 @@ -use crate::domain::license::types::LicenseStatus; -use serde::{Deserialize, Serialize}; -use specta::Type; - -/// License activation status response -#[derive(Serialize, Deserialize, Type, Debug)] -pub struct LicenseStatusResponse { - /// Current license activation status - pub status: LicenseStatus, - /// Email address associated with the license - pub email: Option, -} diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 244a4445..7c4b6535 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -2,8 +2,6 @@ pub mod config; /// Custom AI model definitions pub mod custom_models; -/// License activation and validation data types -pub mod license; /// Application module metadata and state pub mod modules; /// Application settings data models @@ -15,7 +13,6 @@ pub mod ui_state; pub use config::*; pub use custom_models::*; -pub use license::*; pub use modules::*; pub use settings::*; pub use system::*; diff --git a/src/app/CoreRuntimeSupport.ts b/src/app/CoreRuntimeSupport.ts index d55023f6..d1de0bec 100644 --- a/src/app/CoreRuntimeSupport.ts +++ b/src/app/CoreRuntimeSupport.ts @@ -71,7 +71,6 @@ const INITIAL_TEMPLATE_TARGETS = [ ['pages/home', 'page-home'], ['pages/chat', 'page-chat'], ['pages/modules', 'page-modules'], - ['pages/marketplace', 'page-marketplace'], ['pages/downloads', 'page-downloads'], ['pages/console', 'page-console'], ['pages/settings', 'page-settings'], diff --git a/src/assets/icons.ts b/src/assets/icons.ts index c9e247c8..3b60fe7c 100644 --- a/src/assets/icons.ts +++ b/src/assets/icons.ts @@ -278,13 +278,6 @@ export const svgIcons = ` - - - `; diff --git a/src/features/settings/ui/GeneralSettingsRenderer.test.ts b/src/features/settings/ui/GeneralSettingsRenderer.test.ts index 1ad05e20..5d5517d9 100644 --- a/src/features/settings/ui/GeneralSettingsRenderer.test.ts +++ b/src/features/settings/ui/GeneralSettingsRenderer.test.ts @@ -48,7 +48,6 @@ describe('GeneralSettingsRenderer', () => { -
    @@ -236,7 +235,7 @@ describe('GeneralSettingsRenderer', () => { t: (_key: string, fallback: string) => `ignored:${fallback}`, } as never); - expect(document.querySelectorAll('#taskbar-toggles .monitor-toggle-btn')).toHaveLength(6); + expect(document.querySelectorAll('#taskbar-toggles .monitor-toggle-btn')).toHaveLength(5); expect(ResizeObserverMock.instances).toHaveLength(2); const taskbar = document.getElementById('taskbar-toggles') as HTMLElement; diff --git a/src/index.html b/src/index.html index dc422a0e..217f2c4e 100644 --- a/src/index.html +++ b/src/index.html @@ -180,7 +180,6 @@
    -
    diff --git a/src/public/templates/pages/marketplace.html b/src/public/templates/pages/marketplace.html deleted file mode 100644 index f930a431..00000000 --- a/src/public/templates/pages/marketplace.html +++ /dev/null @@ -1,9 +0,0 @@ -
    -
    - Coming soon -
    -
    diff --git a/src/shared/config/AppPages.ts b/src/shared/config/AppPages.ts index 2c44345d..d9a1b3c1 100644 --- a/src/shared/config/AppPages.ts +++ b/src/shared/config/AppPages.ts @@ -29,13 +29,6 @@ export const APP_PAGES: IAppPage[] = [ defaultLabel: 'Integrations', inSettings: true, }, - { - id: 'marketplace', - icon: '#icon-marketplace', - i18nKey: 'ui.launcher.web.marketplace', - defaultLabel: 'Market', - inSettings: true, - }, { id: 'settings', icon: '#icon-settings', diff --git a/src/shared/services/WindowService.ts b/src/shared/services/WindowService.ts index ec471f41..f5e84c6f 100644 --- a/src/shared/services/WindowService.ts +++ b/src/shared/services/WindowService.ts @@ -49,7 +49,6 @@ const PAGE_ZOOM_PROFILES: Record = { home: { minWidth: 800, minHeight: 600 }, chat: { minWidth: 920, minHeight: 640 }, modules: { minWidth: 1024, minHeight: 650 }, - marketplace: { minWidth: 1024, minHeight: 650 }, downloads: { minWidth: 900, minHeight: 600 }, console: { minWidth: 1080, minHeight: 650 }, settings: { minWidth: 980, minHeight: 680 }, diff --git a/src/shared/services/state/UiStateStore.ts b/src/shared/services/state/UiStateStore.ts index 34a39389..0e6b0c31 100644 --- a/src/shared/services/state/UiStateStore.ts +++ b/src/shared/services/state/UiStateStore.ts @@ -42,7 +42,7 @@ const DEFAULT_UI_STATE: IUIState = { sidebar_collapsed: false, sidebar_manual_override: false, sidebar_width: 280, - hidden_nav_items: ['marketplace'], + hidden_nav_items: [], hidden_monitors: [], card_widths: {}, download_limit_enabled: false, diff --git a/src/shared/shell/SidebarNavigationRenderer.test.ts b/src/shared/shell/SidebarNavigationRenderer.test.ts index 2424395f..bddd1a3f 100644 --- a/src/shared/shell/SidebarNavigationRenderer.test.ts +++ b/src/shared/shell/SidebarNavigationRenderer.test.ts @@ -21,7 +21,7 @@ describe('SidebarNavigationRenderer', () => { renderer.render(sidebar); - expect(document.querySelectorAll('.main-menu .nav-btn')).toHaveLength(6); + expect(document.querySelectorAll('.main-menu .nav-btn')).toHaveLength(5); expect(document.querySelectorAll('.bottom-menu .nav-btn')).toHaveLength(1); expect(document.querySelector('.console-trigger')?.getAttribute('data-page')).toBe( 'console', diff --git a/src/shared/shell/SidebarUI.test.ts b/src/shared/shell/SidebarUI.test.ts index 81e003f7..29a83f46 100644 --- a/src/shared/shell/SidebarUI.test.ts +++ b/src/shared/shell/SidebarUI.test.ts @@ -110,7 +110,7 @@ describe('SidebarUI', () => { await sidebarUi.init(); - expect(document.querySelectorAll('.main-menu .nav-btn')).toHaveLength(6); + expect(document.querySelectorAll('.main-menu .nav-btn')).toHaveLength(5); expect(document.querySelectorAll('.bottom-menu .nav-btn')).toHaveLength(1); expect(document.querySelector('.console-trigger')?.getAttribute('data-page')).toBe( 'console', diff --git a/src/shared/types/bindings.ts b/src/shared/types/bindings.ts index 552eaee1..2e05e3bb 100644 --- a/src/shared/types/bindings.ts +++ b/src/shared/types/bindings.ts @@ -104,14 +104,6 @@ export const commands = { hideWindow: () => typedError(__TAURI_INVOKE("hide_window")), // Retrieves translation strings for the specified language getTranslations: (lang: string) => typedError<"Null" | ({ Bool: boolean }) & { Array?: never; Number?: never; Object?: never; String?: never } | ({ Number: ({ f64: number }) & { i64?: never; u64?: never } | ({ i64: number }) & { f64?: never; u64?: never } | ({ u64: number }) & { f64?: never; i64?: never } }) & { Array?: never; Bool?: never; Object?: never; String?: never } | ({ String: string }) & { Array?: never; Bool?: never; Number?: never; Object?: never } | ({ Array: Value[] }) & { Bool?: never; Number?: never; Object?: never; String?: never } | ({ Object: { [key in string]: Value } }) & { Array?: never; Bool?: never; Number?: never; String?: never }, AppError>(__TAURI_INVOKE("get_translations", { lang })), - // Retrieves current license activation status - getLicenseStatus: () => typedError(__TAURI_INVOKE("get_license_status")), - // Activates a license key with optional email - activateLicense: (key: string, email: string | null) => typedError(__TAURI_INVOKE("activate_license", { key, email })), - // Deactivates the current license - deactivateLicense: () => typedError(__TAURI_INVOKE("deactivate_license")), - // Checks if a specific feature is enabled by the current license - checkFeature: (feature: string) => typedError(__TAURI_INVOKE("check_feature", { feature })), // Retrieves current theme color palette getThemeColors: () => typedError<{ [key in string]: string }, AppError>(__TAURI_INVOKE("get_theme_colors")), // Retrieves persisted window settings (size, position, maximized state) @@ -819,27 +811,6 @@ export type ImageGenerationResponse = { error: string | null, }; -// License tier status -export type LicenseStatus = -// Free tier -"Free" | -// Pro tier -"Pro" | -// Enterprise tier -"Enterprise" | -// Expired license -"Expired" | -// Invalid license key -"Invalid"; - -// License activation status response -export type LicenseStatusResponse = { - // Current license activation status - status: LicenseStatus, - // Email address associated with the license - email: string | null, -}; - // Log entry for frontend display export type LogEntry = { // Unix timestamp From 89ba267dae906cfb230554fe8ae38896d0dd6544 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Wed, 6 May 2026 14:24:10 +0300 Subject: [PATCH 098/126] chore: add Codex project actions --- .codex/environments/environment.toml | 31 +++++++++++++++++++++++++++ .github/scripts/lib/tooling-paths.mjs | 11 ++-------- .github/scripts/run-hook.mjs | 22 +++++++++++-------- 3 files changed, 46 insertions(+), 18 deletions(-) create mode 100644 .codex/environments/environment.toml diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml new file mode 100644 index 00000000..5ac14a0f --- /dev/null +++ b/.codex/environments/environment.toml @@ -0,0 +1,31 @@ +# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY +version = 1 +name = "Axelate" + +[setup] +script = "npm run setup" + +[[actions]] +name = "Development" +icon = "run" +command = "npm run dev" + +[[actions]] +name = "Development (release-like)" +icon = "run" +command = "npm run dev:release-like" + +[[actions]] +name = "Check project" +icon = "check" +command = "npm run verify" + +[[actions]] +name = "Doctor" +icon = "terminal" +command = "npm run doctor" + +[[actions]] +name = "Release" +icon = "package" +command = "npm run release" diff --git a/.github/scripts/lib/tooling-paths.mjs b/.github/scripts/lib/tooling-paths.mjs index 90b29e59..70a1a143 100644 --- a/.github/scripts/lib/tooling-paths.mjs +++ b/.github/scripts/lib/tooling-paths.mjs @@ -26,9 +26,7 @@ export function prependPathEntries(env, entries) { const existing = String(env[pathKey] ?? '') .split(path.delimiter) .filter(Boolean); - const normalized = new Set( - existing.map((entry) => (isWindows ? entry.toLowerCase() : entry)), - ); + const normalized = new Set(existing.map((entry) => (isWindows ? entry.toLowerCase() : entry))); for (const entry of entries) { if (!entry || !existsSync(entry)) { @@ -178,12 +176,7 @@ function findVsDevCmd() { const vswhere = programFilesX86 === undefined ? null - : path.join( - programFilesX86, - 'Microsoft Visual Studio', - 'Installer', - 'vswhere.exe', - ); + : path.join(programFilesX86, 'Microsoft Visual Studio', 'Installer', 'vswhere.exe'); if (vswhere && existsSync(vswhere)) { const result = spawnSync( diff --git a/.github/scripts/run-hook.mjs b/.github/scripts/run-hook.mjs index f80400ab..218ebb3e 100644 --- a/.github/scripts/run-hook.mjs +++ b/.github/scripts/run-hook.mjs @@ -63,15 +63,19 @@ function runCommitMsg() { fail('commit-msg hook requires a path to the commit message file'); } - run('npm', [ - 'exec', - '--', - 'commitlint', - '--config', - path.join(repoRoot, '.github', 'commitlint.config.js'), - '--edit', - commitMessageFile, - ], srcDir); + run( + 'npm', + [ + 'exec', + '--', + 'commitlint', + '--config', + path.join(repoRoot, '.github', 'commitlint.config.js'), + '--edit', + commitMessageFile, + ], + srcDir, + ); } const hooks = { From 1c233a72e9ad1475c05279ab66e3e1eb9d93c8e7 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Wed, 6 May 2026 16:36:01 +0300 Subject: [PATCH 099/126] fix(integrations): harden SDK tooling and API tokens --- .github/scripts/integration/doctor.mjs | 65 +++--- .github/scripts/integration/scaffold.mjs | 55 ++++- .github/scripts/workflow.mjs | 2 + docs/en/CUSTOM_INTEGRATIONS.md | 2 +- docs/en/USER_GUIDE.md | 2 +- .../settings-ui/axelate-settings-bridge.js | 16 +- .../integrations/python-ai-tool/src/main.py | 15 +- .../sdk/browser/axelate-settings-bridge.js | 13 +- .../sdk/javascript/axelate-client.mjs | 36 +++- docs/examples/sdk/python/axelate_sdk.py | 81 ++++++- docs/ru/INTEGRATION_DEVELOPMENT.md | 7 +- .../resources/module_settings_host/host.js | 201 ++++++++---------- src-tauri/src/api/secure/mod.rs | 6 +- src-tauri/src/domain/integration_api.rs | 92 ++++++-- .../domain/modules/controller/lifecycle.rs | 4 +- .../src/domain/modules/controller/mod.rs | 1 + .../modules/controller/script_runtime.rs | 2 +- src-tauri/src/domain/modules/downloader.rs | 1 + .../src/domain/modules/downloader_transfer.rs | 61 +++--- .../domain/modules/settings_ui_protocol.rs | 4 +- src-tauri/src/errors.rs | 5 + src-tauri/src/lib.rs | 1 + .../settings/ui/ModuleSettingsHost.test.ts | 10 +- src/package-lock.json | 14 ++ src/package.json | 1 + src/shared/types/bindings.ts | 18 +- 26 files changed, 465 insertions(+), 250 deletions(-) diff --git a/.github/scripts/integration/doctor.mjs b/.github/scripts/integration/doctor.mjs index c3e729c8..e4b50213 100644 --- a/.github/scripts/integration/doctor.mjs +++ b/.github/scripts/integration/doctor.mjs @@ -1,8 +1,14 @@ #!/usr/bin/env node import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { createRequire } from 'node:module'; import path from 'node:path'; import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); +const requireFromFrontend = createRequire(path.join(repoRoot, 'src', 'package.json')); +const { parse: parseToml } = requireFromFrontend('smol-toml'); const FORBIDDEN_ENTRIES = new Set([ '.axelate', @@ -47,46 +53,27 @@ function readManifest() { } function parseManifest(source) { + const parsed = parseToml(source); const values = new Map(); - let section = ''; - - source.split(/\r?\n/u).forEach((rawLine) => { - const line = rawLine.replace(/#.*/u, '').trim(); - if (line.length === 0) { - return; - } - - const sectionMatch = line.match(/^\[([A-Za-z0-9_.-]+)\]$/u); - if (sectionMatch) { - section = sectionMatch[1]; - return; - } - - const fieldMatch = line.match(/^([A-Za-z0-9_.-]+)\s*=\s*(.+)$/u); - if (!fieldMatch) { - return; - } - - const key = section.length > 0 ? `${section}.${fieldMatch[1]}` : fieldMatch[1]; - values.set(key, normalizeTomlScalar(fieldMatch[2])); - }); - + flattenToml(parsed, '', values); return values; } -function normalizeTomlScalar(rawValue) { - const trimmed = rawValue.trim(); - const quoted = trimmed.match(/^"([\s\S]*)"$/u); - if (quoted) { - return quoted[1].replace(/\\"/gu, '"'); - } - - const singleQuoted = trimmed.match(/^'([\s\S]*)'$/u); - if (singleQuoted) { - return singleQuoted[1]; +function flattenToml(value, prefix, values) { + if ( + value === null || + typeof value !== 'object' || + value instanceof Date || + Array.isArray(value) + ) { + values.set(prefix, value); + return; } - return trimmed; + Object.entries(value).forEach(([key, child]) => { + const childKey = prefix.length === 0 ? key : `${prefix}.${key}`; + flattenToml(child, childKey, values); + }); } function isSafeRelativePath(value) { @@ -216,9 +203,13 @@ function checkManifest(values) { const source = readManifest(); if (source !== null) { - const values = parseManifest(source); - checkManifest(values); - checkFilesystemTree(); + try { + const values = parseManifest(source); + checkManifest(values); + checkFilesystemTree(); + } catch (error) { + add('error', `Failed to parse axelate-module.toml: ${String(error)}`); + } } const errors = findings.filter((finding) => finding.level === 'error'); diff --git a/.github/scripts/integration/scaffold.mjs b/.github/scripts/integration/scaffold.mjs index b5268e6b..792a27b5 100644 --- a/.github/scripts/integration/scaffold.mjs +++ b/.github/scripts/integration/scaffold.mjs @@ -33,10 +33,33 @@ function slugFromName(value) { ); } +function fail(message) { + console.error(`[integration:new] ${message}`); + process.exit(1); +} + +function validateId(value) { + const trimmed = String(value).trim(); + if (!/^[A-Za-z0-9_-]+$/u.test(trimmed)) { + fail('Integration id may contain only letters, numbers, "-" and "_".'); + } + + return trimmed; +} + +function validateName(value) { + const trimmed = String(value).trim().replace(/\s+/gu, ' '); + if (!/^[\p{L}\p{N} _-]+$/u.test(trimmed)) { + fail('Integration name may contain only letters, numbers, spaces, "-" and "_".'); + } + + return trimmed; +} + const target = path.resolve(targetArg); const defaultId = slugFromName(path.basename(target)); -const id = optionValue('--id', defaultId); -const name = optionValue('--name', id.replace(/[-_]+/gu, ' ')); +const id = validateId(optionValue('--id', defaultId)); +const name = validateName(optionValue('--name', id.replace(/[-_]+/gu, ' '))); if (existsSync(target)) { console.error(`Target already exists: ${target}`); @@ -88,13 +111,22 @@ writeFileSync( import json import os +import urllib.parse import urllib.error import urllib.request -BASE_URL = os.environ["AXELATE_HTTP_API_BASE"] +def validate_base_url(value: str) -> str: + parsed = urllib.parse.urlparse(value) + if parsed.scheme not in {"http", "https"}: + raise ValueError("AXELATE_HTTP_API_BASE must use http or https.") + return value.rstrip("/") + + +BASE_URL = validate_base_url(os.environ["AXELATE_HTTP_API_BASE"]) TOKEN = os.environ["AXELATE_HTTP_API_TOKEN"] MODULE_ID = os.environ["AXELATE_MODULE_ID"] +MODULE_PATH_ID = urllib.parse.quote(MODULE_ID) def request(method: str, path: str, payload: dict | None = None) -> dict: @@ -113,11 +145,11 @@ def request(method: str, path: str, payload: dict | None = None) -> dict: def main() -> None: - settings = request("GET", f"/v1/modules/{MODULE_ID}/settings").get("settings", {}) + settings = request("GET", f"/v1/modules/{MODULE_PATH_ID}/settings").get("settings", {}) prompt = settings.get("prompt") or "Write a short status update." request( "POST", - f"/v1/modules/{MODULE_ID}/stage", + f"/v1/modules/{MODULE_PATH_ID}/stage", {"stage": "ai.request", "label": "Calling Axelate AI", "progress": 0.5}, ) result = request("POST", "/v1/ai/text", {"prompt": prompt, "sessionId": MODULE_ID}) @@ -138,8 +170,9 @@ writeFileSync( `const CHANNEL = "axelate:module-settings"; export class AxelateSettingsBridge { - constructor(target = window.parent) { + constructor({ target = window.parent, allowedOrigin = window.location.origin } = {}) { this.target = target; + this.allowedOrigin = allowedOrigin; this.pending = new Map(); this.context = null; this.settings = {}; @@ -147,11 +180,11 @@ export class AxelateSettingsBridge { } ready() { - this.target.postMessage({ channel: CHANNEL, type: "module-ready" }, "*"); + this.target.postMessage({ channel: CHANNEL, type: "module-ready" }, this.allowedOrigin); } rendered() { - this.target.postMessage({ channel: CHANNEL, type: "module-rendered" }, "*"); + this.target.postMessage({ channel: CHANNEL, type: "module-rendered" }, this.allowedOrigin); } waitForHost() { @@ -174,7 +207,7 @@ export class AxelateSettingsBridge { request(method, payload) { const requestId = crypto.randomUUID(); - this.target.postMessage({ channel: CHANNEL, requestId, method, payload }, "*"); + this.target.postMessage({ channel: CHANNEL, requestId, method, payload }, this.allowedOrigin); return new Promise((resolve, reject) => { this.pending.set(requestId, { resolve, reject }); @@ -182,6 +215,10 @@ export class AxelateSettingsBridge { } handleMessage(event) { + if (event.origin !== this.allowedOrigin || event.source !== this.target) { + return; + } + const payload = event.data; if (payload?.channel !== CHANNEL) { return; diff --git a/.github/scripts/workflow.mjs b/.github/scripts/workflow.mjs index c27bf28e..dd072365 100644 --- a/.github/scripts/workflow.mjs +++ b/.github/scripts/workflow.mjs @@ -951,6 +951,7 @@ Tasks: run('npm', ['ci'], { cwd: srcDir }); }, 'integration:doctor'() { + ensureFrontendDependencies(); run( 'node', withPassthroughArgs([ @@ -959,6 +960,7 @@ Tasks: ); }, 'integration:new'() { + ensureFrontendDependencies(); run( 'node', withPassthroughArgs([ diff --git a/docs/en/CUSTOM_INTEGRATIONS.md b/docs/en/CUSTOM_INTEGRATIONS.md index cafbbcc9..192aafd8 100644 --- a/docs/en/CUSTOM_INTEGRATIONS.md +++ b/docs/en/CUSTOM_INTEGRATIONS.md @@ -35,7 +35,7 @@ settings_ui = "settings-ui/index.html" [runtime] kind = "python" -version = "3.14" +version = "3.11" entry = "src/main.py" dependencies = "requirements.txt" ``` diff --git a/docs/en/USER_GUIDE.md b/docs/en/USER_GUIDE.md index f01c5272..ebf4b071 100644 --- a/docs/en/USER_GUIDE.md +++ b/docs/en/USER_GUIDE.md @@ -7,7 +7,7 @@ Axelate is a Windows-first AI workstation. It can: -- use BYOK cloud AI providers through the launcher UI +- use BYOK (Bring Your Own Key) cloud AI providers through the launcher UI - run chat and image requests - manage local AI engines such as `llamacpp` and `sdcpp` - import local integrations from folders, archives, or supported URLs diff --git a/docs/examples/integrations/python-ai-tool/settings-ui/axelate-settings-bridge.js b/docs/examples/integrations/python-ai-tool/settings-ui/axelate-settings-bridge.js index 47bbf1e0..f81b1f9f 100644 --- a/docs/examples/integrations/python-ai-tool/settings-ui/axelate-settings-bridge.js +++ b/docs/examples/integrations/python-ai-tool/settings-ui/axelate-settings-bridge.js @@ -1,8 +1,9 @@ const CHANNEL = 'axelate:module-settings'; export class AxelateSettingsBridge { - constructor(target = window.parent) { + constructor({ target = window.parent, allowedOrigin = window.location.origin } = {}) { this.target = target; + this.allowedOrigin = allowedOrigin; this.pending = new Map(); this.context = null; this.settings = {}; @@ -10,11 +11,11 @@ export class AxelateSettingsBridge { } ready() { - this.target.postMessage({ channel: CHANNEL, type: 'module-ready' }, '*'); + this.target.postMessage({ channel: CHANNEL, type: 'module-ready' }, this.allowedOrigin); } rendered() { - this.target.postMessage({ channel: CHANNEL, type: 'module-rendered' }, '*'); + this.target.postMessage({ channel: CHANNEL, type: 'module-rendered' }, this.allowedOrigin); } waitForHost() { @@ -37,7 +38,10 @@ export class AxelateSettingsBridge { request(method, payload) { const requestId = crypto.randomUUID(); - this.target.postMessage({ channel: CHANNEL, requestId, method, payload }, '*'); + this.target.postMessage( + { channel: CHANNEL, requestId, method, payload }, + this.allowedOrigin, + ); return new Promise((resolve, reject) => { this.pending.set(requestId, { resolve, reject }); @@ -45,6 +49,10 @@ export class AxelateSettingsBridge { } handleMessage(event) { + if (event.origin !== this.allowedOrigin || event.source !== this.target) { + return; + } + const payload = event.data; if (payload?.channel !== CHANNEL) { return; diff --git a/docs/examples/integrations/python-ai-tool/src/main.py b/docs/examples/integrations/python-ai-tool/src/main.py index aded972c..b703c278 100644 --- a/docs/examples/integrations/python-ai-tool/src/main.py +++ b/docs/examples/integrations/python-ai-tool/src/main.py @@ -3,12 +3,21 @@ import json import os import urllib.error +import urllib.parse import urllib.request -BASE_URL = os.environ["AXELATE_HTTP_API_BASE"] +def validate_base_url(value: str) -> str: + parsed = urllib.parse.urlparse(value) + if parsed.scheme not in {"http", "https"}: + raise ValueError("AXELATE_HTTP_API_BASE must use http or https.") + return value.rstrip("/") + + +BASE_URL = validate_base_url(os.environ["AXELATE_HTTP_API_BASE"]) TOKEN = os.environ["AXELATE_HTTP_API_TOKEN"] MODULE_ID = os.environ["AXELATE_MODULE_ID"] +MODULE_PATH_ID = urllib.parse.quote(MODULE_ID) def request(method: str, path: str, payload: dict | None = None) -> dict: @@ -27,12 +36,12 @@ def request(method: str, path: str, payload: dict | None = None) -> dict: def main() -> None: - settings = request("GET", f"/v1/modules/{MODULE_ID}/settings").get("settings", {}) + settings = request("GET", f"/v1/modules/{MODULE_PATH_ID}/settings").get("settings", {}) prompt = settings.get("prompt") or "Write a short status update." request( "POST", - f"/v1/modules/{MODULE_ID}/stage", + f"/v1/modules/{MODULE_PATH_ID}/stage", {"stage": "ai.request", "label": "Calling Axelate AI", "progress": 0.5}, ) diff --git a/docs/examples/sdk/browser/axelate-settings-bridge.js b/docs/examples/sdk/browser/axelate-settings-bridge.js index bd2b2dd6..0ec13563 100644 --- a/docs/examples/sdk/browser/axelate-settings-bridge.js +++ b/docs/examples/sdk/browser/axelate-settings-bridge.js @@ -1,8 +1,9 @@ const CHANNEL = 'axelate:module-settings'; export class AxelateSettingsBridge { - constructor(target = window.parent) { + constructor({ target = window.parent, allowedOrigin = window.location.origin } = {}) { this.target = target; + this.allowedOrigin = allowedOrigin; this.pending = new Map(); this.context = null; this.settings = {}; @@ -10,11 +11,11 @@ export class AxelateSettingsBridge { } ready() { - this.target.postMessage({ channel: CHANNEL, type: 'module-ready' }, '*'); + this.target.postMessage({ channel: CHANNEL, type: 'module-ready' }, this.allowedOrigin); } rendered() { - this.target.postMessage({ channel: CHANNEL, type: 'module-rendered' }, '*'); + this.target.postMessage({ channel: CHANNEL, type: 'module-rendered' }, this.allowedOrigin); } waitForHost() { @@ -52,7 +53,7 @@ export class AxelateSettingsBridge { method, payload, }, - '*', + this.allowedOrigin, ); return new Promise((resolve, reject) => { @@ -61,6 +62,10 @@ export class AxelateSettingsBridge { } handleMessage(event) { + if (event.origin !== this.allowedOrigin || event.source !== this.target) { + return; + } + const payload = event.data; if (payload?.channel !== CHANNEL) { return; diff --git a/docs/examples/sdk/javascript/axelate-client.mjs b/docs/examples/sdk/javascript/axelate-client.mjs index 2ef21ee7..abfcc922 100644 --- a/docs/examples/sdk/javascript/axelate-client.mjs +++ b/docs/examples/sdk/javascript/axelate-client.mjs @@ -19,22 +19,30 @@ export class AxelateClient { body: payload === undefined ? undefined : JSON.stringify(payload), }); - const body = await response.json(); + const body = await readResponseBody(response); if (!response.ok) { - throw new Error(body.error ?? `Axelate request failed: ${response.status}`); + const message = + body && typeof body === 'object' && 'error' in body + ? body.error + : `Axelate request failed: ${response.status}`; + throw new Error(String(message)); } return body; } settings() { - return this.request('GET', `/v1/modules/${this.moduleId}/settings`).then( + return this.request('GET', `/v1/modules/${encodeURIComponent(this.moduleId)}/settings`).then( (body) => body.settings ?? {}, ); } saveSettings(settings) { - return this.request('PUT', `/v1/modules/${this.moduleId}/settings`, settings); + return this.request( + 'PUT', + `/v1/modules/${encodeURIComponent(this.moduleId)}/settings`, + settings, + ); } stage(stage, label, progress) { @@ -42,7 +50,7 @@ export class AxelateClient { if (progress !== undefined) { payload.progress = progress; } - return this.request('POST', `/v1/modules/${this.moduleId}/stage`, payload); + return this.request('POST', `/v1/modules/${encodeURIComponent(this.moduleId)}/stage`, payload); } aiText(prompt, options = {}) { @@ -53,3 +61,21 @@ export class AxelateClient { }); } } + +async function readResponseBody(response) { + if (response.status === 204) { + return {}; + } + + const contentType = response.headers.get('content-type') ?? ''; + const text = await response.text(); + if (text.length === 0) { + return {}; + } + + if (contentType.includes('application/json')) { + return JSON.parse(text); + } + + return { text }; +} diff --git a/docs/examples/sdk/python/axelate_sdk.py b/docs/examples/sdk/python/axelate_sdk.py index 7f768f26..01b6817f 100644 --- a/docs/examples/sdk/python/axelate_sdk.py +++ b/docs/examples/sdk/python/axelate_sdk.py @@ -2,15 +2,28 @@ import json import os +import urllib.error +import urllib.parse import urllib.request from typing import Any +LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"} + + +class AxelateApiError(RuntimeError): + def __init__(self, method: str, path: str, status: int | None, body: str, message: str) -> None: + self.method = method + self.path = path + self.status = status + self.body = body + super().__init__(f"{method} {path} failed: {message}") + class AxelateClient: def __init__(self) -> None: - self.base_url = os.environ["AXELATE_HTTP_API_BASE"].rstrip("/") - self.token = os.environ["AXELATE_HTTP_API_TOKEN"] - self.module_id = os.environ["AXELATE_MODULE_ID"] + self.base_url = validate_base_url(required_env("AXELATE_HTTP_API_BASE")).rstrip("/") + self.token = required_env("AXELATE_HTTP_API_TOKEN") + self.module_id = required_env("AXELATE_MODULE_ID") def request(self, method: str, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: data = None if payload is None else json.dumps(payload).encode("utf-8") @@ -23,21 +36,71 @@ def request(self, method: str, path: str, payload: dict[str, Any] | None = None) "Content-Type": "application/json", }, ) - with urllib.request.urlopen(request, timeout=120) as response: - return json.loads(response.read().decode("utf-8")) + try: + with urllib.request.urlopen(request, timeout=120) as response: + body = response.read().decode("utf-8") + return {} if body == "" else json.loads(body) + except urllib.error.HTTPError as error: + body = error.read().decode("utf-8", errors="replace") + raise AxelateApiError( + method, + path, + error.code, + body, + extract_error_message(body) or error.reason, + ) from error + except urllib.error.URLError as error: + raise AxelateApiError(method, path, None, "", str(error.reason)) from error def settings(self) -> dict[str, Any]: - return self.request("GET", f"/v1/modules/{self.module_id}/settings").get("settings", {}) + payload = self.request("GET", f"/v1/modules/{urllib.parse.quote(self.module_id)}/settings") + if "error" in payload: + raise AxelateApiError("GET", "/settings", None, json.dumps(payload), str(payload["error"])) + return payload.get("settings", {}) def save_settings(self, settings: dict[str, Any]) -> dict[str, Any]: - return self.request("PUT", f"/v1/modules/{self.module_id}/settings", settings) + return self.request( + "PUT", + f"/v1/modules/{urllib.parse.quote(self.module_id)}/settings", + settings, + ) def stage(self, stage: str, label: str, progress: float | None = None) -> dict[str, Any]: payload: dict[str, Any] = {"stage": stage, "label": label} if progress is not None: payload["progress"] = progress - return self.request("POST", f"/v1/modules/{self.module_id}/stage", payload) + return self.request("POST", f"/v1/modules/{urllib.parse.quote(self.module_id)}/stage", payload) def ai_text(self, prompt: str, **options: Any) -> dict[str, Any]: - payload = {"prompt": prompt, "sessionId": self.module_id, **options} + """Run text generation. sessionId defaults to module_id and can be overridden.""" + session_id = options.pop("sessionId", self.module_id) + payload = {"prompt": prompt, "sessionId": session_id, **options} return self.request("POST", "/v1/ai/text", payload) + + +def required_env(name: str) -> str: + value = os.environ.get(name) + if value is None or value.strip() == "": + raise ValueError(f"Missing required Axelate integration env var: set {name}.") + return value + + +def validate_base_url(value: str) -> str: + parsed = urllib.parse.urlparse(value) + if parsed.scheme not in {"http", "https"}: + raise ValueError("AXELATE_HTTP_API_BASE must use http or https.") + if parsed.hostname not in LOOPBACK_HOSTS: + raise ValueError("AXELATE_HTTP_API_BASE must point to localhost, 127.0.0.1, or ::1.") + return value + + +def extract_error_message(body: str) -> str | None: + try: + parsed = json.loads(body) + except json.JSONDecodeError: + return body or None + if isinstance(parsed, dict): + error = parsed.get("error") or parsed.get("message") + if isinstance(error, str): + return error + return body or None diff --git a/docs/ru/INTEGRATION_DEVELOPMENT.md b/docs/ru/INTEGRATION_DEVELOPMENT.md index e5d43bb0..adafd62d 100644 --- a/docs/ru/INTEGRATION_DEVELOPMENT.md +++ b/docs/ru/INTEGRATION_DEVELOPMENT.md @@ -28,8 +28,8 @@ npm run integration:doctor -- ./my-integration - `docs/examples/sdk/browser/axelate-settings-bridge.js` - helper для iframe-протокола custom settings UI. -Главный контракт все равно описан в [Launcher SDK](../en/LAUNCHER_SDK.md): это -локальный HTTP API лаунчера. +Главный контракт все равно описан в [Launcher SDK](../en/LAUNCHER_SDK.md) +(англ., в `docs/en/LAUNCHER_SDK.md`): это локальный HTTP API лаунчера. ## Структура интеграции @@ -129,4 +129,5 @@ console.log(reply); ## Правило доверия Импортированные интеграции - это локальный код, который пользователь сам решил -запустить. Сейчас это не reviewed, signed или sandboxed packages. +запустить. Сейчас это не проверенные, не подписанные и не выполняемые в +песочнице пакеты. diff --git a/src-tauri/resources/module_settings_host/host.js b/src-tauri/resources/module_settings_host/host.js index 49857a4c..c1c386c3 100644 --- a/src-tauri/resources/module_settings_host/host.js +++ b/src-tauri/resources/module_settings_host/host.js @@ -1,43 +1,42 @@ -const BRIDGE_CHANNEL = "axelate:module-settings"; -const HOST_CHANNEL = "axelate:module-settings-host"; +const BRIDGE_CHANNEL = 'axelate:module-settings'; +const HOST_CHANNEL = 'axelate:module-settings-host'; const SETTINGS_LOAD_TIMEOUT_MS = 2500; const MODULE_BOOT_TIMEOUT_MS = 5000; const STRINGS = { en: { - loading: "Loading module settings…", - loadingModule: "Loading module settings…", - failed: "Failed to load the module or integration settings UI.", - invalidSavePayload: "Settings payload must be a plain object.", - moduleBootTimedOut: - "The module or integration settings UI did not finish loading.", + loading: 'Loading module settings…', + loadingModule: 'Loading module settings…', + failed: 'Failed to load the module or integration settings UI.', + invalidSavePayload: 'Settings payload must be a plain object.', + moduleBootTimedOut: 'The module or integration settings UI did not finish loading.', }, ru: { - loading: "Загрузка интерфейса настроек модуля…", - loadingModule: "Загрузка интерфейса настроек модуля…", - failed: "Не удалось загрузить интерфейс настроек этого модуля или интеграции.", - invalidSavePayload: "Настройки должны передаваться как обычный объект.", - moduleBootTimedOut: - "Интерфейс настроек этого модуля или интеграции не завершил загрузку.", + loading: 'Загрузка интерфейса настроек модуля…', + loadingModule: 'Загрузка интерфейса настроек модуля…', + failed: 'Не удалось загрузить интерфейс настроек этого модуля или интеграции.', + invalidSavePayload: 'Настройки должны передаваться как обычный объект.', + moduleBootTimedOut: 'Интерфейс настроек этого модуля или интеграции не завершил загрузку.', }, zh: { - loading: "正在加载模块设置界面…", - loadingModule: "正在加载模块设置界面…", - failed: "无法加载该模块或集成的设置界面。", - invalidSavePayload: "设置载荷必须是普通对象。", - moduleBootTimedOut: "该模块或集成的设置界面未能完成加载。", + loading: '正在加载模块设置界面…', + loadingModule: '正在加载模块设置界面…', + failed: '无法加载该模块或集成的设置界面。', + invalidSavePayload: '设置载荷必须是普通对象。', + moduleBootTimedOut: '该模块或集成的设置界面未能完成加载。', }, }; const params = new URLSearchParams(globalThis.location.search); -const language = normalizeLanguage(params.get("language")); +const language = normalizeLanguage(params.get('language')); const strings = STRINGS[language] ?? STRINGS.en; const sessionPrefix = resolveSessionPrefix(globalThis.location.pathname); +const hostOrigin = globalThis.location.origin; const elements = { - frame: document.getElementById("module-frame"), - overlay: document.getElementById("overlay"), - overlayMessage: document.getElementById("overlay-message"), + frame: document.getElementById('module-frame'), + overlay: document.getElementById('overlay'), + overlayMessage: document.getElementById('overlay-message'), }; const state = { @@ -52,7 +51,7 @@ const state = { }; document.documentElement.dataset.theme = state.context.launcher.theme; -setOverlay("loading", strings.loadingModule); +setOverlay('loading', strings.loadingModule); void bootstrap().catch((error) => { showFatalError(error); @@ -60,8 +59,8 @@ void bootstrap().catch((error) => { async function bootstrap() { ensureElements(); - postHostStatus("host-ready"); - globalThis.addEventListener("message", handleModuleMessage); + postHostStatus('host-ready'); + globalThis.addEventListener('message', handleModuleMessage); const settingsLoad = loadSettingsSafe(); mountModuleFrame(); await settingsLoad; @@ -73,7 +72,7 @@ function ensureElements() { !(elements.overlay instanceof HTMLElement) || !(elements.overlayMessage instanceof HTMLElement) ) { - throw new Error("Host UI elements are missing"); + throw new Error('Host UI elements are missing'); } } @@ -81,52 +80,51 @@ function buildContext(searchParams) { return { bridgeVersion: 1, module: { - id: searchParams.get("moduleId") ?? "", - name: - searchParams.get("name") ?? searchParams.get("moduleId") ?? "", - category: searchParams.get("category") ?? "", - type: searchParams.get("type") ?? "", - settingsUi: searchParams.get("settingsUi"), + id: searchParams.get('moduleId') ?? '', + name: searchParams.get('name') ?? searchParams.get('moduleId') ?? '', + category: searchParams.get('category') ?? '', + type: searchParams.get('type') ?? '', + settingsUi: searchParams.get('settingsUi'), }, launcher: { language, - theme: normalizeTheme(searchParams.get("theme")), + theme: normalizeTheme(searchParams.get('theme')), }, }; } function normalizeLanguage(rawLanguage) { - const normalized = String(rawLanguage ?? "en") + const normalized = String(rawLanguage ?? 'en') .trim() .toLowerCase(); - if (normalized.startsWith("ru")) { - return "ru"; + if (normalized.startsWith('ru')) { + return 'ru'; } - if (normalized.startsWith("zh")) { - return "zh"; + if (normalized.startsWith('zh')) { + return 'zh'; } - return "en"; + return 'en'; } function normalizeTheme(rawTheme) { - return String(rawTheme ?? "dark") + return String(rawTheme ?? 'dark') .trim() - .toLowerCase() === "light" - ? "light" - : "dark"; + .toLowerCase() === 'light' + ? 'light' + : 'dark'; } function resolveSessionPrefix(pathname) { const match = pathname.match(/^\/session\/([^/]+)/); - return match === null ? "" : `/session/${match[1]}`; + return match === null ? '' : `/session/${match[1]}`; } async function loadSettingsSafe() { try { state.settings = await withTimeout( - requestJson("/api/settings"), + requestJson('/api/settings'), SETTINGS_LOAD_TIMEOUT_MS, - "Settings load timed out.", + 'Settings load timed out.', ); } catch { state.settings = {}; @@ -142,16 +140,16 @@ function mountModuleFrame() { return; } - elements.frame.addEventListener("load", () => { + elements.frame.addEventListener('load', () => { state.frameLoaded = true; applyEmbeddedModuleChrome(); revealModuleWhenReady(); }); - elements.frame.addEventListener("error", () => { + elements.frame.addEventListener('error', () => { showFatalError(new Error(strings.failed)); }); armModuleBootTimeout(); - elements.frame.src = buildUrl("/module/"); + elements.frame.src = buildUrl('/module/'); } function handleModuleMessage(event) { @@ -159,19 +157,23 @@ function handleModuleMessage(event) { return; } + if (event.origin !== hostOrigin || event.source !== elements.frame.contentWindow) { + return; + } + const payload = event.data; if (!isBridgePayload(payload)) { return; } - if (payload.type === "module-ready") { + if (payload.type === 'module-ready') { state.moduleReady = true; postHostReadyWhenPossible(); revealModuleWhenReady(); return; } - if (payload.type === "module-rendered") { + if (payload.type === 'module-rendered') { state.moduleReady = true; state.moduleRendered = true; postHostReadyWhenPossible(); @@ -179,10 +181,7 @@ function handleModuleMessage(event) { return; } - if ( - typeof payload.requestId !== "string" || - typeof payload.method !== "string" - ) { + if (typeof payload.requestId !== 'string' || typeof payload.method !== 'string') { return; } @@ -190,29 +189,22 @@ function handleModuleMessage(event) { } function isBridgePayload(payload) { - return ( - typeof payload === "object" && - payload !== null && - payload.channel === BRIDGE_CHANNEL - ); + return typeof payload === 'object' && payload !== null && payload.channel === BRIDGE_CHANNEL; } function postHostReady() { - if ( - !(elements.frame instanceof HTMLIFrameElement) || - elements.frame.contentWindow === null - ) { + if (!(elements.frame instanceof HTMLIFrameElement) || elements.frame.contentWindow === null) { return; } elements.frame.contentWindow.postMessage( { channel: BRIDGE_CHANNEL, - type: "host-ready", + type: 'host-ready', context: state.context, settings: state.settings, }, - "*", + hostOrigin, ); } @@ -275,30 +267,28 @@ async function processModuleRequest(request) { async function resolveRequest(request) { switch (request.method) { - case "getContext": + case 'getContext': return state.context; - case "getSettings": + case 'getSettings': return state.settings; - case "saveSettings": + case 'saveSettings': return await saveSettings(request.payload); // Keep optional bridge hooks compatible without letting the host own module UX. - case "markDirty": + case 'markDirty': return { dirty: true }; - case "notify": + case 'notify': return { shown: true }; default: - throw new Error( - `Unsupported custom settings method: ${request.method}`, - ); + throw new Error(`Unsupported custom settings method: ${request.method}`); } } async function saveSettings(payload) { const normalizedSettings = normalizeSettingsPayload(payload); - const savedSettings = await requestJson("/api/settings", { - method: "POST", + const savedSettings = await requestJson('/api/settings', { + method: 'POST', headers: { - "content-type": "application/json", + 'content-type': 'application/json', }, body: JSON.stringify(normalizedSettings), }); @@ -316,18 +306,15 @@ function normalizeSettingsPayload(payload) { } function isPlainObject(value) { - return typeof value === "object" && value !== null && !Array.isArray(value); + return typeof value === 'object' && value !== null && !Array.isArray(value); } function postBridgeResponse(message) { - if ( - !(elements.frame instanceof HTMLIFrameElement) || - elements.frame.contentWindow === null - ) { + if (!(elements.frame instanceof HTMLIFrameElement) || elements.frame.contentWindow === null) { return; } - elements.frame.contentWindow.postMessage(message, "*"); + elements.frame.contentWindow.postMessage(message, hostOrigin); } function setOverlay(stateName, message) { @@ -349,7 +336,7 @@ function hideOverlay() { } elements.overlay.hidden = true; - postHostStatus("module-rendered"); + postHostStatus('module-rendered'); } function applyEmbeddedModuleChrome() { @@ -368,12 +355,12 @@ function applyEmbeddedModuleChrome() { return; } - const styleId = "axelate-embedded-module-settings-style"; + const styleId = 'axelate-embedded-module-settings-style'; if (frameDocument.getElementById(styleId) !== null) { return; } - const style = frameDocument.createElement("style"); + const style = frameDocument.createElement('style'); style.id = styleId; style.textContent = ` :root { @@ -434,10 +421,10 @@ function applyEmbeddedModuleChrome() { styleHost.appendChild(style); frameDocument.documentElement?.dataset && - (frameDocument.documentElement.dataset.axelateEmbedded = "true"); + (frameDocument.documentElement.dataset.axelateEmbedded = 'true'); } -function postHostStatus(type, message = "") { +function postHostStatus(type, message = '') { if (globalThis.parent === globalThis) { return; } @@ -448,22 +435,22 @@ function postHostStatus(type, message = "") { type, message, }, - "*", + hostOrigin, ); } async function requestJson(path, init = {}) { const response = await fetch(buildUrl(path), { - cache: "no-store", + cache: 'no-store', ...init, }); const bodyText = await response.text(); - const parsedBody = bodyText === "" ? {} : safeParseJson(bodyText); + const parsedBody = bodyText === '' ? {} : safeParseJson(bodyText); if (!response.ok) { const errorMessage = - isPlainObject(parsedBody) && typeof parsedBody.message === "string" + isPlainObject(parsedBody) && typeof parsedBody.message === 'string' ? parsedBody.message : strings.failed; throw new Error(errorMessage); @@ -500,19 +487,13 @@ function safeParseJson(input) { } function buildUrl(path) { - const normalizedPath = path.startsWith("/") ? path : `/${path}`; - const scopedPath = - sessionPrefix === "" - ? normalizedPath - : `${sessionPrefix}${normalizedPath}`; - - const url = new URL( - scopedPath, - `${globalThis.location.protocol}//${globalThis.location.host}`, - ); - if (normalizedPath === "/module/") { - url.searchParams.set("embedded", "1"); - url.searchParams.set("host", "axelate"); + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + const scopedPath = sessionPrefix === '' ? normalizedPath : `${sessionPrefix}${normalizedPath}`; + + const url = new URL(scopedPath, `${globalThis.location.protocol}//${globalThis.location.host}`); + if (normalizedPath === '/module/') { + url.searchParams.set('embedded', '1'); + url.searchParams.set('host', 'axelate'); } return url.toString(); @@ -521,9 +502,7 @@ function buildUrl(path) { function showFatalError(error) { clearModuleBootTimeout(); const message = - error instanceof Error && error.message.trim() !== "" - ? error.message - : strings.failed; - setOverlay("error", message); - postHostStatus("host-error", message); + error instanceof Error && error.message.trim() !== '' ? error.message : strings.failed; + setOverlay('error', message); + postHostStatus('host-error', message); } diff --git a/src-tauri/src/api/secure/mod.rs b/src-tauri/src/api/secure/mod.rs index a2ce8435..d06633dd 100644 --- a/src-tauri/src/api/secure/mod.rs +++ b/src-tauri/src/api/secure/mod.rs @@ -28,7 +28,7 @@ fn is_frontend_readable_secret(service: &str) -> bool { fn ensure_frontend_managed_secret(service: &str) -> Result { let normalized = normalize_service_name(service); if !is_frontend_managed_secret(&normalized) { - return Err(AppError::Validation(format!( + return Err(AppError::FrontendSecretForbidden(format!( "Secret is not allowed through the frontend secure API: {normalized}" ))); } @@ -135,7 +135,7 @@ mod tests { assert!(matches!( err, - AppError::Validation(message) if message.contains("frontend secure API") + AppError::FrontendSecretForbidden(message) if message.contains("frontend secure API") )); } @@ -147,7 +147,7 @@ mod tests { assert!(matches!( err, - AppError::Validation(message) if message.contains("frontend secure API") + AppError::FrontendSecretForbidden(message) if message.contains("frontend secure API") )); } } diff --git a/src-tauri/src/domain/integration_api.rs b/src-tauri/src/domain/integration_api.rs index 2d5c4523..c38e687f 100644 --- a/src-tauri/src/domain/integration_api.rs +++ b/src-tauri/src/domain/integration_api.rs @@ -26,6 +26,7 @@ use serde_json::json; use std::collections::HashMap; use std::io::{Read, Write}; use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::panic::AssertUnwindSafe; use std::sync::{Arc, Mutex, mpsc}; use std::time::Duration; use tauri::{AppHandle, Emitter}; @@ -78,30 +79,60 @@ pub fn api_token() -> &'static str { } /// Adds local launcher API environment variables to a module process. -pub fn apply_process_env(command: &mut tokio::process::Command, module_id: &str) { +pub fn apply_process_env( + command: &mut tokio::process::Command, + module_id: &str, +) -> Result<(), AppError> { let module_dir = crate::domain::modules::downloader::get_module_path(module_id); let module_runtime_dir = crate::domain::modules::paths::runtime_root(module_id); let module_log_dir = crate::domain::modules::paths::log_dir(module_id); + let token = issue_module_api_token(module_id)?; command .env("AXELATE_HTTP_API_BASE", api_base_url()) - .env("AXELATE_HTTP_API_TOKEN", issue_module_api_token(module_id)) + .env("AXELATE_HTTP_API_TOKEN", token) .env("AXELATE_SDK_VERSION", SDK_API_VERSION) .env("AXELATE_MODULE_ID", module_id) .env("AXELATE_MODULE_DIR", module_dir) .env("AXELATE_RUNTIME_DIR", &*crate::utils::paths::RUNTIME_DIR) .env("AXELATE_MODULE_RUNTIME_DIR", module_runtime_dir) .env("AXELATE_MODULE_LOG_DIR", module_log_dir); + + Ok(()) } -fn issue_module_api_token(module_id: &str) -> String { +fn issue_module_api_token(module_id: &str) -> Result { let token = format!("{module_id}.{}", uuid::Uuid::new_v4().simple()); - if let Ok(mut tokens) = MODULE_API_TOKENS.lock() { - tokens.insert(module_id.to_string(), token.clone()); - } else { - tracing::warn!("Failed to register module API token for {module_id}"); + let mut tokens = MODULE_API_TOKENS.lock().map_err(|_| AppError::Internal { + request_id: None, + message: format!("Failed to register module API token for {module_id}"), + })?; + tokens.insert(module_id.to_string(), token.clone()); + Ok(token) +} + +/// Revokes the local API token for a module process. +pub fn revoke_module_api_token(module_id: &str) { + match MODULE_API_TOKENS.lock() { + Ok(mut tokens) => { + tokens.remove(module_id); + } + Err(error) => { + tracing::warn!("Failed to revoke module API token for {module_id}: {error}"); + } + } +} + +/// Revokes all module-scoped local API tokens. +pub fn revoke_all_module_api_tokens() { + match MODULE_API_TOKENS.lock() { + Ok(mut tokens) => { + tokens.clear(); + } + Err(error) => { + tracing::warn!("Failed to revoke module API tokens: {error}"); + } } - token } /// Starts the local launcher HTTP API server. @@ -312,16 +343,27 @@ struct ModuleStageChangedEvent { fn serve_launcher_http_api(listener: &TcpListener, context: &LauncherHttpApiContext) { let (sender, receiver) = mpsc::sync_channel(MAX_HTTP_API_QUEUE); let receiver = Arc::new(Mutex::new(receiver)); + let mut started_workers = 0_usize; for worker_index in 0..MAX_HTTP_API_WORKERS { let worker_receiver = Arc::clone(&receiver); - if let Err(error) = std::thread::Builder::new() + match std::thread::Builder::new() .name(format!("axelate-local-http-worker-{worker_index}")) .spawn(move || run_http_api_worker(&worker_receiver)) { - tracing::warn!("Failed to spawn launcher HTTP API worker: {error}"); + Ok(_) => { + started_workers += 1; + } + Err(error) => { + tracing::warn!("Failed to spawn launcher HTTP API worker: {error}"); + } } } + if started_workers == 0 { + tracing::error!("Launcher HTTP API started with zero worker threads"); + return; + } + for incoming in listener.incoming() { match incoming { Ok(mut stream) => { @@ -372,14 +414,21 @@ fn run_http_api_worker(receiver: &HttpWorkerReceiver) { let job = { let Ok(receiver) = receiver.lock() else { tracing::warn!("Launcher HTTP API worker receiver lock is poisoned"); - return; + std::thread::sleep(Duration::from_millis(100)); + continue; }; receiver.recv() }; match job { Ok(job) => { - handle_validated_request(job.stream, job.request, job.context, job.peer_addr); + if std::panic::catch_unwind(AssertUnwindSafe(|| { + handle_validated_request(job.stream, job.request, job.context, job.peer_addr); + })) + .is_err() + { + tracing::error!("Launcher HTTP API worker recovered from request panic"); + } } Err(_) => return, } @@ -625,7 +674,7 @@ const fn status_for_app_error(error: &AppError) -> u16 { match error { AppError::Validation(_) | AppError::Config(_) => 400, AppError::NotFound(_) => 404, - AppError::PermissionDenied(_) => 403, + AppError::PermissionDenied(_) | AppError::FrontendSecretForbidden(_) => 403, AppError::Io(_) | AppError::Serialization(_) | AppError::External { .. } @@ -1464,7 +1513,7 @@ mod tests { #[test] fn authorization_maps_module_tokens_to_module_owner() { - let token = super::issue_module_api_token("sample-module"); + let token = super::issue_module_api_token("sample-module").expect("module token"); let mut headers = HashMap::new(); headers.insert("authorization".to_string(), format!("Bearer {token}")); @@ -1490,8 +1539,8 @@ mod tests { #[test] fn issuing_new_module_token_invalidates_previous_token() { - let old_token = super::issue_module_api_token("rotating-module"); - let new_token = super::issue_module_api_token("rotating-module"); + let old_token = super::issue_module_api_token("rotating-module").expect("old token"); + let new_token = super::issue_module_api_token("rotating-module").expect("new token"); let mut headers = HashMap::new(); headers.insert("authorization".to_string(), format!("Bearer {old_token}")); @@ -1664,7 +1713,7 @@ mod tests { fn apply_process_env_sets_documented_integration_contract() { let module_id = "sample"; let mut command = tokio::process::Command::new("sample-command"); - super::apply_process_env(&mut command, module_id); + super::apply_process_env(&mut command, module_id).expect("process env"); let envs = command .as_std() @@ -1686,7 +1735,14 @@ mod tests { Some(module_id) ); assert!(envs.contains_key("AXELATE_HTTP_API_BASE")); - assert!(envs.contains_key("AXELATE_HTTP_API_TOKEN")); + let token = envs + .get("AXELATE_HTTP_API_TOKEN") + .expect("module API token"); + let headers = HashMap::from([("authorization".to_string(), format!("Bearer {token}"))]); + assert_eq!( + super::authorize_request(&headers), + Some(super::AuthorizedClient::Module(module_id.to_string())) + ); assert!(envs.contains_key("AXELATE_MODULE_DIR")); assert!(envs.contains_key("AXELATE_RUNTIME_DIR")); assert!(envs.contains_key("AXELATE_MODULE_RUNTIME_DIR")); diff --git a/src-tauri/src/domain/modules/controller/lifecycle.rs b/src-tauri/src/domain/modules/controller/lifecycle.rs index f1a5864d..8e36955b 100644 --- a/src-tauri/src/domain/modules/controller/lifecycle.rs +++ b/src-tauri/src/domain/modules/controller/lifecycle.rs @@ -158,7 +158,7 @@ impl<'a> LifecycleExecutor<'a> { .map_err(|e| AppError::Io(e.to_string()))?, )) .stderr(Stdio::from(log_file)); - crate::domain::integration_api::apply_process_env(&mut builder, &self.module_id); + crate::domain::integration_api::apply_process_env(&mut builder, &self.module_id)?; builder.spawn().map_err(|e| AppError::Internal { request_id: None, @@ -207,6 +207,7 @@ impl<'a> LifecycleExecutor<'a> { match outcome { Ok(Some(_status)) => { controller_registry.remove(&module_id); + crate::domain::integration_api::revoke_module_api_token(&module_id); tracing::info!( "Module {module_id} exited naturally and was cleaned up from registry" ); @@ -220,6 +221,7 @@ impl<'a> LifecycleExecutor<'a> { "Failed to poll child status for module {module_id}: {error}" ); if let Some((_, mut child)) = controller_registry.remove(&module_id) { + crate::domain::integration_api::revoke_module_api_token(&module_id); if let Err(kill_error) = child.kill().await { tracing::warn!( module_id = %module_id, diff --git a/src-tauri/src/domain/modules/controller/mod.rs b/src-tauri/src/domain/modules/controller/mod.rs index ba75f0b3..e259b8ca 100644 --- a/src-tauri/src/domain/modules/controller/mod.rs +++ b/src-tauri/src/domain/modules/controller/mod.rs @@ -381,6 +381,7 @@ pub async fn control( } } downloader::delete_module(module_id).await?; + crate::domain::integration_api::revoke_module_api_token(module_id); return Ok(ControlResponse { success: true, message: format!("Module {module_id} uninstalled successfully"), diff --git a/src-tauri/src/domain/modules/controller/script_runtime.rs b/src-tauri/src/domain/modules/controller/script_runtime.rs index 9c459d70..dd46dcf9 100644 --- a/src-tauri/src/domain/modules/controller/script_runtime.rs +++ b/src-tauri/src/domain/modules/controller/script_runtime.rs @@ -257,7 +257,7 @@ async fn spawn_runtime_command( AppError::Io(format!("Failed to clone runtime log file: {e}")) })?)) .stderr(Stdio::from(log_file)); - crate::domain::integration_api::apply_process_env(&mut command, module_id); + crate::domain::integration_api::apply_process_env(&mut command, module_id)?; command.spawn().map_err(|e| AppError::Internal { request_id: None, diff --git a/src-tauri/src/domain/modules/downloader.rs b/src-tauri/src/domain/modules/downloader.rs index c737089d..a9995892 100644 --- a/src-tauri/src/domain/modules/downloader.rs +++ b/src-tauri/src/domain/modules/downloader.rs @@ -83,6 +83,7 @@ pub async fn delete_module(module_id: &str) -> Result<(), AppError> { "Downloader", "info", ); + crate::domain::integration_api::revoke_module_api_token(module_id); Ok(()) } else { Err(AppError::NotFound("Module not found".to_string())) diff --git a/src-tauri/src/domain/modules/downloader_transfer.rs b/src-tauri/src/domain/modules/downloader_transfer.rs index 34b3da45..1d0bea1b 100644 --- a/src-tauri/src/domain/modules/downloader_transfer.rs +++ b/src-tauri/src/domain/modules/downloader_transfer.rs @@ -42,26 +42,29 @@ pub(super) async fn resolve_download_url( return Ok(main_url); } - if response.status() == reqwest::StatusCode::NOT_FOUND { - tracing::info!("main branch not found, trying master: {master_url}"); - let response_master = - client - .get(&master_url) - .send() - .await - .map_err(|error| AppError::External { - request_id: None, - message: format!("Failed to connect: {error}"), - })?; - - if response_master.status().is_success() { - return Ok(master_url); - } + let main_status = response.status(); + tracing::info!("main branch probe returned {main_status}, trying master: {master_url}"); + let response_master = + client + .get(&master_url) + .send() + .await + .map_err(|error| AppError::External { + request_id: None, + message: format!("Failed to connect: {error}"), + })?; + + if response_master.status().is_success() { + return Ok(master_url); } - return Err(AppError::NotFound(format!( - "Module source not found. Tried both 'main' and 'master' branches at {base_url}" - ))); + let master_status = response_master.status(); + return Err(AppError::External { + request_id: None, + message: format!( + "Module source probes failed at {base_url}: main.zip returned {main_status}, master.zip returned {master_status}" + ), + }); } Ok(download_url.to_string()) @@ -163,21 +166,23 @@ pub(super) async fn clone_repository_into( } pub(super) fn build_client(module_id: &str) -> Result { - let client_builder = reqwest::Client::builder() - .user_agent("Axelate/1.0.0 (Tauri; Windows)") - .timeout(std::time::Duration::from_secs(600)); - tracing::debug!(module_id = module_id, "Building module download client"); - client_builder.build().map_err(|error| AppError::External { - request_id: None, - message: format!("Client error: {error}"), - }) + construct_client_builder() + .build() + .map_err(|error| AppError::External { + request_id: None, + message: format!("Client error: {error}"), + }) } -pub(super) fn build_public_client() -> Result { +fn construct_client_builder() -> reqwest::ClientBuilder { reqwest::Client::builder() - .user_agent("Axelate/1.0.0 (Tauri; Windows)") + .user_agent(format!("Axelate/1.0.0 (Tauri; {})", std::env::consts::OS)) .timeout(std::time::Duration::from_secs(600)) +} + +pub(super) fn build_public_client() -> Result { + construct_client_builder() .build() .map_err(|error| AppError::External { request_id: None, diff --git a/src-tauri/src/domain/modules/settings_ui_protocol.rs b/src-tauri/src/domain/modules/settings_ui_protocol.rs index 3ac874f2..a82e5e54 100644 --- a/src-tauri/src/domain/modules/settings_ui_protocol.rs +++ b/src-tauri/src/domain/modules/settings_ui_protocol.rs @@ -484,7 +484,9 @@ const fn status_for_error(error: &AppError) -> StatusCode { match error { AppError::Validation(_) => StatusCode::BAD_REQUEST, AppError::NotFound(_) => StatusCode::NOT_FOUND, - AppError::PermissionDenied(_) => StatusCode::FORBIDDEN, + AppError::PermissionDenied(_) | AppError::FrontendSecretForbidden(_) => { + StatusCode::FORBIDDEN + } AppError::Io(_) | AppError::Serialization(_) | AppError::Config(_) diff --git a/src-tauri/src/errors.rs b/src-tauri/src/errors.rs index 66568e37..ddfbf7bb 100644 --- a/src-tauri/src/errors.rs +++ b/src-tauri/src/errors.rs @@ -24,6 +24,10 @@ pub enum AppError { #[error("Permission denied: {0}")] PermissionDenied(String), + /// Frontend tried to access a secret outside the managed allowlist + #[error("Frontend secret access forbidden: {0}")] + FrontendSecretForbidden(String), + /// File system I/O error #[error("IO error: {0}")] Io(String), @@ -82,6 +86,7 @@ impl From for IpcError { AppError::Validation(msg) => ("VALIDATION", msg.clone()), AppError::NotFound(msg) => ("NOT_FOUND", msg.clone()), AppError::PermissionDenied(msg) => ("PERMISSION_DENIED", msg.clone()), + AppError::FrontendSecretForbidden(msg) => ("FRONTEND_SECRET_FORBIDDEN", msg.clone()), AppError::Io(msg) => ("IO_ERROR", msg.clone()), AppError::Serialization(msg) => ("SERIALIZATION", msg.clone()), AppError::Config(msg) => ("CONFIG", msg.clone()), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d8964d1c..f14581b6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -418,6 +418,7 @@ pub fn run() { ); if IS_QUITTING.load(Ordering::Relaxed) { tracing::info!("App Exiting..."); + crate::domain::integration_api::revoke_all_module_api_tokens(); if let Some(sessions) = app_handle.try_state::>() && let Err(error) = sessions.save_to_disk() diff --git a/src/features/settings/ui/ModuleSettingsHost.test.ts b/src/features/settings/ui/ModuleSettingsHost.test.ts index 2f8f5072..0f6a0824 100644 --- a/src/features/settings/ui/ModuleSettingsHost.test.ts +++ b/src/features/settings/ui/ModuleSettingsHost.test.ts @@ -30,9 +30,11 @@ function renderHostShell(): HTMLIFrameElement { Object.defineProperty(frame, 'contentWindow', { configurable: true, - value: { - postMessage: vi.fn(), - }, + value: window, + }); + Object.defineProperty(window, 'postMessage', { + configurable: true, + value: vi.fn(), }); return frame; @@ -84,6 +86,8 @@ describe('module settings host', () => { window.dispatchEvent( new MessageEvent('message', { + origin: window.location.origin, + source: frame.contentWindow, data: { channel: 'axelate:module-settings', type: 'module-ready', diff --git a/src/package-lock.json b/src/package-lock.json index b267cdf4..6f293835 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -35,6 +35,7 @@ "globals": "^17.5.0", "jsdom": "^29.1.0", "prettier": "^3.8.3", + "smol-toml": "^1.6.1", "terser": "^5.46.1", "typescript": "^6.0.3", "typescript-eslint": "^8.59.1", @@ -4072,6 +4073,19 @@ "node": ">=18" } }, + "node_modules/smol-toml": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", + "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", diff --git a/src/package.json b/src/package.json index d43a2414..0c5da30c 100644 --- a/src/package.json +++ b/src/package.json @@ -40,6 +40,7 @@ "globals": "^17.5.0", "jsdom": "^29.1.0", "prettier": "^3.8.3", + "smol-toml": "^1.6.1", "terser": "^5.46.1", "typescript": "^6.0.3", "typescript-eslint": "^8.59.1", diff --git a/src/shared/types/bindings.ts b/src/shared/types/bindings.ts index 2e05e3bb..2b118131 100644 --- a/src/shared/types/bindings.ts +++ b/src/shared/types/bindings.ts @@ -322,31 +322,33 @@ export type AppConfig_Serialize = { // Application-level errors export type AppError = // Validation error (invalid input, malformed data) -({ Validation: string }) & { Config?: never; External?: never; Internal?: never; Io?: never; NotFound?: never; PermissionDenied?: never; Serialization?: never } | +({ Validation: string }) & { Config?: never; External?: never; FrontendSecretForbidden?: never; Internal?: never; Io?: never; NotFound?: never; PermissionDenied?: never; Serialization?: never } | // Resource not found error -({ NotFound: string }) & { Config?: never; External?: never; Internal?: never; Io?: never; PermissionDenied?: never; Serialization?: never; Validation?: never } | +({ NotFound: string }) & { Config?: never; External?: never; FrontendSecretForbidden?: never; Internal?: never; Io?: never; PermissionDenied?: never; Serialization?: never; Validation?: never } | // Permission denied or unauthorized access -({ PermissionDenied: string }) & { Config?: never; External?: never; Internal?: never; Io?: never; NotFound?: never; Serialization?: never; Validation?: never } | +({ PermissionDenied: string }) & { Config?: never; External?: never; FrontendSecretForbidden?: never; Internal?: never; Io?: never; NotFound?: never; Serialization?: never; Validation?: never } | +// Frontend tried to access a secret outside the managed allowlist +({ FrontendSecretForbidden: string }) & { Config?: never; External?: never; Internal?: never; Io?: never; NotFound?: never; PermissionDenied?: never; Serialization?: never; Validation?: never } | // File system I/O error -({ Io: string }) & { Config?: never; External?: never; Internal?: never; NotFound?: never; PermissionDenied?: never; Serialization?: never; Validation?: never } | +({ Io: string }) & { Config?: never; External?: never; FrontendSecretForbidden?: never; Internal?: never; NotFound?: never; PermissionDenied?: never; Serialization?: never; Validation?: never } | // JSON serialization/deserialization error -({ Serialization: string }) & { Config?: never; External?: never; Internal?: never; Io?: never; NotFound?: never; PermissionDenied?: never; Validation?: never } | +({ Serialization: string }) & { Config?: never; External?: never; FrontendSecretForbidden?: never; Internal?: never; Io?: never; NotFound?: never; PermissionDenied?: never; Validation?: never } | // Configuration loading or parsing error -({ Config: string }) & { External?: never; Internal?: never; Io?: never; NotFound?: never; PermissionDenied?: never; Serialization?: never; Validation?: never } | +({ Config: string }) & { External?: never; FrontendSecretForbidden?: never; Internal?: never; Io?: never; NotFound?: never; PermissionDenied?: never; Serialization?: never; Validation?: never } | // External service or API error ({ External: { // Unique request identifier for tracing request_id: string | null, // error message message: string, -} }) & { Config?: never; Internal?: never; Io?: never; NotFound?: never; PermissionDenied?: never; Serialization?: never; Validation?: never } | +} }) & { Config?: never; FrontendSecretForbidden?: never; Internal?: never; Io?: never; NotFound?: never; PermissionDenied?: never; Serialization?: never; Validation?: never } | // Internal server error (unexpected failures) ({ Internal: { // Unique request identifier for tracing request_id: string | null, // error message message: string, -} }) & { Config?: never; External?: never; Io?: never; NotFound?: never; PermissionDenied?: never; Serialization?: never; Validation?: never }; +} }) & { Config?: never; External?: never; FrontendSecretForbidden?: never; Io?: never; NotFound?: never; PermissionDenied?: never; Serialization?: never; Validation?: never }; // Global application settings export type AppSettings = { From 89b1898e16e67c7329e55116eeceef45dc953f78 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 7 May 2026 14:42:15 +0300 Subject: [PATCH 100/126] chore(deps): fold dependabot updates into refactor --- src-tauri/Cargo.lock | 911 +++++++++++++----------------------------- src-tauri/Cargo.toml | 6 +- src/package-lock.json | 566 ++++++++++++-------------- src/package.json | 35 +- 4 files changed, 556 insertions(+), 962 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b4061965..c226323e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -146,9 +146,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.41" +version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" dependencies = [ "compression-codecs", "compression-core", @@ -318,7 +318,7 @@ dependencies = [ "num_cpus", "nvml-wrapper", "once_cell", - "rand 0.10.1", + "rand", "reqwest", "scraper", "serde", @@ -599,9 +599,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.60" +version = "1.2.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" dependencies = [ "find-msvc-tools", "jobserver", @@ -714,9 +714,9 @@ dependencies = [ [[package]] name = "compression-codecs" -version = "0.4.37" +version = "0.4.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" dependencies = [ "compression-core", "flate2", @@ -725,9 +725,9 @@ dependencies = [ [[package]] name = "compression-core" -version = "0.4.31" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" [[package]] name = "concurrent-queue" @@ -750,12 +750,6 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" -[[package]] -name = "convert_case" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" - [[package]] name = "cookie" version = "0.18.1" @@ -880,23 +874,6 @@ dependencies = [ "hybrid-array", ] -[[package]] -name = "cssparser" -version = "0.29.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" -dependencies = [ - "cssparser-macros", - "dtoa-short", - "itoa", - "matches", - "phf 0.10.1", - "proc-macro2", - "quote", - "smallvec", - "syn 1.0.109", -] - [[package]] name = "cssparser" version = "0.36.0" @@ -906,7 +883,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf 0.13.1", + "phf", "smallvec", ] @@ -922,14 +899,20 @@ dependencies = [ [[package]] name = "ctor" -version = "0.2.9" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" dependencies = [ - "quote", - "syn 2.0.117", + "ctor-proc-macro", + "dtor", ] +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + [[package]] name = "ctr" version = "0.9.2" @@ -1031,6 +1014,17 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "dbus" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + [[package]] name = "deflate64" version = "0.1.12" @@ -1047,19 +1041,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "derive_more" -version = "0.99.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.117", -] - [[package]] name = "derive_more" version = "2.1.1" @@ -1093,9 +1074,9 @@ dependencies = [ [[package]] name = "digest" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.0", "const-oid", @@ -1178,12 +1159,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" dependencies = [ "bit-set", - "cssparser 0.36.0", + "cssparser", "foldhash 0.2.0", "html5ever 0.38.0", "precomputed-hash", - "selectors 0.36.1", - "tendril 0.5.0", + "selectors", + "tendril", ] [[package]] @@ -1216,6 +1197,21 @@ dependencies = [ "dtoa", ] +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + [[package]] name = "dunce" version = "1.0.5" @@ -1236,14 +1232,14 @@ checksum = "b04dc5a38e4f151a79d9f2451ae6037fb6eaf5cba34771f44781f80e508498e3" [[package]] name = "embed-resource" -version = "3.0.8" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63a1d0de4f2249aa0ff5884d7080814f446bb241a559af6c170a41e878ed2d45" +checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb" dependencies = [ "cc", "memchr", "rustc_version", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.2+spec-1.1.0", "vswhom", "winreg", ] @@ -1363,23 +1359,9 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fax" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" -dependencies = [ - "fax_derive", -] - -[[package]] -name = "fax_derive" -version = "0.2.0" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" [[package]] name = "fdeflate" @@ -1512,16 +1494,6 @@ dependencies = [ "libc", ] -[[package]] -name = "futf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" -dependencies = [ - "mac", - "new_debug_unreachable", -] - [[package]] name = "futures" version = "0.3.32" @@ -1623,15 +1595,6 @@ dependencies = [ "slab", ] -[[package]] -name = "fxhash" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" -dependencies = [ - "byteorder", -] - [[package]] name = "gdk" version = "0.18.2" @@ -1760,17 +1723,6 @@ dependencies = [ "unicode-width", ] -[[package]] -name = "getrandom" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" -dependencies = [ - "cfg-if", - "libc", - "wasi 0.9.0+wasi-snapshot-preview1", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -1779,7 +1731,7 @@ checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", ] [[package]] @@ -2054,19 +2006,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "digest 0.11.2", -] - -[[package]] -name = "html5ever" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" -dependencies = [ - "log", - "mac", - "markup5ever 0.14.1", - "match_token", + "digest 0.11.3", ] [[package]] @@ -2130,9 +2070,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hybrid-array" -version = "0.4.10" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5" dependencies = [ "typenum", ] @@ -2337,9 +2277,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -2436,16 +2376,6 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" -[[package]] -name = "iri-string" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "is-docker" version = "0.2.0" @@ -2550,9 +2480,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.95" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" dependencies = [ "cfg-if", "futures-util", @@ -2613,18 +2543,6 @@ dependencies = [ "libc", ] -[[package]] -name = "kuchikiki" -version = "0.8.8-speedreader" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" -dependencies = [ - "cssparser 0.29.6", - "html5ever 0.29.1", - "indexmap 2.14.0", - "selectors 0.24.0", -] - [[package]] name = "lazy_static" version = "1.5.0" @@ -2669,9 +2587,18 @@ checksum = "b3a6a8c165077efc8f3a971534c50ea6a1a18b329ef4a66e897a7e3a1494565f" [[package]] name = "libc" -version = "0.2.185" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] [[package]] name = "libloading" @@ -2702,7 +2629,7 @@ dependencies = [ "bitflags 2.11.1", "libc", "plain", - "redox_syscall 0.7.4", + "redox_syscall 0.7.5", ] [[package]] @@ -2741,12 +2668,6 @@ dependencies = [ "sha2 0.10.9", ] -[[package]] -name = "mac" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" - [[package]] name = "machine-uid" version = "0.5.4" @@ -2758,20 +2679,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "markup5ever" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" -dependencies = [ - "log", - "phf 0.11.3", - "phf_codegen 0.11.3", - "string_cache 0.8.9", - "string_cache_codegen 0.5.4", - "tendril 0.4.3", -] - [[package]] name = "markup5ever" version = "0.38.0" @@ -2779,7 +2686,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" dependencies = [ "log", - "tendril 0.5.0", + "tendril", "web_atoms", ] @@ -2790,21 +2697,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" dependencies = [ "log", - "tendril 0.5.0", + "tendril", "web_atoms", ] -[[package]] -name = "match_token" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "matchers" version = "0.2.0" @@ -2814,12 +2710,6 @@ dependencies = [ "regex-automata", ] -[[package]] -name = "matches" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" - [[package]] name = "memchr" version = "2.8.0" @@ -2859,7 +2749,7 @@ checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "log", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "windows-sys 0.61.2", ] @@ -2875,9 +2765,9 @@ dependencies = [ [[package]] name = "muda" -version = "0.17.2" +version = "0.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c9fec5a4e89860383d778d10563a605838f8f0b2f9303868937e5ff32e86177" +checksum = "0ae8844f63b5b118e334e205585b8c5c17b984121dbdb179d44aeb087ffad3cb" dependencies = [ "crossbeam-channel", "dpi", @@ -2888,10 +2778,10 @@ dependencies = [ "objc2-core-foundation", "objc2-foundation", "once_cell", - "png 0.17.16", + "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2926,12 +2816,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "ndk-context" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" - [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -2947,12 +2831,6 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" -[[package]] -name = "nodrop" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" - [[package]] name = "nom" version = "8.0.0" @@ -3101,6 +2979,27 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -3125,6 +3024,38 @@ dependencies = [ "objc2-io-surface", ] +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -3193,8 +3124,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.11.1", + "block2", "objc2", + "objc2-cloud-kit", + "objc2-core-data", "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", "objc2-foundation", ] @@ -3238,15 +3188,14 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.78" +version = "0.10.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" +checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" dependencies = [ "bitflags 2.11.1", "cfg-if", "foreign-types 0.3.2", "libc", - "once_cell", "openssl-macros", "openssl-sys", ] @@ -3270,9 +3219,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.114" +version = "0.9.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6" +checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" dependencies = [ "cc", "libc", @@ -3378,7 +3327,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" dependencies = [ - "digest 0.11.2", + "digest 0.11.3", "hmac", ] @@ -3399,105 +3348,25 @@ dependencies = [ "indexmap 2.14.0", ] -[[package]] -name = "phf" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" -dependencies = [ - "phf_shared 0.8.0", -] - -[[package]] -name = "phf" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" -dependencies = [ - "phf_macros 0.10.0", - "phf_shared 0.10.0", - "proc-macro-hack", -] - -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_macros 0.11.3", - "phf_shared 0.11.3", -] - [[package]] name = "phf" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_macros 0.13.1", - "phf_shared 0.13.1", + "phf_macros", + "phf_shared", "serde", ] -[[package]] -name = "phf_codegen" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" -dependencies = [ - "phf_generator 0.8.0", - "phf_shared 0.8.0", -] - -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", -] - [[package]] name = "phf_codegen" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", -] - -[[package]] -name = "phf_generator" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" -dependencies = [ - "phf_shared 0.8.0", - "rand 0.7.3", -] - -[[package]] -name = "phf_generator" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" -dependencies = [ - "phf_shared 0.10.0", - "rand 0.8.6", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared 0.11.3", - "rand 0.8.6", + "phf_generator", + "phf_shared", ] [[package]] @@ -3507,34 +3376,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared 0.13.1", -] - -[[package]] -name = "phf_macros" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" -dependencies = [ - "phf_generator 0.10.0", - "phf_shared 0.10.0", - "proc-macro-hack", - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", - "syn 2.0.117", + "phf_shared", ] [[package]] @@ -3543,47 +3385,20 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", + "phf_generator", + "phf_shared", "proc-macro2", "quote", "syn 2.0.117", ] -[[package]] -name = "phf_shared" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" -dependencies = [ - "siphasher 0.3.11", -] - -[[package]] -name = "phf_shared" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" -dependencies = [ - "siphasher 0.3.11", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher 1.0.2", -] - [[package]] name = "phf_shared" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" dependencies = [ - "siphasher 1.0.2", + "siphasher", ] [[package]] @@ -3617,13 +3432,13 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "plist" -version = "1.8.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", - "quick-xml 0.38.4", + "quick-xml", "serde", "time", ] @@ -3701,15 +3516,6 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24" -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "precomputed-hash" version = "0.1.1" @@ -3779,12 +3585,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "proc-macro-hack" -version = "0.5.20+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" - [[package]] name = "proc-macro2" version = "1.0.106" @@ -3808,18 +3608,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quick-xml" -version = "0.38.4" +version = "0.39.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" -dependencies = [ - "memchr", -] - -[[package]] -name = "quick-xml" -version = "0.39.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" +checksum = "721da970c312655cde9b4ffe0547f20a8494866a4af5ff51f18b7c633d0c870b" dependencies = [ "memchr", ] @@ -3845,31 +3636,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" -dependencies = [ - "getrandom 0.1.16", - "libc", - "rand_chacha 0.2.2", - "rand_core 0.5.1", - "rand_hc", - "rand_pcg", -] - -[[package]] -name = "rand" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - [[package]] name = "rand" version = "0.10.1" @@ -3881,35 +3647,6 @@ dependencies = [ "rand_core 0.10.1", ] -[[package]] -name = "rand_chacha" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" -dependencies = [ - "ppv-lite86", - "rand_core 0.5.1", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" -dependencies = [ - "getrandom 0.1.16", -] - [[package]] name = "rand_core" version = "0.6.4" @@ -3925,24 +3662,6 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" -[[package]] -name = "rand_hc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" -dependencies = [ - "rand_core 0.5.1", -] - -[[package]] -name = "rand_pcg" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" -dependencies = [ - "rand_core 0.5.1", -] - [[package]] name = "raw-window-handle" version = "0.6.2" @@ -3960,9 +3679,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.7.4" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" dependencies = [ "bitflags 2.11.1", ] @@ -4125,9 +3844,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "zeroize", ] @@ -4219,13 +3938,13 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f5297102b8b62b4454ee8561601b2d551b4913148feb4241ca9d1a04bf4526" dependencies = [ - "cssparser 0.36.0", + "cssparser", "ego-tree", "getopts", "html5ever 0.39.0", "precomputed-hash", - "selectors 0.36.1", - "tendril 0.5.0", + "selectors", + "tendril", ] [[package]] @@ -4251,24 +3970,6 @@ dependencies = [ "libc", ] -[[package]] -name = "selectors" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" -dependencies = [ - "bitflags 1.3.2", - "cssparser 0.29.6", - "derive_more 0.99.20", - "fxhash", - "log", - "phf 0.8.0", - "phf_codegen 0.8.0", - "precomputed-hash", - "servo_arc 0.2.0", - "smallvec", -] - [[package]] name = "selectors" version = "0.36.1" @@ -4276,15 +3977,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" dependencies = [ "bitflags 2.11.1", - "cssparser 0.36.0", - "derive_more 2.1.1", + "cssparser", + "derive_more", "log", "new_debug_unreachable", - "phf 0.13.1", - "phf_codegen 0.13.1", + "phf", + "phf_codegen", "precomputed-hash", "rustc-hash 2.1.2", - "servo_arc 0.4.3", + "servo_arc", "smallvec", ] @@ -4395,9 +4096,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.18.0" +version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" +checksum = "f05839ce67618e14a09b286535c0d9c94e85ef25469b0e13cb4f844e5593eb19" dependencies = [ "base64 0.22.1", "chrono", @@ -4414,9 +4115,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.18.0" +version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" +checksum = "cf2ebbe86054f9b45bc3881e865683ccfaccce97b9b4cb53f3039d67f355a334" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -4446,16 +4147,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "servo_arc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" -dependencies = [ - "nodrop", - "stable_deref_trait", -] - [[package]] name = "servo_arc" version = "0.4.3" @@ -4491,7 +4182,7 @@ checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "digest 0.11.2", + "digest 0.11.3", ] [[package]] @@ -4513,7 +4204,7 @@ checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "digest 0.11.2", + "digest 0.11.3", ] [[package]] @@ -4581,15 +4272,9 @@ checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "siphasher" -version = "0.3.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" - -[[package]] -name = "siphasher" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "slab" @@ -4727,19 +4412,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" -[[package]] -name = "string_cache" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" -dependencies = [ - "new_debug_unreachable", - "parking_lot", - "phf_shared 0.11.3", - "precomputed-hash", - "serde", -] - [[package]] name = "string_cache" version = "0.9.0" @@ -4748,30 +4420,18 @@ checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" dependencies = [ "new_debug_unreachable", "parking_lot", - "phf_shared 0.13.1", + "phf_shared", "precomputed-hash", ] -[[package]] -name = "string_cache_codegen" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", -] - [[package]] name = "string_cache_codegen" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", + "phf_generator", + "phf_shared", "proc-macro2", "quote", ] @@ -4812,7 +4472,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", - "quote", "unicode-ident", ] @@ -4885,15 +4544,16 @@ dependencies = [ [[package]] name = "tao" -version = "0.34.8" +version = "0.35.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20" +checksum = "a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4" dependencies = [ "bitflags 2.11.1", "block2", "core-foundation", "core-graphics", "crossbeam-channel", + "dbus", "dispatch2", "dlopen2", "dpi", @@ -4904,13 +4564,14 @@ dependencies = [ "libc", "log", "ndk", - "ndk-context", "ndk-sys", "objc2", "objc2-app-kit", "objc2-foundation", + "objc2-ui-kit", "once_cell", "parking_lot", + "percent-encoding", "raw-window-handle", "tao-macros", "unicode-segmentation", @@ -4951,9 +4612,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.10.3" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da77cc00fb9028caf5b5d4650f75e31f1ef3693459dfca7f7e506d1ecef0ba2d" +checksum = "b93bd86d231f0a8138f11a02a584769fe4b703dc36ae133d783228dbc4801405" dependencies = [ "anyhow", "bytes", @@ -5004,9 +4665,9 @@ dependencies = [ [[package]] name = "tauri-build" -version = "2.5.6" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d" +checksum = "3a318b234cc2dea65f575467bafcfb76286bce228ebc3778e337d61d03213007" dependencies = [ "anyhow", "cargo_toml", @@ -5020,15 +4681,14 @@ dependencies = [ "serde_json", "tauri-utils", "tauri-winres", - "toml 0.9.12+spec-1.1.0", "walkdir", ] [[package]] name = "tauri-codegen" -version = "2.5.5" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4a24476afd977c5d5d169f72425868613d82747916dd29e0a357c84c4bd6d29" +checksum = "6bd11644962add2549a60b7e7c6800f17d7020156e02f516021d8103e80cc528" dependencies = [ "base64 0.22.1", "brotli", @@ -5053,9 +4713,9 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.5.5" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d39b349a98dadaffebb73f0a40dcd1f23c999211e5a2e744403db384d0c33de7" +checksum = "fed9d3742a37a355d2e47c9af924e9fbc112abb76f9835d35d4780e318419502" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -5067,9 +4727,9 @@ dependencies = [ [[package]] name = "tauri-plugin" -version = "2.5.4" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddde7d51c907b940fb573006cdda9a642d6a7c8153657e88f8a5c3c9290cd4aa" +checksum = "eefb2c18e8a605c23edb48fc56bb77381199e1a1e7f6ff0c9b970afe7b3cb8ee" dependencies = [ "anyhow", "glob", @@ -5078,7 +4738,6 @@ dependencies = [ "serde", "serde_json", "tauri-utils", - "toml 0.9.12+spec-1.1.0", "walkdir", ] @@ -5099,9 +4758,9 @@ dependencies = [ [[package]] name = "tauri-plugin-dialog" -version = "2.7.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1fa4150c95ae391946cc8b8f905ab14797427caba3a8a2f79628e956da91809" +checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" dependencies = [ "log", "raw-window-handle", @@ -5117,9 +4776,9 @@ dependencies = [ [[package]] name = "tauri-plugin-fs" -version = "2.5.0" +version = "2.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36e1ec28b79f3d0683f4507e1615c36292c0ea6716668770d4396b9b39871ed8" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" dependencies = [ "anyhow", "dunce", @@ -5135,7 +4794,7 @@ dependencies = [ "tauri-plugin", "tauri-utils", "thiserror 2.0.18", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.2+spec-1.1.0", "url", ] @@ -5177,9 +4836,9 @@ dependencies = [ [[package]] name = "tauri-plugin-single-instance" -version = "2.4.1" +version = "2.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a33a5b7d78f0dec4406b003ea87c40bf928d801b6fd9323a556172c91d8712c1" +checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af" dependencies = [ "serde", "serde_json", @@ -5192,9 +4851,9 @@ dependencies = [ [[package]] name = "tauri-runtime" -version = "2.10.1" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2826d79a3297ed08cd6ea7f412644ef58e32969504bc4fbd8d7dbeabc4445ea2" +checksum = "8fef478ba1d2ac21c2d528740b24d0cb315e1e8b1111aae53fafac34804371fc" dependencies = [ "cookie", "dpi", @@ -5217,9 +4876,9 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" -version = "2.10.1" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11ea2e6f801d275fdd890d6c9603736012742a1c33b96d0db788c9cdebf7f9e" +checksum = "a3989df2ae1c476404fe0a2e8ffc4cfbde97e51efd613c2bb5355fbc9ab52cf0" dependencies = [ "gtk", "http", @@ -5274,24 +4933,24 @@ dependencies = [ [[package]] name = "tauri-utils" -version = "2.8.3" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219a1f983a2af3653f75b5747f76733b0da7ff03069c7a41901a5eb3ace4557d" +checksum = "d57200389a2f82b4b0a40ae29ca19b6978116e8f4d4e974c3234ce40c0ffbdec" dependencies = [ "anyhow", "brotli", "cargo_metadata", "ctor", + "dom_query", "dunce", "glob", - "html5ever 0.29.1", "http", "infer", "json-patch", - "kuchikiki", "log", "memchr", - "phf 0.11.3", + "phf", + "plist", "proc-macro2", "quote", "regex", @@ -5303,7 +4962,7 @@ dependencies = [ "serde_with", "swift-rs", "thiserror 2.0.18", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.2+spec-1.1.0", "url", "urlpattern", "uuid", @@ -5312,13 +4971,13 @@ dependencies = [ [[package]] name = "tauri-winres" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" dependencies = [ "dunce", "embed-resource", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.2+spec-1.1.0", ] [[package]] @@ -5334,17 +4993,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "tendril" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" -dependencies = [ - "futf", - "mac", - "utf-8", -] - [[package]] name = "tendril" version = "0.5.0" @@ -5477,9 +5125,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.52.1" +version = "1.52.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386" dependencies = [ "bytes", "libc", @@ -5662,9 +5310,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" dependencies = [ "async-compression", "bitflags 2.11.1", @@ -5674,13 +5322,13 @@ dependencies = [ "http", "http-body", "http-body-util", - "iri-string", "pin-project-lite", "tokio", "tokio-util", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -5785,9 +5433,9 @@ dependencies = [ [[package]] name = "tray-icon" -version = "0.21.3" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" +checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773" dependencies = [ "crossbeam-channel", "dirs", @@ -5799,10 +5447,10 @@ dependencies = [ "objc2-core-graphics", "objc2-foundation", "once_cell", - "png 0.17.16", + "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6038,12 +5686,6 @@ dependencies = [ "try-lock", ] -[[package]] -name = "wasi" -version = "0.9.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -6070,9 +5712,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.118" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" dependencies = [ "cfg-if", "once_cell", @@ -6083,9 +5725,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.68" +version = "0.4.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" dependencies = [ "js-sys", "wasm-bindgen", @@ -6093,9 +5735,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.118" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6103,9 +5745,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.118" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" dependencies = [ "bumpalo", "proc-macro2", @@ -6116,9 +5758,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.118" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" dependencies = [ "unicode-ident", ] @@ -6227,7 +5869,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" dependencies = [ "proc-macro2", - "quick-xml 0.39.2", + "quick-xml", "quote", ] @@ -6242,9 +5884,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.95" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" dependencies = [ "js-sys", "wasm-bindgen", @@ -6256,10 +5898,10 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" dependencies = [ - "phf 0.13.1", - "phf_codegen 0.13.1", - "string_cache 0.9.0", - "string_cache_codegen 0.6.1", + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", ] [[package]] @@ -6849,9 +6491,6 @@ name = "winnow" version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] [[package]] name = "winnow" @@ -7019,9 +6658,9 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "wry" -version = "0.54.4" +version = "0.55.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5a8135d8676225e5744de000d4dff5a082501bf7db6a1c1495034f8c314edbc" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" dependencies = [ "base64 0.22.1", "block2", @@ -7140,9 +6779,9 @@ dependencies = [ [[package]] name = "zbus" -version = "5.14.0" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" +checksum = "c3bcbf15c8708d7fc1be0c993622e0a5cbd5e8b52bfa40afa4c3e0cd8d724ac1" dependencies = [ "async-broadcast", "async-executor", @@ -7167,7 +6806,7 @@ dependencies = [ "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow 0.7.15", + "winnow 1.0.2", "zbus_macros", "zbus_names", "zvariant", @@ -7175,9 +6814,9 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.14.0" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" +checksum = "51fa5406ad9175a8c825a931f8cf347116b531b3634fcb0b627c290f1f2516ff" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", @@ -7190,12 +6829,12 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.3.1" +version = "4.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" dependencies = [ "serde", - "winnow 0.7.15", + "winnow 1.0.2", "zvariant", ] @@ -7375,23 +7014,23 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.10.0" +version = "5.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" +checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee" dependencies = [ "endi", "enumflags2", "serde", - "winnow 0.7.15", + "winnow 1.0.2", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.10.0" +version = "5.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" +checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", @@ -7402,13 +7041,13 @@ dependencies = [ [[package]] name = "zvariant_utils" -version = "3.3.0" +version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691" dependencies = [ "proc-macro2", "quote", "serde", "syn 2.0.117", - "winnow 0.7.15", + "winnow 1.0.2", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ed75cea5..d89b883c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -32,7 +32,7 @@ path = "src/main.rs" # --- Tauri Ecosystem (UI & Runtime) --- [dependencies.tauri] -version = "~2.10" +version = "~2.11" features = ["image-ico", "image-png", "tray-icon", "unstable"] [dependencies] @@ -52,7 +52,7 @@ serde_json = "1.0.149" toml = "1.1" # --- Async Core & Networking --- -tokio = { version = "1.52.1", features = ["rt-multi-thread", "macros", "time", "sync", "process", "fs"] } +tokio = { version = "1.52.2", features = ["rt-multi-thread", "macros", "time", "sync", "process", "fs"] } reqwest = { version = "0.13.3", features = ["json", "stream", "native-tls", "gzip"], default-features = false } futures-util = "0.3.32" scraper = "0.26.0" @@ -119,7 +119,7 @@ windows-future = "0.3.2" # --- Build Time --- [build-dependencies] -tauri-build = { version = "~2.5", features = [] } +tauri-build = { version = "~2.6", features = [] } # ============================================================================== # OPTIMIZATION PROFILES (The 0.7 MB Magic) diff --git a/src/package-lock.json b/src/package-lock.json index 6f293835..022a2fff 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -8,38 +8,37 @@ "name": "axelate-frontend", "version": "0.1.5", "dependencies": { - "@tauri-apps/api": "~2.10.1", - "@tauri-apps/plugin-dialog": "~2.7.0", - "@tauri-apps/plugin-fs": "~2.5.0", - "axelate-frontend": "file:", - "dompurify": "^3.4.1", - "marked": "^18.0.2", + "@tauri-apps/api": "~2.11.0", + "@tauri-apps/plugin-dialog": "~2.7.1", + "@tauri-apps/plugin-fs": "~2.5.1", + "dompurify": "^3.4.2", + "marked": "^18.0.3", "marked-alert": "^2.1.2", "marked-footnote": "^1.4.0", "wawoff2": "^2.0.1" }, "devDependencies": { - "@commitlint/cli": "^20.5.2", - "@commitlint/config-conventional": "^20.5.0", + "@commitlint/cli": "^20.5.3", + "@commitlint/config-conventional": "^20.5.3", "@eslint/js": "^10.0.1", - "@tauri-apps/cli": "~2.10.1", + "@tauri-apps/cli": "~2.11.1", "@types/node": "^25.6.0", - "@typescript-eslint/eslint-plugin": "^8.59.1", - "@typescript-eslint/parser": "^8.59.1", - "@typescript-eslint/utils": "^8.59.1", + "@typescript-eslint/eslint-plugin": "^8.59.2", + "@typescript-eslint/parser": "^8.59.2", + "@typescript-eslint/utils": "^8.59.2", "@vitest/coverage-v8": "^4.1.5", "@vitest/ui": "^4.1.5", - "eslint": "^10.2.1", + "eslint": "^10.3.0", "eslint-config-prettier": "^10.1.8", "fonteditor-core": "^2.6.3", - "globals": "^17.5.0", - "jsdom": "^29.1.0", + "globals": "^17.6.0", + "jsdom": "^29.1.1", "prettier": "^3.8.3", "smol-toml": "^1.6.1", - "terser": "^5.46.1", + "terser": "^5.47.0", "typescript": "^6.0.3", - "typescript-eslint": "^8.59.1", - "vite": "^8.0.10", + "typescript-eslint": "^8.59.2", + "vite": "^8.0.11", "vitest": "^4.1.5" }, "engines": { @@ -193,15 +192,15 @@ } }, "node_modules/@commitlint/cli": { - "version": "20.5.2", - "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-20.5.2.tgz", - "integrity": "sha512-IXr5xd3IX8SEG936P8gcpozRplkDeDSwJlt8UvoY1winwIy2udTbQ/cOCgbaaxcjdDqVoS29VUcz/wkwnSozbA==", + "version": "20.5.3", + "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-20.5.3.tgz", + "integrity": "sha512-OJdL0EXWD5y9LPa0nr/geOwzaS8BsdaybKkcloB0JgsguGxNv2R+hC2FTPqrAcprg35zF33KOQerY0x8W1aesA==", "dev": true, "license": "MIT", "dependencies": { "@commitlint/format": "^20.5.0", - "@commitlint/lint": "^20.5.0", - "@commitlint/load": "^20.5.2", + "@commitlint/lint": "^20.5.3", + "@commitlint/load": "^20.5.3", "@commitlint/read": "^20.5.0", "@commitlint/types": "^20.5.0", "tinyexec": "^1.0.0", @@ -215,9 +214,9 @@ } }, "node_modules/@commitlint/config-conventional": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-20.5.0.tgz", - "integrity": "sha512-t3Ni88rFw1XMa4nZHgOKJ8fIAT9M2j5TnKyTqJzsxea7FUetlNdYFus9dz+MhIRZmc16P0PPyEfh6X2d/qw8SA==", + "version": "20.5.3", + "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-20.5.3.tgz", + "integrity": "sha512-j34Qqeaa152chJgz2ysyk0BCpHenJn1lV0Rx0VXf8k3ccQcED+48EZrzMvo9jLmJUyBrrBwvu89I+2er4gW7QQ==", "dev": true, "license": "MIT", "dependencies": { @@ -243,18 +242,14 @@ } }, "node_modules/@commitlint/ensure": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-20.5.0.tgz", - "integrity": "sha512-IpHqAUesBeW1EDDdjzJeaOxU9tnogLAyXLRBn03SHlj1SGENn2JGZqSWGkFvBJkJzfXAuCNtsoYzax+ZPS+puw==", + "version": "20.5.3", + "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-20.5.3.tgz", + "integrity": "sha512-4i4AgNvH62owG9MwSiWKrle7HGNpBHHdLnWFIp5fTsHUYe5kRuh15t08L/0pdbbrRk8JKXQxxN4hZQcn+szkrw==", "dev": true, "license": "MIT", "dependencies": { "@commitlint/types": "^20.5.0", - "lodash.camelcase": "^4.3.0", - "lodash.kebabcase": "^4.1.1", - "lodash.snakecase": "^4.1.1", - "lodash.startcase": "^4.4.0", - "lodash.upperfirst": "^4.3.1" + "es-toolkit": "^1.46.0" }, "engines": { "node": ">=v18" @@ -299,15 +294,15 @@ } }, "node_modules/@commitlint/lint": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-20.5.0.tgz", - "integrity": "sha512-jiM3hNUdu04jFBf1VgPdjtIPvbuVfDTBAc6L98AWcoLjF5sYqkulBHBzlVWll4rMF1T5zeQFB6r//a+s+BBKlA==", + "version": "20.5.3", + "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-20.5.3.tgz", + "integrity": "sha512-M7JbWBNr2gXKaPc4i/KipsuW1gkDHpj35KPjWtKy3Z+2AQw5wu1gBi1LIO0uoaij67CqY4K8PxPZSGens4evCw==", "dev": true, "license": "MIT", "dependencies": { "@commitlint/is-ignored": "^20.5.0", "@commitlint/parse": "^20.5.0", - "@commitlint/rules": "^20.5.0", + "@commitlint/rules": "^20.5.3", "@commitlint/types": "^20.5.0" }, "engines": { @@ -396,13 +391,13 @@ } }, "node_modules/@commitlint/rules": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-20.5.0.tgz", - "integrity": "sha512-5NdQXQEdnDPT5pK8O39ZA7HohzPRHEsDGU23cyVCNPQy4WegAbAwrQk3nIu7p2sl3dutPk8RZd91yKTrMTnRkQ==", + "version": "20.5.3", + "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-20.5.3.tgz", + "integrity": "sha512-MPlMnb9D3wbszYMp+1hPtuhtPJndRo6I6yfkZVA4+jR8w7Kqp0u2u/Y+gzbaItx5Lltq5rw7FSZQWJMoXUC4NQ==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/ensure": "^20.5.0", + "@commitlint/ensure": "^20.5.3", "@commitlint/message": "^20.4.3", "@commitlint/to-lines": "^20.0.0", "@commitlint/types": "^20.5.0" @@ -904,9 +899,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", - "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "version": "0.128.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.128.0.tgz", + "integrity": "sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==", "dev": true, "license": "MIT", "funding": { @@ -921,9 +916,9 @@ "license": "MIT" }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.18.tgz", + "integrity": "sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ==", "cpu": [ "arm64" ], @@ -938,9 +933,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.18.tgz", + "integrity": "sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ==", "cpu": [ "arm64" ], @@ -955,9 +950,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.18.tgz", + "integrity": "sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g==", "cpu": [ "x64" ], @@ -972,9 +967,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.18.tgz", + "integrity": "sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw==", "cpu": [ "x64" ], @@ -989,9 +984,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", - "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.18.tgz", + "integrity": "sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg==", "cpu": [ "arm" ], @@ -1006,9 +1001,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ==", "cpu": [ "arm64" ], @@ -1023,9 +1018,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.18.tgz", + "integrity": "sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==", "cpu": [ "arm64" ], @@ -1040,9 +1035,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==", "cpu": [ "ppc64" ], @@ -1057,9 +1052,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==", "cpu": [ "s390x" ], @@ -1074,9 +1069,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==", "cpu": [ "x64" ], @@ -1091,9 +1086,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.18.tgz", + "integrity": "sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==", "cpu": [ "x64" ], @@ -1108,9 +1103,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.18.tgz", + "integrity": "sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==", "cpu": [ "arm64" ], @@ -1125,9 +1120,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", - "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.18.tgz", + "integrity": "sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg==", "cpu": [ "wasm32" ], @@ -1144,9 +1139,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.18.tgz", + "integrity": "sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ==", "cpu": [ "arm64" ], @@ -1161,9 +1156,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.18.tgz", + "integrity": "sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg==", "cpu": [ "x64" ], @@ -1178,9 +1173,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", - "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.18.tgz", + "integrity": "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==", "dev": true, "license": "MIT" }, @@ -1221,9 +1216,9 @@ "license": "MIT" }, "node_modules/@tauri-apps/api": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz", - "integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz", + "integrity": "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==", "license": "Apache-2.0 OR MIT", "funding": { "type": "opencollective", @@ -1231,9 +1226,9 @@ } }, "node_modules/@tauri-apps/cli": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.1.tgz", - "integrity": "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g==", + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.1.tgz", + "integrity": "sha512-rpEbaJ/HzNb6fwsquwoAbq29/Vt4gADhS423A8fdkwL4edJ0wZmoB8ar7O6JPDL834MUKOCm/rrJ7c9oAaEaYQ==", "dev": true, "license": "Apache-2.0 OR MIT", "bin": { @@ -1247,23 +1242,23 @@ "url": "https://opencollective.com/tauri" }, "optionalDependencies": { - "@tauri-apps/cli-darwin-arm64": "2.10.1", - "@tauri-apps/cli-darwin-x64": "2.10.1", - "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", - "@tauri-apps/cli-linux-arm64-gnu": "2.10.1", - "@tauri-apps/cli-linux-arm64-musl": "2.10.1", - "@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", - "@tauri-apps/cli-linux-x64-gnu": "2.10.1", - "@tauri-apps/cli-linux-x64-musl": "2.10.1", - "@tauri-apps/cli-win32-arm64-msvc": "2.10.1", - "@tauri-apps/cli-win32-ia32-msvc": "2.10.1", - "@tauri-apps/cli-win32-x64-msvc": "2.10.1" + "@tauri-apps/cli-darwin-arm64": "2.11.1", + "@tauri-apps/cli-darwin-x64": "2.11.1", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.1", + "@tauri-apps/cli-linux-arm64-gnu": "2.11.1", + "@tauri-apps/cli-linux-arm64-musl": "2.11.1", + "@tauri-apps/cli-linux-riscv64-gnu": "2.11.1", + "@tauri-apps/cli-linux-x64-gnu": "2.11.1", + "@tauri-apps/cli-linux-x64-musl": "2.11.1", + "@tauri-apps/cli-win32-arm64-msvc": "2.11.1", + "@tauri-apps/cli-win32-ia32-msvc": "2.11.1", + "@tauri-apps/cli-win32-x64-msvc": "2.11.1" } }, "node_modules/@tauri-apps/cli-darwin-arm64": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.1.tgz", - "integrity": "sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==", + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.1.tgz", + "integrity": "sha512-6eEKMBXsQPCuM1EmvrjT2+aBuxWQuFdKdW8pzNuNQtpq45nEEpBlD5gr8pUeAyOU1DQKlkFaEc/MPBxb/Pfjtg==", "cpu": [ "arm64" ], @@ -1278,9 +1273,9 @@ } }, "node_modules/@tauri-apps/cli-darwin-x64": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.1.tgz", - "integrity": "sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw==", + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.1.tgz", + "integrity": "sha512-LQUO7exfRWjWALNhetph5guWpMeHphRpokOLk0OIbTTExaNwJNFu3I4vb+CCM/4G/QGoZe/5XikZOJdNEFP1ig==", "cpu": [ "x64" ], @@ -1295,9 +1290,9 @@ } }, "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.1.tgz", - "integrity": "sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w==", + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.1.tgz", + "integrity": "sha512-5i/awiBCRRhOUG8yjn0fMHXIWD5Ez8eEk5LtvOxyQrKuJkRaZDvnbIjZbE183blAwkoA4xN3aO/prJiqscl02Q==", "cpu": [ "arm" ], @@ -1312,9 +1307,9 @@ } }, "node_modules/@tauri-apps/cli-linux-arm64-gnu": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.1.tgz", - "integrity": "sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA==", + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.1.tgz", + "integrity": "sha512-9LrwDw3S9Fygtw/Q6WDhOP+3svJRGAsejeE+GKrc0eO1ThMVhwi2LL6hw4dlKw93IfS7VY1G19sWGxJ/NcU4nA==", "cpu": [ "arm64" ], @@ -1329,9 +1324,9 @@ } }, "node_modules/@tauri-apps/cli-linux-arm64-musl": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.1.tgz", - "integrity": "sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==", + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.1.tgz", + "integrity": "sha512-mNA5dbbqPqDUdTIwdUYYuhO2GvIe9UnB2r0VU2njxBOS3Opbx4gKNC5yP0Iu4rYmEmqdlwry9VzGZQ3wq9dyFg==", "cpu": [ "arm64" ], @@ -1346,9 +1341,9 @@ } }, "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.1.tgz", - "integrity": "sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==", + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.1.tgz", + "integrity": "sha512-fZj3Gwq+6fUs305T5WQiD5iSGJw+j/4w/HGmk4sHDAcy+rp9zU5eaxB7nOyz5/I/nkNAuKPqfp6uIbiUBXkBCw==", "cpu": [ "riscv64" ], @@ -1363,9 +1358,9 @@ } }, "node_modules/@tauri-apps/cli-linux-x64-gnu": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.1.tgz", - "integrity": "sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==", + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.1.tgz", + "integrity": "sha512-XFxGxOvHM7jjeD6ozCKdGfhzJ7lERYDGZl1/Kb4fsvchaJsfLJ981TlyTG8Qy/gFq+f5GitH3bfrX9JAkjPEyw==", "cpu": [ "x64" ], @@ -1380,9 +1375,9 @@ } }, "node_modules/@tauri-apps/cli-linux-x64-musl": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.1.tgz", - "integrity": "sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==", + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.1.tgz", + "integrity": "sha512-d5C2/Zm+68v7R9wTuTCjRQEVrWjcdMkJBZ1+rXse+QdMMlTB9+u9PDNDLw9PQflWxYLaYZ7tjxxL9Nb9II6PbA==", "cpu": [ "x64" ], @@ -1397,9 +1392,9 @@ } }, "node_modules/@tauri-apps/cli-win32-arm64-msvc": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.1.tgz", - "integrity": "sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==", + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.1.tgz", + "integrity": "sha512-YdeVWFAR1pTXzUU6NLstPq4G6OLxuDrXCXEBdmBH+5EZIDXUx0D2kJlz3+YjpazkKvAzYpgziTsyRagls0OfRQ==", "cpu": [ "arm64" ], @@ -1414,9 +1409,9 @@ } }, "node_modules/@tauri-apps/cli-win32-ia32-msvc": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.1.tgz", - "integrity": "sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw==", + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.1.tgz", + "integrity": "sha512-VBGkuH0eB9K9LLSMv361Gzr5Ou72sCS4+ztpmkWEQ+wd/amhcYOsf3X6qn1RJZDzIhiOYHJEOysZUC3baD01rA==", "cpu": [ "ia32" ], @@ -1430,10 +1425,10 @@ "node": ">= 10" } }, - "node_modules/@tauri-apps/cli/node_modules/@tauri-apps/cli-win32-x64-msvc": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.1.tgz", - "integrity": "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg==", + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.1.tgz", + "integrity": "sha512-b3ORhIAKgp9ZYY+zBt7b7r0kLU2kjvyGF0+MS2SBym3emsweGPybEqocJcmtMuxyBhkOKHP4CiuEJEDuAlTx6A==", "cpu": [ "x64" ], @@ -1448,27 +1443,27 @@ } }, "node_modules/@tauri-apps/plugin-dialog": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.0.tgz", - "integrity": "sha512-4nS/hfGMGCXiAS3LtVjH9AgsSAPJeG/7R+q8agTFqytjnMa4Zq95Bq8WzVDkckpanX+yyRHXnRtrKXkANKDHvw==", + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz", + "integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==", "license": "MIT OR Apache-2.0", "dependencies": { - "@tauri-apps/api": "^2.10.1" + "@tauri-apps/api": "^2.11.0" } }, "node_modules/@tauri-apps/plugin-fs": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.5.0.tgz", - "integrity": "sha512-c83kbz61AK+rKjhS+je9+stIO27nXj7p9cqeg36TwkIUtxpCFTttlHHtqon6h6FN54cXjyAjlMPOJcW3mwE5XQ==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.5.1.tgz", + "integrity": "sha512-9Lz+Jopp6QyeEWhlpkMx4R/+P9HgR+AVAI4vOZhlT8Xaymtz8iVI/Ov984/XTqgJz/5gz5NretqPB/XEMS3NhQ==", "license": "MIT OR Apache-2.0", "dependencies": { - "@tauri-apps/api": "^2.10.1" + "@tauri-apps/api": "^2.11.0" } }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "dev": true, "license": "MIT", "optional": true, @@ -1533,17 +1528,17 @@ "optional": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz", - "integrity": "sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz", + "integrity": "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.59.1", - "@typescript-eslint/type-utils": "8.59.1", - "@typescript-eslint/utils": "8.59.1", - "@typescript-eslint/visitor-keys": "8.59.1", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/type-utils": "8.59.2", + "@typescript-eslint/utils": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -1556,22 +1551,22 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.59.1", + "@typescript-eslint/parser": "^8.59.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.1.tgz", - "integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.2.tgz", + "integrity": "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.59.1", - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/typescript-estree": "8.59.1", - "@typescript-eslint/visitor-keys": "8.59.1", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3" }, "engines": { @@ -1587,14 +1582,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.1.tgz", - "integrity": "sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.2.tgz", + "integrity": "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.59.1", - "@typescript-eslint/types": "^8.59.1", + "@typescript-eslint/tsconfig-utils": "^8.59.2", + "@typescript-eslint/types": "^8.59.2", "debug": "^4.4.3" }, "engines": { @@ -1609,14 +1604,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz", - "integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz", + "integrity": "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/visitor-keys": "8.59.1" + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1627,9 +1622,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.1.tgz", - "integrity": "sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz", + "integrity": "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==", "dev": true, "license": "MIT", "engines": { @@ -1644,15 +1639,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.1.tgz", - "integrity": "sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.2.tgz", + "integrity": "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/typescript-estree": "8.59.1", - "@typescript-eslint/utils": "8.59.1", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -1669,9 +1664,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz", - "integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.2.tgz", + "integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==", "dev": true, "license": "MIT", "engines": { @@ -1683,16 +1678,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz", - "integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz", + "integrity": "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.59.1", - "@typescript-eslint/tsconfig-utils": "8.59.1", - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/visitor-keys": "8.59.1", + "@typescript-eslint/project-service": "8.59.2", + "@typescript-eslint/tsconfig-utils": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -1711,16 +1706,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.1.tgz", - "integrity": "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.2.tgz", + "integrity": "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.59.1", - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/typescript-estree": "8.59.1" + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1735,13 +1730,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz", - "integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz", + "integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/types": "8.59.2", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -2042,10 +2037,6 @@ "js-tokens": "^10.0.0" } }, - "node_modules/axelate-frontend": { - "resolved": "", - "link": true - }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -2340,9 +2331,9 @@ } }, "node_modules/dompurify": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.1.tgz", - "integrity": "sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz", + "integrity": "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -2443,9 +2434,9 @@ } }, "node_modules/eslint": { - "version": "10.2.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.1.tgz", - "integrity": "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==", + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.3.0.tgz", + "integrity": "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==", "dev": true, "license": "MIT", "dependencies": { @@ -2885,9 +2876,9 @@ } }, "node_modules/globals": { - "version": "17.5.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.5.0.tgz", - "integrity": "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==", + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", "dev": true, "license": "MIT", "engines": { @@ -3142,9 +3133,9 @@ } }, "node_modules/jsdom": { - "version": "29.1.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.0.tgz", - "integrity": "sha512-YNUc7fB9QuvSSQWfrH0xF+TyABkxUwx8sswgIDaCrw4Hol8BghdZDkITtZheRJeMtzWlnTfsM3bBBusRvpO1wg==", + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", "dev": true, "license": "MIT", "dependencies": { @@ -3518,41 +3509,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash.camelcase": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", - "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.kebabcase": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", - "integrity": "sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.snakecase": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", - "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.startcase": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz", - "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.upperfirst": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz", - "integrity": "sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==", - "dev": true, - "license": "MIT" - }, "node_modules/lru-cache": { "version": "11.3.5", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", @@ -3602,9 +3558,9 @@ } }, "node_modules/marked": { - "version": "18.0.2", - "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.2.tgz", - "integrity": "sha512-NsmlUYBS/Zg57rgDWMYdnre6OTj4e+qq/JS2ot3KrYLSoHLw+sDu0Nm1ZGpRgYAq6c+b1ekaY5NzVchMCQnzcg==", + "version": "18.0.3", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.3.tgz", + "integrity": "sha512-7VT90JOkDeaRWpfjOReRGPEKn0ecdARBkDGL+tT1wZY0efPPqkUxLUSmzy/C7TIylQYJC9STISEsCHrqb/7VIA==", "license": "MIT", "bin": { "marked": "bin/marked.js" @@ -3695,9 +3651,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -3874,9 +3830,9 @@ } }, "node_modules/postcss": { - "version": "8.5.10", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", - "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "dev": true, "funding": [ { @@ -3969,14 +3925,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", - "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.18.tgz", + "integrity": "sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.127.0", - "@rolldown/pluginutils": "1.0.0-rc.17" + "@oxc-project/types": "=0.128.0", + "@rolldown/pluginutils": "1.0.0-rc.18" }, "bin": { "rolldown": "bin/cli.mjs" @@ -3985,21 +3941,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-x64": "1.0.0-rc.17", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + "@rolldown/binding-android-arm64": "1.0.0-rc.18", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.18", + "@rolldown/binding-darwin-x64": "1.0.0-rc.18", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.18", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.18", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.18", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.18", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.18", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.18", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.18", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.18" } }, "node_modules/saxes": { @@ -4180,9 +4136,9 @@ "license": "MIT" }, "node_modules/terser": { - "version": "5.46.2", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.2.tgz", - "integrity": "sha512-uxfo9fPcSgLDYob/w1FuL0c99MWiJDnv+5qXSQc5+Ki5NjVNsYi66INnMFBjf6uFz6OnX12piJQPF4IpjJTNTw==", + "version": "5.47.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.47.1.tgz", + "integrity": "sha512-tPbLXTI6ohPASb/1YViL428oEHu6/qv1OxqYnfaonVCFHqx4+wCd95pHrQWsL5X4pl90CTyW9piSAsS2L0VoMw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -4347,16 +4303,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.1.tgz", - "integrity": "sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.2.tgz", + "integrity": "sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.59.1", - "@typescript-eslint/parser": "8.59.1", - "@typescript-eslint/typescript-estree": "8.59.1", - "@typescript-eslint/utils": "8.59.1" + "@typescript-eslint/eslint-plugin": "8.59.2", + "@typescript-eslint/parser": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4398,16 +4354,16 @@ } }, "node_modules/vite": { - "version": "8.0.10", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", - "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "version": "8.0.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.11.tgz", + "integrity": "sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.10", - "rolldown": "1.0.0-rc.17", + "postcss": "^8.5.14", + "rolldown": "1.0.0-rc.18", "tinyglobby": "^0.2.16" }, "bin": { @@ -4424,7 +4380,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.0", + "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", diff --git a/src/package.json b/src/package.json index 0c5da30c..1288cca1 100644 --- a/src/package.json +++ b/src/package.json @@ -24,36 +24,35 @@ "prepare": "node scripts/setup-git-hooks.mjs" }, "devDependencies": { - "@commitlint/cli": "^20.5.2", - "@commitlint/config-conventional": "^20.5.0", + "@commitlint/cli": "^20.5.3", + "@commitlint/config-conventional": "^20.5.3", "@eslint/js": "^10.0.1", - "@tauri-apps/cli": "~2.10.1", + "@tauri-apps/cli": "~2.11.1", "@types/node": "^25.6.0", - "@typescript-eslint/eslint-plugin": "^8.59.1", - "@typescript-eslint/parser": "^8.59.1", - "@typescript-eslint/utils": "^8.59.1", + "@typescript-eslint/eslint-plugin": "^8.59.2", + "@typescript-eslint/parser": "^8.59.2", + "@typescript-eslint/utils": "^8.59.2", "@vitest/coverage-v8": "^4.1.5", "@vitest/ui": "^4.1.5", - "eslint": "^10.2.1", + "eslint": "^10.3.0", "eslint-config-prettier": "^10.1.8", "fonteditor-core": "^2.6.3", - "globals": "^17.5.0", - "jsdom": "^29.1.0", + "globals": "^17.6.0", + "jsdom": "^29.1.1", "prettier": "^3.8.3", "smol-toml": "^1.6.1", - "terser": "^5.46.1", + "terser": "^5.47.0", "typescript": "^6.0.3", - "typescript-eslint": "^8.59.1", - "vite": "^8.0.10", + "typescript-eslint": "^8.59.2", + "vite": "^8.0.11", "vitest": "^4.1.5" }, "dependencies": { - "@tauri-apps/api": "~2.10.1", - "@tauri-apps/plugin-dialog": "~2.7.0", - "@tauri-apps/plugin-fs": "~2.5.0", - "axelate-frontend": "file:", - "dompurify": "^3.4.1", - "marked": "^18.0.2", + "@tauri-apps/api": "~2.11.0", + "@tauri-apps/plugin-dialog": "~2.7.1", + "@tauri-apps/plugin-fs": "~2.5.1", + "dompurify": "^3.4.2", + "marked": "^18.0.3", "marked-alert": "^2.1.2", "marked-footnote": "^1.4.0", "wawoff2": "^2.0.1" From 98e843a58c826e158e5292fcd366ff67d9964a92 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 7 May 2026 14:44:28 +0300 Subject: [PATCH 101/126] docs: reorganize integration guidance --- .nvmrc | 2 -- .../python-ai-tool/axelate-module.toml | 2 +- .../integrations/python-ai-tool/src/main.py | 2 +- docs/examples/sdk/python/axelate_sdk.py | 7 ++++--- docs/{ => localization}/en/ARCHITECTURE.md | 2 +- docs/{ => localization}/en/CURRENT_STATE.md | 6 +++--- .../en/CUSTOM_INTEGRATIONS.md | 6 +++--- .../en/DEVELOPMENT_WORKFLOW.md | 0 docs/{ => localization}/en/GETTING_STARTED.md | 0 .../en/INTEGRATION_API.md} | 17 +++++++---------- .../en/INTEGRATION_DEVELOPMENT.md | 6 +++--- docs/{ => localization}/en/RELEASES.md | 0 docs/{ => localization}/en/ROADMAP.md | 6 +++--- docs/{ => localization}/en/TRUST_MODEL.md | 0 docs/{ => localization}/en/USER_GUIDE.md | 0 docs/{ => localization}/en/VISION.md | 0 .../ru/INTEGRATION_DEVELOPMENT.md | 8 ++++---- .../zh/INTEGRATION_DEVELOPMENT.md | 6 +++--- 18 files changed, 33 insertions(+), 37 deletions(-) delete mode 100644 .nvmrc rename docs/{ => localization}/en/ARCHITECTURE.md (99%) rename docs/{ => localization}/en/CURRENT_STATE.md (98%) rename docs/{ => localization}/en/CUSTOM_INTEGRATIONS.md (93%) rename docs/{ => localization}/en/DEVELOPMENT_WORKFLOW.md (100%) rename docs/{ => localization}/en/GETTING_STARTED.md (100%) rename docs/{en/LAUNCHER_SDK.md => localization/en/INTEGRATION_API.md} (95%) rename docs/{ => localization}/en/INTEGRATION_DEVELOPMENT.md (96%) rename docs/{ => localization}/en/RELEASES.md (100%) rename docs/{ => localization}/en/ROADMAP.md (98%) rename docs/{ => localization}/en/TRUST_MODEL.md (100%) rename docs/{ => localization}/en/USER_GUIDE.md (100%) rename docs/{ => localization}/en/VISION.md (100%) rename docs/{ => localization}/ru/INTEGRATION_DEVELOPMENT.md (95%) rename docs/{ => localization}/zh/INTEGRATION_DEVELOPMENT.md (95%) diff --git a/.nvmrc b/.nvmrc deleted file mode 100644 index 35f49783..00000000 --- a/.nvmrc +++ /dev/null @@ -1,2 +0,0 @@ -20 - diff --git a/docs/examples/integrations/python-ai-tool/axelate-module.toml b/docs/examples/integrations/python-ai-tool/axelate-module.toml index 44d5e448..781fd147 100644 --- a/docs/examples/integrations/python-ai-tool/axelate-module.toml +++ b/docs/examples/integrations/python-ai-tool/axelate-module.toml @@ -4,7 +4,7 @@ name = "Python AI Tool" version = "0.1.0" description = "Example integration that calls Axelate AI and stores settings." author = "Axelate" -type = "service" +category = "service" icon = "⚙" readme = "README.md" settings_ui = "settings-ui/index.html" diff --git a/docs/examples/integrations/python-ai-tool/src/main.py b/docs/examples/integrations/python-ai-tool/src/main.py index b703c278..2f97ab64 100644 --- a/docs/examples/integrations/python-ai-tool/src/main.py +++ b/docs/examples/integrations/python-ai-tool/src/main.py @@ -17,7 +17,7 @@ def validate_base_url(value: str) -> str: BASE_URL = validate_base_url(os.environ["AXELATE_HTTP_API_BASE"]) TOKEN = os.environ["AXELATE_HTTP_API_TOKEN"] MODULE_ID = os.environ["AXELATE_MODULE_ID"] -MODULE_PATH_ID = urllib.parse.quote(MODULE_ID) +MODULE_PATH_ID = urllib.parse.quote(MODULE_ID, safe="") def request(method: str, path: str, payload: dict | None = None) -> dict: diff --git a/docs/examples/sdk/python/axelate_sdk.py b/docs/examples/sdk/python/axelate_sdk.py index 01b6817f..8abe009d 100644 --- a/docs/examples/sdk/python/axelate_sdk.py +++ b/docs/examples/sdk/python/axelate_sdk.py @@ -24,6 +24,7 @@ def __init__(self) -> None: self.base_url = validate_base_url(required_env("AXELATE_HTTP_API_BASE")).rstrip("/") self.token = required_env("AXELATE_HTTP_API_TOKEN") self.module_id = required_env("AXELATE_MODULE_ID") + self.encoded_module_id = urllib.parse.quote(self.module_id, safe="") def request(self, method: str, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: data = None if payload is None else json.dumps(payload).encode("utf-8") @@ -53,7 +54,7 @@ def request(self, method: str, path: str, payload: dict[str, Any] | None = None) raise AxelateApiError(method, path, None, "", str(error.reason)) from error def settings(self) -> dict[str, Any]: - payload = self.request("GET", f"/v1/modules/{urllib.parse.quote(self.module_id)}/settings") + payload = self.request("GET", f"/v1/modules/{self.encoded_module_id}/settings") if "error" in payload: raise AxelateApiError("GET", "/settings", None, json.dumps(payload), str(payload["error"])) return payload.get("settings", {}) @@ -61,7 +62,7 @@ def settings(self) -> dict[str, Any]: def save_settings(self, settings: dict[str, Any]) -> dict[str, Any]: return self.request( "PUT", - f"/v1/modules/{urllib.parse.quote(self.module_id)}/settings", + f"/v1/modules/{self.encoded_module_id}/settings", settings, ) @@ -69,7 +70,7 @@ def stage(self, stage: str, label: str, progress: float | None = None) -> dict[s payload: dict[str, Any] = {"stage": stage, "label": label} if progress is not None: payload["progress"] = progress - return self.request("POST", f"/v1/modules/{urllib.parse.quote(self.module_id)}/stage", payload) + return self.request("POST", f"/v1/modules/{self.encoded_module_id}/stage", payload) def ai_text(self, prompt: str, **options: Any) -> dict[str, Any]: """Run text generation. sessionId defaults to module_id and can be overridden.""" diff --git a/docs/en/ARCHITECTURE.md b/docs/localization/en/ARCHITECTURE.md similarity index 99% rename from docs/en/ARCHITECTURE.md rename to docs/localization/en/ARCHITECTURE.md index 929970f7..fc2f3c7d 100644 --- a/docs/en/ARCHITECTURE.md +++ b/docs/localization/en/ARCHITECTURE.md @@ -103,7 +103,7 @@ viable: - [Getting Started](GETTING_STARTED.md) - [User Guide](USER_GUIDE.md) - [Development Workflow](DEVELOPMENT_WORKFLOW.md) -- [Launcher SDK](LAUNCHER_SDK.md) +- [Integration API](INTEGRATION_API.md) - [Integration Development](INTEGRATION_DEVELOPMENT.md) - [Custom Integrations](CUSTOM_INTEGRATIONS.md) - [Trust Model](TRUST_MODEL.md) diff --git a/docs/en/CURRENT_STATE.md b/docs/localization/en/CURRENT_STATE.md similarity index 98% rename from docs/en/CURRENT_STATE.md rename to docs/localization/en/CURRENT_STATE.md index 343c730c..334b2880 100644 --- a/docs/en/CURRENT_STATE.md +++ b/docs/localization/en/CURRENT_STATE.md @@ -156,7 +156,7 @@ The local module catalog currently includes these known entries. - type: local - capability: image -- engine: `stable-diffusion.cpp` +- engine: `sdcpp` - role: lightweight local image generation ### 3. `comfyui` @@ -256,13 +256,13 @@ OpenRouter is a strong accelerator for the current stage, but it also means: - provider abstraction is not yet the main product story - business differentiation cannot come from model catalog alone -### 3. Placeholder and Legacy Surfaces +### 3. Placeholder and Unfinished Surfaces The repository still contains surfaces or ideas that are ahead of the stable product: - home overview placeholder - ComfyUI presence without product-ready positioning -- legacy wording and shell assumptions carried from earlier product framing +- old wording and shell assumptions carried from earlier product framing ### 4. Incomplete Package Trust Foundation diff --git a/docs/en/CUSTOM_INTEGRATIONS.md b/docs/localization/en/CUSTOM_INTEGRATIONS.md similarity index 93% rename from docs/en/CUSTOM_INTEGRATIONS.md rename to docs/localization/en/CUSTOM_INTEGRATIONS.md index 192aafd8..c21c6526 100644 --- a/docs/en/CUSTOM_INTEGRATIONS.md +++ b/docs/localization/en/CUSTOM_INTEGRATIONS.md @@ -28,7 +28,7 @@ name = "My Integration" version = "0.1.0" description = "Connects my product to Axelate." author = "Your Name" -type = "service" +category = "service" icon = "⚙" readme = "README.md" settings_ui = "settings-ui/index.html" @@ -52,7 +52,7 @@ Rules: Launcher-managed script-runtime integrations receive: -- `AXELATE_SDK_VERSION` +- `AXELATE_INTEGRATION_API_VERSION` - `AXELATE_HTTP_API_BASE` - `AXELATE_HTTP_API_TOKEN` - `AXELATE_RUNTIME_DIR` @@ -61,7 +61,7 @@ Launcher-managed script-runtime integrations receive: - `AXELATE_MODULE_LOG_DIR` - `AXELATE_MODULE_ID` -Use the local HTTP API from [LAUNCHER_SDK.md](./LAUNCHER_SDK.md) to call AI, +Use the local HTTP API from [INTEGRATION_API.md](./INTEGRATION_API.md) to call AI, read and save integration settings, report stages, and control integration status. Store integration-owned runtime files under `AXELATE_MODULE_RUNTIME_DIR` and logs under `AXELATE_MODULE_LOG_DIR`; do not write generated files into the diff --git a/docs/en/DEVELOPMENT_WORKFLOW.md b/docs/localization/en/DEVELOPMENT_WORKFLOW.md similarity index 100% rename from docs/en/DEVELOPMENT_WORKFLOW.md rename to docs/localization/en/DEVELOPMENT_WORKFLOW.md diff --git a/docs/en/GETTING_STARTED.md b/docs/localization/en/GETTING_STARTED.md similarity index 100% rename from docs/en/GETTING_STARTED.md rename to docs/localization/en/GETTING_STARTED.md diff --git a/docs/en/LAUNCHER_SDK.md b/docs/localization/en/INTEGRATION_API.md similarity index 95% rename from docs/en/LAUNCHER_SDK.md rename to docs/localization/en/INTEGRATION_API.md index 52fa5ed4..a44be017 100644 --- a/docs/en/LAUNCHER_SDK.md +++ b/docs/localization/en/INTEGRATION_API.md @@ -1,8 +1,8 @@ -# Launcher SDK +# Integration API This guide describes the current versioned contract external integrations use to control Axelate. The contract is language-neutral: every integration talks to the -launcher through a local HTTP API. Language SDKs can wrap this contract later, +launcher through a local HTTP API. Language clients can wrap this contract later, but the HTTP API is the source of truth. For scaffolding, validation, and examples, start with @@ -17,7 +17,8 @@ runtime token. Launcher-managed script-runtime integration processes receive these environment variables: -- `AXELATE_SDK_VERSION`: local launcher integration API version, currently `1` +- `AXELATE_INTEGRATION_API_VERSION`: local launcher integration API version, + currently `1` - `AXELATE_HTTP_API_BASE`: local base URL, for example `http://127.0.0.1:3000` - `AXELATE_HTTP_API_TOKEN`: bearer token issued by `apply_process_env` through `issue_module_api_token` and scoped to this integration. It authorizes shared @@ -33,13 +34,12 @@ Standalone tools that are not launched by Axelate are not the primary public contract yet. They should use a launcher-managed integration flow instead of persisting or guessing local API credentials. -Script integrations declare their runtime in `axelate-module.toml`. Legacy top-level -`entry` and `dependencies` fields are not supported. +Script integrations declare their runtime in `axelate-module.toml`. ```toml [runtime] kind = "python" # python | node | bun | binary -version = "3.14" +version = "3.11" entry = "src/main.py" dependencies = "requirements.txt" ``` @@ -53,15 +53,12 @@ dependencies inside the integration directory. ## Authentication -Every endpoint except `GET /v1/health` requires one of these headers: +Every endpoint except `GET /v1/health` requires bearer-token authentication: ```http Authorization: Bearer -X-Axelate-Token: ``` -Prefer `Authorization: Bearer ...` for new clients. - ## Client Rules - Treat `AXELATE_HTTP_API_BASE` and `AXELATE_HTTP_API_TOKEN` as runtime values. diff --git a/docs/en/INTEGRATION_DEVELOPMENT.md b/docs/localization/en/INTEGRATION_DEVELOPMENT.md similarity index 96% rename from docs/en/INTEGRATION_DEVELOPMENT.md rename to docs/localization/en/INTEGRATION_DEVELOPMENT.md index b1a5f86c..940ee6d2 100644 --- a/docs/en/INTEGRATION_DEVELOPMENT.md +++ b/docs/localization/en/INTEGRATION_DEVELOPMENT.md @@ -28,7 +28,7 @@ Then import the folder in the launcher integrations screen and launch it. helper for custom settings UI iframe messaging. These helpers are developer tools. The runtime contract is still the local HTTP -API documented in [Launcher SDK](LAUNCHER_SDK.md). +API documented in [Integration API](INTEGRATION_API.md). ## Integration Layout @@ -49,7 +49,7 @@ api_version = "1" id = "my-integration" name = "My Integration" version = "0.1.0" -type = "service" +category = "service" settings_ui = "settings-ui/index.html" [runtime] @@ -64,7 +64,7 @@ Supported runtime kinds are `python`, `node`, `bun`, and `binary`. When Axelate launches a script-runtime integration it sets: -- `AXELATE_SDK_VERSION` +- `AXELATE_INTEGRATION_API_VERSION` - `AXELATE_HTTP_API_BASE` - `AXELATE_HTTP_API_TOKEN` - `AXELATE_MODULE_ID` diff --git a/docs/en/RELEASES.md b/docs/localization/en/RELEASES.md similarity index 100% rename from docs/en/RELEASES.md rename to docs/localization/en/RELEASES.md diff --git a/docs/en/ROADMAP.md b/docs/localization/en/ROADMAP.md similarity index 98% rename from docs/en/ROADMAP.md rename to docs/localization/en/ROADMAP.md index 68218385..6c171b4d 100644 --- a/docs/en/ROADMAP.md +++ b/docs/localization/en/ROADMAP.md @@ -133,7 +133,7 @@ Difficulty: `1/10` Work: - keep `CURRENT_STATE.md`, `ROADMAP.md`, `TRUST_MODEL.md`, and - `LAUNCHER_SDK.md` aligned with actual behavior + `INTEGRATION_API.md` aligned with actual behavior - remove stale claims when backend behavior changes - document the exact local API, environment variables, runtime directories, and settings ownership rules @@ -213,7 +213,7 @@ Work: - helpers for chat, image, settings, stage reporting, and module control - typed errors - examples that match the integration template -- version compatibility checks using `AXELATE_SDK_VERSION` +- version compatibility checks using `AXELATE_INTEGRATION_API_VERSION` Exit criteria: @@ -327,7 +327,7 @@ Remove identity confusion and define one honest product direction. - consolidate documentation into English canonical docs - define the product as a Windows AI workstation, not a generic chat client - define future platform boundaries before adding new layers -- remove or demote legacy positioning that implies distribution features already +- remove or demote old positioning that implies distribution features already exist ### Exit Criteria diff --git a/docs/en/TRUST_MODEL.md b/docs/localization/en/TRUST_MODEL.md similarity index 100% rename from docs/en/TRUST_MODEL.md rename to docs/localization/en/TRUST_MODEL.md diff --git a/docs/en/USER_GUIDE.md b/docs/localization/en/USER_GUIDE.md similarity index 100% rename from docs/en/USER_GUIDE.md rename to docs/localization/en/USER_GUIDE.md diff --git a/docs/en/VISION.md b/docs/localization/en/VISION.md similarity index 100% rename from docs/en/VISION.md rename to docs/localization/en/VISION.md diff --git a/docs/ru/INTEGRATION_DEVELOPMENT.md b/docs/localization/ru/INTEGRATION_DEVELOPMENT.md similarity index 95% rename from docs/ru/INTEGRATION_DEVELOPMENT.md rename to docs/localization/ru/INTEGRATION_DEVELOPMENT.md index adafd62d..4b21bf96 100644 --- a/docs/ru/INTEGRATION_DEVELOPMENT.md +++ b/docs/localization/ru/INTEGRATION_DEVELOPMENT.md @@ -28,8 +28,8 @@ npm run integration:doctor -- ./my-integration - `docs/examples/sdk/browser/axelate-settings-bridge.js` - helper для iframe-протокола custom settings UI. -Главный контракт все равно описан в [Launcher SDK](../en/LAUNCHER_SDK.md) -(англ., в `docs/en/LAUNCHER_SDK.md`): это локальный HTTP API лаунчера. +Главный контракт все равно описан в [Integration API](../en/INTEGRATION_API.md) +(англ., в `docs/en/INTEGRATION_API.md`): это локальный HTTP API лаунчера. ## Структура интеграции @@ -50,7 +50,7 @@ api_version = "1" id = "my-integration" name = "My Integration" version = "0.1.0" -type = "service" +category = "service" settings_ui = "settings-ui/index.html" [runtime] @@ -65,7 +65,7 @@ entry = "src/main.py" Когда Axelate запускает script-runtime интеграцию, он передает: -- `AXELATE_SDK_VERSION` +- `AXELATE_INTEGRATION_API_VERSION` - `AXELATE_HTTP_API_BASE` - `AXELATE_HTTP_API_TOKEN` - `AXELATE_MODULE_ID` diff --git a/docs/zh/INTEGRATION_DEVELOPMENT.md b/docs/localization/zh/INTEGRATION_DEVELOPMENT.md similarity index 95% rename from docs/zh/INTEGRATION_DEVELOPMENT.md rename to docs/localization/zh/INTEGRATION_DEVELOPMENT.md index 1719b2ff..c6cad1fa 100644 --- a/docs/zh/INTEGRATION_DEVELOPMENT.md +++ b/docs/localization/zh/INTEGRATION_DEVELOPMENT.md @@ -25,7 +25,7 @@ npm run integration:doctor -- ./my-integration - `docs/examples/sdk/browser/axelate-settings-bridge.js` 是 custom settings UI iframe 消息协议的可复制 helper。 -真正的运行时契约仍然是 [Launcher SDK](../en/LAUNCHER_SDK.md) 中描述的本地 +真正的运行时契约仍然是 [Integration API](../en/INTEGRATION_API.md) 中描述的本地 HTTP API。 ## 集成结构 @@ -47,7 +47,7 @@ api_version = "1" id = "my-integration" name = "My Integration" version = "0.1.0" -type = "service" +category = "service" settings_ui = "settings-ui/index.html" [runtime] @@ -62,7 +62,7 @@ entry = "src/main.py" Axelate 启动 script-runtime 集成时会设置: -- `AXELATE_SDK_VERSION` +- `AXELATE_INTEGRATION_API_VERSION` - `AXELATE_HTTP_API_BASE` - `AXELATE_HTTP_API_TOKEN` - `AXELATE_MODULE_ID` From 6205fa7515a94e8df890503b0f615a1e4a03186e Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 7 May 2026 14:46:29 +0300 Subject: [PATCH 102/126] refactor(core): tighten engine and integration contracts --- .../api_providers/image/gemini-image.json | 12 +- .../api_providers/image/gpt-image.json | 12 +- .../api_providers/image/seedream-image.json | 4 +- .../resources/api_providers/text/claude.json | 12 +- .../api_providers/text/deepseek.json | 8 +- .../resources/api_providers/text/gemini.json | 12 +- .../resources/api_providers/text/gpt.json | 16 +- src-tauri/resources/locales/en.json | 10 +- src-tauri/resources/locales/ru.json | 6 +- src-tauri/resources/locales/zh.json | 6 +- src-tauri/src/api/ai/mod.rs | 98 +------ src-tauri/src/api/engine/mod.rs | 36 ++- src-tauri/src/api/system/logs.rs | 168 ++++++++--- src-tauri/src/domain/ai/ai_dispatch.rs | 53 +--- src-tauri/src/domain/ai/ai_service.rs | 17 +- src-tauri/src/domain/ai/image_cloud.rs | 3 +- src-tauri/src/domain/ai/image_comfyui.rs | 5 +- .../src/domain/ai/image_generation_state.rs | 9 +- src-tauri/src/domain/ai/image_local.rs | 48 ++-- .../src/domain/ai/image_provider_adapter.rs | 159 +++++++++++ src-tauri/src/domain/ai/image_service.rs | 29 +- src-tauri/src/domain/ai/image_settings.rs | 86 ++---- src-tauri/src/domain/ai/mod.rs | 5 +- src-tauri/src/domain/ai/provider_payload.rs | 33 ++- src-tauri/src/domain/ai/streaming.rs | 25 +- src-tauri/src/domain/engine/config.rs | 7 +- src-tauri/src/domain/engine/detector.rs | 28 ++ src-tauri/src/domain/engine/engine_args.rs | 32 ++- src-tauri/src/domain/engine/engine_profile.rs | 8 + src-tauri/src/domain/engine/manager.rs | 119 +++----- src-tauri/src/domain/engine/mod.rs | 1 + src-tauri/src/domain/engine/registry.rs | 1 + src-tauri/src/domain/engine/types.rs | 5 + src-tauri/src/domain/integration_api.rs | 102 ++++--- .../modules/controller/script_runtime.rs | 3 +- src-tauri/src/domain/modules/downloader.rs | 7 +- .../src/domain/modules/downloader_install.rs | 2 + .../src/domain/modules/downloader_support.rs | 9 +- .../modules/github_release_selection.rs | 55 +++- .../src/domain/modules/github_releases.rs | 125 ++++++++- src-tauri/src/domain/modules/lifecycle.rs | 24 +- .../config/config_repository.rs | 142 +++++----- src-tauri/src/infrastructure/config/theme.rs | 7 - .../src/infrastructure/config/ui_state.rs | 2 +- .../infrastructure/config/window_settings.rs | 2 +- .../infrastructure/engine/tauri_emitter.rs | 11 +- .../src/infrastructure/logging/logger.rs | 31 ++- src-tauri/src/models/config.rs | 13 +- src-tauri/src/models/ui_state.rs | 6 +- src-tauri/src/utils/paths.rs | 261 +----------------- src/shared/types/bindings.ts | 26 +- src/shared/types/coreTypes.ts | 28 +- 52 files changed, 1017 insertions(+), 912 deletions(-) create mode 100644 src-tauri/src/domain/ai/image_provider_adapter.rs create mode 100644 src-tauri/src/domain/engine/engine_profile.rs diff --git a/src-tauri/resources/api_providers/image/gemini-image.json b/src-tauri/resources/api_providers/image/gemini-image.json index 2cc1ac17..949673fb 100644 --- a/src-tauri/resources/api_providers/image/gemini-image.json +++ b/src-tauri/resources/api_providers/image/gemini-image.json @@ -19,8 +19,8 @@ "contextWindow": 65536, "maxOutputTokens": 8192, "pricing": { - "input_per_1m": 2, - "output_per_1m": 12, + "input": 2, + "output": 12, "currency": "USD", "notes": "OpenRouter pricing" }, @@ -48,8 +48,8 @@ "contextWindow": 32768, "maxOutputTokens": 8192, "pricing": { - "input_per_1m": 0.3, - "output_per_1m": 2.5, + "input": 0.3, + "output": 2.5, "currency": "USD", "notes": "OpenRouter pricing" }, @@ -77,8 +77,8 @@ "contextWindow": 65536, "maxOutputTokens": 8192, "pricing": { - "input_per_1m": 0.5, - "output_per_1m": 3, + "input": 0.5, + "output": 3, "currency": "USD", "notes": "OpenRouter pricing" }, diff --git a/src-tauri/resources/api_providers/image/gpt-image.json b/src-tauri/resources/api_providers/image/gpt-image.json index c1d75042..1688b6c0 100644 --- a/src-tauri/resources/api_providers/image/gpt-image.json +++ b/src-tauri/resources/api_providers/image/gpt-image.json @@ -19,8 +19,8 @@ "contextWindow": 272000, "maxOutputTokens": 8192, "pricing": { - "input_per_1m": 8, - "output_per_1m": 15, + "input": 8, + "output": 15, "currency": "USD", "notes": "OpenRouter pricing" }, @@ -48,8 +48,8 @@ "contextWindow": 400000, "maxOutputTokens": 8192, "pricing": { - "input_per_1m": 10, - "output_per_1m": 10, + "input": 10, + "output": 10, "currency": "USD", "notes": "OpenRouter pricing" }, @@ -77,8 +77,8 @@ "contextWindow": 400000, "maxOutputTokens": 8192, "pricing": { - "input_per_1m": 2.5, - "output_per_1m": 2, + "input": 2.5, + "output": 2, "currency": "USD", "notes": "OpenRouter pricing" }, diff --git a/src-tauri/resources/api_providers/image/seedream-image.json b/src-tauri/resources/api_providers/image/seedream-image.json index ce943cfd..b7e474cf 100644 --- a/src-tauri/resources/api_providers/image/seedream-image.json +++ b/src-tauri/resources/api_providers/image/seedream-image.json @@ -19,8 +19,8 @@ "contextWindow": 4096, "maxOutputTokens": 4096, "pricing": { - "input_per_1m": 0.04, - "output_per_1m": 0, + "input": 0.04, + "output": 0, "currency": "USD", "notes": "$0.04 / image" }, diff --git a/src-tauri/resources/api_providers/text/claude.json b/src-tauri/resources/api_providers/text/claude.json index 5f045968..8ec8b5ea 100644 --- a/src-tauri/resources/api_providers/text/claude.json +++ b/src-tauri/resources/api_providers/text/claude.json @@ -16,8 +16,8 @@ "contextWindow": 1000000, "maxOutputTokens": 128000, "pricing": { - "input_per_1m": 5, - "output_per_1m": 25, + "input": 5, + "output": 25, "currency": "USD" }, "stats": { @@ -44,8 +44,8 @@ "contextWindow": 1000000, "maxOutputTokens": 128000, "pricing": { - "input_per_1m": 3, - "output_per_1m": 15, + "input": 3, + "output": 15, "currency": "USD" }, "stats": { @@ -72,8 +72,8 @@ "contextWindow": 200000, "maxOutputTokens": 64000, "pricing": { - "input_per_1m": 1, - "output_per_1m": 5, + "input": 1, + "output": 5, "currency": "USD" }, "stats": { diff --git a/src-tauri/resources/api_providers/text/deepseek.json b/src-tauri/resources/api_providers/text/deepseek.json index 0f414348..528b88a3 100644 --- a/src-tauri/resources/api_providers/text/deepseek.json +++ b/src-tauri/resources/api_providers/text/deepseek.json @@ -16,8 +16,8 @@ "contextWindow": 1048576, "maxOutputTokens": 65536, "pricing": { - "input_per_1m": 1.74, - "output_per_1m": 3.48, + "input": 1.74, + "output": 3.48, "currency": "USD" }, "stats": { @@ -44,8 +44,8 @@ "contextWindow": 1048576, "maxOutputTokens": 65536, "pricing": { - "input_per_1m": 0.14, - "output_per_1m": 0.28, + "input": 0.14, + "output": 0.28, "currency": "USD" }, "stats": { diff --git a/src-tauri/resources/api_providers/text/gemini.json b/src-tauri/resources/api_providers/text/gemini.json index b2666e54..474fd221 100644 --- a/src-tauri/resources/api_providers/text/gemini.json +++ b/src-tauri/resources/api_providers/text/gemini.json @@ -16,8 +16,8 @@ "contextWindow": 1048576, "maxOutputTokens": 65536, "pricing": { - "input_per_1m": 2, - "output_per_1m": 12, + "input": 2, + "output": 12, "currency": "USD" }, "stats": { @@ -46,8 +46,8 @@ "contextWindow": 1048576, "maxOutputTokens": 65500, "pricing": { - "input_per_1m": 0.5, - "output_per_1m": 3, + "input": 0.5, + "output": 3, "currency": "USD" }, "stats": { @@ -76,8 +76,8 @@ "contextWindow": 1048576, "maxOutputTokens": 65500, "pricing": { - "input_per_1m": 0.25, - "output_per_1m": 1.5, + "input": 0.25, + "output": 1.5, "currency": "USD" }, "stats": { diff --git a/src-tauri/resources/api_providers/text/gpt.json b/src-tauri/resources/api_providers/text/gpt.json index 7287da1e..ed1bb613 100644 --- a/src-tauri/resources/api_providers/text/gpt.json +++ b/src-tauri/resources/api_providers/text/gpt.json @@ -16,8 +16,8 @@ "contextWindow": 1050000, "maxOutputTokens": 128000, "pricing": { - "input_per_1m": 5, - "output_per_1m": 30, + "input": 5, + "output": 30, "currency": "USD" }, "stats": { @@ -44,8 +44,8 @@ "contextWindow": 1050000, "maxOutputTokens": 128000, "pricing": { - "input_per_1m": 30, - "output_per_1m": 180, + "input": 30, + "output": 180, "currency": "USD" }, "stats": { @@ -72,8 +72,8 @@ "contextWindow": 400000, "maxOutputTokens": 8192, "pricing": { - "input_per_1m": 0.75, - "output_per_1m": 4.5, + "input": 0.75, + "output": 4.5, "currency": "USD" }, "stats": { @@ -100,8 +100,8 @@ "contextWindow": 400000, "maxOutputTokens": 4096, "pricing": { - "input_per_1m": 0.2, - "output_per_1m": 1.25, + "input": 0.2, + "output": 1.25, "currency": "USD" }, "stats": { diff --git a/src-tauri/resources/locales/en.json b/src-tauri/resources/locales/en.json index 71af4336..5de2ecf4 100644 --- a/src-tauri/resources/locales/en.json +++ b/src-tauri/resources/locales/en.json @@ -87,6 +87,7 @@ "ui.chat.regenerate_failed": "Failed to regenerate response", "ui.ai.communication_failure": "Communication failure", "ui.ai.no_api_key": "API key missing", + "ui.ai.no_model_selected": "No AI model selected", "ui.ai.no_provider": "No AI module running. Please launch a module first.", "ui.ai.provider_activation_failed": "Provider activation failed", "ui.claude.model.46sonnet.desc": "Anthropic's most capable Sonnet-class model yet, with frontier performance across coding, agents, and professional work", @@ -219,8 +220,8 @@ "ui.launcher.settings.monitor_ram": "RAM", "ui.launcher.settings.monitor_title": "Monitoring Management", "ui.launcher.settings.monitor_vram": "VRAM", - "ui.launcher.settings.taskbar_desc": "Configure tab visibility", - "ui.launcher.settings.taskbar_title": "Taskbar Management", + "ui.launcher.settings.taskbar_desc": "Configure sidebar page visibility", + "ui.launcher.settings.taskbar_title": "Sidebar Management", "ui.launcher.web.app_title": "Axelate", "ui.launcher.web.chat": "Chat", "ui.launcher.web.chat_clear": "Clear chat", @@ -295,8 +296,8 @@ "ui.settings.internet_access_hint": "Lets the model use web tools when needed. It does not force every reply to search.", "ui.settings.context_short": "Ctx", "ui.settings.free": "Free", - "ui.settings.price_input": "In", - "ui.settings.price_output": "Out", + "ui.settings.price_input": "Input", + "ui.settings.price_output": "Output", "ui.settings.key_reveal_error": "Failed to reveal stored key", "ui.settings.model_stats": "Model Stats", "ui.settings.image_stats.control": "Control", @@ -507,6 +508,7 @@ "ui.download.compute_target": "Compute target", "ui.download.gpu_package": "GPU", "ui.download.cpu_package": "CPU", + "ui.download.both_packages": "CPU + GPU", "ui.download.unavailable": "Unavailable", "ui.download.version": "Version", "ui.download.latest_suffix": "(latest)", diff --git a/src-tauri/resources/locales/ru.json b/src-tauri/resources/locales/ru.json index 463f4bb1..c7506f54 100644 --- a/src-tauri/resources/locales/ru.json +++ b/src-tauri/resources/locales/ru.json @@ -87,6 +87,7 @@ "ui.chat.regenerate_failed": "Не удалось повторить ответ", "ui.ai.communication_failure": "Ошибка соединения", "ui.ai.no_api_key": "API ключ отсутствует", + "ui.ai.no_model_selected": "AI-модель не выбрана", "ui.ai.no_provider": "Нет активного AI-модуля. Сначала выберите и запустите модуль.", "ui.ai.provider_activation_failed": "Не удалось активировать провайдер", "ui.claude.model.46sonnet.desc": "Самая сильная Sonnet-модель Anthropic с фронтирной производительностью для кодинга, агентов и профессиональной работы", @@ -220,8 +221,8 @@ "ui.launcher.settings.monitor_ram": "RAM", "ui.launcher.settings.monitor_title": "Управление мониторингом", "ui.launcher.settings.monitor_vram": "VRAM", - "ui.launcher.settings.taskbar_desc": "Настройка видимости вкладок", - "ui.launcher.settings.taskbar_title": "Управление панелью задач", + "ui.launcher.settings.taskbar_desc": "Настройка видимости страниц в боковой панели", + "ui.launcher.settings.taskbar_title": "Управление боковой панелью", "ui.launcher.web.app_title": "Axelate", "ui.launcher.web.chat": "Чат", "ui.launcher.web.chat_clear": "Очистить чат", @@ -508,6 +509,7 @@ "ui.download.compute_target": "Тип пакета", "ui.download.gpu_package": "GPU", "ui.download.cpu_package": "CPU", + "ui.download.both_packages": "CPU + GPU", "ui.download.unavailable": "Недоступно", "ui.download.version": "Версия", "ui.download.latest_suffix": "(последняя)", diff --git a/src-tauri/resources/locales/zh.json b/src-tauri/resources/locales/zh.json index 875a329a..326885fe 100644 --- a/src-tauri/resources/locales/zh.json +++ b/src-tauri/resources/locales/zh.json @@ -87,6 +87,7 @@ "ui.chat.regenerate_failed": "重新生成回复失败", "ui.ai.communication_failure": "通信失败", "ui.ai.no_api_key": "缺少 API 密钥", + "ui.ai.no_model_selected": "未选择 AI 模型", "ui.ai.no_provider": "当前没有运行中的 AI 模块。请先选择并启动一个模块。", "ui.ai.provider_activation_failed": "提供商激活失败", "ui.claude.model.46sonnet.desc": "Anthropic 最强的 Sonnet 系列模型之一,适合编码、代理与专业工作场景", @@ -216,8 +217,8 @@ "ui.launcher.settings.monitor_ram": "内存", "ui.launcher.settings.monitor_title": "监控管理", "ui.launcher.settings.monitor_vram": "显存", - "ui.launcher.settings.taskbar_desc": "配置选项卡可见性", - "ui.launcher.settings.taskbar_title": "任务栏管理", + "ui.launcher.settings.taskbar_desc": "配置侧边栏页面可见性", + "ui.launcher.settings.taskbar_title": "侧边栏管理", "ui.launcher.web.app_title": "Axelate", "ui.launcher.web.chat": "聊天", "ui.launcher.web.chat_clear": "清除聊天", @@ -504,6 +505,7 @@ "ui.download.compute_target": "计算目标", "ui.download.gpu_package": "GPU", "ui.download.cpu_package": "CPU", + "ui.download.both_packages": "CPU + GPU", "ui.download.unavailable": "不可用", "ui.download.version": "版本", "ui.download.latest_suffix": "(最新)", diff --git a/src-tauri/src/api/ai/mod.rs b/src-tauri/src/api/ai/mod.rs index 8ef44281..85476996 100644 --- a/src-tauri/src/api/ai/mod.rs +++ b/src-tauri/src/api/ai/mod.rs @@ -4,7 +4,6 @@ use crate::domain::ai::{ }; use crate::domain::ai::{StreamEvent, StreamSink}; use crate::domain::engine::manager::EngineManager; -use crate::domain::engine::types::Capability; use crate::domain::system::config_service::ConfigService; use crate::errors::AppError; use crate::infrastructure::crypto::secure_storage::SecureStorage; @@ -232,15 +231,7 @@ fn configured_provider_secret_service( )); } - default_secret_service_for_provider(provider) -} - -fn default_secret_service_for_provider(provider: &str) -> Option { - if is_local_provider(provider) { - return None; - } - - Some("openrouter_api_key".to_string()) + None } async fn load_stored_provider_api_key( @@ -270,62 +261,6 @@ pub(crate) async fn fill_chat_request_api_key( Ok(()) } -async fn cancel_comfyui_job( - provider: &str, - image_generation_state: &crate::domain::ai::ImageGenerationState, -) -> Result<(), AppError> { - if let Some(job) = image_generation_state.cancel(provider).await { - let client = reqwest::Client::new(); - let response = client - .post(format!("{}/interrupt", job.base_url.trim_end_matches('/'))) - .send() - .await?; - - if !response.status().is_success() { - let body = response.text().await.unwrap_or_default(); - return Err(AppError::External { - request_id: None, - message: format!("Failed to interrupt ComfyUI job: {body}"), - }); - } - } - - Ok(()) -} - -async fn cancel_sdcpp_job( - provider: &str, - engine_manager: &EngineManager, - image_generation_state: &crate::domain::ai::ImageGenerationState, -) -> Result<(), AppError> { - let mut should_stop_engine = true; - - if let Some(job) = image_generation_state.cancel(provider).await - && let Some(job_id) = job.prompt_id - { - let client = reqwest::Client::new(); - let response = client - .post(format!( - "{}/sdcpp/v1/jobs/{job_id}/cancel", - job.base_url.trim_end_matches('/') - )) - .send() - .await?; - - should_stop_engine = !response.status().is_success(); - if should_stop_engine && response.status().as_u16() != 409 { - let body = response.text().await.unwrap_or_default(); - tracing::warn!("Failed to cancel stable-diffusion.cpp job via native API: {body}"); - } - } - - if should_stop_engine { - engine_manager.stop_slot(Capability::Image).await?; - } - - Ok(()) -} - fn read_image_generation_preview_file(path: &Path) -> Option { let metadata = match std::fs::metadata(path) { Ok(metadata) => metadata, @@ -642,14 +577,7 @@ pub async fn cancel_image_generation( engine_manager: State<'_, Arc>, image_generation_state: State<'_, Arc>, ) -> Result<(), AppError> { - if provider == "comfyui" { - return cancel_comfyui_job(&provider, &image_generation_state).await; - } - if matches!(provider.as_str(), "sdcpp" | "stable-diffusion") { - return cancel_sdcpp_job(&provider, &engine_manager, &image_generation_state).await; - } - - engine_manager.stop_slot(Capability::Image).await + ai::cancel_image_provider_generation(&provider, &engine_manager, &image_generation_state).await } #[tauri::command] @@ -659,7 +587,8 @@ pub async fn get_image_generation_preview( engine_manager: State<'_, Arc>, image_generation_state: State<'_, Arc>, ) -> Result, AppError> { - let log_progress = image_generation_state.latest_progress("sdcpp").await; + let active_job = image_generation_state.active_job().await; + let log_progress = active_job.as_ref().and_then(|job| job.progress.clone()); let merged_progress = log_progress.as_ref().and_then(|snapshot| snapshot.progress); let step = log_progress.as_ref().and_then(|snapshot| snapshot.step); let total = log_progress.as_ref().and_then(|snapshot| snapshot.total); @@ -668,7 +597,7 @@ pub async fn get_image_generation_preview( .and_then(|snapshot| snapshot.speed.clone()); let has_status = merged_progress.is_some() || step.is_some() || total.is_some() || speed.is_some(); - let has_active_job = image_generation_state.is_active("sdcpp").await; + let has_active_job = active_job.as_ref().is_some_and(|job| !job.cancelled); let Some(path) = engine_manager.active_image_preview_path().await else { return Ok(if has_status || has_active_job { @@ -946,23 +875,6 @@ fn create_stream_sink( }) } -fn is_local_provider(provider: &str) -> bool { - !matches!( - provider, - "gpt" - | "gemini" - | "gemini-image" - | "gpt-image" - | "seedream-image" - | "openai" - | "openrouter" - | "anthropic" - | "mistral" - | "claude" - | "deepseek" - ) -} - #[cfg(test)] #[allow(clippy::expect_used)] mod tests { diff --git a/src-tauri/src/api/engine/mod.rs b/src-tauri/src/api/engine/mod.rs index fea8596e..a4aaa640 100644 --- a/src-tauri/src/api/engine/mod.rs +++ b/src-tauri/src/api/engine/mod.rs @@ -10,7 +10,7 @@ use crate::domain::engine::config::{ use crate::domain::engine::manager::EngineManager; use crate::domain::engine::manager::canonical_engine_id; use crate::domain::engine::types::{ - Capability, EngineConfig, EngineDefinition, EngineState, EngineStatus, + Capability, EngineComputeMode, EngineConfig, EngineDefinition, EngineState, EngineStatus, }; use crate::errors::AppError; use crate::infrastructure::config::engine_settings::{ @@ -26,10 +26,17 @@ pub struct EngineSettingsPayload { } fn engine_config_for_definition(def: &EngineDefinition, saved: &EngineConfigMap) -> EngineConfig { - saved.get(&def.id).map_or_else( + let mut config = saved.get(&def.id).map_or_else( || build_default_engine_config(def), |config| merge_user_engine_config(def, config), - ) + ); + let installed_modes = crate::domain::engine::detector::installed_compute_modes(&def.id); + if installed_modes.len() == 1 + && let Some(mode) = installed_modes.first().copied() + { + config.compute_mode = mode; + } + config } fn engine_settings_payload_for_definition( @@ -49,9 +56,15 @@ fn normalize_config_for_save(def: &EngineDefinition, mut config: EngineConfig) - fn mark_engine_definitions_installed( defs: &mut [EngineDefinition], mut is_installed: impl FnMut(&EngineDefinition) -> bool, + mut installed_compute_modes: impl FnMut(&EngineDefinition) -> Vec, ) { for def in defs { def.installed = def.managed_externally || is_installed(def); + def.installed_compute_modes = if def.installed && !def.managed_externally { + installed_compute_modes(def) + } else { + Vec::new() + }; } } @@ -125,9 +138,11 @@ pub async fn get_engine_definitions( ) -> Result, AppError> { let mut defs = engine_manager.list_definitions().await; // Populate `installed` at request time — no extra round-trip needed from frontend - mark_engine_definitions_installed(&mut defs, |def| { - crate::domain::engine::detector::is_engine_installed(&def.id, def.binary.as_deref()) - }); + mark_engine_definitions_installed( + &mut defs, + |def| crate::domain::engine::detector::is_engine_installed(&def.id, def.binary.as_deref()), + |def| crate::domain::engine::detector::installed_compute_modes(&def.id), + ); Ok(defs) } @@ -206,6 +221,7 @@ mod tests { default_context_size: 8192, config_schema: None, installed: false, + installed_compute_modes: Vec::new(), managed_externally: false, } } @@ -259,9 +275,9 @@ mod tests { } #[test] - fn normalize_config_for_save_canonicalizes_aliases_before_persisting() { + fn normalize_config_for_save_uses_definition_id_before_persisting() { let def = sample_definition("sdcpp"); - let normalized = normalize_config_for_save(&def, saved_config("stable-diffusion")); + let normalized = normalize_config_for_save(&def, saved_config("sdcpp")); assert_eq!(normalized.engine_id, "sdcpp"); assert_eq!(normalized.compute_mode, EngineComputeMode::Cpu); @@ -277,7 +293,7 @@ mod tests { }; let mut defs = vec![sample_definition("missing"), external]; - mark_engine_definitions_installed(&mut defs, |def| def.id == "missing"); + mark_engine_definitions_installed(&mut defs, |def| def.id == "missing", |_| Vec::new()); assert!(defs.iter().all(|def| def.installed)); } @@ -286,7 +302,7 @@ mod tests { fn mark_engine_definitions_installed_marks_missing_local_engines_uninstalled() { let mut defs = vec![sample_definition("missing")]; - mark_engine_definitions_installed(&mut defs, |_| false); + mark_engine_definitions_installed(&mut defs, |_| false, |_| Vec::new()); assert!(defs.iter().all(|def| !def.installed)); } diff --git a/src-tauri/src/api/system/logs.rs b/src-tauri/src/api/system/logs.rs index 8800a9c8..3b1d37db 100644 --- a/src-tauri/src/api/system/logs.rs +++ b/src-tauri/src/api/system/logs.rs @@ -1,3 +1,5 @@ +use crate::domain::engine::manager::canonical_engine_id; +use crate::domain::engine::types::EngineDefinition; use crate::errors::AppError; use crate::infrastructure::logging::logger; use crate::infrastructure::logging::{self as logs, LogEntry}; @@ -133,6 +135,7 @@ pub async fn get_console_overview( ui_state_service: State<'_, crate::infrastructure::config::ui_state::UiStateService>, ) -> Result { let engine_state = engine_manager.state().await; + let engine_definitions = engine_manager.list_definitions().await; let ui_state = ui_state_service .get_ui_state() .await @@ -141,7 +144,7 @@ pub async fn get_console_overview( UIState::default() }); let logs = logger::get_frontend_logs_since(0.0); - Ok(ConsoleOverviewBuilder::build(&engine_state, &ui_state, &logs).await) + Ok(ConsoleOverviewBuilder::build(&engine_state, &engine_definitions, &ui_state, &logs).await) } #[tauri::command] @@ -200,20 +203,6 @@ const fn describe_status(status: ConsoleRuntimeStatus) -> &'static str { } } -fn canonical_engine_id(engine_id: &str) -> String { - let key = engine_id - .trim() - .to_ascii_lowercase() - .replace([' ', '_'], "-"); - match key.as_str() { - "stable-diffusion" - | "stable-diffusion.cpp" - | "stable-diffusion-cpp" - | "stable.diffusion.cpp" => "sdcpp".to_string(), - _ => key, - } -} - fn resolve_console_log_target(view_id: &str) -> PathBuf { if let Some(engine_id) = view_id.strip_prefix("engine:") { return crate::utils::paths::ENGINE_LOGS_DIR.join(canonical_engine_id(engine_id)); @@ -295,19 +284,30 @@ fn clear_all_console_log_files(root: &Path) -> Result<(), AppError> { impl ConsoleOverviewBuilder { async fn build( engine_state: &crate::domain::engine::types::EngineState, + engine_definitions: &[EngineDefinition], ui_state: &UIState, logs: &[LogEntry], ) -> ConsoleOverview { + let registry_engine_labels = Self::collect_registry_engine_labels(engine_definitions); let module_labels = Self::collect_module_labels(&ui_state.selected_modules); let module_ids = Self::collect_module_ids(logs, &module_labels); let mut engine_labels = Self::collect_engine_labels(engine_state); engine_labels.extend(Self::collect_selected_engine_labels( &ui_state.selected_modules, )); + engine_labels.extend(Self::collect_logged_engine_labels( + logs, + ®istry_engine_labels, + )); let views = Self::build_views(&engine_labels, &module_labels, &module_ids); - let status_items = - Self::build_status_items(engine_state, &engine_labels, &module_labels, &module_ids) - .await; + let status_items = Self::build_status_items( + engine_state, + ®istry_engine_labels, + &engine_labels, + &module_labels, + &module_ids, + ) + .await; ConsoleOverview { views, @@ -315,6 +315,21 @@ impl ConsoleOverviewBuilder { } } + fn collect_registry_engine_labels( + engine_definitions: &[EngineDefinition], + ) -> BTreeMap { + engine_definitions + .iter() + .map(|definition| { + ( + canonical_engine_id(&definition.id), + definition.name.trim().to_string(), + ) + }) + .filter(|(_, name)| !name.is_empty()) + .collect() + } + fn collect_module_labels( modules: &std::collections::HashMap, ) -> BTreeMap { @@ -368,6 +383,20 @@ impl ConsoleOverviewBuilder { labels } + fn collect_logged_engine_labels( + logs: &[LogEntry], + registry_engine_labels: &BTreeMap, + ) -> BTreeMap { + logs.iter() + .filter(|entry| entry.module_id.is_none() && !entry.source.starts_with("module:")) + .filter_map(|entry| { + let engine_id = canonical_engine_id(&entry.source); + let label = registry_engine_labels.get(&engine_id)?; + Some((engine_id, label.clone())) + }) + .collect() + } + fn build_views( engine_labels: &BTreeMap, module_labels: &BTreeMap, @@ -436,11 +465,13 @@ impl ConsoleOverviewBuilder { async fn build_status_items( engine_state: &crate::domain::engine::types::EngineState, + registry_engine_labels: &BTreeMap, engine_labels: &BTreeMap, module_labels: &BTreeMap, module_ids: &BTreeSet, ) -> Vec { - let mut status_items = Self::build_engine_status_items(engine_state); + let mut status_items = + Self::build_engine_status_items(engine_state, registry_engine_labels); let known_status_ids = status_items .iter() .map(|item| item.id.clone()) @@ -492,6 +523,7 @@ impl ConsoleOverviewBuilder { fn build_engine_status_items( state: &crate::domain::engine::types::EngineState, + registry_engine_labels: &BTreeMap, ) -> Vec { use crate::domain::engine::types::EngineState; @@ -505,21 +537,21 @@ impl ConsoleOverviewBuilder { }], EngineState::Starting { engine_id } => vec![ConsoleStatusItem { id: format!("engine:{}", canonical_engine_id(engine_id)), - label: ConsoleLabelFormatter::format_module_label(engine_id), + label: Self::engine_label_for_id(engine_id, registry_engine_labels), kind: "engine".to_string(), status: ConsoleRuntimeStatus::Starting, detail: "Starting…".to_string(), }], EngineState::Swapping { from, to } => vec![ConsoleStatusItem { id: format!("engine:{}", canonical_engine_id(to)), - label: ConsoleLabelFormatter::format_module_label(to), + label: Self::engine_label_for_id(to, registry_engine_labels), kind: "engine".to_string(), status: ConsoleRuntimeStatus::Starting, detail: format!("Switching from {from}"), }], EngineState::Error { engine_id, message } => vec![ConsoleStatusItem { id: format!("engine:{}", canonical_engine_id(engine_id)), - label: ConsoleLabelFormatter::format_module_label(engine_id), + label: Self::engine_label_for_id(engine_id, registry_engine_labels), kind: "engine".to_string(), status: ConsoleRuntimeStatus::Failed, detail: message.clone(), @@ -554,6 +586,16 @@ impl ConsoleOverviewBuilder { } } } + + fn engine_label_for_id( + engine_id: &str, + registry_engine_labels: &BTreeMap, + ) -> String { + registry_engine_labels + .get(&canonical_engine_id(engine_id)) + .cloned() + .unwrap_or_else(|| ConsoleLabelFormatter::format_module_label(engine_id)) + } } fn open_folder(path: &std::path::Path) -> std::io::Result<()> { @@ -614,6 +656,7 @@ mod tests { canonical_console_view_id, canonical_engine_id, clear_all_console_log_files, clear_console_log_target, resolve_console_log_target, }; + use crate::domain::engine::types::EngineDefinition; use crate::domain::engine::types::{Capability, EngineState, EngineStatus, SlotStatus}; use crate::infrastructure::logging::LogEntry; use crate::models::{SelectedModule, UIState}; @@ -651,14 +694,39 @@ mod tests { } } + fn engine_log_entry(source: &str) -> LogEntry { + LogEntry { + source: source.to_string(), + ..log_entry(None) + } + } + + fn engine_definition(id: &str, name: &str) -> EngineDefinition { + EngineDefinition { + id: id.to_string(), + name: name.to_string(), + desc: String::new(), + icon: String::new(), + capabilities: vec![Capability::Text], + binary: None, + repo_url: None, + version: "1.0.0".to_string(), + default_port: 8081, + default_context_size: 4096, + config_schema: None, + installed: false, + installed_compute_modes: Vec::new(), + managed_externally: false, + } + } + #[test] fn canonicalizes_engine_ids_and_console_view_ids() { - assert_eq!(canonical_engine_id(" Stable_Diffusion.cpp "), "sdcpp"); + assert_eq!(canonical_engine_id(" sdcpp "), "sdcpp"); assert_eq!(canonical_engine_id("llama cpp"), "llama-cpp"); - assert_eq!( - canonical_console_view_id("engine:Stable Diffusion.cpp"), - "engine:sdcpp" - ); + assert_eq!(canonical_engine_id("llama.cpp"), "llama-cpp"); + assert_eq!(canonical_engine_id("llama_cpp"), "llama-cpp"); + assert_eq!(canonical_console_view_id("engine:sdcpp"), "engine:sdcpp"); assert_eq!( canonical_console_view_id(" module:example "), "module:example" @@ -688,7 +756,7 @@ mod tests { #[test] fn resolves_console_log_targets_by_view_kind() { - let engine_target = resolve_console_log_target("engine:Stable Diffusion.cpp"); + let engine_target = resolve_console_log_target("engine:sdcpp"); let module_target = resolve_console_log_target("module:comfyui"); let general_target = resolve_console_log_target("general"); @@ -743,7 +811,7 @@ mod tests { ); ui_state.selected_modules.insert( "ai_text".to_string(), - selected_module("stable diffusion.cpp", "Stable Diffusion.cpp", "local"), + selected_module("sdcpp", "Stable Diffusion.cpp", "local"), ); ui_state.selected_modules.insert( "ai_image".to_string(), @@ -751,7 +819,7 @@ mod tests { ); let logs = vec![log_entry(Some("comfyui")), log_entry(Some("unknown"))]; let engine = EngineStatus { - id: "stable_diffusion.cpp".to_string(), + id: "sdcpp".to_string(), name: "Stable Diffusion.cpp".to_string(), capabilities: vec![Capability::Image], endpoint: "http://127.0.0.1:7860".to_string(), @@ -770,7 +838,9 @@ mod tests { ], }; - let overview = ConsoleOverviewBuilder::build(&state, &ui_state, &logs).await; + let engine_definitions = vec![engine_definition("sdcpp", "Stable Diffusion.cpp")]; + let overview = + ConsoleOverviewBuilder::build(&state, &engine_definitions, &ui_state, &logs).await; let views = overview .views .iter() @@ -790,6 +860,32 @@ mod tests { assert_eq!(engine_status.detail, "image, vision"); } + #[tokio::test] + async fn console_overview_names_logged_engines_from_registry_definitions() { + let logs = vec![engine_log_entry("custom_engine")]; + let engine_definitions = vec![engine_definition("custom-engine", "Custom Engine")]; + + let overview = ConsoleOverviewBuilder::build( + &EngineState::Idle, + &engine_definitions, + &UIState::default(), + &logs, + ) + .await; + + let view = overview + .views + .iter() + .find(|view| view.id == "engine:custom-engine"); + assert!( + view.is_some(), + "logged custom engine should create a console view" + ); + let view = view.unwrap(); + + assert_eq!(view.label, "Custom Engine"); + } + #[tokio::test] async fn console_overview_builds_status_rows_for_non_ready_states() { let cases = [ @@ -828,9 +924,13 @@ mod tests { ]; for (state, expected_id, expected_status, expected_detail) in cases { - let overview = - ConsoleOverviewBuilder::build(&state, &UIState::default(), &Vec::::new()) - .await; + let overview = ConsoleOverviewBuilder::build( + &state, + &[engine_definition("new", "New Engine")], + &UIState::default(), + &Vec::::new(), + ) + .await; let item = overview.status_items.first().unwrap(); assert_eq!(item.id, expected_id); assert!( diff --git a/src-tauri/src/domain/ai/ai_dispatch.rs b/src-tauri/src/domain/ai/ai_dispatch.rs index 2c02a028..737e9449 100644 --- a/src-tauri/src/domain/ai/ai_dispatch.rs +++ b/src-tauri/src/domain/ai/ai_dispatch.rs @@ -162,11 +162,7 @@ pub(super) async fn build_engine_config( )) } -pub(super) fn resolve_local_text_model_id( - request_model: &str, - model_path: Option<&str>, - provider: &str, -) -> String { +pub(super) fn resolve_local_text_model_id(request_model: &str, model_path: Option<&str>) -> String { let requested = request_model.trim(); if !requested.is_empty() && requested != "default" { return requested.to_string(); @@ -179,7 +175,7 @@ pub(super) fn resolve_local_text_model_id( } } - provider.to_string() + "default".to_string() } async fn resolve_local_engine_request( @@ -212,15 +208,9 @@ async fn resolve_local_engine_request( } let local_context_size = usize::try_from(config.context_size.max(4096)).unwrap_or(4096); - let local_model_for_context = config - .model_path - .clone() - .unwrap_or_else(|| request.model.clone()); - let effective_model = resolve_local_text_model_id( - &request.model, - config.model_path.as_deref(), - &request.provider, - ); + let effective_model = + resolve_local_text_model_id(&request.model, config.model_path.as_deref()); + let local_model_for_context = effective_model.clone(); super::ai_service::stop_conflicting_local_engine( engine_manager, @@ -262,17 +252,14 @@ async fn resolve_local_engine_request( ) .await?; let base_url = format!("{}/v1", status.endpoint); - let effective_model = - resolve_local_text_model_id(&request.model, None, &request.provider); let config = build_engine_config(&definition).await?; + let effective_model = + resolve_local_text_model_id(&request.model, config.model_path.as_deref()); let local_context_size = usize::try_from(config.context_size.max(4096)).unwrap_or(4096); - let local_model_for_context = config - .model_path - .clone() - .unwrap_or_else(|| request.model.clone()); - let mut messages_context = request.messages.clone(); + let local_model_for_context = effective_model.clone(); + let mut messages_context = prepared_messages_context.to_vec(); - if let Some(session_id) = &request.session_id { + if let Some(session_id) = normalize_session_id(request.session_id.as_deref()) { messages_context = sessions.build_local_context( session_id, local_context_size, @@ -321,11 +308,9 @@ async fn prepend_local_system_prompt( }; let canonical_provider = canonical_engine_id(provider); let canonical_key = format!("{canonical_provider}_system_prompt"); - let raw_key = format!("{provider}_system_prompt"); let prompt = settings .extra_settings .get(&canonical_key) - .or_else(|| settings.extra_settings.get(&raw_key)) .map(String::as_str) .unwrap_or_default() .trim(); @@ -473,7 +458,6 @@ mod tests { release_date: None, context_window: Some(128_000), max_output_tokens: Some(16_384), - deprecated: None, pricing: None, stats: ModelStats { speed: 8, @@ -509,11 +493,7 @@ mod tests { #[test] fn resolve_local_text_model_id_prefers_explicit_model() { assert_eq!( - resolve_local_text_model_id( - "custom-model.gguf", - Some("C:/models/default.gguf"), - "llamacpp" - ), + resolve_local_text_model_id("custom-model.gguf", Some("C:/models/default.gguf")), "custom-model.gguf" ); } @@ -521,18 +501,15 @@ mod tests { #[test] fn resolve_local_text_model_id_uses_model_file_name_for_default_request() { assert_eq!( - resolve_local_text_model_id("default", Some("C:/models/chat-model.gguf"), "llamacpp"), + resolve_local_text_model_id("default", Some("C:/models/chat-model.gguf")), "chat-model.gguf" ); } #[test] - fn resolve_local_text_model_id_falls_back_to_provider() { - assert_eq!( - resolve_local_text_model_id("default", None, "llamacpp"), - "llamacpp" - ); - assert_eq!(resolve_local_text_model_id(" ", None, "sdcpp"), "sdcpp"); + fn resolve_local_text_model_id_uses_default_when_model_is_not_known() { + assert_eq!(resolve_local_text_model_id("default", None), "default"); + assert_eq!(resolve_local_text_model_id(" ", None), "default"); } #[test] diff --git a/src-tauri/src/domain/ai/ai_service.rs b/src-tauri/src/domain/ai/ai_service.rs index 2dd257de..e6f91f72 100644 --- a/src-tauri/src/domain/ai/ai_service.rs +++ b/src-tauri/src/domain/ai/ai_service.rs @@ -460,7 +460,6 @@ pub async fn validate_api_key( return Ok(!data.is_empty()); } - // Legacy Gemini fallback if body.get("models").is_some() { return Ok(true); } @@ -666,7 +665,7 @@ mod tests { extra_settings.insert("custom_sd_steps".to_string(), "30".to_string()); extra_settings.insert("sdcpp_steps".to_string(), "20".to_string()); extra_settings.insert( - "custom_sd_positiveprompt".to_string(), + "custom_sd_positive_prompt".to_string(), "portrait".to_string(), ); @@ -676,17 +675,17 @@ mod tests { }; assert_eq!( - resolve_u32_setting(&settings, "custom_sd", "sdcpp", "steps"), + resolve_u32_setting(&settings, "custom_sd", "steps"), Some(30) ); assert_eq!( - resolve_string_setting(&settings, "custom_sd", "sdcpp", "positive_prompt"), + resolve_string_setting(&settings, "custom_sd", "positive_prompt"), Some("portrait".to_string()) ); } #[test] - fn test_resolve_image_setting_falls_back_to_provider_id() { + fn test_resolve_image_setting_does_not_read_provider_key_when_settings_key_differs() { let mut extra_settings = HashMap::new(); extra_settings.insert("sdcpp_cfg_scale".to_string(), "8.5".to_string()); extra_settings.insert("sdcpp_negative_prompt".to_string(), "blurry".to_string()); @@ -697,12 +696,12 @@ mod tests { }; assert_eq!( - resolve_f32_setting(&settings, "custom_sd", "sdcpp", "cfg_scale"), - Some(8.5) + resolve_f32_setting(&settings, "custom_sd", "cfg_scale"), + None ); assert_eq!( - resolve_string_setting(&settings, "custom_sd", "sdcpp", "negative_prompt"), - Some("blurry".to_string()) + resolve_string_setting(&settings, "custom_sd", "negative_prompt"), + None ); } diff --git a/src-tauri/src/domain/ai/image_cloud.rs b/src-tauri/src/domain/ai/image_cloud.rs index f6feb3e5..46d32586 100644 --- a/src-tauri/src/domain/ai/image_cloud.rs +++ b/src-tauri/src/domain/ai/image_cloud.rs @@ -4,13 +4,14 @@ use std::time::Duration; use super::image_http::{build_image_client, parse_image_response_body}; use super::image_payload::build_cloud_image_payload; +use super::image_provider_adapter; use super::image_response::parse_openrouter_generated_images; use super::types::ImageGenerationRequest; use crate::errors::AppError; use crate::infrastructure::crypto::secure_storage::SecureStorage; pub(super) fn is_cloud_image_provider(provider: &str) -> bool { - matches!(provider, "gemini-image" | "gpt-image" | "seedream-image") + image_provider_adapter::is_cloud_image_provider(provider) } pub(super) async fn process_cloud_image_request( diff --git a/src-tauri/src/domain/ai/image_comfyui.rs b/src-tauri/src/domain/ai/image_comfyui.rs index dfb96818..21469498 100644 --- a/src-tauri/src/domain/ai/image_comfyui.rs +++ b/src-tauri/src/domain/ai/image_comfyui.rs @@ -121,7 +121,6 @@ async fn build_comfyui_request_context( resolve_string_setting( &settings_context.settings, &settings_context.settings_key, - &request.provider, "base_url", ) .as_deref() @@ -163,9 +162,7 @@ async fn resolve_comfyui_checkpoint( return Ok(normalize_comfyui_checkpoint(&request.model)); } - if let Some(saved_checkpoint) = - resolve_string_setting(settings, settings_key, &request.provider, "checkpoint") - { + if let Some(saved_checkpoint) = resolve_string_setting(settings, settings_key, "checkpoint") { return Ok(normalize_comfyui_checkpoint(&saved_checkpoint)); } diff --git a/src-tauri/src/domain/ai/image_generation_state.rs b/src-tauri/src/domain/ai/image_generation_state.rs index e4f31bc2..dc01cf3b 100644 --- a/src-tauri/src/domain/ai/image_generation_state.rs +++ b/src-tauri/src/domain/ai/image_generation_state.rs @@ -7,10 +7,6 @@ use tokio::sync::Mutex; fn provider_matches(active: &str, candidate: &str) -> bool { active == candidate - || matches!( - (active, candidate), - ("sdcpp", "stable-diffusion") | ("stable-diffusion", "sdcpp") - ) } /// Latest progress parsed from a local image engine log line. @@ -91,6 +87,11 @@ impl ImageGenerationState { None } + /// Returns the currently active image job, when one exists. + pub async fn active_job(&self) -> Option { + self.inner.lock().await.clone() + } + /// Returns whether a matching provider currently has an active image job. pub async fn is_active(&self, provider: &str) -> bool { let guard = self.inner.lock().await; diff --git a/src-tauri/src/domain/ai/image_local.rs b/src-tauri/src/domain/ai/image_local.rs index ae1f3e75..2fc9f102 100644 --- a/src-tauri/src/domain/ai/image_local.rs +++ b/src-tauri/src/domain/ai/image_local.rs @@ -7,6 +7,7 @@ use super::ai_dispatch::{LocalEngineAccess, active_local_engine_status, build_en use super::ai_service::stop_conflicting_local_engine; use super::image_http::{build_image_client, parse_image_response_body}; use super::image_payload::{build_local_image_payload, build_sdcpp_native_image_payload}; +use super::image_provider_adapter::{LocalImageProtocol, local_image_protocol}; use super::image_response::{ ImageResponseFormat, parse_generated_images, parse_sdcpp_generated_images, summarize_image_response_shape, @@ -20,17 +21,11 @@ use crate::errors::AppError; struct PreparedImageDispatch { base_url: String, request_url: String, - api: LocalImageApi, + protocol: LocalImageProtocol, response_format: ImageResponseFormat, preview_path: Option, } -#[derive(Clone, Copy, PartialEq, Eq)] -enum LocalImageApi { - SdcppNative, - OpenAiCompatible, -} - pub(super) async fn process_local_image_request( request: &ImageGenerationRequest, engine_manager: &EngineManager, @@ -72,27 +67,24 @@ async fn prepare_local_image_dispatch( let (base_url, preview_path) = resolve_local_image_endpoint(request, engine_manager, local_engine_access, &definition) .await?; - let api = local_image_api(&request.provider); - let response_format = image_response_format(api); - let request_url = build_image_generation_url(&base_url, api); + let protocol = local_image_protocol(&request.provider).ok_or_else(|| { + AppError::Validation(format!( + "Provider {} is not a local image engine", + request.provider + )) + })?; + let response_format = image_response_format(protocol); + let request_url = build_image_generation_url(&base_url, protocol); Ok(PreparedImageDispatch { base_url, request_url, - api, + protocol, response_format, preview_path, }) } -fn local_image_api(provider: &str) -> LocalImageApi { - if matches!(provider, "sdcpp" | "stable-diffusion") { - LocalImageApi::SdcppNative - } else { - LocalImageApi::OpenAiCompatible - } -} - async fn resolve_local_image_endpoint( request: &ImageGenerationRequest, engine_manager: &EngineManager, @@ -127,17 +119,17 @@ async fn resolve_local_image_endpoint( } } -const fn image_response_format(api: LocalImageApi) -> ImageResponseFormat { - match api { - LocalImageApi::SdcppNative => ImageResponseFormat::SdApi, - LocalImageApi::OpenAiCompatible => ImageResponseFormat::OpenAiCompatible, +const fn image_response_format(protocol: LocalImageProtocol) -> ImageResponseFormat { + match protocol { + LocalImageProtocol::SdcppNative => ImageResponseFormat::SdApi, + LocalImageProtocol::OpenAiCompatible => ImageResponseFormat::OpenAiCompatible, } } -fn build_image_generation_url(base_url: &str, api: LocalImageApi) -> String { - match api { - LocalImageApi::SdcppNative => format!("{base_url}/sdcpp/v1/img_gen"), - LocalImageApi::OpenAiCompatible => format!("{base_url}/v1/images/generations"), +fn build_image_generation_url(base_url: &str, protocol: LocalImageProtocol) -> String { + match protocol { + LocalImageProtocol::SdcppNative => format!("{base_url}/sdcpp/v1/img_gen"), + LocalImageProtocol::OpenAiCompatible => format!("{base_url}/v1/images/generations"), } } @@ -153,7 +145,7 @@ async fn execute_local_image_request( clear_preview_file(preview_path).await; } - if dispatch.api == LocalImageApi::SdcppNative { + if dispatch.protocol == LocalImageProtocol::SdcppNative { return execute_sdcpp_native_image_request( request, &dispatch, diff --git a/src-tauri/src/domain/ai/image_provider_adapter.rs b/src-tauri/src/domain/ai/image_provider_adapter.rs new file mode 100644 index 00000000..9dfd39f1 --- /dev/null +++ b/src-tauri/src/domain/ai/image_provider_adapter.rs @@ -0,0 +1,159 @@ +//! Image provider routing for cloud, local, and native engine protocols. + +use crate::domain::ai::ImageGenerationState; +use crate::domain::engine::manager::EngineManager; +use crate::domain::engine::types::Capability; +use crate::errors::AppError; + +const COMFYUI_PROVIDER_ID: &str = "comfyui"; +const SDCPP_PROVIDER_ID: &str = "sdcpp"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum LocalImageProtocol { + SdcppNative, + OpenAiCompatible, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ImageProviderRoute { + CloudOpenRouter, + ComfyUi, + Local(LocalImageProtocol), +} + +pub(super) fn route_for_image_provider(provider: &str) -> ImageProviderRoute { + match provider { + "gemini-image" | "gpt-image" | "seedream-image" => ImageProviderRoute::CloudOpenRouter, + COMFYUI_PROVIDER_ID => ImageProviderRoute::ComfyUi, + SDCPP_PROVIDER_ID => ImageProviderRoute::Local(LocalImageProtocol::SdcppNative), + _ => ImageProviderRoute::Local(LocalImageProtocol::OpenAiCompatible), + } +} + +pub(super) fn is_cloud_image_provider(provider: &str) -> bool { + route_for_image_provider(provider) == ImageProviderRoute::CloudOpenRouter +} + +pub(super) fn local_image_protocol(provider: &str) -> Option { + match route_for_image_provider(provider) { + ImageProviderRoute::Local(protocol) => Some(protocol), + ImageProviderRoute::CloudOpenRouter | ImageProviderRoute::ComfyUi => None, + } +} + +/// Cancels the active image-generation job for the selected provider. +pub async fn cancel_image_provider_generation( + provider: &str, + engine_manager: &EngineManager, + image_generation_state: &ImageGenerationState, +) -> Result<(), AppError> { + match route_for_image_provider(provider) { + ImageProviderRoute::ComfyUi => cancel_comfyui_job(provider, image_generation_state).await, + ImageProviderRoute::Local(LocalImageProtocol::SdcppNative) => { + cancel_sdcpp_job(provider, engine_manager, image_generation_state).await + } + ImageProviderRoute::Local(LocalImageProtocol::OpenAiCompatible) => { + image_generation_state.cancel(provider).await; + engine_manager.stop_slot(Capability::Image).await + } + ImageProviderRoute::CloudOpenRouter => { + image_generation_state.cancel(provider).await; + Ok(()) + } + } +} + +async fn cancel_comfyui_job( + provider: &str, + image_generation_state: &ImageGenerationState, +) -> Result<(), AppError> { + if let Some(job) = image_generation_state.cancel(provider).await { + let client = reqwest::Client::new(); + let response = client + .post(format!("{}/interrupt", job.base_url.trim_end_matches('/'))) + .send() + .await + .map_err(|error| AppError::External { + request_id: None, + message: format!("Failed to interrupt ComfyUI job: {error}"), + })?; + + if !response.status().is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(AppError::External { + request_id: None, + message: format!("Failed to interrupt ComfyUI job: {body}"), + }); + } + } + + Ok(()) +} + +async fn cancel_sdcpp_job( + provider: &str, + engine_manager: &EngineManager, + image_generation_state: &ImageGenerationState, +) -> Result<(), AppError> { + let mut should_stop_engine = true; + + if let Some(job) = image_generation_state.cancel(provider).await + && let Some(job_id) = job.prompt_id + { + let client = reqwest::Client::new(); + let response = client + .post(format!( + "{}/sdcpp/v1/jobs/{job_id}/cancel", + job.base_url.trim_end_matches('/') + )) + .send() + .await + .map_err(|error| AppError::External { + request_id: None, + message: format!("Failed to cancel stable-diffusion.cpp job: {error}"), + })?; + + should_stop_engine = !response.status().is_success(); + if should_stop_engine && response.status().as_u16() != 409 { + let body = response.text().await.unwrap_or_default(); + tracing::warn!("Failed to cancel stable-diffusion.cpp job via native API: {body}"); + } + } + + if should_stop_engine { + engine_manager.stop_slot(Capability::Image).await?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ + ImageProviderRoute, LocalImageProtocol, local_image_protocol, route_for_image_provider, + }; + + #[test] + fn routes_native_image_protocols_by_adapter() { + assert_eq!( + route_for_image_provider("sdcpp"), + ImageProviderRoute::Local(LocalImageProtocol::SdcppNative) + ); + assert_eq!( + local_image_protocol("custom-image-engine"), + Some(LocalImageProtocol::OpenAiCompatible) + ); + } + + #[test] + fn routes_non_local_image_adapters() { + assert_eq!( + route_for_image_provider("comfyui"), + ImageProviderRoute::ComfyUi + ); + assert_eq!( + route_for_image_provider("gpt-image"), + ImageProviderRoute::CloudOpenRouter + ); + } +} diff --git a/src-tauri/src/domain/ai/image_service.rs b/src-tauri/src/domain/ai/image_service.rs index a04dbfce..b5ac246b 100644 --- a/src-tauri/src/domain/ai/image_service.rs +++ b/src-tauri/src/domain/ai/image_service.rs @@ -3,6 +3,7 @@ use super::ai_service::stop_conflicting_local_engine; use super::image_cloud::{is_cloud_image_provider, process_cloud_image_request}; use super::image_comfyui::process_comfyui_request; use super::image_local::process_local_image_request; +use super::image_provider_adapter::{ImageProviderRoute, route_for_image_provider}; use super::image_settings::apply_image_request_defaults; use super::session::ChatSessionManager; use super::types::{ChatMessage, ChatReply, ImageGenerationRequest, ImageGenerationResponse}; @@ -64,19 +65,21 @@ async fn process_image_request_with_local_engine_access( Some(engine_manager.acquire_local_workload().await) }; - if request.provider == "comfyui" { - stop_conflicting_local_engine(engine_manager, Capability::Image).await?; - process_comfyui_request(&request, image_generation_state, settings_service).await? - } else if is_cloud_image_provider(&request.provider) { - process_cloud_image_request(&request).await? - } else { - process_local_image_request( - &request, - engine_manager, - image_generation_state, - local_engine_access, - ) - .await? + match route_for_image_provider(&request.provider) { + ImageProviderRoute::ComfyUi => { + stop_conflicting_local_engine(engine_manager, Capability::Image).await?; + process_comfyui_request(&request, image_generation_state, settings_service).await? + } + ImageProviderRoute::CloudOpenRouter => process_cloud_image_request(&request).await?, + ImageProviderRoute::Local(_) => { + process_local_image_request( + &request, + engine_manager, + image_generation_state, + local_engine_access, + ) + .await? + } } }; diff --git a/src-tauri/src/domain/ai/image_settings.rs b/src-tauri/src/domain/ai/image_settings.rs index 9c8285fa..e16fd2c9 100644 --- a/src-tauri/src/domain/ai/image_settings.rs +++ b/src-tauri/src/domain/ai/image_settings.rs @@ -43,61 +43,54 @@ fn apply_saved_image_defaults( settings: &AppSettings, settings_key: &str, ) { - if let Some(prefix) = - resolve_string_setting(settings, settings_key, &request.provider, "positive_prompt") - { + if let Some(prefix) = resolve_string_setting(settings, settings_key, "positive_prompt") { request.prompt = format!("{prefix}, {}", request.prompt); } - request.negative_prompt = request.negative_prompt.take().or_else(|| { - resolve_string_setting(settings, settings_key, &request.provider, "negative_prompt") - }); + request.negative_prompt = request + .negative_prompt + .take() + .or_else(|| resolve_string_setting(settings, settings_key, "negative_prompt")); request.steps = request .steps - .or_else(|| resolve_u32_setting(settings, settings_key, &request.provider, "steps")); + .or_else(|| resolve_u32_setting(settings, settings_key, "steps")); request.cfg_scale = request .cfg_scale - .or_else(|| resolve_f32_setting(settings, settings_key, &request.provider, "cfg_scale")); - request.denoising_strength = request.denoising_strength.or_else(|| { - resolve_f32_setting( - settings, - settings_key, - &request.provider, - "denoising_strength", - ) - }); + .or_else(|| resolve_f32_setting(settings, settings_key, "cfg_scale")); + request.denoising_strength = request + .denoising_strength + .or_else(|| resolve_f32_setting(settings, settings_key, "denoising_strength")); request.width = request .width - .or_else(|| resolve_u32_setting(settings, settings_key, &request.provider, "width")); + .or_else(|| resolve_u32_setting(settings, settings_key, "width")); request.height = request .height - .or_else(|| resolve_u32_setting(settings, settings_key, &request.provider, "height")); + .or_else(|| resolve_u32_setting(settings, settings_key, "height")); request.sampler = request .sampler .take() - .or_else(|| resolve_string_setting(settings, settings_key, &request.provider, "sampler")); + .or_else(|| resolve_string_setting(settings, settings_key, "sampler")); request.seed = request .seed - .or_else(|| resolve_i32_setting(settings, settings_key, &request.provider, "seed")); + .or_else(|| resolve_i32_setting(settings, settings_key, "seed")); request.batch_size = request .batch_size - .or_else(|| resolve_u32_setting(settings, settings_key, &request.provider, "batch_size")); + .or_else(|| resolve_u32_setting(settings, settings_key, "batch_size")); request.scheduler = request .scheduler .take() - .or_else(|| resolve_string_setting(settings, settings_key, &request.provider, "scheduler")); + .or_else(|| resolve_string_setting(settings, settings_key, "scheduler")); request.clip_skip = request .clip_skip - .or_else(|| resolve_i32_setting(settings, settings_key, &request.provider, "clip_skip")); + .or_else(|| resolve_i32_setting(settings, settings_key, "clip_skip")); } pub(super) fn resolve_string_setting( settings: &AppSettings, settings_key: &str, - provider_id: &str, suffix: &str, ) -> Option { - resolve_setting_value(settings, settings_key, provider_id, suffix).and_then(|value| { + resolve_setting_value(settings, settings_key, suffix).and_then(|value| { let trimmed = value.trim(); if trimmed.is_empty() { None @@ -110,60 +103,33 @@ pub(super) fn resolve_string_setting( pub(super) fn resolve_u32_setting( settings: &AppSettings, settings_key: &str, - provider_id: &str, suffix: &str, ) -> Option { - resolve_setting_value(settings, settings_key, provider_id, suffix) + resolve_setting_value(settings, settings_key, suffix) .and_then(|value| value.parse::().ok()) } -fn resolve_i32_setting( - settings: &AppSettings, - settings_key: &str, - provider_id: &str, - suffix: &str, -) -> Option { - resolve_setting_value(settings, settings_key, provider_id, suffix) +fn resolve_i32_setting(settings: &AppSettings, settings_key: &str, suffix: &str) -> Option { + resolve_setting_value(settings, settings_key, suffix) .and_then(|value| value.parse::().ok()) } pub(super) fn resolve_f32_setting( settings: &AppSettings, settings_key: &str, - provider_id: &str, suffix: &str, ) -> Option { - resolve_setting_value(settings, settings_key, provider_id, suffix) + resolve_setting_value(settings, settings_key, suffix) .and_then(|value| value.parse::().ok()) } fn resolve_setting_value( settings: &AppSettings, settings_key: &str, - provider_id: &str, suffix: &str, ) -> Option { - for key in build_setting_candidates(settings_key, suffix) { - if let Some(value) = settings.extra_settings.get(&key) { - return Some(value.clone()); - } - } - - if settings_key != provider_id { - for key in build_setting_candidates(provider_id, suffix) { - if let Some(value) = settings.extra_settings.get(&key) { - return Some(value.clone()); - } - } - } - - None -} - -fn build_setting_candidates(prefix: &str, suffix: &str) -> [String; 3] { - [ - format!("{prefix}_{suffix}"), - format!("{prefix}_{}", suffix.to_lowercase()), - format!("{prefix}_{}", suffix.replace('_', "")), - ] + settings + .extra_settings + .get(&format!("{settings_key}_{suffix}")) + .cloned() } diff --git a/src-tauri/src/domain/ai/mod.rs b/src-tauri/src/domain/ai/mod.rs index 5f658bc8..cd54feba 100644 --- a/src-tauri/src/domain/ai/mod.rs +++ b/src-tauri/src/domain/ai/mod.rs @@ -10,6 +10,7 @@ pub mod image_generation_state; mod image_http; mod image_local; mod image_payload; +mod image_provider_adapter; mod image_response; mod image_service; mod image_settings; @@ -29,9 +30,9 @@ pub use ai_service::{ process_chat_request, validate_api_key, }; pub use image_generation_state::ImageGenerationState; +pub use image_provider_adapter::cancel_image_provider_generation; pub use session::ChatSessionManager; pub use streaming::{ - AiProvider, ChannelSink, NoopSink, OpenAiCompatibleProvider, OpenRouterProvider, StreamEvent, - StreamSink, + AiProvider, ChannelSink, NoopSink, OpenAiCompatibleProvider, StreamEvent, StreamSink, }; pub use types::{ImageGenerationRequest, ImageGenerationResponse, WebSearchOptions}; diff --git a/src-tauri/src/domain/ai/provider_payload.rs b/src-tauri/src/domain/ai/provider_payload.rs index fdf18c0b..6424ac50 100644 --- a/src-tauri/src/domain/ai/provider_payload.rs +++ b/src-tauri/src/domain/ai/provider_payload.rs @@ -7,7 +7,22 @@ use super::types::{ChatRequest, WebSearchOptions}; pub(super) fn is_local_base_url(base_url: &str) -> bool { - base_url.contains("localhost") || base_url.contains("127.0.0.1") + let Ok(url) = reqwest::Url::parse(base_url.trim()) else { + return false; + }; + + let Some(host) = url.host_str() else { + return false; + }; + + if host.eq_ignore_ascii_case("localhost") { + return true; + } + + host.trim_start_matches('[') + .trim_end_matches(']') + .parse::() + .is_ok_and(|address| address.is_loopback()) } pub(super) fn build_chat_completion_payload( @@ -44,8 +59,7 @@ pub(super) fn build_chat_completion_payload( ); } - if let Some(level) = &req.thinking_level - && level != "off" + if let Some(level) = normalized_reasoning_effort(req.thinking_level.as_deref()) && !is_local { payload.insert( @@ -88,6 +102,19 @@ pub(super) fn build_chat_completion_payload( payload } +fn normalized_reasoning_effort(level: Option<&str>) -> Option { + let level = level?.trim().to_ascii_lowercase(); + if level.is_empty() { + return None; + } + + Some(if level == "off" { + "none".to_string() + } else { + level + }) +} + pub(super) fn should_attach_web_search(req: &ChatRequest) -> bool { if !req .web_search diff --git a/src-tauri/src/domain/ai/streaming.rs b/src-tauri/src/domain/ai/streaming.rs index 2ae1bbf8..bb427c86 100644 --- a/src-tauri/src/domain/ai/streaming.rs +++ b/src-tauri/src/domain/ai/streaming.rs @@ -109,9 +109,6 @@ pub struct OpenAiCompatibleProvider { client: Client, } -/// Backward-compatible alias for the legacy provider name. -pub type OpenRouterProvider = OpenAiCompatibleProvider; - struct RequestExecution { endpoint: String, api_key: String, @@ -701,7 +698,9 @@ mod tests { fn local_base_url_detection_matches_local_endpoints() { assert!(is_local_base_url("http://localhost:8081/v1")); assert!(is_local_base_url("http://127.0.0.1:8081/v1")); + assert!(is_local_base_url("http://[::1]:8081/v1")); assert!(!is_local_base_url("https://openrouter.ai/api/v1")); + assert!(!is_local_base_url("https://localhost.example.com/v1")); } #[test] @@ -739,6 +738,26 @@ mod tests { assert_eq!(payload.get("reasoning"), Some(&json!({ "effort": "high" }))); } + #[test] + fn build_request_payload_maps_off_reasoning_to_openrouter_none() { + let mut request = sample_request(); + request.thinking_level = Some("off".to_string()); + + let payload = provider_payload::build_chat_completion_payload(&request, true, false); + + assert_eq!(payload.get("reasoning"), Some(&json!({ "effort": "none" }))); + } + + #[test] + fn build_request_payload_keeps_explicit_none_reasoning() { + let mut request = sample_request(); + request.thinking_level = Some("none".to_string()); + + let payload = provider_payload::build_chat_completion_payload(&request, true, false); + + assert_eq!(payload.get("reasoning"), Some(&json!({ "effort": "none" }))); + } + #[test] fn build_request_payload_skips_web_search_for_generic_prompts() { let mut request = sample_request(); diff --git a/src-tauri/src/domain/engine/config.rs b/src-tauri/src/domain/engine/config.rs index 1c69d5e9..1c27c83b 100644 --- a/src-tauri/src/domain/engine/config.rs +++ b/src-tauri/src/domain/engine/config.rs @@ -5,7 +5,7 @@ use crate::domain::engine::types::{EngineComputeMode, EngineConfig, EngineDefinition}; -const MIN_LLAMACPP_CONTEXT_SIZE: u32 = 4096; +use super::engine_profile::minimum_context_size; /// Builds a runtime engine config from engine definition defaults. #[must_use] @@ -39,8 +39,8 @@ pub fn merge_user_engine_config(def: &EngineDefinition, saved: &EngineConfig) -> /// Normalizes launcher-managed engine settings. #[must_use] pub fn normalize_engine_config(mut config: EngineConfig) -> EngineConfig { - if config.engine_id == "llamacpp" && config.context_size < MIN_LLAMACPP_CONTEXT_SIZE { - config.context_size = MIN_LLAMACPP_CONTEXT_SIZE; + if let Some(min_context_size) = minimum_context_size(&config.engine_id) { + config.context_size = config.context_size.max(min_context_size); } config @@ -64,6 +64,7 @@ mod tests { default_context_size: 4096, config_schema: None, installed: false, + installed_compute_modes: Vec::new(), managed_externally: false, } } diff --git a/src-tauri/src/domain/engine/detector.rs b/src-tauri/src/domain/engine/detector.rs index 488831a1..0c9b1208 100644 --- a/src-tauri/src/domain/engine/detector.rs +++ b/src-tauri/src/domain/engine/detector.rs @@ -6,6 +6,7 @@ use std::path::PathBuf; +use crate::domain::engine::types::EngineComputeMode; use crate::errors::AppError; use crate::utils::paths::ENGINES_DIR; @@ -64,6 +65,33 @@ pub fn is_engine_installed(engine_id: &str, binary_name: Option<&str>) -> bool { false } +/// Reads Axelate install metadata and returns the compute modes present on disk. +/// +/// Empty means the install source is unknown or predates metadata tracking. +pub fn installed_compute_modes(engine_id: &str) -> Vec { + if !is_safe_id(engine_id) { + return Vec::new(); + } + + let metadata_path = installed_engine_dir(engine_id).join("metadata.json"); + let Ok(source) = std::fs::read_to_string(metadata_path) else { + return Vec::new(); + }; + let Ok(value) = serde_json::from_str::(&source) else { + return Vec::new(); + }; + + match value + .get("compute_target") + .and_then(serde_json::Value::as_str) + { + Some("gpu") => vec![EngineComputeMode::Gpu], + Some("cpu") => vec![EngineComputeMode::Cpu], + Some("both") => vec![EngineComputeMode::Gpu, EngineComputeMode::Cpu], + _ => Vec::new(), + } +} + /// Returns the absolute path to an engine binary if found. /// /// Search order: diff --git a/src-tauri/src/domain/engine/engine_args.rs b/src-tauri/src/domain/engine/engine_args.rs index 30195271..5961a05a 100644 --- a/src-tauri/src/domain/engine/engine_args.rs +++ b/src-tauri/src/domain/engine/engine_args.rs @@ -1,5 +1,6 @@ use std::path::PathBuf; +use super::engine_profile::minimum_context_size; use super::types::{EngineComputeMode, EngineConfig}; const SDCPP_UNSUPPORTED_FLAGS: [&str; 4] = [ @@ -111,7 +112,25 @@ pub(super) fn sdcpp_preview_enabled(extra_args: &[String]) -> bool { }) } -pub(super) fn build_sdcpp_args(config: &EngineConfig, port: u16) -> Vec { +fn build_generic_engine_args(config: &EngineConfig, port: u16) -> Vec { + let mut args = vec!["--port".to_string(), port.to_string()]; + if let Some(model_path) = config.model_path.as_deref() { + args.push("--model".to_string()); + args.push(model_path.to_string()); + } + args.extend(config.extra_args.clone()); + args +} + +pub(super) fn build_engine_args(config: &EngineConfig, port: u16) -> Vec { + match config.engine_id.as_str() { + "llamacpp" => build_llamacpp_args(config, port), + "sdcpp" => build_sdcpp_args(config, port), + _ => build_generic_engine_args(config, port), + } +} + +fn build_sdcpp_args(config: &EngineConfig, port: u16) -> Vec { let mut args = vec!["--listen-port".to_string(), port.to_string()]; let extra_args = sdcpp_extra_args(config); push_sdcpp_compute_args(&mut args, config); @@ -125,8 +144,11 @@ pub(super) fn build_sdcpp_args(config: &EngineConfig, port: u16) -> Vec args } -pub(super) fn build_llamacpp_args(config: &EngineConfig, port: u16) -> Vec { - let effective_context_size = config.context_size.max(4096); +fn build_llamacpp_args(config: &EngineConfig, port: u16) -> Vec { + let effective_context_size = minimum_context_size(&config.engine_id) + .map_or(config.context_size, |min_context_size| { + config.context_size.max(min_context_size) + }); let mut args = vec![ "--port".to_string(), port.to_string(), @@ -134,6 +156,10 @@ pub(super) fn build_llamacpp_args(config: &EngineConfig, port: u16) -> Vec Option { + match engine_id { + "llamacpp" => Some(LLAMACPP_MIN_CONTEXT_SIZE), + _ => None, + } +} diff --git a/src-tauri/src/domain/engine/manager.rs b/src-tauri/src/domain/engine/manager.rs index 1780cdc9..e528ef6c 100644 --- a/src-tauri/src/domain/engine/manager.rs +++ b/src-tauri/src/domain/engine/manager.rs @@ -17,7 +17,7 @@ use tracing::{error, info, warn}; use crate::errors::AppError; -use super::engine_args::{build_llamacpp_args, build_sdcpp_args, sdcpp_preview_enabled}; +use super::engine_args::{build_engine_args, sdcpp_preview_enabled}; use super::engine_runtime::{ diagnose_engine_start_failure, find_available_local_port, is_endpoint_healthy, spawn_log_reader, wait_for_health, @@ -194,7 +194,7 @@ impl EngineManager { pub async fn active_image_preview_path(&self) -> Option { let slots = self.slots.lock().await; let engine = slots.get(&Capability::Image)?; - if engine.definition.id != "sdcpp" && engine.definition.id != "stable-diffusion" { + if engine.definition.id != "sdcpp" { return None; } @@ -370,30 +370,7 @@ impl EngineManager { cmd.creation_flags(CREATE_NO_WINDOW); } - if config.engine_id == "llamacpp" { - cmd.args(build_llamacpp_args(&config, selected_port)); - } else if config.engine_id == "sdcpp" { - cmd.args(build_sdcpp_args(&config, selected_port)); - } else { - // Default fallback for other engines - cmd.arg("--port").arg(selected_port.to_string()); - } - - if config.engine_id != "sdcpp" && config.engine_id != "llamacpp" { - if let Some(ref model) = config.model_path { - cmd.arg("--model").arg(model); - } - - for arg in &config.extra_args { - cmd.arg(arg); - } - } - - if config.engine_id == "llamacpp" { - if let Some(ref model) = config.model_path { - cmd.arg("--model").arg(model); - } - } + cmd.args(build_engine_args(&config, selected_port)); // Pipe engine stdout/stderr to files in logs directory let log_dir = @@ -714,24 +691,19 @@ impl EngineManager { } } -/// Returns the registry id used internally for known engine aliases. +/// Returns the normalized engine registry id. pub fn canonical_engine_id(engine_id: &str) -> String { - let mut normalized = engine_id + let normalized = engine_id .trim() .to_ascii_lowercase() - .replace(['.', '_'], "-"); - if let Some(stripped) = normalized.strip_suffix("-cpp") { - normalized = stripped.to_string(); - } + .replace([' ', '.', '_'], "-"); + + let mut normalized = normalized; while normalized.contains("--") { normalized = normalized.replace("--", "-"); } - if normalized == "stable-diffusion" || normalized.starts_with("stable-diffusion-") { - "sdcpp".to_string() - } else { - normalized - } + normalized } fn canonical_engine_log_id(engine_id: &str) -> String { @@ -744,7 +716,6 @@ mod tests { use super::*; use crate::domain::engine::engine_runtime::classify_engine_start_failure; - use crate::domain::engine::events::NoopEmitter; use crate::domain::engine::types::EngineComputeMode; use crate::domain::system::ports::ENGINE_LOCAL_PORT_RANGE; use std::net::TcpListener; @@ -772,18 +743,28 @@ mod tests { #[test] fn builds_single_slot_llamacpp_args_by_default() { - let args = build_llamacpp_args(&sample_config(None), 8081); + let args = build_engine_args(&sample_config(None), 8081); assert!(args.windows(2).any(|w| w == ["-ngl", "all"])); assert!(!args.contains(&"--parallel".to_string())); assert!(!args.contains(&"--reasoning".to_string())); } + #[test] + fn builds_llamacpp_model_args_from_config() { + let args = build_engine_args(&sample_config(Some("C:/models/chat.gguf")), 8081); + + assert!( + args.windows(2) + .any(|w| w == ["--model", "C:/models/chat.gguf"]) + ); + } + #[test] fn builds_cpu_only_llamacpp_args_when_requested() { let mut config = sample_config(None); config.compute_mode = EngineComputeMode::Cpu; - let args = build_llamacpp_args(&config, 8081); + let args = build_engine_args(&config, 8081); assert!(args.windows(2).any(|w| w == ["--device", "none"])); assert!(args.windows(2).any(|w| w == ["-ngl", "0"])); @@ -795,7 +776,7 @@ mod tests { let mut config = sample_config(None); config.context_size = 1024; - let args = build_llamacpp_args(&config, 8081); + let args = build_engine_args(&config, 8081); assert!(args.windows(2).any(|w| w == ["--ctx-size", "4096"])); } @@ -851,7 +832,7 @@ mod tests { #[test] fn builds_plain_sdcpp_model_args() { - let args = build_sdcpp_args( + let args = build_engine_args( &sample_sdcpp_config(Some("C:/models/sd15.safetensors")), 8082, ); @@ -868,47 +849,12 @@ mod tests { let mut config = sample_sdcpp_config(Some("C:/models/sd15.safetensors")); config.compute_mode = EngineComputeMode::Cpu; - let args = build_sdcpp_args(&config, 8082); + let args = build_engine_args(&config, 8082); assert!(args.contains(&"--clip-on-cpu".to_string())); assert!(args.contains(&"--vae-on-cpu".to_string())); } - #[test] - fn canonicalizes_stable_diffusion_variants_to_sdcpp() { - assert_eq!(canonical_engine_id("stable-diffusion"), "sdcpp"); - assert_eq!(canonical_engine_id("Stable_Diffusion.cpp"), "sdcpp"); - assert_eq!(canonical_engine_id("stable.diffusion.cpp"), "sdcpp"); - } - - #[tokio::test] - async fn resolves_stable_diffusion_alias_to_sdcpp_definition() { - let manager = EngineManager::new(Arc::new(NoopEmitter)); - manager - .register_definitions(vec![EngineDefinition { - id: "sdcpp".to_string(), - name: "Stable Diffusion.cpp".to_string(), - desc: String::new(), - icon: String::new(), - capabilities: vec![Capability::Image], - binary: Some("sd-server".to_string()), - repo_url: None, - version: "1.0.0".to_string(), - default_port: 8082, - default_context_size: 4096, - config_schema: None, - installed: false, - managed_externally: false, - }]) - .await; - - let Some(resolved) = manager.get_definition("stable-diffusion").await else { - panic!("stable-diffusion alias should resolve to sdcpp"); - }; - - assert_eq!(resolved.id, "sdcpp"); - } - #[test] fn sdcpp_keeps_user_supplied_cpu_extra_args() { let mut config = sample_sdcpp_config(Some("C:/models/sd15.safetensors")); @@ -919,7 +865,7 @@ mod tests { "--mmap".to_string(), ]; - let args = build_sdcpp_args(&config, 8082); + let args = build_engine_args(&config, 8082); assert!(args.contains(&"--offload-to-cpu".to_string())); assert!(args.contains(&"--clip-on-cpu".to_string())); @@ -938,7 +884,7 @@ mod tests { "--preview-interval=1".to_string(), ]; - let args = build_sdcpp_args(&config, 8082); + let args = build_engine_args(&config, 8082); assert!(!args.contains(&"--preview".to_string())); assert!(!args.contains(&"--preview-path".to_string())); @@ -958,7 +904,7 @@ mod tests { "C:/tmp/preview.png".to_string(), ]; - let args = build_sdcpp_args(&config, 8082); + let args = build_engine_args(&config, 8082); assert!(!args.contains(&"--vae-on-gpu".to_string())); assert!(!args.contains(&"1".to_string())); @@ -976,7 +922,7 @@ mod tests { "--mmap".to_string(), ]; - let args = build_sdcpp_args(&config, 8082); + let args = build_engine_args(&config, 8082); assert_eq!( resolve_sdcpp_preview_path(&config.extra_args), @@ -995,4 +941,13 @@ mod tests { assert!(sdcpp_preview_enabled(&extra_args)); assert!(resolve_sdcpp_preview_path(&extra_args).is_none()); } + + #[test] + fn canonical_engine_id_normalizes_without_remapping_cpp_engines() { + assert_eq!(canonical_engine_id(" sdcpp "), "sdcpp"); + assert_eq!(canonical_engine_id("llama cpp"), "llama-cpp"); + assert_eq!(canonical_engine_id("llama_cpp"), "llama-cpp"); + assert_eq!(canonical_engine_id("llama.cpp"), "llama-cpp"); + assert_eq!(canonical_engine_id("sd.cpp"), "sd-cpp"); + } } diff --git a/src-tauri/src/domain/engine/mod.rs b/src-tauri/src/domain/engine/mod.rs index 27e9c309..d50a6763 100644 --- a/src-tauri/src/domain/engine/mod.rs +++ b/src-tauri/src/domain/engine/mod.rs @@ -8,6 +8,7 @@ pub mod config; /// Engine binary detection (installed check + path resolution) pub mod detector; mod engine_args; +mod engine_profile; mod engine_runtime; /// Engine event emission trait pub mod events; diff --git a/src-tauri/src/domain/engine/registry.rs b/src-tauri/src/domain/engine/registry.rs index 638ad9ec..3135c7b1 100644 --- a/src-tauri/src/domain/engine/registry.rs +++ b/src-tauri/src/domain/engine/registry.rs @@ -67,6 +67,7 @@ fn convert_module_to_definition(item: &ModuleItem) -> EngineDefinition { default_context_size, config_schema: item.raw_config_schema.clone(), installed: false, // populated at request time by get_engine_definitions + installed_compute_modes: Vec::new(), managed_externally: item.managed_externally, } } diff --git a/src-tauri/src/domain/engine/types.rs b/src-tauri/src/domain/engine/types.rs index f2475513..2b9016fd 100644 --- a/src-tauri/src/domain/engine/types.rs +++ b/src-tauri/src/domain/engine/types.rs @@ -85,6 +85,11 @@ pub struct EngineDefinition { /// Whether the engine binary is currently installed (populated at runtime, not from JSON) #[serde(default)] pub installed: bool, + /// Compute modes present in the Axelate-managed install metadata. + /// + /// Empty means unknown, usually a system PATH install or an older install without metadata. + #[serde(default)] + pub installed_compute_modes: Vec, /// True when the launcher connects to a user-managed external engine instead of installing it #[serde(default)] pub managed_externally: bool, diff --git a/src-tauri/src/domain/integration_api.rs b/src-tauri/src/domain/integration_api.rs index c38e687f..42dbba24 100644 --- a/src-tauri/src/domain/integration_api.rs +++ b/src-tauri/src/domain/integration_api.rs @@ -91,7 +91,7 @@ pub fn apply_process_env( command .env("AXELATE_HTTP_API_BASE", api_base_url()) .env("AXELATE_HTTP_API_TOKEN", token) - .env("AXELATE_SDK_VERSION", SDK_API_VERSION) + .env("AXELATE_INTEGRATION_API_VERSION", SDK_API_VERSION) .env("AXELATE_MODULE_ID", module_id) .env("AXELATE_MODULE_DIR", module_dir) .env("AXELATE_RUNTIME_DIR", &*crate::utils::paths::RUNTIME_DIR) @@ -742,8 +742,8 @@ async fn route_authorized_request( json!({ "ok": response.success, "response": response }), )) } - ("POST", ["v1", "ai", "text"]) => handle_text_request(request, context).await, - ("POST", ["v1", "ai", "image"]) => handle_image_request(request, context).await, + ("POST", ["v1", "ai", "text"]) => handle_text_request(request, context, client).await, + ("POST", ["v1", "ai", "image"]) => handle_image_request(request, context, client).await, _ => Ok(json_error(404, "Unknown launcher API route")), } } @@ -940,6 +940,7 @@ fn parse_module_action(action: &str) -> Result { async fn handle_text_request( request: &HttpRequest, context: LauncherHttpApiContext, + client: &AuthorizedClient, ) -> Result { let payload: IntegrationTextRequest = parse_json_body(request)?; let prompt = payload.prompt.as_deref().unwrap_or(""); @@ -963,8 +964,7 @@ async fn handle_text_request( "text", ) .await?; - let session_id = - resolve_session_id(&context.ui_state_service, payload.session_id.as_deref()).await; + let session_id = resolve_session_id(payload.session_id.as_deref(), client); let mut messages = payload.messages.unwrap_or_default(); if messages.is_empty() && prompt.trim().is_empty() { return Err(AppError::Validation( @@ -982,11 +982,11 @@ async fn handle_text_request( let thinking_level = match payload.thinking_level { Some(value) => Some(value), - None => selected_thinking_level(&context.ui_state_service, &ui_provider, &provider).await?, + None => selected_thinking_level(&context.ui_state_service, &ui_provider).await?, }; let web_search = match payload.web_search { Some(value) => Some(value), - None => selected_web_search(&context.ui_state_service, &ui_provider, &provider).await?, + None => selected_web_search(&context.ui_state_service, &ui_provider).await?, }; let mut chat_request = ChatRequest { @@ -1025,6 +1025,7 @@ async fn handle_text_request( async fn handle_image_request( request: &HttpRequest, context: LauncherHttpApiContext, + client: &AuthorizedClient, ) -> Result { let payload: IntegrationImageRequest = parse_json_body(request)?; if payload.prompt.trim().is_empty() { @@ -1052,8 +1053,7 @@ async fn handle_image_request( "image", ) .await?; - let session_id = - resolve_session_id(&context.ui_state_service, payload.session_id.as_deref()).await; + let session_id = resolve_session_id(payload.session_id.as_deref(), client); let image_request = ImageGenerationRequest { provider: provider.clone(), prompt: payload.prompt.clone(), @@ -1179,46 +1179,37 @@ async fn selected_module_id( .filter(|value| !value.is_empty())) } -async fn resolve_session_id( - ui_state_service: &UiStateService, - requested: Option<&str>, -) -> Option { +fn resolve_session_id(requested: Option<&str>, client: &AuthorizedClient) -> Option { if let Some(session_id) = requested.map(str::trim).filter(|value| !value.is_empty()) { return Some(session_id.to_string()); } - ui_state_service - .get_ui_state() - .await - .ok() - .and_then(|state| state.ai_session_id) - .filter(|value| !value.trim().is_empty()) + match client { + AuthorizedClient::Module(module_id) => Some(format!("integration:{module_id}")), + AuthorizedClient::Launcher => None, + } } async fn selected_thinking_level( ui_state_service: &UiStateService, - primary_provider: &str, - fallback_provider: &str, + provider_id: &str, ) -> Result, AppError> { let state = ui_state_service.get_ui_state().await?; Ok(state .ai_thinking_level - .get(primary_provider) - .or_else(|| state.ai_thinking_level.get(fallback_provider)) + .get(provider_id) .cloned() .filter(|value| !value.trim().is_empty())) } async fn selected_web_search( ui_state_service: &UiStateService, - primary_provider: &str, - fallback_provider: &str, + provider_id: &str, ) -> Result, AppError> { let state = ui_state_service.get_ui_state().await?; let enabled = state .ai_web_search_enabled - .get(primary_provider) - .or_else(|| state.ai_web_search_enabled.get(fallback_provider)) + .get(provider_id) .copied() .unwrap_or(false); @@ -1244,12 +1235,11 @@ async fn resolve_model_id( } let state = ui_state_service.get_ui_state().await?; - let selected_model = { - ui_provider_id - .and_then(|id| state.selected_ai_models.get(id)) - .or_else(|| state.selected_ai_models.get(provider_id)) - .cloned() - }; + let selected_model = match ui_provider_id { + Some(id) => state.selected_ai_models.get(id), + None => state.selected_ai_models.get(provider_id), + } + .cloned(); let config = config_service.load_full_config()?; let provider = config .api_providers @@ -1295,7 +1285,6 @@ fn strongest_provider_model(provider: &ApiProvider, capability: &str) -> Option< let models = provider.models.as_ref()?; models .iter() - .filter(|model| model.deprecated != Some(true)) .max_by_key(|model| { ( tier_rank(&model.tier), @@ -1335,11 +1324,6 @@ fn authorize_request(headers: &HashMap) -> Option Option { @@ -1445,7 +1429,6 @@ mod tests { release_date: None, context_window: None, max_output_tokens: None, - deprecated: None, pricing: None, stats: ModelStats { speed: 1, @@ -1489,7 +1472,7 @@ mod tests { } #[test] - fn authorization_accepts_bearer_or_header_token() { + fn authorization_accepts_bearer_token() { let mut headers = HashMap::new(); headers.insert( "authorization".to_string(), @@ -1502,13 +1485,17 @@ mod tests { format!("bearer {}", super::api_token()), ); assert!(is_authorized(&headers)); + } - headers.clear(); + #[test] + fn authorization_rejects_old_header_token() { + let mut headers = HashMap::new(); headers.insert( "x-axelate-token".to_string(), super::api_token().to_string(), ); - assert!(is_authorized(&headers)); + + assert!(!is_authorized(&headers)); } #[test] @@ -1555,6 +1542,32 @@ mod tests { ); } + #[test] + fn module_requests_default_to_module_scoped_session() { + assert_eq!( + super::resolve_session_id( + None, + &super::AuthorizedClient::Module("sample-module".to_string()) + ), + Some("integration:sample-module".to_string()) + ); + assert_eq!( + super::resolve_session_id( + Some(" explicit-session "), + &super::AuthorizedClient::Module("sample-module".to_string()) + ), + Some("explicit-session".to_string()) + ); + } + + #[test] + fn launcher_requests_without_session_do_not_use_ui_state() { + assert_eq!( + super::resolve_session_id(None, &super::AuthorizedClient::Launcher), + None + ); + } + #[test] fn authorization_rejects_malformed_bearer_values() { let mut headers = HashMap::new(); @@ -1727,7 +1740,8 @@ mod tests { .collect::>(); assert_eq!( - envs.get("AXELATE_SDK_VERSION").map(String::as_str), + envs.get("AXELATE_INTEGRATION_API_VERSION") + .map(String::as_str), Some("1") ); assert_eq!( diff --git a/src-tauri/src/domain/modules/controller/script_runtime.rs b/src-tauri/src/domain/modules/controller/script_runtime.rs index dd46dcf9..7bea3e5d 100644 --- a/src-tauri/src/domain/modules/controller/script_runtime.rs +++ b/src-tauri/src/domain/modules/controller/script_runtime.rs @@ -238,8 +238,7 @@ async fn spawn_runtime_command( command .current_dir(module_path) - .env("BOT_CONFIG_DIR", CONFIG_DIR.as_os_str()) - .env("AXELATE_SDK_VERSION", SDK_API_VERSION) + .env("AXELATE_INTEGRATION_API_VERSION", SDK_API_VERSION) .env("AXELATE_CONFIG_DIR", CONFIG_DIR.as_os_str()) .env("AXELATE_RUNTIME_DIR", RUNTIME_DIR.as_os_str()) .env( diff --git a/src-tauri/src/domain/modules/downloader.rs b/src-tauri/src/domain/modules/downloader.rs index a9995892..3265fc02 100644 --- a/src-tauri/src/domain/modules/downloader.rs +++ b/src-tauri/src/domain/modules/downloader.rs @@ -372,11 +372,16 @@ pub async fn download_module( final_progress_snapshot = latest_progress_snapshot; ensure_not_interrupted(&control)?; + let release_compute_target = release_selection + .as_ref() + .map(|selection| selection.compute_target.as_metadata_value()); + ArchiveExtractor::finalize( &module_id, &extraction_path, expected_hash.as_ref(), release_tag.as_deref(), + release_compute_target, )?; Ok::<(), AppError>(()) @@ -499,7 +504,7 @@ fn validate_integration_manifest(manifest: &ModuleManifest) -> Result, release_tag: Option<&str>, + release_compute_target: Option<&str>, ) -> Result<(), AppError> { let final_path = package_install_dir(module_id); @@ -714,6 +715,7 @@ impl ArchiveExtractor { "archive_hash": expected_hash.cloned(), "status": "complete", "version": release_tag.unwrap_or("unknown"), + "compute_target": release_compute_target, }); let manifest_path = extraction_path.join("metadata.json"); let manifest_file = fs::File::create(&manifest_path).map_err(|error| { diff --git a/src-tauri/src/domain/modules/downloader_support.rs b/src-tauri/src/domain/modules/downloader_support.rs index 72ebedcf..a33961b9 100644 --- a/src-tauri/src/domain/modules/downloader_support.rs +++ b/src-tauri/src/domain/modules/downloader_support.rs @@ -6,6 +6,7 @@ const MAX_ARCHIVE_TOTAL_UNCOMPRESSED_SIZE: u64 = 3 * 1024 * 1024 * 1024; const MAX_ARCHIVE_TOTAL_UNCOMPRESSED_SIZE_LARGE_MODULE: u64 = 12 * 1024 * 1024 * 1024; const MAX_ARCHIVE_FILE_COUNT: usize = 10000; const MAX_ARCHIVE_FILE_COUNT_LARGE_MODULE: usize = 100_000; +const LARGE_ARCHIVE_MODULE_IDS: [&str; 1] = ["comfyui"]; pub(super) fn is_engine_package(package_id: &str) -> bool { serde_json::from_str::>(include_str!( @@ -228,7 +229,7 @@ pub(super) fn strip_archive_root(path: &Path, root_to_skip: Option<&str>) -> Pat } pub(super) fn archive_file_count_limit(module_id: &str) -> usize { - if module_id == "comfyui" { + if uses_large_archive_limits(module_id) { return MAX_ARCHIVE_FILE_COUNT_LARGE_MODULE; } @@ -236,9 +237,13 @@ pub(super) fn archive_file_count_limit(module_id: &str) -> usize { } pub(super) fn archive_total_uncompressed_size_limit(module_id: &str) -> u64 { - if module_id == "comfyui" { + if uses_large_archive_limits(module_id) { return MAX_ARCHIVE_TOTAL_UNCOMPRESSED_SIZE_LARGE_MODULE; } MAX_ARCHIVE_TOTAL_UNCOMPRESSED_SIZE } + +fn uses_large_archive_limits(module_id: &str) -> bool { + LARGE_ARCHIVE_MODULE_IDS.contains(&module_id) +} diff --git a/src-tauri/src/domain/modules/github_release_selection.rs b/src-tauri/src/domain/modules/github_release_selection.rs index d256cc52..c9f122f9 100644 --- a/src-tauri/src/domain/modules/github_release_selection.rs +++ b/src-tauri/src/domain/modules/github_release_selection.rs @@ -12,6 +12,37 @@ enum CudaTrack { Cuda13, } +#[derive(Clone, Copy)] +struct ModuleReleaseNaming { + runtime_prefix: &'static str, + main_prefix: &'static str, + main_requires_bin_marker: bool, +} + +const DEFAULT_RELEASE_NAMING: ModuleReleaseNaming = ModuleReleaseNaming { + runtime_prefix: "cudart-", + main_prefix: "", + main_requires_bin_marker: false, +}; + +const SDCPP_RELEASE_NAMING: ModuleReleaseNaming = ModuleReleaseNaming { + runtime_prefix: "cudart-sd-", + main_prefix: "sd-", + main_requires_bin_marker: true, +}; + +const LLAMACPP_RELEASE_NAMING: ModuleReleaseNaming = ModuleReleaseNaming { + runtime_prefix: "cudart-llama-", + main_prefix: "llama-", + main_requires_bin_marker: true, +}; + +const COMFYUI_RELEASE_NAMING: ModuleReleaseNaming = ModuleReleaseNaming { + runtime_prefix: "cudart-", + main_prefix: "comfyui_windows_portable_", + main_requires_bin_marker: false, +}; + impl CudaTrack { const fn min_driver_major(self) -> u32 { match self { @@ -183,11 +214,7 @@ fn is_runtime_asset(module_id: &str, name: &str) -> bool { return false; } - match module_id { - "sdcpp" => lower.starts_with("cudart-sd-"), - "llamacpp" => lower.starts_with("cudart-llama-"), - _ => lower.starts_with("cudart-"), - } + lower.starts_with(release_naming(module_id).runtime_prefix) } fn is_main_asset(module_id: &str, name: &str) -> bool { @@ -196,11 +223,21 @@ fn is_main_asset(module_id: &str, name: &str) -> bool { return false; } + let naming = release_naming(module_id); + if naming.main_prefix.is_empty() { + return !lower.starts_with(naming.runtime_prefix); + } + + lower.starts_with(naming.main_prefix) + && (!naming.main_requires_bin_marker || lower.contains("-bin-")) +} + +fn release_naming(module_id: &str) -> ModuleReleaseNaming { match module_id { - "sdcpp" => lower.starts_with("sd-") && lower.contains("-bin-"), - "llamacpp" => lower.starts_with("llama-") && lower.contains("-bin-"), - "comfyui" => lower.starts_with("comfyui_windows_portable_"), - _ => !lower.starts_with("cudart-"), + "sdcpp" => SDCPP_RELEASE_NAMING, + "llamacpp" => LLAMACPP_RELEASE_NAMING, + "comfyui" => COMFYUI_RELEASE_NAMING, + _ => DEFAULT_RELEASE_NAMING, } } diff --git a/src-tauri/src/domain/modules/github_releases.rs b/src-tauri/src/domain/modules/github_releases.rs index f67490d0..3ca7616b 100644 --- a/src-tauri/src/domain/modules/github_releases.rs +++ b/src-tauri/src/domain/modules/github_releases.rs @@ -42,6 +42,21 @@ pub enum ReleaseComputeTarget { Gpu, /// Prefer a CPU package. Cpu, + /// Download both CPU and GPU packages when both are compatible. + Both, +} + +impl ReleaseComputeTarget { + /// Stable string stored in install metadata. + #[must_use] + pub const fn as_metadata_value(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::Gpu => "gpu", + Self::Cpu => "cpu", + Self::Both => "both", + } + } } /// Explicit release package selection passed from the frontend. @@ -330,15 +345,18 @@ fn find_compatible_release_bundle( let selected_target = selection.map_or(ReleaseComputeTarget::Auto, |selection| { selection.compute_target }); - let selected_hardware = hardware_for_target(hardware, selected_target); - releases .into_iter() .filter(|release| !release.draft && !release.prerelease) .filter(|release| selected_tag.is_none_or(|tag| release.tag_name == tag)) .find_map(|release| { - let assets = - select_release_assets(module_id, platform, selected_hardware, &release.assets)?; + let assets = select_assets_for_target( + module_id, + platform, + hardware, + selected_target, + &release.assets, + )?; if selected_target != ReleaseComputeTarget::Auto && !release_assets_match_target(&assets, selected_target) { @@ -442,6 +460,10 @@ const fn recommended_release_target( fn release_assets_match_target(assets: &[ReleaseAsset], target: ReleaseComputeTarget) -> bool { match target { ReleaseComputeTarget::Auto => true, + ReleaseComputeTarget::Both => { + release_assets_match_target(assets, ReleaseComputeTarget::Gpu) + && release_assets_match_target(assets, ReleaseComputeTarget::Cpu) + } ReleaseComputeTarget::Gpu => assets .iter() .filter(|asset| !is_runtime_asset_name(&asset.name)) @@ -540,7 +562,7 @@ const fn hardware_for_target( target: ReleaseComputeTarget, ) -> HardwareProfile { match target { - ReleaseComputeTarget::Auto => hardware, + ReleaseComputeTarget::Auto | ReleaseComputeTarget::Both => hardware, ReleaseComputeTarget::Gpu => { if matches!( hardware.accelerator, @@ -567,6 +589,47 @@ const fn hardware_for_target( } } +fn select_assets_for_target( + module_id: &str, + platform: Platform, + hardware: HardwareProfile, + target: ReleaseComputeTarget, + assets: &[Asset], +) -> Option> { + if target != ReleaseComputeTarget::Both { + return select_release_assets( + module_id, + platform, + hardware_for_target(hardware, target), + assets, + ); + } + + let mut selected = select_release_assets( + module_id, + platform, + hardware_for_target(hardware, ReleaseComputeTarget::Gpu), + assets, + )?; + let cpu_assets = select_release_assets( + module_id, + platform, + hardware_for_target(hardware, ReleaseComputeTarget::Cpu), + assets, + )?; + + for asset in cpu_assets { + if selected.iter().any(|existing| { + existing.download_url == asset.download_url || existing.name == asset.name + }) { + continue; + } + selected.push(asset); + } + + Some(selected) +} + fn parse_repo(repo_url: &str) -> Result { let trimmed = repo_url .trim() @@ -993,6 +1056,58 @@ mod tests { ); } + #[test] + fn explicit_release_selection_can_download_both_targets() { + let platform = Platform { + os: PlatformOs::Windows, + arch: PlatformArch::X64, + }; + let hardware = HardwareProfile { + accelerator: AcceleratorClass::NvidiaCuda, + cpu_tier: CpuInstructionTier::Avx2, + cuda_driver_major: Some(580), + cuda_driver_minor: Some(0), + }; + let releases = vec![Release { + tag_name: "b8971".to_string(), + published_at: Some("2026-04-29T10:00:00Z".to_string()), + draft: false, + prerelease: false, + assets: vec![ + asset("cudart-llama-bin-win-cuda-13.1-x64.zip"), + asset("llama-b8971-bin-win-cuda-13.1-x64.zip"), + asset("llama-b8971-bin-win-cpu-x64.zip"), + ], + }]; + let selection = ReleaseDownloadSelection { + tag_name: Some("b8971".to_string()), + compute_target: ReleaseComputeTarget::Both, + }; + + let bundle = find_compatible_release_bundle( + "llamacpp", + platform, + hardware, + releases, + Some(&selection), + ) + .expect("expected combined CPU and GPU release bundle"); + + let asset_names = bundle + .assets + .iter() + .map(|asset| asset.name.as_str()) + .collect::>(); + assert_eq!( + asset_names, + vec![ + "cudart-llama-bin-win-cuda-13.1-x64.zip", + "llama-b8971-bin-win-cuda-13.1-x64.zip", + "llama-b8971-bin-win-cpu-x64.zip", + ] + ); + } + #[test] fn release_download_versions_keeps_compatible_older_release_options() { let platform = Platform { diff --git a/src-tauri/src/domain/modules/lifecycle.rs b/src-tauri/src/domain/modules/lifecycle.rs index c750f36c..5c25ac2c 100644 --- a/src-tauri/src/domain/modules/lifecycle.rs +++ b/src-tauri/src/domain/modules/lifecycle.rs @@ -64,7 +64,7 @@ pub struct ModuleManifest { #[serde(default)] pub author: Option, /// Module category used by the launcher UI. - #[serde(default, alias = "type")] + #[serde(default)] pub category: Option, /// Module icon shown in the launcher UI. #[serde(default)] @@ -75,18 +75,18 @@ pub struct ModuleManifest { /// Human-readable module documentation file. #[serde(default)] pub readme: Option, - /// Legacy launcher-owned schema file path for richer forms. - #[serde(default, alias = "settingsSchema")] + /// Launcher-owned schema file path for richer forms. + #[serde(default)] pub settings_schema: Option, /// Module-owned custom settings UI entry point. - #[serde(default, alias = "settingsUi")] + #[serde(default)] pub settings_ui: Option, /// Launcher-managed module runtime. pub runtime: ModuleRuntime, /// Lifecycle scripts pub lifecycle: Option, /// Configuration schema - #[serde(default, alias = "configSchema")] + #[serde(default)] pub config_schema: Option>, } @@ -262,7 +262,7 @@ name = "Demo Module" version = "1.2.3" description = "Example manifest" author = "Axelate" -type = "service" +category = "service" icon = "🤖" settings_ui = "settings-ui/index.html" @@ -302,7 +302,7 @@ start = { program = "uv", args = ["run", "src/main.py"] } } #[test] - fn rejects_non_toml_manifest_files() { + fn rejects_json_manifest_files() { let temp_dir = tempfile::tempdir().expect("temp dir"); let manifest_path = temp_dir.path().join("module.json"); @@ -310,10 +310,10 @@ start = { program = "uv", args = ["run", "src/main.py"] } &manifest_path, r#"{ "api_version": "1", - "id": "legacy-demo", - "name": "Legacy Demo", + "id": "json-demo", + "name": "JSON Demo", "version": "0.1.0", - "description": "Legacy manifest", + "description": "JSON manifest", "dependencies": [] }"#, ) @@ -358,8 +358,8 @@ entry = "src/main.py" temp_dir.path().join(PRIMARY_MANIFEST_FILE), r#" api_version = "1" -id = "legacy" -name = "Legacy" +id = "missing-runtime" +name = "Invalid Runtime" version = "1.0.0" entry = "src/main.py" dependencies = ["python"] diff --git a/src-tauri/src/infrastructure/config/config_repository.rs b/src-tauri/src/infrastructure/config/config_repository.rs index cb35d6a5..57423a9b 100644 --- a/src-tauri/src/infrastructure/config/config_repository.rs +++ b/src-tauri/src/infrastructure/config/config_repository.rs @@ -94,23 +94,6 @@ impl FileConfigRepository { }) } - fn parse_api_providers(content: &str) -> Result, AppError> { - let raw: serde_json::Value = serde_json::from_str(content) - .map_err(|e| AppError::Config(format!("Failed to parse api_providers.json: {e}")))?; - let providers = raw.as_array().ok_or_else(|| { - AppError::Config("Failed to parse api_providers.json: expected array".to_string()) - })?; - - let mut parsed_providers = Vec::with_capacity(providers.len()); - for (index, provider) in providers.iter().cloned().enumerate() { - if let Some(parsed) = Self::parse_api_provider(provider, index) { - parsed_providers.push(parsed); - } - } - - Ok(parsed_providers) - } - fn load_api_provider_directory(root: &Path) -> Result, AppError> { let mut providers = Vec::new(); @@ -236,15 +219,15 @@ impl ConfigRepository for FileConfigRepository { return Ok(providers); } - tracing::warn!( - "No API providers loaded from {}, trying legacy api_providers.json", + return Err(AppError::Config(format!( + "No API providers loaded from {}", root.display() - ); + ))); } - let content = Self::load_file_content("api_providers.json", "[]"); - - Self::parse_api_providers(&content) + Err(AppError::Config( + "API provider directory not found".to_string(), + )) } fn load_local_modules(&self) -> Result, AppError> { @@ -270,61 +253,90 @@ mod tests { #[test] fn api_provider_parser_skips_invalid_models_without_dropping_catalog() -> Result<(), String> { - let providers = FileConfigRepository::parse_api_providers( + let provider = FileConfigRepository::parse_api_provider_file( r#" - [ - { - "id": "broken-provider", - "name": "Broken Provider", - "type": "api", - "models": [ - { - "id": "good-model", - "name": "Good Model", - "desc": "Valid model", - "tier": "medium", - "stats": { "speed": 8, "logic": 8, "creative": 6 } - }, - { - "id": "bad-model", - "name": "Bad Model", - "desc": "Invalid tier should not break the catalog", - "tier": "invalid", - "stats": { "speed": 8, "logic": 8, "creative": 6 } - } - ] - }, - { - "id": "healthy-provider", - "name": "Healthy Provider", - "type": "api", - "models": [] - } - ] + { + "id": "broken-provider", + "name": "Broken Provider", + "type": "api", + "models": [ + { + "id": "good-model", + "name": "Good Model", + "desc": "Valid model", + "tier": "medium", + "stats": { "speed": 8, "logic": 8, "creative": 6 } + }, + { + "id": "bad-model", + "name": "Bad Model", + "desc": "Invalid tier should not break the catalog", + "tier": "invalid", + "stats": { "speed": 8, "logic": 8, "creative": 6 } + } + ] + } "#, + PathBuf::from("broken-provider.json").as_path(), ) - .expect("provider list should parse"); + .ok_or_else(|| "provider should parse".to_string())?; - assert_eq!(providers.len(), 2); - let broken_provider = providers - .first() - .ok_or_else(|| "broken provider".to_string())?; - assert_eq!(broken_provider.id, "broken-provider"); - let models = broken_provider + assert_eq!(provider.id, "broken-provider"); + let models = provider .models .as_ref() .ok_or_else(|| "models".to_string())?; assert_eq!(models.len(), 1); let model = models.first().ok_or_else(|| "model".to_string())?; assert_eq!(model.id, "good-model"); - - let healthy_provider = providers - .get(1) - .ok_or_else(|| "healthy provider".to_string())?; - assert_eq!(healthy_provider.id, "healthy-provider"); Ok(()) } + #[test] + fn api_provider_directory_loads_multiple_provider_files() { + let temp = tempfile::tempdir().expect("temp dir"); + let text_dir = temp.path().join("text"); + let image_dir = temp.path().join("image"); + std::fs::create_dir_all(&text_dir).expect("text dir"); + std::fs::create_dir_all(&image_dir).expect("image dir"); + + std::fs::write( + text_dir.join("first.json"), + r#"{ + "id": "first-provider", + "name": "First Provider", + "type": "api", + "models": [] + }"#, + ) + .expect("first provider"); + std::fs::write( + image_dir.join("second.json"), + r#"{ + "id": "second-provider", + "name": "Second Provider", + "type": "api", + "models": [] + }"#, + ) + .expect("second provider"); + + let providers = + FileConfigRepository::load_api_provider_directory(temp.path()).expect("providers"); + + assert_eq!(providers.len(), 2); + assert!( + providers + .iter() + .any(|provider| provider.id == "first-provider") + ); + assert!( + providers + .iter() + .any(|provider| provider.id == "second-provider") + ); + } + #[test] fn api_provider_directory_skips_invalid_files_without_dropping_others() { let temp = tempfile::tempdir().expect("temp dir"); diff --git a/src-tauri/src/infrastructure/config/theme.rs b/src-tauri/src/infrastructure/config/theme.rs index 7c14b376..34de4181 100644 --- a/src-tauri/src/infrastructure/config/theme.rs +++ b/src-tauri/src/infrastructure/config/theme.rs @@ -34,12 +34,5 @@ pub fn get_theme_colors() -> HashMap { colors.insert("text_muted".to_string(), "#6c757d".to_string()); colors.insert("secondary".to_string(), "#a0a0a0".to_string()); - // Legacy aliases - colors.insert("bg".to_string(), "#111015".to_string()); - colors.insert("sidebar".to_string(), "#1a1920".to_string()); - colors.insert("input_bg".to_string(), "#26252d".to_string()); - colors.insert("hover".to_string(), "#31303a".to_string()); - colors.insert("card_bg".to_string(), "#1a1920".to_string()); - colors } diff --git a/src-tauri/src/infrastructure/config/ui_state.rs b/src-tauri/src/infrastructure/config/ui_state.rs index 17d8f6d9..927b2722 100644 --- a/src-tauri/src/infrastructure/config/ui_state.rs +++ b/src-tauri/src/infrastructure/config/ui_state.rs @@ -122,7 +122,7 @@ mod tests { ); assert!( state_result.is_ok(), - "legacy UI state should remain readable" + "UI state with omitted optional maps should remain readable" ); if let Ok(state) = state_result { assert!(state.local_max_output_tokens.is_empty()); diff --git a/src-tauri/src/infrastructure/config/window_settings.rs b/src-tauri/src/infrastructure/config/window_settings.rs index 87feb86d..42eeff96 100644 --- a/src-tauri/src/infrastructure/config/window_settings.rs +++ b/src-tauri/src/infrastructure/config/window_settings.rs @@ -261,7 +261,7 @@ mod tests { }; #[test] - fn normalize_window_settings_raises_legacy_small_sizes() { + fn normalize_window_settings_raises_too_small_sizes() { let normalized = normalize_window_settings(WindowSettings { width: 1000, height: 600, diff --git a/src-tauri/src/infrastructure/engine/tauri_emitter.rs b/src-tauri/src/infrastructure/engine/tauri_emitter.rs index d7d92597..0ea62043 100644 --- a/src-tauri/src/infrastructure/engine/tauri_emitter.rs +++ b/src-tauri/src/infrastructure/engine/tauri_emitter.rs @@ -30,13 +30,6 @@ impl TauriEngineEmitter { } } -fn canonical_image_engine_id(engine_id: &str) -> &str { - match engine_id { - "stable-diffusion" => "sdcpp", - value => value, - } -} - fn parse_step_totals(line: &str) -> Option<(u32, u32)> { for token in line.split_whitespace() { let Some((step, total)) = token.split_once('/') else { @@ -165,11 +158,11 @@ impl EngineEventEmitter for TauriEngineEmitter { } fn emit_log(&self, engine_id: &str, line: &str) { - if engine_id == "sdcpp" || engine_id == "stable-diffusion" { + if engine_id == "sdcpp" { crate::app::tray::update_background_generation_progress(&self.handle, line); if let Some(progress) = parse_sdcpp_progress_line(line) { let state = Arc::clone(&self.image_generation_state); - let provider = canonical_image_engine_id(engine_id).to_string(); + let provider = engine_id.to_string(); tauri::async_runtime::spawn(async move { state.update_progress(&provider, progress).await; }); diff --git a/src-tauri/src/infrastructure/logging/logger.rs b/src-tauri/src/infrastructure/logging/logger.rs index bacd493f..aa83989f 100644 --- a/src-tauri/src/infrastructure/logging/logger.rs +++ b/src-tauri/src/infrastructure/logging/logger.rs @@ -1,3 +1,4 @@ +use crate::domain::engine::manager::canonical_engine_id as normalize_engine_id; use chrono::TimeZone; use serde::Serialize; use std::cmp::Ordering; @@ -544,18 +545,11 @@ fn sanitize_module_id(raw: &str) -> Option { fn infer_runtime_log_source(namespace: RuntimeLogNamespace, runtime_id: &str) -> String { match namespace { - RuntimeLogNamespace::Engine => canonical_engine_id(runtime_id).to_string(), + RuntimeLogNamespace::Engine => normalize_engine_id(runtime_id), RuntimeLogNamespace::Module => format!("module:{runtime_id}"), } } -fn canonical_engine_id(engine_id: &str) -> &str { - match engine_id { - "stable-diffusion" => "sdcpp", - value => value, - } -} - fn parse_log_timestamp(line: &str) -> Option { let timestamp_text = line.get(..19)?; chrono::NaiveDateTime::parse_from_str(timestamp_text, "%Y-%m-%d %H:%M:%S") @@ -618,7 +612,7 @@ fn is_entry_in_console_view(entry: &LogEntry, view_id: &str) -> bool { } if let Some(engine_id) = view_id.strip_prefix("engine:") { - return canonical_engine_id(&entry.source) == canonical_engine_id(engine_id); + return normalize_engine_id(&entry.source) == normalize_engine_id(engine_id); } false @@ -680,10 +674,10 @@ fn clear_startup_log_files(log_dir: &Path) -> std::io::Result<()> { impl RuntimeLogCollector { fn is_known_engine_source(source: &str) -> bool { - let source = canonical_engine_id(source); + let source = normalize_engine_id(source); Self::runtime_ids(&crate::utils::paths::ENGINE_LOGS_DIR) .into_iter() - .any(|runtime_id| canonical_engine_id(&runtime_id) == source) + .any(|runtime_id| normalize_engine_id(&runtime_id) == source) } fn runtime_ids(root: &Path) -> Vec { @@ -892,4 +886,19 @@ mod tests { assert_eq!(entry.source_label.as_deref(), Some("Llamacpp")); Ok(()) } + + #[test] + fn engine_runtime_log_line_uses_shared_engine_id_normalization() -> Result<(), String> { + let entry = parse_runtime_log_line( + RuntimeLogNamespace::Engine, + "llama.cpp", + "2026-04-24 07:00:00 [INFO] model loaded", + 0, + 0.0, + ) + .ok_or_else(|| "engine runtime log entry".to_string())?; + + assert_eq!(entry.source, "llama-cpp"); + Ok(()) + } } diff --git a/src-tauri/src/models/config.rs b/src-tauri/src/models/config.rs index 879c3988..d642ab4d 100644 --- a/src-tauri/src/models/config.rs +++ b/src-tauri/src/models/config.rs @@ -18,10 +18,10 @@ pub struct ApiModelConfig { #[derive(Debug, Serialize, Deserialize, Clone, Type)] #[serde(rename_all = "snake_case")] pub struct PricingConfig { - /// Cost per 1M input tokens - pub input_per_1m: Option, - /// Cost per 1M output tokens - pub output_per_1m: Option, + /// Input-side cost or score shown in the launcher UI + pub input: Option, + /// Output-side cost or score shown in the launcher UI + pub output: Option, /// Currency code pub currency: Option, /// Additional notes @@ -150,10 +150,7 @@ pub struct AiModel { pub context_window: Option, /// Maximum output tokens allowed pub max_output_tokens: Option, - /// Whether the model is deprecated - pub deprecated: Option, - - /// Pricing configuration (New Object Format) + /// Pricing configuration pub pricing: Option, /// Performance statistics diff --git a/src-tauri/src/models/ui_state.rs b/src-tauri/src/models/ui_state.rs index 8c18f52f..b37d1bf1 100644 --- a/src-tauri/src/models/ui_state.rs +++ b/src-tauri/src/models/ui_state.rs @@ -65,9 +65,9 @@ pub struct UIState { /// Per-provider local model output token limits. #[serde(default)] pub local_max_output_tokens: std::collections::HashMap, - /// Current persistent AI session identifier + /// Last directory used by the custom integration import dialog. #[serde(default)] - pub ai_session_id: Option, + pub integration_import_last_directory: Option, /// Preferred launcher interface language #[serde(default)] pub preferred_language: Option, @@ -96,7 +96,7 @@ impl Default for UIState { ai_thinking_level: std::collections::HashMap::new(), ai_web_search_enabled: std::collections::HashMap::new(), local_max_output_tokens: std::collections::HashMap::new(), - ai_session_id: None, + integration_import_last_directory: None, preferred_language: None, pending_chat_reveal: false, } diff --git a/src-tauri/src/utils/paths.rs b/src-tauri/src/utils/paths.rs index c96e79d6..e3cdf401 100644 --- a/src-tauri/src/utils/paths.rs +++ b/src-tauri/src/utils/paths.rs @@ -1,6 +1,5 @@ use crate::errors::AppError; -use std::fs::{self, OpenOptions}; -use std::io::Write; +use std::fs; use std::path::{Path, PathBuf}; use std::sync::LazyLock; @@ -17,26 +16,8 @@ fn append_appdata_dir(root: &Path) -> PathBuf { #[cfg(test)] const TEST_APPDATA_ROOT_DIR_NAME: &str = "axelate-tests"; -#[cfg(test)] -fn cleanup_legacy_test_roots() { - let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - for legacy_root in [ - manifest_dir.join("test_appdata_roaming"), - manifest_dir.join("test_appdata_local"), - ] { - if !legacy_root.exists() { - continue; - } - - let _ = fs::remove_dir_all(legacy_root); - } -} - #[cfg(test)] fn resolve_test_root(kind: &str) -> PathBuf { - static CLEANUP_LEGACY_TEST_ROOTS: LazyLock<()> = LazyLock::new(cleanup_legacy_test_roots); - LazyLock::force(&CLEANUP_LEGACY_TEST_ROOTS); - std::env::temp_dir() .join(TEST_APPDATA_ROOT_DIR_NAME) .join(std::process::id().to_string()) @@ -71,23 +52,6 @@ fn resolve_config_root() -> PathBuf { } } -#[cfg(target_os = "windows")] -fn resolve_windows_local_data_root() -> PathBuf { - #[cfg(test)] - { - resolve_test_root("local") - } - - #[cfg(not(test))] - { - let root = dirs::data_local_dir() - .or_else(|| std::env::var("LOCALAPPDATA").ok().map(PathBuf::from)) - .unwrap_or_else(resolve_config_root); - - append_appdata_dir(&root) - } -} - fn is_valid_resource_dir(path: &Path) -> bool { path.join(LOCALES_DIR_NAME).exists() } @@ -253,239 +217,16 @@ fn managed_directories() -> [&'static PathBuf; 15] { /// # Errors /// Returns `AppError::Io` if directory creation fails. pub fn init_filesystem() -> Result<(), AppError> { - migrate_windows_system_root_to_roaming()?; - migrate_legacy_module_directories()?; - for dir in managed_directories() { fs::create_dir_all(dir)?; } - migrate_legacy_module_runtime_logs()?; - // Cleanup old log files (keep only last MAX_LOG_FILES) cleanup_old_logs()?; Ok(()) } -#[cfg(target_os = "windows")] -fn legacy_windows_system_root() -> PathBuf { - resolve_windows_local_data_root().join("System") -} - -fn ensure_parent_dir(path: &Path) -> Result<(), AppError> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - - Ok(()) -} - -fn move_file(source_path: &Path, target_path: &Path) -> Result<(), AppError> { - ensure_parent_dir(target_path)?; - - if fs::rename(source_path, target_path).is_err() { - fs::copy(source_path, target_path)?; - fs::remove_file(source_path)?; - } - - Ok(()) -} - -fn append_file(source_path: &Path, target_path: &Path) -> Result<(), AppError> { - ensure_parent_dir(target_path)?; - - let content = fs::read(source_path)?; - let mut target = OpenOptions::new() - .create(true) - .append(true) - .open(target_path)?; - if target_path - .metadata() - .is_ok_and(|metadata| metadata.len() > 0) - { - target.write_all(b"\n")?; - } - target.write_all(&content)?; - fs::remove_file(source_path)?; - Ok(()) -} - -fn migrate_legacy_module_runtime_logs() -> Result<(), AppError> { - let legacy_module_logs_dir = LOG_DIR.join("Modules"); - if legacy_module_logs_dir.exists() { - merge_directories(&legacy_module_logs_dir, &INTEGRATION_LOGS_DIR)?; - remove_empty_dirs(&legacy_module_logs_dir)?; - } - - let legacy_engine_logs_dir = LOG_DIR.join("Engines"); - if legacy_engine_logs_dir.exists() && legacy_engine_logs_dir != *ENGINE_LOGS_DIR { - merge_directories(&legacy_engine_logs_dir, &ENGINE_LOGS_DIR)?; - remove_empty_dirs(&legacy_engine_logs_dir)?; - } - - let legacy_runtime_engine_logs_dir = ENGINE_RUNTIME_DIR.join("Logs"); - if legacy_runtime_engine_logs_dir.exists() && legacy_runtime_engine_logs_dir != *ENGINE_LOGS_DIR - { - merge_directories(&legacy_runtime_engine_logs_dir, &ENGINE_LOGS_DIR)?; - remove_empty_dirs(&legacy_runtime_engine_logs_dir)?; - } - - if !ENGINE_LOGS_DIR.exists() { - return Ok(()); - } - - for entry in fs::read_dir(&*ENGINE_LOGS_DIR)? { - let entry = entry?; - if !entry.file_type()?.is_dir() { - continue; - } - - let legacy_runtime_log = entry.path().join("runtime.log"); - if !legacy_runtime_log.exists() { - continue; - } - - let target_runtime_log = INTEGRATION_LOGS_DIR - .join(entry.file_name()) - .join("runtime.log"); - - if target_runtime_log.exists() { - append_file(&legacy_runtime_log, &target_runtime_log)?; - } else { - move_file(&legacy_runtime_log, &target_runtime_log)?; - } - } - - Ok(()) -} - -fn legacy_engine_ids() -> std::collections::HashSet { - serde_json::from_str::>(include_str!( - "../../resources/config/local_modules.json" - )) - .unwrap_or_default() - .into_iter() - .filter(|item| item.type_name == "local") - .map(|item| item.id) - .collect() -} - -fn migrate_legacy_module_directories() -> Result<(), AppError> { - let legacy_modules_dir = SYSTEM_ROOT.join("Modules"); - if !legacy_modules_dir.exists() { - return Ok(()); - } - - let engine_ids = legacy_engine_ids(); - fs::create_dir_all(&*INTEGRATIONS_DIR)?; - fs::create_dir_all(&*ENGINES_DIR)?; - - for entry in fs::read_dir(&legacy_modules_dir)? { - let entry = entry?; - if !entry.file_type()?.is_dir() { - continue; - } - - let id = entry.file_name().to_string_lossy().to_string(); - let target_root = if engine_ids.contains(&id) { - &*ENGINES_DIR - } else { - &*INTEGRATIONS_DIR - }; - let target_path = target_root.join(entry.file_name()); - - if target_path.exists() { - merge_directories(&entry.path(), &target_path)?; - remove_empty_dirs(&entry.path())?; - } else { - fs::rename(entry.path(), target_path)?; - } - } - - remove_empty_dirs(&legacy_modules_dir)?; - Ok(()) -} - -fn migrate_windows_system_root_to_roaming() -> Result<(), AppError> { - #[cfg(target_os = "windows")] - { - let legacy_system_root = legacy_windows_system_root(); - if legacy_system_root == *SYSTEM_ROOT || !legacy_system_root.exists() { - return Ok(()); - } - - ensure_parent_dir(&SYSTEM_ROOT)?; - - if matches!(fs::rename(&legacy_system_root, &*SYSTEM_ROOT), Ok(())) { - tracing::info!( - from = %legacy_system_root.display(), - to = %SYSTEM_ROOT.display(), - "Migrated system data from Local AppData to Roaming AppData" - ); - } else { - merge_directories(&legacy_system_root, &SYSTEM_ROOT)?; - remove_empty_dirs(&legacy_system_root)?; - tracing::info!( - from = %legacy_system_root.display(), - to = %SYSTEM_ROOT.display(), - "Merged legacy system data from Local AppData into Roaming AppData" - ); - } - } - - Ok(()) -} - -fn merge_directories(source: &Path, target: &Path) -> Result<(), AppError> { - if !source.exists() { - return Ok(()); - } - - fs::create_dir_all(target)?; - - for entry in fs::read_dir(source)? { - let entry = entry?; - let source_path = entry.path(); - let target_path = target.join(entry.file_name()); - - if entry.file_type()?.is_dir() { - merge_directories(&source_path, &target_path)?; - remove_empty_dirs(&source_path)?; - continue; - } - - if target_path.exists() { - fs::remove_file(&source_path)?; - continue; - } - - move_file(&source_path, &target_path)?; - } - - Ok(()) -} - -fn remove_empty_dirs(path: &Path) -> Result<(), AppError> { - if !path.exists() || !path.is_dir() { - return Ok(()); - } - - for entry in fs::read_dir(path)? { - let entry = entry?; - let child = entry.path(); - if entry.file_type()?.is_dir() { - remove_empty_dirs(&child)?; - } - } - - if fs::read_dir(path)?.next().is_none() { - fs::remove_dir(path)?; - } - - Ok(()) -} - /// Remove old log files, keeping only the most recent MAX_LOG_FILES. /// /// # Errors diff --git a/src/shared/types/bindings.ts b/src/shared/types/bindings.ts index 2b118131..26751993 100644 --- a/src/shared/types/bindings.ts +++ b/src/shared/types/bindings.ts @@ -250,9 +250,7 @@ export type AiModel = { contextWindow: number | null, // Maximum output tokens allowed maxOutputTokens: number | null, - // Whether the model is deprecated - deprecated: boolean | null, - // Pricing configuration (New Object Format) + // Pricing configuration pricing: PricingConfig | null, // Performance statistics stats: ModelStats, @@ -662,6 +660,12 @@ export type EngineDefinition = { config_schema?: "Null" | ({ Bool: boolean }) & { Array?: never; Number?: never; Object?: never; String?: never } | ({ Number: ({ f64: number }) & { i64?: never; u64?: never } | ({ i64: number }) & { f64?: never; u64?: never } | ({ u64: number }) & { f64?: never; i64?: never } }) & { Array?: never; Bool?: never; Object?: never; String?: never } | ({ String: string }) & { Array?: never; Bool?: never; Number?: never; Object?: never } | ({ Array: Value[] }) & { Bool?: never; Number?: never; Object?: never; String?: never } | ({ Object: { [key in string]: Value } }) & { Array?: never; Bool?: never; Number?: never; String?: never } | null, // Whether the engine binary is currently installed (populated at runtime, not from JSON) installed?: boolean, + /** + * Compute modes present in the Axelate-managed install metadata. + * + * Empty means unknown, usually a system PATH install or an older install without metadata. + */ + installed_compute_modes?: EngineComputeMode[], // True when the launcher connects to a user-managed external engine instead of installing it managed_externally?: boolean, }; @@ -1033,10 +1037,10 @@ export type NetworkStats = { // Pricing configuration for a model export type PricingConfig = { - // Cost per 1M input tokens - input_per_1m: number | null, - // Cost per 1M output tokens - output_per_1m: number | null, + // Input-side cost or score shown in the launcher UI + input: number | null, + // Output-side cost or score shown in the launcher UI + output: number | null, // Currency code currency: string | null, // Additional notes @@ -1091,7 +1095,9 @@ export type ReleaseComputeTarget = // Prefer a GPU package, for example CUDA, Vulkan, HIP, or SYCL. "gpu" | // Prefer a CPU package. -"cpu"; +"cpu" | +// Download both CPU and GPU packages when both are compatible. +"both"; // User-visible release download options for a single module. export type ReleaseDownloadOptions = { @@ -1279,8 +1285,8 @@ export type UIState = { ai_web_search_enabled?: { [key in string]: boolean }, // Per-provider local model output token limits. local_max_output_tokens?: { [key in string]: number }, - // Current persistent AI session identifier - ai_session_id?: string | null, + // Last directory used by the custom integration import dialog. + integration_import_last_directory?: string | null, // Preferred launcher interface language preferred_language?: string | null, // Request to reopen the chat and reveal the latest message after background work. diff --git a/src/shared/types/coreTypes.ts b/src/shared/types/coreTypes.ts index 31a48529..649bde04 100644 --- a/src/shared/types/coreTypes.ts +++ b/src/shared/types/coreTypes.ts @@ -6,31 +6,6 @@ import type { IUIState } from '../services/state/UiStateStore'; import type { IWindowConfig } from '../services/WindowService'; -/** - * Interface for the Tauri host instance. - */ -export interface ITauriInstance { - core: { - invoke: (_cmd: string, _args?: Record) => Promise; - }; - window: { - getCurrentWindow: () => { - isMaximized: () => Promise; - setSize: (_size: { width: number; height: number }) => Promise; - center: () => Promise; - innerSize: () => Promise<{ width: number; height: number }>; - outerPosition: () => Promise<{ x: number; y: number }>; - }; - LogicalSize: new (_width: number, _height: number) => { width: number; height: number }; - }; - event: { - listen: ( - _event: string, - _handler: (_event: { payload: unknown }) => void, - ) => Promise<() => void>; - }; -} - /** * Application/Module metadata from the catalog. */ @@ -51,6 +26,7 @@ export interface IApp { type?: 'api' | 'local'; capability?: 'text' | 'image'; // AI output capability; used for modal filter tabs installed?: boolean; + installedComputeModes?: Array<'gpu' | 'cpu'>; repoUrl?: string; expectedHash?: string; dlType?: string; @@ -124,7 +100,7 @@ export interface IModuleDownloadState { error?: unknown; } -export type ReleaseComputeTarget = 'auto' | 'gpu' | 'cpu'; +export type ReleaseComputeTarget = 'auto' | 'gpu' | 'cpu' | 'both'; export interface ReleaseDownloadSelection { tag_name: string | null; From fa5f2d9276fff051e85387797e2d95cd4bd178cf Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 7 May 2026 14:47:44 +0300 Subject: [PATCH 103/126] refactor(frontend): simplify launcher flows --- src/app/CoreUiFactory.ts | 5 + src/app/bridge.ts | 10 +- src/eslint.config.js | 19 -- src/features/ai/services/AIBridge.test.ts | 37 ++-- src/features/ai/services/AIBridge.ts | 4 +- src/features/ai/services/AIBridgeContext.ts | 2 - .../AIBridgeMessageController.test.ts | 59 +++++- .../ai/services/AIBridgeMessageController.ts | 27 ++- .../services/AIBridgeProviderPolicy.test.ts | 27 ++- .../ai/services/AIBridgeProviderPolicy.ts | 33 +++- .../ai/services/AIBridgeRuntime.test.ts | 9 +- src/features/ai/services/AIBridgeRuntime.ts | 24 ++- .../ai/services/AIProviderManager.test.ts | 49 +++-- src/features/ai/services/AIProviderManager.ts | 24 +-- src/features/ai/types/aiTypes.ts | 7 +- src/features/ai/ui/AISettingsMarkup.ts | 41 +---- src/features/ai/ui/AISettingsRenderer.test.ts | 4 +- src/features/ai/ui/AISettingsRenderer.ts | 4 +- .../ai/ui/AISettingsSelectionController.ts | 2 +- .../ai/ui/AISettingsViewPolicy.test.ts | 20 +- src/features/ai/ui/AISettingsViewPolicy.ts | 26 +-- src/features/ai/utils/catalogHelpers.test.ts | 8 +- src/features/ai/utils/catalogHelpers.ts | 4 +- src/features/chat/chat.ts | 5 +- .../ChatGenerationController.test.ts | 12 +- .../controllers/ChatGenerationController.ts | 7 +- .../controllers/ChatSendController.test.ts | 6 + .../chat/controllers/ChatSendController.ts | 18 +- .../chat/controllers/FilePickerController.ts | 13 +- .../chat/services/ChatControllerFactory.ts | 2 + .../chat/services/ChatFileHandler.test.ts | 12 +- .../chat/services/ChatService.test.ts | 4 +- src/features/chat/services/ChatService.ts | 12 +- src/features/chat/types/chatTypes.ts | 2 - .../chat/ui/ChatAttachmentRenderer.ts | 4 +- src/features/chat/ui/ChatImageController.ts | 4 +- .../ui/ChatMessageInteractionController.ts | 4 +- src/features/chat/ui/ChatMessageRenderer.ts | 4 +- src/features/chat/ui/ChatUiTypes.ts | 1 + .../console/services/ConsoleLogNormalizer.ts | 16 ++ .../services/ConsoleLogService.test.ts | 39 +++- .../console/services/ConsoleLogService.ts | 10 +- .../console/ui/ConsoleFilterControlHelper.ts | 5 +- src/features/console/ui/ConsoleUI.test.ts | 48 +++-- src/features/console/ui/ConsoleUI.ts | 33 ++++ .../downloads/ui/DownloadCardRenderer.ts | 6 +- .../downloads/ui/DownloadProgressPresenter.ts | 16 +- src/features/downloads/ui/DownloadUI.test.ts | 13 +- .../services/MonitoringService.test.ts | 31 ++-- .../monitoring/services/MonitoringService.ts | 18 +- .../settings/services/SettingsService.test.ts | 2 +- .../ui/GeneralSettingsRenderer.test.ts | 6 + .../settings/ui/GeneralSettingsRenderer.ts | 4 +- .../ui/ModuleSettingsBridgeController.test.ts | 30 --- .../ui/ModuleSettingsBridgeController.ts | 34 ---- .../ui/ModuleSettingsEngineFieldCatalog.ts | 8 +- .../ui/ModuleSettingsEngineFieldSupport.ts | 4 +- .../ui/ModuleSettingsEngineRenderFlow.ts | 14 +- .../ui/ModuleSettingsEngineRenderer.test.ts | 2 +- .../ui/ModuleSettingsEngineRenderer.ts | 9 +- src/features/settings/ui/ModuleSettingsUI.ts | 11 +- src/infrastructure/i18n/I18nService.test.ts | 12 +- src/infrastructure/i18n/I18nService.ts | 1 - src/infrastructure/i18n/I18nUI.ts | 3 - .../logging/LoggerService.test.ts | 2 +- src/infrastructure/logging/LoggerService.ts | 4 +- .../tauri/TauriProvider.test.ts | 148 +++++---------- src/infrastructure/tauri/TauriProvider.ts | 75 +------- src/public/templates/pages/settings.html | 20 +- src/shared/config/catalog_fallback.ts | 11 -- src/shared/services/CatalogLoadSnapshot.ts | 1 + src/shared/services/CatalogService.test.ts | 39 ++-- src/shared/services/CatalogService.ts | 81 +++------ src/shared/services/ModuleService.test.ts | 13 +- src/shared/services/ModuleService.ts | 19 -- .../services/WindowNativeBridgeHelper.test.ts | 28 ++- .../services/WindowNativeBridgeHelper.ts | 65 ++----- src/shared/services/WindowService.test.ts | 172 +++++++++--------- src/shared/services/WindowService.ts | 9 +- src/shared/services/WindowServiceActions.ts | 13 +- .../services/ai/AISettingsService.test.ts | 16 +- src/shared/services/ai/AISettingsService.ts | 11 +- .../modules/ModuleSettingsService.test.ts | 2 +- .../services/state/UiStateStore.test.ts | 135 ++++++-------- src/shared/services/state/UiStateStore.ts | 42 +++-- src/shared/shell/AppUI.test.ts | 2 + src/shared/shell/AppUI.ts | 6 + src/shared/shell/SidebarUI.ts | 1 - src/shared/shell/ui/AppUiModuleFlow.test.ts | 36 +++- src/shared/shell/ui/AppUiModuleFlow.ts | 61 +++++-- .../shell/ui/DownloadSelectionDialog.test.ts | 68 +++++++ .../shell/ui/DownloadSelectionDialog.ts | 36 +++- src/shared/shell/ui/ModalManager.test.ts | 5 +- src/shared/shell/ui/ModalManagerSupport.ts | 8 +- src/shared/types/global.d.ts | 26 --- src/shared/types/global_bridge_types.ts | 13 -- src/shared/utils/providerSupport.ts | 20 -- src/styles/features/chat-page.css | 17 +- src/styles/features/console-page.css | 27 ++- src/styles/features/downloads-page.css | 14 +- .../features/home-page-and-module-cards.css | 8 +- .../features/module-selection-modal.css | 45 +++-- src/styles/layouts/app-sidebar.css | 53 ++---- .../CatalogService.integration.test.ts | 18 +- src/test/integration/CoreContainer.test.ts | 2 +- src/test/mocks/mockUiStateStore.ts | 2 +- src/test/setup.test.ts | 4 - src/test/setup.ts | 21 +-- 108 files changed, 1154 insertions(+), 1175 deletions(-) create mode 100644 src/features/chat/ui/ChatUiTypes.ts delete mode 100644 src/features/settings/ui/ModuleSettingsBridgeController.test.ts delete mode 100644 src/features/settings/ui/ModuleSettingsBridgeController.ts delete mode 100644 src/shared/config/catalog_fallback.ts create mode 100644 src/shared/shell/ui/DownloadSelectionDialog.test.ts delete mode 100644 src/shared/types/global_bridge_types.ts diff --git a/src/app/CoreUiFactory.ts b/src/app/CoreUiFactory.ts index 153c2e8a..8d82b246 100644 --- a/src/app/CoreUiFactory.ts +++ b/src/app/CoreUiFactory.ts @@ -117,6 +117,11 @@ export function createAppUI(deps: CreateAppUIDeps): AppUI { setSelectedModule: (category, moduleData) => { deps.stateStore.setSelectedModule(category, moduleData); }, + getIntegrationImportLastDirectory: () => + deps.stateStore.getIntegrationImportLastDirectory(), + setIntegrationImportLastDirectory: (path) => { + deps.stateStore.setIntegrationImportLastDirectory(path); + }, }, launchApp: async (category, app) => { await deps.bridge.launchApp(category, app); diff --git a/src/app/bridge.ts b/src/app/bridge.ts index 3e3f609b..f2c52c7b 100644 --- a/src/app/bridge.ts +++ b/src/app/bridge.ts @@ -16,24 +16,20 @@ export interface ICoreBridge { readonly tauriProvider: TauriProvider; } -type GlobalBridgeRuntime = {}; - /** * GlobalBridge keeps runtime transport concerns out of Core boot logic. */ export class GlobalBridge { private readonly _core: ICoreBridge; - constructor(core: ICoreBridge, _runtime?: GlobalBridgeRuntime) { + constructor(core: ICoreBridge) { this._core = core; } /** - * Initialize runtime interceptors. + * Lifecycle hook kept with other core services. */ - public init(): void { - /* no-op: legacy fetch interceptor removed */ - } + public init(): void {} public destroy(): void { /* no-op */ diff --git a/src/eslint.config.js b/src/eslint.config.js index 84167a76..68954fc8 100644 --- a/src/eslint.config.js +++ b/src/eslint.config.js @@ -31,25 +31,6 @@ export default [ parser: tsParser, globals: { ...globals.browser, - updateModuleSettings: 'writable', - openModuleSettings: 'writable', - closeModuleSettings: 'writable', - initTaskbarToggles: 'writable', - initMonitorToggles: 'writable', - loadCardWidths: 'writable', - toggleTaskbarItem: 'writable', - toggleNavItem: 'writable', - toggleMonitorItem: 'writable', - loadSdModels: 'writable', - diskUtil: 'writable', - formatBytes: 'writable', - agentLog: 'writable', - removeQuotes: 'writable', - updateRangeProgress: 'writable', - markUnsaved: 'writable', - updateSaveButton: 'writable', - showNotification: 'writable', - loadSettings: 'writable', __APP_VERSION__: 'readonly', }, parserOptions: { diff --git a/src/features/ai/services/AIBridge.test.ts b/src/features/ai/services/AIBridge.test.ts index f6d0b0ce..5088db11 100644 --- a/src/features/ai/services/AIBridge.test.ts +++ b/src/features/ai/services/AIBridge.test.ts @@ -17,14 +17,6 @@ const mockListen = vi.fn().mockResolvedValue(() => { }); const mockEmit = vi.fn(); -const tauriMock = { - core: { invoke: mockInvoke }, - event: { listen: mockListen, emit: mockEmit }, -}; - -// Set before import -(globalThis as unknown as Record)['__TAURI__'] = tauriMock; - // Mock Core dependency const mockCore = { tauriProvider: { @@ -44,7 +36,6 @@ const mockCore = { }), }, aiSettings: { - setAiSessionId: vi.fn(), setSelectedAIModel: vi.fn(), getSelectedAIModel: vi.fn(), getThinkingLevel: vi.fn().mockReturnValue('high'), @@ -69,6 +60,19 @@ const mockCore = { stateStore: { getSelectedModule: vi.fn().mockReturnValue(undefined), }, + catalog: { + getCatalog: vi.fn().mockReturnValue({ + ai: [ + { id: 'gpt', capability: 'text' }, + { id: 'gemini', capability: 'text' }, + { id: 'llamacpp', capability: 'text' }, + { id: 'sdcpp', capability: 'image' }, + { id: 'gpt-image', capability: 'image' }, + { id: 'seedream-image', capability: 'image' }, + ], + services: [], + }), + }, state: { get: vi.fn((key: string) => { if (key === 'ai_thinking_level') return {}; @@ -129,7 +133,7 @@ describe('AIBridge', () => { mockCore.tauriProvider.isTauri.mockReset(); mockCore.tauriProvider.isTauri.mockReturnValue(true); mockCore.aiSettings.getSelectedAIModel.mockReset(); - mockCore.aiSettings.getSelectedAIModel.mockReturnValue(undefined); + mockCore.aiSettings.getSelectedAIModel.mockReturnValue('gpt-4'); mockCore.aiSettings.getThinkingLevel.mockReset(); mockCore.aiSettings.getThinkingLevel.mockReturnValue('high'); mockCore.aiSettings.getInternetAccessEnabled.mockReset(); @@ -143,7 +147,7 @@ describe('AIBridge', () => { mockCore.settingsService.getSettings.mockReturnValue({}); mockCore.stateStore.getSelectedModule.mockClear(); mockCore.stateStore.getSelectedModule.mockReturnValue(undefined); - (globalThis as unknown as Record)['__TAURI__'] = tauriMock; + mockCore.catalog.getCatalog.mockClear(); localStorage.clear(); aiBridge = new AIBridge(mockTracer); // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument @@ -805,7 +809,7 @@ describe('AIBridge', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument bridge2.setCore(mockCore as any); mockInvoke.mockResolvedValueOnce('session-id'); - await bridge2.init(); // should not throw, logs web mode active (line 81) + await bridge2.init(); // should not throw when IPC streaming is unavailable bridge2.stopProvider(); mockCore.tauriProvider.isTauri.mockReturnValue(true); @@ -964,11 +968,11 @@ describe('AIBridge', () => { (import.meta.env as any).DEV = orgDev; }); - it('should handle sendMessage when _core is null (Line 175)', async () => { + it('should reject sendMessage when _core is null and no model can be resolved', async () => { const tempBridge = new AIBridge(mockTracer); // Do NOT call setCore here to leave _core as null - // Bypass API key checks logic just to test the core check + // Bypass API key checks logic just to test missing core/model resolution. // eslint-disable-next-line @typescript-eslint/no-explicit-any Object.defineProperty((tempBridge as any)._manager, 'activeProviderId', { get: () => 'gemini', @@ -986,8 +990,9 @@ describe('AIBridge', () => { text: 'hi', }); - const res = await tempBridge.sendMessage('test message'); - expect(res.ok).toBe(true); + const result = await tempBridge.sendMessage('test message'); + expect(result.ok).toBe(false); + expect(result.error).toBe('No AI model selected'); }); it('should handle an empty error string in backend mismatch logic (Line 218)', async () => { diff --git a/src/features/ai/services/AIBridge.ts b/src/features/ai/services/AIBridge.ts index 7ad3b8ef..4aa8c4e1 100644 --- a/src/features/ai/services/AIBridge.ts +++ b/src/features/ai/services/AIBridge.ts @@ -38,7 +38,9 @@ export class AIBridge implements IAIBridge { private readonly _transport: IChatTransport; private readonly _manager: AIProviderManager; private readonly _engineStatus: EngineStatusService; - private readonly _providerPolicy = new AIBridgeProviderPolicy(); + private readonly _providerPolicy = new AIBridgeProviderPolicy(() => + this._context?.catalog.getCatalog(), + ); private readonly _runtime: AIBridgeRuntime; private readonly _inactivityController: AIBridgeInactivityController; private readonly _messageController: AIBridgeMessageController; diff --git a/src/features/ai/services/AIBridgeContext.ts b/src/features/ai/services/AIBridgeContext.ts index 0c2618b9..49f8167a 100644 --- a/src/features/ai/services/AIBridgeContext.ts +++ b/src/features/ai/services/AIBridgeContext.ts @@ -15,8 +15,6 @@ export type AIProviderManagerContext = AITransportContext & { aiSettings: { getSelectedAIModel: (appId: string) => string | undefined; setSelectedAIModel: (appId: string, modelKey: string) => void; - getAiSessionId: () => string | null; - setAiSessionId: (sessionId: string | null) => void; getThinkingLevel: (appId: string) => ThinkingLevel; getInternetAccessEnabled: (appId: string) => boolean; }; diff --git a/src/features/ai/services/AIBridgeMessageController.test.ts b/src/features/ai/services/AIBridgeMessageController.test.ts index bd64d2e1..c918b14d 100644 --- a/src/features/ai/services/AIBridgeMessageController.test.ts +++ b/src/features/ai/services/AIBridgeMessageController.test.ts @@ -7,6 +7,16 @@ import { CUSTOM_TEXT_PROVIDER_ID, } from '@/shared/utils/customProviderSupport'; +function createProviderPolicy(): AIBridgeProviderPolicy { + return new AIBridgeProviderPolicy(() => ({ + ai: [ + { id: CUSTOM_TEXT_PROVIDER_ID, capability: 'text' }, + { id: CUSTOM_IMAGE_PROVIDER_ID, capability: 'image' }, + { id: 'llamacpp', capability: 'text' }, + ], + })); +} + function createTextController() { const transport = { send: vi.fn().mockResolvedValue({ ok: true, text: 'done' }), @@ -59,7 +69,7 @@ function createTextController() { transport: transport as never, manager: manager as never, events: events as never, - providerPolicy: new AIBridgeProviderPolicy(), + providerPolicy: createProviderPolicy(), tracer: { error: vi.fn() }, translate: (_key, fallback) => fallback, showToast, @@ -126,7 +136,7 @@ function createImageController() { transport: transport as never, manager: manager as never, events: events as never, - providerPolicy: new AIBridgeProviderPolicy(), + providerPolicy: createProviderPolicy(), tracer: { error: vi.fn() }, translate: (_key, fallback) => fallback, showToast: vi.fn(), @@ -162,6 +172,26 @@ describe('AIBridgeMessageController custom providers', () => { ); }); + it('uses custom text provider settings for thinking and internet access', async () => { + const { controller, transport, context } = createTextController(); + context.aiSettings.getThinkingLevel.mockReturnValue('off'); + context.aiSettings.getInternetAccessEnabled.mockReturnValue(true); + + await controller.sendMessage('What is the latest OpenAI news today?', 'chat', [], []); + + expect(context.aiSettings.getThinkingLevel).toHaveBeenCalledWith(CUSTOM_TEXT_PROVIDER_ID); + expect(context.aiSettings.getInternetAccessEnabled).toHaveBeenCalledWith( + CUSTOM_TEXT_PROVIDER_ID, + ); + expect(transport.send).toHaveBeenCalledWith( + expect.objectContaining({ + provider: 'gpt', + thinking_level: 'none', + web_search: { enabled: true }, + }), + ); + }); + it('routes custom image providers through image generation and keeps raw model ids', async () => { const { controller, transport, events, onLongActivityStart, onLongActivityEnd } = createImageController(); @@ -206,7 +236,7 @@ describe('AIBridgeMessageController custom providers', () => { broadcastResponse: vi.fn(), broadcastReplaceChunk: vi.fn(), } as never, - providerPolicy: new AIBridgeProviderPolicy(), + providerPolicy: createProviderPolicy(), tracer: { error: vi.fn() }, translate: (_key, fallback) => fallback, showToast: vi.fn(), @@ -345,6 +375,29 @@ describe('AIBridgeMessageController custom providers', () => { expect(events.broadcastResponse).not.toHaveBeenCalled(); }); + it('rejects cloud text messages when no model is selected', async () => { + const { controller, transport, events, manager, showToast } = createTextController(); + manager.model = ''; + + const response = await controller.sendMessage('hello', 'chat', [], []); + + expect(response).toEqual({ ok: false, error: 'No AI model selected' }); + expect(showToast).toHaveBeenCalledWith('No AI model selected', 'error'); + expect(transport.send).not.toHaveBeenCalled(); + expect(events.broadcastResponse).not.toHaveBeenCalled(); + }); + + it('rejects silent cloud prompt preparation when no model is selected', async () => { + const { controller, transport, manager, showToast } = createTextController(); + manager.model = ''; + + const response = await controller.prepareImagePrompt('rewrite image prompt'); + + expect(response).toEqual({ ok: false, error: 'No AI model selected' }); + expect(showToast).toHaveBeenCalledWith('No AI model selected', 'error'); + expect(transport.sendSilent).not.toHaveBeenCalled(); + }); + it('marks silent image prompt preparation as provider activity', async () => { const { controller, transport, onActivity, onLongActivityStart, onLongActivityEnd } = createTextController(); diff --git a/src/features/ai/services/AIBridgeMessageController.ts b/src/features/ai/services/AIBridgeMessageController.ts index cd092415..fc953ec3 100644 --- a/src/features/ai/services/AIBridgeMessageController.ts +++ b/src/features/ai/services/AIBridgeMessageController.ts @@ -89,6 +89,10 @@ export class AIBridgeMessageController { const providerId = this._deps.manager.activeProviderId; const backendProviderId = resolveCustomProviderBackendId(providerId); + const requestModel = this._resolveRequestModel(providerId); + if (requestModel === null) { + return this._handleMissingModel(); + } const requestOptions = this._deps.providerPolicy.buildRequestOptions({ hasApiKey: this._deps.manager.apiKey !== null, maxOutputTokens: Math.min(this._deps.manager.maxOutputTokens ?? 320, 420), @@ -104,7 +108,7 @@ export class AIBridgeMessageController { [], { providerId: backendProviderId, - model: this._deps.manager.model || 'default', + model: requestModel, apiKey: null, sessionId: '', ...requestOptions, @@ -215,6 +219,10 @@ export class AIBridgeMessageController { const backendProviderId = resolveCustomProviderBackendId(providerId); const requestHistory = isLocalTextProvider ? this._toTextOnlyMessages(history) : history; const requestAttachments = isLocalTextProvider ? [] : attachments; + const requestModel = this._resolveRequestModel(providerId); + if (requestModel === null) { + return this._handleMissingModel(); + } const requestOptions = this._deps.providerPolicy.buildRequestOptions({ hasApiKey: this._deps.manager.apiKey !== null, maxOutputTokens: this._deps.manager.maxOutputTokens, @@ -223,7 +231,7 @@ export class AIBridgeMessageController { }); const request = constructChatRequest(requestHistory, newMessage, requestAttachments, { providerId: backendProviderId, - model: this._deps.manager.model || 'default', + model: requestModel, apiKey: null, sessionId: this._deps.manager.sessionId, ...requestOptions, @@ -277,6 +285,15 @@ export class AIBridgeMessageController { return part.name !== undefined ? `[File attached: ${part.name}]` : '[File attached]'; } + private _resolveRequestModel(providerId: string): string | null { + const model = this._deps.manager.model.trim(); + if (model !== '') { + return model; + } + + return this._deps.providerPolicy.isLocalTextProvider(providerId) ? 'default' : null; + } + private _withModelContext( response: IBridgeResponse, providerId: string, @@ -310,6 +327,12 @@ export class AIBridgeMessageController { return { ok: false, error: msg }; } + private _handleMissingModel(): IBridgeResponse { + const msg = this._deps.translate('ui.ai.no_model_selected', 'No AI model selected'); + this._deps.showToast(msg, 'error'); + return { ok: false, error: msg }; + } + private _handleTransportResponse( response: IBridgeResponse, source: MessageSource, diff --git a/src/features/ai/services/AIBridgeProviderPolicy.test.ts b/src/features/ai/services/AIBridgeProviderPolicy.test.ts index 77aee9d1..3625ef6c 100644 --- a/src/features/ai/services/AIBridgeProviderPolicy.test.ts +++ b/src/features/ai/services/AIBridgeProviderPolicy.test.ts @@ -6,7 +6,16 @@ import { } from '@/shared/utils/customProviderSupport'; describe('AIBridgeProviderPolicy', () => { - const policy = new AIBridgeProviderPolicy(); + const policy = new AIBridgeProviderPolicy(() => ({ + ai: [ + { id: 'llamacpp', capability: 'text' }, + { id: 'sdcpp', capability: 'image' }, + { id: 'comfyui', capability: 'image' }, + { id: 'seedream-image', capability: 'image' }, + { id: CUSTOM_IMAGE_PROVIDER_ID, capability: 'image' }, + { id: CUSTOM_TEXT_PROVIDER_ID, capability: 'text' }, + ], + })); it('should classify cloud and image providers consistently', () => { expect(policy.isCloudProvider('gemini')).toBe(true); @@ -20,12 +29,26 @@ describe('AIBridgeProviderPolicy', () => { expect(policy.isImageProvider('gemini')).toBe(false); expect(policy.isImageProvider(CUSTOM_TEXT_PROVIDER_ID)).toBe(false); expect(policy.isManagedLocalImageEngine('sdcpp')).toBe(true); - expect(policy.isManagedLocalImageEngine('comfyui')).toBe(false); + expect(policy.isManagedLocalImageEngine('comfyui')).toBe(true); expect(policy.isLocalTextProvider('llamacpp')).toBe(true); expect(policy.isLocalTextProvider('sdcpp')).toBe(false); expect(policy.isLocalTextProvider(CUSTOM_TEXT_PROVIDER_ID)).toBe(false); }); + it('should prefer catalog capabilities for provider output type', () => { + const catalogPolicy = new AIBridgeProviderPolicy(() => ({ + ai: [ + { id: 'local-image-engine', capability: 'image' }, + { id: 'local-text-engine', capability: 'text' }, + ], + })); + + expect(catalogPolicy.isImageProvider('local-image-engine')).toBe(true); + expect(catalogPolicy.isLocalTextProvider('local-image-engine')).toBe(false); + expect(catalogPolicy.isImageProvider('local-text-engine')).toBe(false); + expect(catalogPolicy.isLocalTextProvider('local-text-engine')).toBe(true); + }); + it('should map off thinking level to explicit OpenRouter none effort', () => { expect( policy.buildRequestOptions({ diff --git a/src/features/ai/services/AIBridgeProviderPolicy.ts b/src/features/ai/services/AIBridgeProviderPolicy.ts index 95f7f5dd..ea768b0d 100644 --- a/src/features/ai/services/AIBridgeProviderPolicy.ts +++ b/src/features/ai/services/AIBridgeProviderPolicy.ts @@ -1,8 +1,5 @@ -import { - isCloudProviderId, - isImageProviderId, - isManagedLocalImageProviderId, -} from '@/shared/utils/providerSupport'; +import { isCloudProviderId } from '@/shared/utils/providerSupport'; +import type { IApp } from '@/shared/types/coreTypes'; type ThinkingLevel = 'off' | 'low' | 'medium' | 'high'; type CloudReasoningEffort = 'none' | Exclude; @@ -20,17 +17,21 @@ type RequestOptionInput = { webSearchEnabled: boolean | undefined; }; +type ProviderCatalogGetter = () => { ai?: unknown[] } | null | undefined; + export class AIBridgeProviderPolicy { + public constructor(private readonly _getCatalog?: ProviderCatalogGetter) {} + public isCloudProvider(providerId: string): boolean { return isCloudProviderId(providerId); } public isImageProvider(providerId: string): boolean { - return isImageProviderId(providerId); + return this._catalogCapability(providerId) === 'image'; } public isManagedLocalImageEngine(providerId: string): boolean { - return isManagedLocalImageProviderId(providerId); + return !this.isCloudProvider(providerId) && this.isImageProvider(providerId); } public isLocalTextProvider(providerId: string): boolean { @@ -64,4 +65,22 @@ export class AIBridgeProviderPolicy { return requestOptions; } + + private _catalogCapability(providerId: string): IApp['capability'] | null { + const catalog = this._getCatalog?.(); + const ai = catalog?.ai; + if (!Array.isArray(ai)) { + return null; + } + + const provider = ai.find((entry): entry is Partial => { + return ( + typeof entry === 'object' && + entry !== null && + (entry as Partial).id === providerId + ); + }); + const capability = provider?.capability; + return capability === 'image' || capability === 'text' ? capability : null; + } } diff --git a/src/features/ai/services/AIBridgeRuntime.test.ts b/src/features/ai/services/AIBridgeRuntime.test.ts index 87766138..5c784291 100644 --- a/src/features/ai/services/AIBridgeRuntime.test.ts +++ b/src/features/ai/services/AIBridgeRuntime.test.ts @@ -34,9 +34,12 @@ describe('AIBridgeRuntime', () => { expect(buildImageGenerationProgressChunk('server ready')).toBeNull(); }); - it('accepts local image engine logs even when active provider alias differs', () => { - expect(isActiveEngineLog('custom_sd', 'sdcpp')).toBe(true); - expect(isActiveEngineLog(null, 'sdcpp')).toBe(true); + it('accepts selected image engine logs for image progress regardless of active text provider', () => { + expect(isActiveEngineLog('custom_text', 'local-image-engine', 'local-image-engine')).toBe( + true, + ); + expect(isActiveEngineLog(null, 'local-image-engine', 'local-image-engine')).toBe(true); + expect(isActiveEngineLog(null, 'local-image-engine', null)).toBe(false); expect(isActiveEngineLog('llamacpp', 'llamacpp')).toBe(true); expect(isActiveEngineLog('llamacpp', 'other')).toBe(false); }); diff --git a/src/features/ai/services/AIBridgeRuntime.ts b/src/features/ai/services/AIBridgeRuntime.ts index db6ad694..79b10565 100644 --- a/src/features/ai/services/AIBridgeRuntime.ts +++ b/src/features/ai/services/AIBridgeRuntime.ts @@ -74,10 +74,12 @@ export const buildImageGenerationProgressChunk = (line: string): string | null = return `${fields.join(' ')}\n`; }; -const LOCAL_IMAGE_ENGINE_IDS = new Set(['sdcpp', 'stable-diffusion']); - -export const isActiveEngineLog = (activeProviderId: string | null, engineId: string): boolean => { - if (LOCAL_IMAGE_ENGINE_IDS.has(engineId)) { +export const isActiveEngineLog = ( + activeProviderId: string | null, + engineId: string, + selectedImageProviderId: string | null = null, +): boolean => { + if (selectedImageProviderId !== null && selectedImageProviderId === engineId) { return true; } @@ -90,7 +92,7 @@ export const isActiveEngineLog = (activeProviderId: string | null, engineId: str return true; } - return LOCAL_IMAGE_ENGINE_IDS.has(activeBackendId) && LOCAL_IMAGE_ENGINE_IDS.has(engineId); + return false; }; export class AIBridgeRuntime { @@ -98,7 +100,7 @@ export class AIBridgeRuntime { public async initializeStreaming(args: InitializeStreamingArgs): Promise<(() => void)[]> { if (!args.context.tauriProvider.isTauri()) { - this._tracer.info('[AIBridge] Web mode active (Mocks)'); + this._tracer.info('[AIBridge] Tauri IPC unavailable; streaming disabled'); return []; } @@ -109,7 +111,15 @@ export class AIBridgeRuntime { line: string; }>('ai:engine:log', (payload) => { const line = payload.line; - if (!isActiveEngineLog(args.getActiveProviderId(), payload.engine_id)) { + const selectedImageProviderId = + args.context.stateStore.getSelectedModule('ai_image')?.id ?? null; + if ( + !isActiveEngineLog( + args.getActiveProviderId(), + payload.engine_id, + selectedImageProviderId, + ) + ) { return; } diff --git a/src/features/ai/services/AIProviderManager.test.ts b/src/features/ai/services/AIProviderManager.test.ts index d1395232..8203538e 100644 --- a/src/features/ai/services/AIProviderManager.test.ts +++ b/src/features/ai/services/AIProviderManager.test.ts @@ -34,8 +34,6 @@ function createMockCore( getCatalog: vi.fn().mockReturnValue({ ai: [] }), }, aiSettings: { - getAiSessionId: vi.fn().mockReturnValue(null), - setAiSessionId: vi.fn(), setSelectedAIModel: vi.fn(), getSelectedAIModel: vi.fn().mockReturnValue(undefined), getThinkingLevel: vi.fn().mockReturnValue('auto'), @@ -72,7 +70,6 @@ describe('AIProviderManager', () => { 'ai_session_id', expect.any(String), ); - expect(mockCore.aiSettings.setAiSessionId).toHaveBeenCalledWith(expect.any(String)); expect(manager.sessionId).not.toBe('default'); }); @@ -86,43 +83,39 @@ describe('AIProviderManager', () => { expect(manager.sessionId).toBe('existing-session-abc'); }); - it('should recover session ID from UI state when secure storage is empty', async () => { + it('should generate session ID when secure storage is empty', async () => { const mockCore = createMockCore(() => Promise.resolve(null)); - vi.mocked(mockCore.aiSettings.getAiSessionId).mockReturnValue('ui-session-abc'); manager.setCore(mockCore); await manager.init(); - expect(manager.sessionId).toBe('ui-session-abc'); expect(mockCore.tauriProvider.saveSecureKey).toHaveBeenCalledWith( 'ai_session_id', - 'ui-session-abc', + manager.sessionId, ); - expect(mockCore.aiSettings.setAiSessionId).toHaveBeenCalledWith('ui-session-abc'); + expect(manager.sessionId).not.toBe('default'); }); - it('should recover session ID from UI state when secure read fails', async () => { + it('should generate session ID when secure read fails', async () => { const mockCore = createMockCore(() => Promise.reject(new Error('secure read failed'))); - vi.mocked(mockCore.aiSettings.getAiSessionId).mockReturnValue('ui-session-abc'); manager.setCore(mockCore); await manager.init(); expect(mockCore.tauriProvider.getSecureKey).toHaveBeenCalledTimes(1); - expect(manager.sessionId).toBe('ui-session-abc'); expect(mockCore.tauriProvider.saveSecureKey).toHaveBeenCalledWith( 'ai_session_id', - 'ui-session-abc', + manager.sessionId, ); + expect(manager.sessionId).not.toBe('default'); expect(tracer.error).toHaveBeenCalledWith( '[AIProviderManager] Failed to read ai_session_id:', expect.any(Error), ); }); - it('should continue with UI session ID when secure persistence fails', async () => { + it('should continue with generated session ID when secure persistence fails', async () => { const mockCore = createMockCore(() => Promise.resolve(null)); - vi.mocked(mockCore.aiSettings.getAiSessionId).mockReturnValue('ui-session-abc'); vi.mocked(mockCore.tauriProvider.saveSecureKey ?? vi.fn()).mockRejectedValueOnce( new Error('secure unavailable'), ); @@ -130,8 +123,7 @@ describe('AIProviderManager', () => { await expect(manager.init()).resolves.toBeUndefined(); - expect(manager.sessionId).toBe('ui-session-abc'); - expect(mockCore.aiSettings.setAiSessionId).toHaveBeenCalledWith('ui-session-abc'); + expect(manager.sessionId).not.toBe('default'); expect(tracer.error).toHaveBeenCalled(); }); @@ -318,7 +310,16 @@ describe('AIProviderManager', () => { expect(manager.maxOutputTokens).toBeUndefined(); }); - it('getProviderDisplayName should return known names', () => { + it('getProviderDisplayName should prefer catalog names', () => { + const mockCore = createMockCore(); + vi.mocked(mockCore.catalog.getCatalog).mockReturnValue({ + ai: [ + { id: 'gpt', name: 'OpenAI GPT' }, + { id: 'gemini', name: 'Google Gemini' }, + ], + }); + manager.setCore(mockCore); + expect(manager.getProviderDisplayName('gpt')).toBe('OpenAI GPT'); expect(manager.getProviderDisplayName('gemini')).toBe('Google Gemini'); expect(manager.getProviderDisplayName(CUSTOM_TEXT_PROVIDER_ID)).toBe('Custom'); @@ -364,7 +365,7 @@ describe('AIProviderManager', () => { expect(manager.model).toBe('default'); }); - it('should ignore empty persisted models and fall back to a non-empty default', async () => { + it('should ignore empty persisted local models and fall back to a non-empty default', async () => { const mockCore = createMockCore(() => Promise.resolve('')); vi.mocked(mockCore.aiSettings.getSelectedAIModel).mockReturnValue(''); manager.setCore(mockCore); @@ -375,6 +376,18 @@ describe('AIProviderManager', () => { expect(manager.model).toBe('default'); }); + it('should not invent a cloud model when catalog and persisted settings are empty', async () => { + const mockCore = createMockCore(() => Promise.resolve('sk-key')); + vi.mocked(mockCore.aiSettings.getSelectedAIModel).mockReturnValue(''); + manager.setCore(mockCore); + + const result = await manager.startProvider('gemini'); + + expect(result).toBe(true); + expect(manager.model).toBe(''); + expect(mockCore.aiSettings.setSelectedAIModel).toHaveBeenCalledWith('gemini', ''); + }); + it('should reflect model changes from settings without restarting the provider', async () => { let selectedModel = 'gemini-3.1-pro'; const mockCore = createMockCore(() => Promise.resolve('sk-key')); diff --git a/src/features/ai/services/AIProviderManager.ts b/src/features/ai/services/AIProviderManager.ts index d58a27e3..16cf760b 100644 --- a/src/features/ai/services/AIProviderManager.ts +++ b/src/features/ai/services/AIProviderManager.ts @@ -29,15 +29,12 @@ export class AIProviderManager { } public async init(): Promise { - // Initialize Session ID using Secure Storage with UI state as a recovery fallback. + // Initialize Session ID from secure storage. const secureSid = await this._getSecureVal('ai_session_id').catch((error: unknown) => { this._tracer.error('[AIProviderManager] Failed to read ai_session_id:', error); return null; }); let sid = secureSid; - if (!this._isValidSessionId(sid)) { - sid = this._context?.aiSettings.getAiSessionId() ?? null; - } if (!this._isValidSessionId(sid)) { sid = crypto.randomUUID(); } @@ -47,11 +44,6 @@ export class AIProviderManager { } this._sessionId = sid; - - // Sync UI state - if (this._context) { - this._context.aiSettings.setAiSessionId(sid); - } } public async startProvider(providerId: string): Promise { @@ -159,11 +151,8 @@ export class AIProviderManager { return customDisplayName; } - const providers: Record = { - gpt: 'OpenAI GPT', - gemini: 'Google Gemini', - }; - return providers[id] ?? id; + const catalogProvider = this._getAiCatalogApps().find((provider) => provider.id === id); + return catalogProvider?.name ?? id; } /** @@ -220,16 +209,11 @@ export class AIProviderManager { return catalogModel; } - const fallbacks: Record = { - gpt: 'gpt-5.5', - gemini: 'gemini-3-pro', - local: 'llama-4-maverick', - }; if (this._isLocalProvider(providerId)) { return 'default'; } - return fallbacks[providerId] ?? 'default'; + return ''; } private _resolveModel(providerId: string): string { diff --git a/src/features/ai/types/aiTypes.ts b/src/features/ai/types/aiTypes.ts index 21b6ba1c..ec66fdb0 100644 --- a/src/features/ai/types/aiTypes.ts +++ b/src/features/ai/types/aiTypes.ts @@ -173,11 +173,11 @@ export interface IAIModelStats { } /** - * Tiered pricing configuration for token-based resource distribution. + * General input/output pricing shown in the model selector. */ export interface IAIModelPricing { - input_per_1m?: number | null; - output_per_1m?: number | null; + input?: number | null; + output?: number | null; currency?: string | null; notes?: string | null; } @@ -209,7 +209,6 @@ export interface IAIModelData { releaseDate?: string | null; contextWindow?: number | null; maxOutputTokens?: number | null; - deprecated?: boolean | null; pricing?: IAIModelPricing | null; capabilities?: IAIModelCapabilities | null; diff --git a/src/features/ai/ui/AISettingsMarkup.ts b/src/features/ai/ui/AISettingsMarkup.ts index 0bce3715..970d9acc 100644 --- a/src/features/ai/ui/AISettingsMarkup.ts +++ b/src/features/ai/ui/AISettingsMarkup.ts @@ -7,13 +7,9 @@ import type { AISettingsViewPolicy } from './AISettingsViewPolicy'; type TranslateFunc = (key: string, fallback: string) => string; interface IAIModelPricing { - input_per_1m?: number; - output_per_1m?: number; + input?: number; + output?: number; currency?: string; - tier?: string; - note?: string; - in?: number; - out?: number; } const PURIFY_CONFIG = { @@ -223,10 +219,6 @@ export function renderModelStats(modelData: IAIModelData | null, translate: Tran function renderPricing(pricing: unknown, translate: TranslateFunc): string { if (pricing === null || pricing === undefined) return ''; - if (Array.isArray(pricing)) { - return renderLegacyPricing(pricing as IAIModelPricing[]); - } - if (typeof pricing === 'object') { return renderNewPricing(pricing as IAIModelPricing, translate); } @@ -250,27 +242,14 @@ function renderContextWindow( `; } -function renderLegacyPricing(pricing: IAIModelPricing[]): string { - return pricing - .map( - (price) => ` -
    - ${price.tier ?? ''} - ${price.note ?? `${String(price.in ?? 0)} / ${String(price.out ?? 0)}`} -
    - `, - ) - .join(''); -} - function renderNewPricing(pricing: IAIModelPricing, translate: TranslateFunc): string { let html = ''; const currency = pricing.currency ?? '$'; const displayCurrency = currency === 'USD' ? '$' : currency; const separator = displayCurrency.length > 1 ? ' ' : ''; - const inputCost = pricing.input_per_1m ?? 0; - const outputCost = pricing.output_per_1m ?? 0; + const inputCost = pricing.input ?? 0; + const outputCost = pricing.output ?? 0; const isFree = inputCost === 0 && outputCost === 0; if (isFree) { @@ -281,20 +260,20 @@ function renderNewPricing(pricing: IAIModelPricing, translate: TranslateFunc): s `; } else { const inPrice = - pricing.input_per_1m === undefined + pricing.input === undefined ? null - : `${displayCurrency}${separator}${String(pricing.input_per_1m)}`; + : `${displayCurrency}${separator}${String(pricing.input)}`; const outPrice = - pricing.output_per_1m === undefined + pricing.output === undefined ? null - : `${displayCurrency}${separator}${String(pricing.output_per_1m)}`; + : `${displayCurrency}${separator}${String(pricing.output)}`; if (inPrice !== null && outPrice !== null) { html += `
    - ${translate('ui.settings.price_input', 'In')}: ${inPrice} - ${translate('ui.settings.price_output', 'Out')}: ${outPrice} + ${translate('ui.settings.price_input', 'Input')}: ${inPrice} + ${translate('ui.settings.price_output', 'Output')}: ${outPrice}
    `; } diff --git a/src/features/ai/ui/AISettingsRenderer.test.ts b/src/features/ai/ui/AISettingsRenderer.test.ts index 5f282559..b7d270bd 100644 --- a/src/features/ai/ui/AISettingsRenderer.test.ts +++ b/src/features/ai/ui/AISettingsRenderer.test.ts @@ -66,7 +66,7 @@ describe('AISettingsRenderer', () => { name: 'Reasoner', desc: 'Reasoning model', descKey: 'model.reasoner', - pricing: { input_per_1m: 1, output_per_1m: 2, currency: 'USD' }, + pricing: { input: 1, output: 2, currency: 'USD' }, contextWindow: 128000, capabilities: { reasoning: true }, stats: { speed: 8, logic: 10, creative: 6 }, @@ -75,7 +75,7 @@ describe('AISettingsRenderer', () => { id: 'fast', name: 'Fast', desc: 'Fast model', - pricing: [{ tier: 'free', note: '0 / 0' }], + pricing: { input: 0, output: 0, currency: 'USD' }, contextWindow: 8000, capabilities: { reasoning: false }, }, diff --git a/src/features/ai/ui/AISettingsRenderer.ts b/src/features/ai/ui/AISettingsRenderer.ts index bfdbbd38..8454a49c 100644 --- a/src/features/ai/ui/AISettingsRenderer.ts +++ b/src/features/ai/ui/AISettingsRenderer.ts @@ -170,8 +170,8 @@ class AISettingsRenderer extends BaseComponent { showCustomModelComposer: isCustomProviderId(appId), translate: t, viewPolicy: this._viewPolicy, - supportsInternetAccess: this._viewPolicy.supportsInternetAccess(appId), - supportsThinking: this._viewPolicy.supportsThinking(appId), + supportsInternetAccess: this._viewPolicy.supportsInternetAccess(appId, app.capability), + supportsThinking: this._viewPolicy.supportsThinking(appId, models), thinkingLevel: this._selectionController.getThinkingLevel(appId, this._aiSettings), internetAccessEnabled: this._selectionController.getInternetAccessEnabled( appId, diff --git a/src/features/ai/ui/AISettingsSelectionController.ts b/src/features/ai/ui/AISettingsSelectionController.ts index 8881c0c2..4cab18c1 100644 --- a/src/features/ai/ui/AISettingsSelectionController.ts +++ b/src/features/ai/ui/AISettingsSelectionController.ts @@ -69,7 +69,7 @@ export class AISettingsSelectionController { viewPolicy: AISettingsViewPolicy, ): string { const modelData = this.getModelData(appId, modelKey); - void viewPolicy.isImageOnlyProvider(appId); + void viewPolicy; return renderModelStats(modelData, translate); } diff --git a/src/features/ai/ui/AISettingsViewPolicy.test.ts b/src/features/ai/ui/AISettingsViewPolicy.test.ts index 78cf76ad..9e7bb5ad 100644 --- a/src/features/ai/ui/AISettingsViewPolicy.test.ts +++ b/src/features/ai/ui/AISettingsViewPolicy.test.ts @@ -12,16 +12,20 @@ describe('AISettingsViewPolicy', () => { expect(policy.isCleanApp('axelate')).toBe(true); expect(policy.isCleanApp('sample-integration')).toBe(false); expect(policy.isCleanApp('gpt')).toBe(false); - expect(policy.supportsInternetAccess('gpt')).toBe(true); + expect(policy.supportsInternetAccess('gpt', 'text')).toBe(true); expect(policy.supportsInternetAccess('axelate')).toBe(false); - expect(policy.supportsInternetAccess('gemini-image')).toBe(false); - expect(policy.supportsInternetAccess('seedream-image')).toBe(false); - expect(policy.supportsInternetAccess(CUSTOM_TEXT_PROVIDER_ID)).toBe(true); - expect(policy.supportsThinking('gpt')).toBe(true); - expect(policy.supportsThinking('openrouter')).toBe(false); + expect(policy.supportsInternetAccess('gemini-image', 'image')).toBe(false); + expect(policy.supportsInternetAccess('seedream-image', 'image')).toBe(false); + expect(policy.supportsInternetAccess(CUSTOM_TEXT_PROVIDER_ID, 'text')).toBe(true); + expect( + policy.supportsThinking('gpt', [ + { id: 'reasoner', capabilities: { reasoning: true } } as never, + ]), + ).toBe(true); + expect(policy.supportsThinking('openrouter', [])).toBe(false); expect(policy.supportsThinking(CUSTOM_TEXT_PROVIDER_ID)).toBe(true); - expect(policy.isImageOnlyProvider('gemini-image')).toBe(true); - expect(policy.isImageOnlyProvider('seedream-image')).toBe(true); + expect(policy.isImageOnlyProvider('gemini-image', 'image')).toBe(true); + expect(policy.isImageOnlyProvider('seedream-image', 'image')).toBe(true); expect(policy.isImageOnlyProvider(CUSTOM_IMAGE_PROVIDER_ID)).toBe(true); expect(policy.shouldShowModelStats(CUSTOM_TEXT_PROVIDER_ID)).toBe(false); expect(policy.shouldForceThinkingVisibility(CUSTOM_TEXT_PROVIDER_ID)).toBe(true); diff --git a/src/features/ai/ui/AISettingsViewPolicy.ts b/src/features/ai/ui/AISettingsViewPolicy.ts index fc273341..867be8d2 100644 --- a/src/features/ai/ui/AISettingsViewPolicy.ts +++ b/src/features/ai/ui/AISettingsViewPolicy.ts @@ -3,38 +3,28 @@ import { isCustomProviderId, isCustomImageProviderId, } from '@/shared/utils/customProviderSupport'; +import type { IAIModelData } from '../types/aiTypes'; export class AISettingsViewPolicy { private static readonly _cleanAppIds = new Set(['axelate', 'axelate-platform']); - private static readonly _thinkingProviders = new Set(['gemini', 'claude', 'gpt', 'deepseek']); - private static readonly _imageOnlyProviders = new Set([ - 'gemini-image', - 'gpt-image', - 'seedream-image', - ]); public isCleanApp(appId: string): boolean { return AISettingsViewPolicy._cleanAppIds.has(appId); } - public supportsInternetAccess(appId: string): boolean { - return ( - !this.isCleanApp(appId) && - !AISettingsViewPolicy._imageOnlyProviders.has(appId) && - !isCustomImageProviderId(appId) - ); + public supportsInternetAccess(appId: string, capability?: 'text' | 'image'): boolean { + return !this.isCleanApp(appId) && capability !== 'image' && !isCustomImageProviderId(appId); } - public supportsThinking(appId: string): boolean { + public supportsThinking(appId: string, models: readonly IAIModelData[] = []): boolean { return ( - AISettingsViewPolicy._thinkingProviders.has(appId) || appId === CUSTOM_TEXT_PROVIDER_ID + models.some((model) => model.capabilities?.reasoning === true) || + appId === CUSTOM_TEXT_PROVIDER_ID ); } - public isImageOnlyProvider(appId: string): boolean { - return ( - AISettingsViewPolicy._imageOnlyProviders.has(appId) || isCustomImageProviderId(appId) - ); + public isImageOnlyProvider(appId: string, capability?: 'text' | 'image'): boolean { + return capability === 'image' || isCustomImageProviderId(appId); } public shouldShowModelStats(appId: string): boolean { diff --git a/src/features/ai/utils/catalogHelpers.test.ts b/src/features/ai/utils/catalogHelpers.test.ts index efac375e..4e1be9c5 100644 --- a/src/features/ai/utils/catalogHelpers.test.ts +++ b/src/features/ai/utils/catalogHelpers.test.ts @@ -78,11 +78,11 @@ describe('catalogHelpers', () => { createAppMock('gpt', [ { id: 'gpt-5.5', - pricing: { input_per_1m: 5, output_per_1m: 30 }, + pricing: { input: 5, output: 30 }, }, { id: 'gpt-5.5-pro', - pricing: { input_per_1m: 30, output_per_1m: 180 }, + pricing: { input: 30, output: 180 }, }, ]), ]; @@ -94,8 +94,8 @@ describe('catalogHelpers', () => { it('sorts models by total token price descending', () => { const models = [ { id: 'free' }, - { id: 'regular', pricing: { input_per_1m: 5, output_per_1m: 30 } }, - { id: 'pro', pricing: { input_per_1m: 30, output_per_1m: 180 } }, + { id: 'regular', pricing: { input: 5, output: 30 } }, + { id: 'pro', pricing: { input: 30, output: 180 } }, ]; expect(sortModelsByPrice(models as never).map((model) => model.id)).toEqual([ diff --git a/src/features/ai/utils/catalogHelpers.ts b/src/features/ai/utils/catalogHelpers.ts index 4b01568d..71fe3f1f 100644 --- a/src/features/ai/utils/catalogHelpers.ts +++ b/src/features/ai/utils/catalogHelpers.ts @@ -58,8 +58,8 @@ export function getModelsFromProvider( } export function getModelPriceRank(model: IAIModelData): number { - const inputPrice = model.pricing?.input_per_1m ?? 0; - const outputPrice = model.pricing?.output_per_1m ?? 0; + const inputPrice = model.pricing?.input ?? 0; + const outputPrice = model.pricing?.output ?? 0; return inputPrice + outputPrice; } diff --git a/src/features/chat/chat.ts b/src/features/chat/chat.ts index 504a41a3..21c9d208 100644 --- a/src/features/chat/chat.ts +++ b/src/features/chat/chat.ts @@ -141,7 +141,7 @@ export class ChatController { ); this._filePicker = this._createFilePicker(_i18n, deps); this._historyController = this._createHistoryController(deps); - this._generationController = this._createGenerationController(_aiBridge, _i18n); + this._generationController = this._createGenerationController(_aiBridge, _i18n, deps); this._sendController = this._createSendController(_aiBridge, deps); this._activationCoordinator = this._createActivationCoordinator(_aiBridge); } @@ -269,6 +269,7 @@ export class ChatController { private _createGenerationController( aiBridge: AIBridge, i18n: I18nService, + deps: ChatControllerDeps, ): ChatGenerationController { return this._factory.createGenerationController({ aiBridge, @@ -297,6 +298,8 @@ export class ChatController { }, isDestroyed: () => this._state.isDestroyed, isSending: () => this._state.isSending, + isImageProvider: (providerId) => + providerId !== null && deps.getSelectedModule('ai_image')?.id === providerId, tracer: this._tracer, }); } diff --git a/src/features/chat/controllers/ChatGenerationController.test.ts b/src/features/chat/controllers/ChatGenerationController.test.ts index a4e889a1..07a3b818 100644 --- a/src/features/chat/controllers/ChatGenerationController.test.ts +++ b/src/features/chat/controllers/ChatGenerationController.test.ts @@ -1,6 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ChatGenerationController } from './ChatGenerationController'; -import { CUSTOM_IMAGE_PROVIDER_ID } from '@/shared/utils/customProviderSupport'; describe('ChatGenerationController', () => { const aiBridge = { @@ -26,6 +25,7 @@ describe('ChatGenerationController', () => { handleError: vi.fn(), isDestroyed: vi.fn().mockReturnValue(false), isSending: vi.fn().mockReturnValue(true), + isImageProvider: vi.fn((providerId: string | null) => providerId === 'selected-image'), tracer: { debug: vi.fn(), }, @@ -165,12 +165,10 @@ describe('ChatGenerationController', () => { vi.useRealTimers(); }); - it('treats cloud and custom image providers as image flows', () => { + it('uses the injected image-provider resolver', () => { const controller = new ChatGenerationController(baseOptions as never); - expect(controller.isImageProvider('gpt-image')).toBe(true); - expect(controller.isImageProvider('seedream-image')).toBe(true); - expect(controller.isImageProvider(CUSTOM_IMAGE_PROVIDER_ID)).toBe(true); + expect(controller.isImageProvider('selected-image')).toBe(true); expect(controller.isImageProvider('gpt')).toBe(false); expect(controller.isImageProvider(null)).toBe(false); }); @@ -223,7 +221,7 @@ describe('ChatGenerationController', () => { await controller.handleChatResponse( { ok: true, - message: 'answer', + reply: { text: 'answer' }, usage: { prompt_tokens: 11, completion_tokens: 7, total_tokens: 18 }, } as never, null, @@ -238,7 +236,7 @@ describe('ChatGenerationController', () => { const controller = new ChatGenerationController(baseOptions as never); await controller.handleChatResponse( - { ok: true, message: 'answer', thought_signature: 'sig-1' } as never, + { ok: true, reply: { text: 'answer' }, thought_signature: 'sig-1' } as never, null, null, ); diff --git a/src/features/chat/controllers/ChatGenerationController.ts b/src/features/chat/controllers/ChatGenerationController.ts index b0a5cd12..2a03926e 100644 --- a/src/features/chat/controllers/ChatGenerationController.ts +++ b/src/features/chat/controllers/ChatGenerationController.ts @@ -1,5 +1,4 @@ import type { AIBridge } from '@/features/ai/services/AIBridge'; -import { AIBridgeProviderPolicy } from '@/features/ai/services/AIBridgeProviderPolicy'; import type { I18nService } from '@/infrastructure/i18n/I18nService'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; import type { IChatMessage, IChatResponse } from '../types/chatTypes'; @@ -43,6 +42,7 @@ type ChatGenerationControllerOptions = { handleError: (errorMsg: unknown, model?: string) => void; isDestroyed: () => boolean; isSending: () => boolean; + isImageProvider: (providerId: string | null) => boolean; tracer: ChatGenerationLogger; }; @@ -55,7 +55,6 @@ export class ChatGenerationController { private _lastImagePreviewUpdatedAtMs = 0; private _imageGenerationStartedAtMs = 0; private _lastConcreteImageProgressAtMs = 0; - private readonly _providerPolicy = new AIBridgeProviderPolicy(); constructor(private readonly _options: ChatGenerationControllerOptions) {} @@ -68,7 +67,7 @@ export class ChatGenerationController { } public isImageProvider(providerId: string | null): boolean { - return providerId !== null && this._providerPolicy.isImageProvider(providerId); + return this._options.isImageProvider(providerId); } public startImagePreviewPolling(handle: ImageGenerationHandle): void { @@ -234,7 +233,7 @@ export class ChatGenerationController { streamingHandle?: StreamingMessageHandle | null, imageHandle?: ImageGenerationHandle | null, ): Promise { - const rawReply = response.message ?? response.reply?.text ?? ''; + const rawReply = response.reply?.text ?? ''; const replyText = this._options.extractText(rawReply); const generatedImages = response.reply?.images ?? []; diff --git a/src/features/chat/controllers/ChatSendController.test.ts b/src/features/chat/controllers/ChatSendController.test.ts index 3fd9ba5b..c4afe54d 100644 --- a/src/features/chat/controllers/ChatSendController.test.ts +++ b/src/features/chat/controllers/ChatSendController.test.ts @@ -311,6 +311,9 @@ describe('ChatSendController', () => { const { controller, options, aiBridge } = createController(); aiBridge.getState.mockReturnValue({ activeProviderId: 'sdcpp', isRunning: true }); options.isImageProvider.mockReturnValue(true); + options.getSelectedModule.mockImplementation((category: 'ai_text' | 'ai_image') => + category === 'ai_image' ? { id: 'sdcpp', type: 'local' } : undefined, + ); const input = document.createElement('textarea'); input.value = 'draw image'; @@ -325,6 +328,9 @@ describe('ChatSendController', () => { const { controller, options, aiBridge, sendMessage } = createController(); aiBridge.getState.mockReturnValue({ activeProviderId: 'sdcpp', isRunning: true }); options.isImageProvider.mockReturnValue(true); + options.getSelectedModule.mockImplementation((category: 'ai_text' | 'ai_image') => + category === 'ai_image' ? { id: 'sdcpp', type: 'local' } : undefined, + ); sendMessage.mockRejectedValueOnce(new Error('generation failed')); const input = document.createElement('textarea'); input.value = 'draw image'; diff --git a/src/features/chat/controllers/ChatSendController.ts b/src/features/chat/controllers/ChatSendController.ts index 06b7e4c8..b45808b1 100644 --- a/src/features/chat/controllers/ChatSendController.ts +++ b/src/features/chat/controllers/ChatSendController.ts @@ -6,7 +6,6 @@ import type { IChatMessage, IChatAttachment } from '../types/chatTypes'; import type { IApp } from '@/shared/types/coreTypes'; import { ChatAutoStartHelper } from '../services/ChatAutoStartHelper'; import { ChatSendFlow } from '../services/ChatSendFlow'; -import { AIBridgeProviderPolicy } from '@/features/ai/services/AIBridgeProviderPolicy'; type ChatSendLogger = Pick; @@ -108,7 +107,6 @@ export class ChatSendController { private _cancelRequested = false; private _activeProviderId: string | null = null; private _sendSequence = 0; - private readonly _providerPolicy = new AIBridgeProviderPolicy(); constructor(private readonly _options: ChatSendControllerOptions) { this._autoStartHelper = new ChatAutoStartHelper({ @@ -230,7 +228,7 @@ export class ChatSendController { if (isImageProvider) { shouldStopImageEngine = activeProviderId !== null && - this._providerPolicy.isManagedLocalImageEngine(activeProviderId); + this._isSelectedLocalImageProvider(activeProviderId); imageHandle = this._options.createImageHandle(); this._options.startImagePreviewPolling(imageHandle); } else { @@ -335,13 +333,8 @@ export class ChatSendController { return prepared === '' ? prompt : this._stripPromptEnvelope(prepared); } - private _extractPreparedPrompt(response: { - ok: boolean; - text?: string; - message?: string; - reply?: { text?: string }; - }): string { - return (response.text ?? response.message ?? response.reply?.text ?? '').trim(); + private _extractPreparedPrompt(response: { ok: boolean; text?: string }): string { + return (response.text ?? '').trim(); } private _buildImagePromptRewriteRequest(prompt: string): string { @@ -362,6 +355,11 @@ export class ChatSendController { return typeof id === 'string' && id.trim() !== '' ? id : null; } + private _isSelectedLocalImageProvider(providerId: string): boolean { + const module = this._options.getSelectedModule('ai_image'); + return module?.id === providerId && module.type !== 'api'; + } + private _wasDestroyed(): boolean { return this._isDestroyed; } diff --git a/src/features/chat/controllers/FilePickerController.ts b/src/features/chat/controllers/FilePickerController.ts index a7b4d66d..f7c22ba0 100644 --- a/src/features/chat/controllers/FilePickerController.ts +++ b/src/features/chat/controllers/FilePickerController.ts @@ -1,6 +1,6 @@ /** * @module chat/controllers/FilePickerController - * @description Handles file picking (native + web), token counting, and file-to-File conversion. + * @description Handles file picking, token counting, and file-to-File conversion. * Extracted from ChatController for SRP compliance. */ @@ -9,7 +9,6 @@ import type { LoggerService } from '@/infrastructure/logging/LoggerService'; import type { I18nService } from '@/infrastructure/i18n/I18nService'; import type { ChatUI } from '../ui/ChatUI'; -// Tauri imports — only used at runtime if in Tauri context import { desktopDir, dirname } from '@tauri-apps/api/path'; import { open } from '@tauri-apps/plugin-dialog'; import { readFile } from '@tauri-apps/plugin-fs'; @@ -34,12 +33,12 @@ export class FilePickerController { ) {} /** - * Entry point for picking files (Native or Web fallback). + * Entry point for picking files. */ public async pick(): Promise { if (this._isNativeRuntime()) { - const success = await this._pickNative(); - if (success) return; + await this._pickNative(); + return; } const input = document.getElementById('chat-file-input') as HTMLInputElement | null; @@ -100,7 +99,7 @@ export class FilePickerController { // --- Private helpers --- - private async _pickNative(): Promise { + private async _pickNative(): Promise { try { const defaultPath = await this._resolveInitialDirectory(); const dialogOptions: { @@ -135,10 +134,8 @@ export class FilePickerController { void this.updateTokenCount(); } } - return true; } catch (err) { this._tracer.error('[FilePickerController] Native file picker failed:', err); - return false; } } diff --git a/src/features/chat/services/ChatControllerFactory.ts b/src/features/chat/services/ChatControllerFactory.ts index 60887955..031b1fdf 100644 --- a/src/features/chat/services/ChatControllerFactory.ts +++ b/src/features/chat/services/ChatControllerFactory.ts @@ -83,6 +83,7 @@ type ChatGenerationFactoryDeps = { handleError: (errorMsg: unknown, model?: string) => void; isDestroyed: () => boolean; isSending: () => boolean; + isImageProvider: (providerId: string | null) => boolean; tracer: ChatTracer; }; @@ -254,6 +255,7 @@ export class ChatControllerFactory { }, isDestroyed: () => deps.isDestroyed(), isSending: () => deps.isSending(), + isImageProvider: (providerId) => deps.isImageProvider(providerId), tracer: deps.tracer, }); } diff --git a/src/features/chat/services/ChatFileHandler.test.ts b/src/features/chat/services/ChatFileHandler.test.ts index b9d56773..99987e9f 100644 --- a/src/features/chat/services/ChatFileHandler.test.ts +++ b/src/features/chat/services/ChatFileHandler.test.ts @@ -179,9 +179,9 @@ describe('ChatFileHandler', () => { }); }); - // ---------------------------------------------------------- processForSend (web fallback) - describe('processForSend (web)', () => { - it('should process text files via web fallback', async () => { + // ---------------------------------------------------------- processForSend (File API) + describe('processForSend (File API)', () => { + it('should process text files via File API', async () => { (isTextFile as unknown as Mock).mockReturnValue(true); (readFileAsText as unknown as Mock).mockResolvedValue('hello world'); @@ -196,7 +196,7 @@ describe('ChatFileHandler', () => { expect(handler.getCount()).toBe(0); }); - it('should process image files via web fallback', async () => { + it('should process image files via File API', async () => { (isTextFile as unknown as Mock).mockReturnValue(false); (readFileAsBase64 as unknown as Mock).mockResolvedValue('imgbase64=='); @@ -370,8 +370,8 @@ describe('ChatFileHandler', () => { expect(result.attachments).toHaveLength(1); }); - it('should process text file with empty type via web fallback (L232)', async () => { - // Force web fallback + it('should process text file with empty type via File API (L232)', async () => { + // Force the File API path. mockBridge.isTauri.mockReturnValue(false); // Override isTextFile mock — real impl checks extension, not MIME type (isTextFile as Mock).mockReturnValueOnce(true); diff --git a/src/features/chat/services/ChatService.test.ts b/src/features/chat/services/ChatService.test.ts index 4f993e22..74cb3567 100644 --- a/src/features/chat/services/ChatService.test.ts +++ b/src/features/chat/services/ChatService.test.ts @@ -89,7 +89,7 @@ describe('ChatService', () => { const result = await chatService.sendMessage('Hi', [], []); expect(result.ok).toBe(true); - expect(result.message).toBe('Hello there'); + expect(result.reply?.text).toBe('Hello there'); }); it('should return error if AIBridge throws', async () => { @@ -116,7 +116,7 @@ describe('ChatService', () => { const result = await chatService.sendMessage('Hello', [], []); expect(result.ok).toBe(true); - expect(result.message).toBe(''); + expect(result.reply?.text).toBe(''); }); it('should handle non-Error throw in sendMessage (L52)', async () => { diff --git a/src/features/chat/services/ChatService.ts b/src/features/chat/services/ChatService.ts index 0e643983..204e1922 100644 --- a/src/features/chat/services/ChatService.ts +++ b/src/features/chat/services/ChatService.ts @@ -92,17 +92,17 @@ export class ChatService { return result; } + const reply: NonNullable = { + text: response.text ?? '', + type: 'markdown', + }; const result: IChatResponse = { ok: true, - message: response.text ?? '', + reply, }; const generatedImages = parseGeneratedImages(response.images); if (generatedImages !== undefined) { - result.reply = { - text: response.text ?? '', - type: 'markdown', - images: generatedImages, - }; + reply.images = generatedImages; } if (response.thought_signature !== undefined) { result.thought_signature = response.thought_signature; diff --git a/src/features/chat/types/chatTypes.ts b/src/features/chat/types/chatTypes.ts index e0c6012a..0e14c085 100644 --- a/src/features/chat/types/chatTypes.ts +++ b/src/features/chat/types/chatTypes.ts @@ -53,8 +53,6 @@ export interface IChatResponse { /** Optional generated images */ images?: { mime: string; data_base64: string }[]; }; - /** Legacy or fallback message field */ - message?: string; /** Error message if ok is false */ error?: string; /** Model identifier used for response */ diff --git a/src/features/chat/ui/ChatAttachmentRenderer.ts b/src/features/chat/ui/ChatAttachmentRenderer.ts index 835a0ce9..b5dc71d9 100644 --- a/src/features/chat/ui/ChatAttachmentRenderer.ts +++ b/src/features/chat/ui/ChatAttachmentRenderer.ts @@ -3,13 +3,13 @@ import DOMPurify from 'dompurify'; import type { ChatFileHandler } from '../services/ChatFileHandler'; import type { IChatAttachment } from '../types/chatTypes'; import { getFileIcon } from '../utils/chatUtils'; -import type { TTranslateFunction } from '@/shared/types/global_bridge_types'; +import type { ChatTranslateFunction } from './ChatUiTypes'; type ChatAttachmentRendererDeps = { fileHandler: Pick; isDestroyed: () => boolean; getRenderVersion: () => number; - translate: TTranslateFunction; + translate: ChatTranslateFunction; }; export class ChatAttachmentRenderer { diff --git a/src/features/chat/ui/ChatImageController.ts b/src/features/chat/ui/ChatImageController.ts index 9e5f227c..0011e98a 100644 --- a/src/features/chat/ui/ChatImageController.ts +++ b/src/features/chat/ui/ChatImageController.ts @@ -3,7 +3,7 @@ import DOMPurify from 'dompurify'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; import { invokeSafe } from '@/shared/api/invoke'; import { commands, type SavedChatImage as SavedChatImageResult } from '@/shared/types/bindings'; -import type { TTranslateFunction } from '@/shared/types/global_bridge_types'; +import type { ChatTranslateFunction } from './ChatUiTypes'; type ChatImageLogger = Pick; @@ -26,7 +26,7 @@ type ChatImageControllerDeps = { type?: 'success' | 'error' | 'warning' | 'info', duration?: number, ) => void; - translate: TTranslateFunction; + translate: ChatTranslateFunction; tracer: ChatImageLogger; }; diff --git a/src/features/chat/ui/ChatMessageInteractionController.ts b/src/features/chat/ui/ChatMessageInteractionController.ts index 3577ed95..c2ea03b2 100644 --- a/src/features/chat/ui/ChatMessageInteractionController.ts +++ b/src/features/chat/ui/ChatMessageInteractionController.ts @@ -2,7 +2,7 @@ import DOMPurify from 'dompurify'; import type { ChatImageController } from './ChatImageController'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; -import type { TTranslateFunction } from '@/shared/types/global_bridge_types'; +import type { ChatTranslateFunction } from './ChatUiTypes'; type ChatMessageInteractionLogger = Pick; @@ -25,7 +25,7 @@ type ChatMessageInteractionControllerDeps = { getRegenerateMessageHandler: () => (() => void | Promise) | null; setLastEditableUserActionBar: (actionBar: HTMLElement) => void; setLastRegeneratableAssistantActionBar: (actionBar: HTMLElement) => void; - translate: TTranslateFunction; + translate: ChatTranslateFunction; tracer: ChatMessageInteractionLogger; }; diff --git a/src/features/chat/ui/ChatMessageRenderer.ts b/src/features/chat/ui/ChatMessageRenderer.ts index d435bc6c..e256cedd 100644 --- a/src/features/chat/ui/ChatMessageRenderer.ts +++ b/src/features/chat/ui/ChatMessageRenderer.ts @@ -2,7 +2,7 @@ import DOMPurify from 'dompurify'; import { marked } from 'marked'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; -import type { TTranslateFunction } from '@/shared/types/global_bridge_types'; +import type { ChatTranslateFunction } from './ChatUiTypes'; import { buildSafeImageDataUrl, normalizeImagePayload, @@ -13,7 +13,7 @@ type ChatMessageRendererLogger = Pick; type ChatMessageRendererDeps = { onImageLoad: () => void; - translate: TTranslateFunction; + translate: ChatTranslateFunction; tracer: ChatMessageRendererLogger; }; diff --git a/src/features/chat/ui/ChatUiTypes.ts b/src/features/chat/ui/ChatUiTypes.ts new file mode 100644 index 00000000..72340f0e --- /dev/null +++ b/src/features/chat/ui/ChatUiTypes.ts @@ -0,0 +1 @@ +export type ChatTranslateFunction = (key: string, defaultValue?: string) => string; diff --git a/src/features/console/services/ConsoleLogNormalizer.ts b/src/features/console/services/ConsoleLogNormalizer.ts index 4946deb0..85ad8443 100644 --- a/src/features/console/services/ConsoleLogNormalizer.ts +++ b/src/features/console/services/ConsoleLogNormalizer.ts @@ -63,6 +63,22 @@ export class ConsoleLogNormalizer { }; } + const plainRuntimeLevelMatch = rawMessage.match( + /^(?:(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+)?(TRACE|DEBUG|INFO|WARN|WARNING|ERROR)\s+(?:\[([^\]]+)\]\s+)?([\s\S]+)$/i, + ); + if (plainRuntimeLevelMatch !== null) { + const rawTime = plainRuntimeLevelMatch[1] ?? null; + const rawLevel = plainRuntimeLevelMatch[2] ?? log.level; + const rawScope = plainRuntimeLevelMatch[3]?.trim() ?? null; + const rawBody = plainRuntimeLevelMatch[4] ?? rawMessage; + return { + time: rawTime !== null && rawTime.trim() !== '' ? rawTime.slice(11) : null, + level: this._normalizeLevel(rawLevel), + scope: rawScope, + message: rawBody.trim(), + }; + } + const scopedMatch = rawMessage.match(/^\[([^\]]+)\]\s+([\s\S]+)$/); if (scopedMatch !== null) { return { diff --git a/src/features/console/services/ConsoleLogService.test.ts b/src/features/console/services/ConsoleLogService.test.ts index 5c722b08..9c920953 100644 --- a/src/features/console/services/ConsoleLogService.test.ts +++ b/src/features/console/services/ConsoleLogService.test.ts @@ -50,6 +50,41 @@ describe('ConsoleLogService', () => { expect(service.getLogsForView('general')).toEqual([]); }); + it('normalizes plain backend timestamp level lines', async () => { + setupTauri(bridge, true); + vi.mocked(bridge.invoke).mockResolvedValue([ + { + timestamp: 100, + source: 'backend', + level: 'INFO', + message: '2026-05-05 19:21:40 ERROR [ModuleService] Control failed', + }, + { + timestamp: 101, + source: 'backend', + level: 'INFO', + message: '2026-05-05 19:21:40 WARN [GlobalBridge] Failed to start local module', + }, + ] satisfies ILogEntry[]); + + const logs = await service.fetchLogs('general'); + + expect(logs).toEqual([ + expect.objectContaining({ + display_time: '19:21:40', + normalized_level: 'ERROR', + scope: 'ModuleService', + message: 'Control failed', + }), + expect.objectContaining({ + display_time: '19:21:40', + normalized_level: 'WARN', + scope: 'GlobalBridge', + message: 'Failed to start local module', + }), + ]); + }); + it('tracks timestamps per view without cross-view filtering', async () => { setupTauri(bridge, true); vi.mocked(bridge.invoke) @@ -120,11 +155,11 @@ describe('ConsoleLogService', () => { expect(service.getLogsForView('general')).toEqual([]); }); - it('opens canonical engine log folders', async () => { + it('opens engine log folders', async () => { setupTauri(bridge, true); vi.mocked(bridge.invoke).mockResolvedValue(undefined); - await expect(service.openLogsFolder('engine:stable-diffusion')).resolves.toBe(true); + await expect(service.openLogsFolder('engine:sdcpp')).resolves.toBe(true); expect(bridge.invoke).toHaveBeenCalledWith('open_console_log_target', { viewId: 'engine:sdcpp', diff --git a/src/features/console/services/ConsoleLogService.ts b/src/features/console/services/ConsoleLogService.ts index 4d94f508..593485e2 100644 --- a/src/features/console/services/ConsoleLogService.ts +++ b/src/features/console/services/ConsoleLogService.ts @@ -288,15 +288,7 @@ export class ConsoleLogService { .trim() .toLowerCase() .replaceAll(/[\s_]+/gu, '-'); - switch (key) { - case 'stable-diffusion': - case 'stable-diffusion.cpp': - case 'stable-diffusion-cpp': - case 'stable.diffusion.cpp': - return 'sdcpp'; - default: - return key; - } + return key; } private _dedupeKey(entry: ILogEntry): string { diff --git a/src/features/console/ui/ConsoleFilterControlHelper.ts b/src/features/console/ui/ConsoleFilterControlHelper.ts index 99e28199..9f8cb1c3 100644 --- a/src/features/console/ui/ConsoleFilterControlHelper.ts +++ b/src/features/console/ui/ConsoleFilterControlHelper.ts @@ -141,7 +141,10 @@ export class ConsoleFilterControlHelper { } private _hasMultiSelectModifier(event: Event): boolean { - return event instanceof MouseEvent && (event.ctrlKey === true || event.metaKey === true); + return ( + event instanceof MouseEvent && + (event.ctrlKey === true || event.metaKey === true || event.shiftKey === true) + ); } private _handleClearButton(button: HTMLButtonElement): void { diff --git a/src/features/console/ui/ConsoleUI.test.ts b/src/features/console/ui/ConsoleUI.test.ts index c8dc0619..4ceda708 100644 --- a/src/features/console/ui/ConsoleUI.test.ts +++ b/src/features/console/ui/ConsoleUI.test.ts @@ -8,6 +8,7 @@ describe('ConsoleUI lifecycle', () => { let ui: ConsoleUI | null = null; let testEventBus: EventBus; let showToastMock: ReturnType; + let copyTextMock: ReturnType; const normalizer = new ConsoleLogNormalizer(); function normalizeLogs(logs: ILogEntry[]): ILogEntry[] { @@ -59,6 +60,7 @@ describe('ConsoleUI lifecycle', () => { fallback, ) => `${key}:${fallback}`; showToastMock = vi.fn(); + copyTextMock = vi.fn().mockResolvedValue(undefined); vi.clearAllMocks(); }); @@ -93,7 +95,7 @@ describe('ConsoleUI lifecycle', () => { )(message, type, duration); }, copyText: async (text: string) => { - await navigator.clipboard.writeText(text); + await (copyTextMock as unknown as (value: string) => Promise)(text); }, }; } @@ -192,7 +194,7 @@ describe('ConsoleUI lifecycle', () => { expect(dropzone.textContent).toContain('drop_here'); }); - it('should clear, copy and render logs through browser clipboard fallback', async () => { + it('should clear, copy and render logs through the injected clipboard writer', async () => { const service = createServiceMock({ getLogs: vi.fn().mockReturnValue( normalizeLogs([ @@ -216,18 +218,13 @@ describe('ConsoleUI lifecycle', () => { }); ui = new ConsoleUI(service, createDeps()); - const clipboardWrite = vi.fn().mockResolvedValue(undefined); - Object.defineProperty(globalThis.navigator, 'clipboard', { - configurable: true, - value: { writeText: clipboardWrite }, - }); ui.init(); await ui.clearLogs(); expect(service.clearLogs).toHaveBeenCalledWith('general'); await ui.copyLogs(); - expect(clipboardWrite).toHaveBeenCalledWith('hello\nboom'); + expect(copyTextMock).toHaveBeenCalledWith('hello\nboom'); (service.getLogsForView as ReturnType).mockReturnValue([]); await ui.copyLogs(); @@ -302,6 +299,24 @@ describe('ConsoleUI lifecycle', () => { vi.useRealTimers(); }); + it('should forward wheel scrolling from the console controls to the logs area', () => { + const service = createServiceMock(); + const container = document.getElementById('console-container') as HTMLDivElement; + Object.defineProperty(container, 'scrollHeight', { configurable: true, value: 720 }); + Object.defineProperty(container, 'clientHeight', { configurable: true, value: 200 }); + container.scrollTop = 0; + + ui = new ConsoleUI(service, createDeps()); + ui.init(); + + const panel = document.querySelector('.console-controls-panel') as HTMLElement; + panel.dispatchEvent( + new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY: 96 }), + ); + + expect(container.scrollTop).toBe(96); + }); + it('should open logs folder from the console actions', async () => { const service = createServiceMock({ openLogsFolder: vi.fn().mockResolvedValue(true), @@ -518,12 +533,6 @@ describe('ConsoleUI lifecycle', () => { }); it('should copy only logs from the active view', async () => { - const clipboardWrite = vi.fn().mockResolvedValue(undefined); - Object.defineProperty(globalThis.navigator, 'clipboard', { - configurable: true, - value: { writeText: clipboardWrite }, - }); - const service = createServiceMock({ getLogsForView: vi.fn((view: string) => normalizeLogs( @@ -560,7 +569,7 @@ describe('ConsoleUI lifecycle', () => { moduleTab.click(); await ui.copyLogs(); - const copiedText = clipboardWrite.mock.calls[0]?.[0] as string; + const copiedText = copyTextMock.mock.calls[0]?.[0] as string; expect(copiedText).toContain('engine line'); expect(copiedText).not.toContain('general line'); }); @@ -696,7 +705,7 @@ describe('ConsoleUI lifecycle', () => { expect(document.getElementById('logs-general')?.textContent).toContain('Page settings'); }); - it('should allow multi-select level filters with ctrl click', async () => { + it('should allow multi-select level filters with ctrl or shift click', async () => { const service = createServiceMock({ getLogsForView: vi.fn().mockReturnValue( normalizeLogs([ @@ -754,6 +763,13 @@ describe('ConsoleUI lifecycle', () => { ); expect(document.getElementById('logs-general')?.textContent).toContain('Page settings'); expect(document.getElementById('logs-general')?.textContent).not.toContain('Page modules'); + + const debugButton = document.querySelector( + '.console-filter-chip[data-level="DEBUG"]', + ) as HTMLButtonElement; + debugButton.dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true })); + + expect(document.getElementById('logs-general')?.textContent).toContain('Page modules'); }); it('should hide launcher source labels like frontend from rendered logs', async () => { diff --git a/src/features/console/ui/ConsoleUI.ts b/src/features/console/ui/ConsoleUI.ts index e08a7dc7..69ebccbc 100644 --- a/src/features/console/ui/ConsoleUI.ts +++ b/src/features/console/ui/ConsoleUI.ts @@ -148,6 +148,7 @@ export class ConsoleUI { this._interactionHelper.bindDropzone(); this.bindTabs(); this._bindTabScrollControls(); + this._bindWorkspaceWheelForwarding(); this._filterControlHelper.bindControls(); this._syncPollingForActivePage(); void this.refreshLogViews(); @@ -248,6 +249,38 @@ export class ConsoleUI { }); } + private _bindWorkspaceWheelForwarding(): void { + const workspace = document.querySelector('.console-workspace'); + const scrollContainer = document.getElementById('console-container'); + if (!(workspace instanceof HTMLElement) || !(scrollContainer instanceof HTMLElement)) { + return; + } + + const handleWheel = (event: WheelEvent) => { + const target = event.target; + if ( + target instanceof Element && + target.closest('.console-logs-area') instanceof HTMLElement + ) { + return; + } + if ( + event.deltaY === 0 || + scrollContainer.scrollHeight <= scrollContainer.clientHeight + ) { + return; + } + + scrollContainer.scrollTop += event.deltaY; + event.preventDefault(); + }; + + workspace.addEventListener('wheel', handleWheel, { passive: false }); + this.unsubscribers.push(() => { + workspace.removeEventListener('wheel', handleWheel); + }); + } + public setTab(tabId: string, btn?: HTMLElement): void { this._activateTab('.debug-tab', '.debug-tab-content', `debug-${tabId}-tab`, btn); } diff --git a/src/features/downloads/ui/DownloadCardRenderer.ts b/src/features/downloads/ui/DownloadCardRenderer.ts index 2d5bb8f8..f4a3257a 100644 --- a/src/features/downloads/ui/DownloadCardRenderer.ts +++ b/src/features/downloads/ui/DownloadCardRenderer.ts @@ -22,7 +22,7 @@ type DownloadCardRendererDeps = { export class DownloadCardRenderer { private static readonly _purifyConfig = { ALLOWED_TAGS: ['div', 'span', 'button', 'svg', 'use'], - ALLOWED_ATTR: ['aria-label', 'class', 'href', 'style', 'title'], + ALLOWED_ATTR: ['aria-label', 'class', 'href', 'style', 'title', 'type'], ALLOW_DATA_ATTR: false, }; @@ -225,7 +225,7 @@ export class DownloadCardRenderer { } if (this._deps.isCancellableStatus(status)) { buttons.push( - this.renderActionButton('download-cancel-btn cancel', cancelTitle, '#icon-stop'), + this.renderActionButton('download-cancel-btn cancel', cancelTitle, '#icon-trash'), ); } @@ -242,7 +242,7 @@ export class DownloadCardRenderer { private renderActionButton(className: string, title: string, iconHref: string): string { return ` - `; diff --git a/src/features/downloads/ui/DownloadProgressPresenter.ts b/src/features/downloads/ui/DownloadProgressPresenter.ts index f839fb7d..21ec41a7 100644 --- a/src/features/downloads/ui/DownloadProgressPresenter.ts +++ b/src/features/downloads/ui/DownloadProgressPresenter.ts @@ -131,15 +131,13 @@ export class DownloadProgressPresenter { } public displayModuleName(moduleId: string): string { - const knownNames: Record = { - llamacpp: 'llama.cpp', - sdcpp: 'stable-diffusion.cpp', - }; - - const known = knownNames[moduleId.toLowerCase()]; - if (known !== undefined) return known; - - return moduleId.replaceAll(/[_-]+/g, ' ').trim(); + return moduleId + .replaceAll(/[_-]+/g, ' ') + .trim() + .split(/\s+/u) + .filter((part) => part !== '') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); } public isActiveStatus(status: string): boolean { diff --git a/src/features/downloads/ui/DownloadUI.test.ts b/src/features/downloads/ui/DownloadUI.test.ts index 19c1b34f..16f04399 100644 --- a/src/features/downloads/ui/DownloadUI.test.ts +++ b/src/features/downloads/ui/DownloadUI.test.ts @@ -730,10 +730,13 @@ describe('DownloadUI', () => { ); const list = document.getElementById('downloads-dynamic-list'); - const cancelBtn = list?.querySelector('.download-cancel-btn'); + const cancelBtn = + list?.querySelector('.download-cancel-btn') ?? null; expect(cancelBtn).not.toBeNull(); + expect(cancelBtn?.type).toBe('button'); + expect(cancelBtn?.querySelector('use')?.getAttribute('href')).toBe('#icon-trash'); - if (cancelBtn !== null) (cancelBtn as HTMLElement).click(); + if (cancelBtn !== null) cancelBtn.click(); expect(cancelFn).toHaveBeenCalledWith('mod-cancel'); }); @@ -753,10 +756,12 @@ describe('DownloadUI', () => { ); const list = document.getElementById('downloads-dynamic-list'); - const pauseBtn = list?.querySelector('.download-pause-btn'); + const pauseBtn = list?.querySelector('.download-pause-btn') ?? null; expect(pauseBtn).not.toBeNull(); + expect(pauseBtn?.type).toBe('button'); + expect(pauseBtn?.querySelector('use')?.getAttribute('href')).toBe('#icon-pause'); - if (pauseBtn !== null) (pauseBtn as HTMLElement).click(); + if (pauseBtn !== null) pauseBtn.click(); expect(pauseFn).toHaveBeenCalledWith('mod-pause'); }); diff --git a/src/features/monitoring/services/MonitoringService.test.ts b/src/features/monitoring/services/MonitoringService.test.ts index dc901c87..374dbb1e 100644 --- a/src/features/monitoring/services/MonitoringService.test.ts +++ b/src/features/monitoring/services/MonitoringService.test.ts @@ -105,23 +105,25 @@ describe('MonitoringService', () => { expect(mockUnlisten).toHaveBeenCalled(); }); - it('should start fallback polling when not in Tauri', async () => { + it('should not poll when event transport is unavailable outside Tauri', async () => { vi.mocked(mockTauri.isTauri).mockReturnValue(false); vi.useFakeTimers(); vi.mocked(mockTauri.invoke).mockResolvedValue(mockStats); await service.startMonitoring(); - - // Fast-forward time to trigger interval await vi.advanceTimersByTimeAsync(2100); - expect(mockTauri.invoke).toHaveBeenCalledWith('get_system_stats'); + expect(mockTauri.invoke).not.toHaveBeenCalled(); + expect(tracer.warn).toHaveBeenCalledWith( + '[MonitoringService] Event transport unavailable outside Tauri', + ); vi.useRealTimers(); }); it('should stop polling on stopMonitoring', async () => { - vi.mocked(mockTauri.isTauri).mockReturnValue(false); + vi.mocked(mockTauri.isTauri).mockReturnValue(true); + vi.mocked(mockTauri.listen).mockRejectedValue(new Error('Listen fail')); vi.useFakeTimers(); const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout'); @@ -196,8 +198,9 @@ describe('MonitoringService', () => { expect((service as unknown as { listeners: unknown[] }).listeners).toHaveLength(1); }); - it('should handle invoke error in fallback polling', async () => { - vi.mocked(mockTauri.isTauri).mockReturnValue(false); + it('should handle invoke error in polling after event subscription fails', async () => { + vi.mocked(mockTauri.isTauri).mockReturnValue(true); + vi.mocked(mockTauri.listen).mockRejectedValue(new Error('Listen fail')); vi.useFakeTimers(); vi.mocked(mockTauri.invoke).mockRejectedValue(new Error('Network error')); @@ -215,13 +218,14 @@ describe('MonitoringService', () => { await service.startMonitoring(); await vi.advanceTimersByTimeAsync(2100); - expect(mockTauri.invoke).toHaveBeenCalledWith('get_system_stats'); + expect(mockTauri.invoke).not.toHaveBeenCalled(); vi.useRealTimers(); }); it('should clear pollingInterval via stopMonitoring (L82)', async () => { - vi.mocked(mockTauri.isTauri).mockReturnValue(false); + vi.mocked(mockTauri.isTauri).mockReturnValue(true); + vi.mocked(mockTauri.listen).mockRejectedValue(new Error('Listen fail')); vi.useFakeTimers(); vi.mocked(mockTauri.invoke).mockResolvedValue(mockStats); @@ -233,7 +237,8 @@ describe('MonitoringService', () => { }); it('should not start fallback twice if already polling (L118)', async () => { - vi.mocked(mockTauri.isTauri).mockReturnValue(false); + vi.mocked(mockTauri.isTauri).mockReturnValue(true); + vi.mocked(mockTauri.listen).mockRejectedValue(new Error('Listen fail')); vi.useFakeTimers(); vi.mocked(mockTauri.invoke).mockResolvedValue(mockStats); @@ -245,7 +250,8 @@ describe('MonitoringService', () => { }); it('should handle failed invoke response in fallback (L118-125)', async () => { - vi.mocked(mockTauri.isTauri).mockReturnValue(false); + vi.mocked(mockTauri.isTauri).mockReturnValue(true); + vi.mocked(mockTauri.listen).mockRejectedValue(new Error('Listen fail')); vi.useFakeTimers(); vi.mocked(mockTauri.invoke).mockRejectedValue(new Error('Backend unavailable')); @@ -262,7 +268,8 @@ describe('MonitoringService', () => { }); it('should not notify subscribers from an in-flight fallback poll after stop', async () => { - vi.mocked(mockTauri.isTauri).mockReturnValue(false); + vi.mocked(mockTauri.isTauri).mockReturnValue(true); + vi.mocked(mockTauri.listen).mockRejectedValue(new Error('Listen fail')); vi.useFakeTimers(); let resolveStats: ((value: ISystemStats) => void) | undefined; diff --git a/src/features/monitoring/services/MonitoringService.ts b/src/features/monitoring/services/MonitoringService.ts index 3b08a814..034ec425 100644 --- a/src/features/monitoring/services/MonitoringService.ts +++ b/src/features/monitoring/services/MonitoringService.ts @@ -4,7 +4,7 @@ import type { ISystemStats, StatsCallback } from '../types/monitoringTypes'; type MonitoringLogger = Pick; -const FALLBACK_MONITORING_POLL_INTERVAL_MS = 2000; +const MONITORING_POLL_INTERVAL_MS = 2000; export class MonitoringService { private isListening = false; @@ -19,7 +19,7 @@ export class MonitoringService { ) {} /** - * Starts listening to system stats or falls back to bridge polling. + * Starts listening to system stats. */ public async startMonitoring(): Promise { if (this.isListening) return; @@ -55,11 +55,11 @@ export class MonitoringService { return; } this._tracer.error('[MonitoringService] Failed to listen to events:', e); - this.startFallback(lifecycleToken); + this.startPolling(lifecycleToken); } } else { - this._tracer.warn('[MonitoringService] Event transport unavailable, starting polling'); - this.startFallback(lifecycleToken); + this._tracer.warn('[MonitoringService] Event transport unavailable outside Tauri'); + this.stopMonitoring(); } } @@ -110,26 +110,26 @@ export class MonitoringService { }); } - private startFallback(lifecycleToken: number): void { + private startPolling(lifecycleToken: number): void { if (this.pollingTimeout !== null) { return; } const poll = (): void => { this.pollingTimeout = globalThis.setTimeout(() => { - void this._pollFallbackStats(lifecycleToken).finally(() => { + void this._pollStats(lifecycleToken).finally(() => { this.pollingTimeout = null; if (this.isListening && lifecycleToken === this._lifecycleToken) { poll(); } }); - }, FALLBACK_MONITORING_POLL_INTERVAL_MS); + }, MONITORING_POLL_INTERVAL_MS); }; poll(); } - private async _pollFallbackStats(lifecycleToken: number): Promise { + private async _pollStats(lifecycleToken: number): Promise { try { const stats = await this._tauri.invoke('get_system_stats'); if (!this.isListening || lifecycleToken !== this._lifecycleToken) { diff --git a/src/features/settings/services/SettingsService.test.ts b/src/features/settings/services/SettingsService.test.ts index 69331486..7ce3177f 100644 --- a/src/features/settings/services/SettingsService.test.ts +++ b/src/features/settings/services/SettingsService.test.ts @@ -234,7 +234,7 @@ describe('SettingsService', () => { }); }); - it('should keep legacy provider-specific slots for unknown providers', async () => { + it('should keep provider-specific slots for unknown providers', async () => { await service.saveSecureKey('unknown-provider', 'my-api-key'); expect(tauri.invoke).toHaveBeenCalledWith('save_secure_key', { service: 'unknown-provider_api_key', diff --git a/src/features/settings/ui/GeneralSettingsRenderer.test.ts b/src/features/settings/ui/GeneralSettingsRenderer.test.ts index 5d5517d9..b675a9eb 100644 --- a/src/features/settings/ui/GeneralSettingsRenderer.test.ts +++ b/src/features/settings/ui/GeneralSettingsRenderer.test.ts @@ -122,6 +122,12 @@ describe('GeneralSettingsRenderer', () => { expect( document.querySelector('#sidebar .nav-btn[data-page="chat"]')?.getAttribute('tabindex'), ).toBe('-1'); + expect(chatButton.querySelector('.toggle-label')?.dataset['i18n']).toBe( + 'ui.launcher.web.chat', + ); + expect(chatButton.querySelector('.toggle-label')?.textContent).toBe( + 't:ui.launcher.web.chat:Chat', + ); const gpuMonitor = document.querySelector( '#monitor-toggles .monitor-toggle-btn[data-monitor-id="gpu"]', diff --git a/src/features/settings/ui/GeneralSettingsRenderer.ts b/src/features/settings/ui/GeneralSettingsRenderer.ts index f95730d6..b1332b26 100644 --- a/src/features/settings/ui/GeneralSettingsRenderer.ts +++ b/src/features/settings/ui/GeneralSettingsRenderer.ts @@ -13,6 +13,7 @@ interface IToggleItem { id: string; label: string; icon: string; + labelKey?: string; } interface IToggleGroupConfig { @@ -118,6 +119,7 @@ export class GeneralSettingsRenderer { const navItems = APP_PAGES.filter((page) => page.inSettings === true).map((page) => ({ id: page.id, label: page.defaultLabel, + labelKey: page.i18nKey, icon: page.icon, })); @@ -128,7 +130,7 @@ export class GeneralSettingsRenderer { dataKey: 'pageId', hiddenItems, items: navItems, - getLabelKey: (item) => `ui.launcher.settings.toggle_${item.id}`, + getLabelKey: (item) => item.labelKey ?? `ui.launcher.settings.toggle_${item.id}`, onToggle: (pageId, enabled) => { this.toggleNavItem(pageId, enabled); }, diff --git a/src/features/settings/ui/ModuleSettingsBridgeController.test.ts b/src/features/settings/ui/ModuleSettingsBridgeController.test.ts deleted file mode 100644 index 02b7e95d..00000000 --- a/src/features/settings/ui/ModuleSettingsBridgeController.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { IApp } from '@/shared/types/coreTypes'; -import { ModuleSettingsBridgeController } from './ModuleSettingsBridgeController'; - -describe('ModuleSettingsBridgeController', () => { - beforeEach(() => { - delete (globalThis as unknown as Record)['openModuleSettings']; - }); - - it('installs and uninstalls global openModuleSettings bridge handler', async () => { - const controller = new ModuleSettingsBridgeController({ - error: vi.fn(), - }); - const openModuleSettings = vi - .fn<(...args: [IApp]) => Promise>() - .mockResolvedValue(undefined); - const app = { id: 'svc' } as IApp; - - controller.install(openModuleSettings); - (globalThis as unknown as { openModuleSettings: (app: IApp) => void }).openModuleSettings( - app, - ); - await Promise.resolve(); - - expect(openModuleSettings).toHaveBeenCalledWith(app); - - controller.uninstall(); - expect('openModuleSettings' in globalThis).toBe(false); - }); -}); diff --git a/src/features/settings/ui/ModuleSettingsBridgeController.ts b/src/features/settings/ui/ModuleSettingsBridgeController.ts deleted file mode 100644 index 71095441..00000000 --- a/src/features/settings/ui/ModuleSettingsBridgeController.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { IApp } from '@/shared/types/coreTypes'; -import type { LoggerService } from '@/infrastructure/logging/LoggerService'; - -type ModuleSettingsBridgeLogger = Pick; - -export class ModuleSettingsBridgeController { - private _previousOpenModuleSettings: unknown; - public constructor( - private readonly _tracer: ModuleSettingsBridgeLogger, - private readonly _bridgeTarget: Window & typeof globalThis = globalThis as Window & - typeof globalThis, - ) {} - - public install(openModuleSettings: (app: IApp) => Promise): void { - this._previousOpenModuleSettings = this._bridgeTarget.openModuleSettings; - - this._bridgeTarget.openModuleSettings = (app: IApp) => { - void openModuleSettings(app).catch((error: unknown) => { - this._tracer.error(String(error)); - }); - }; - } - - public uninstall(): void { - if (typeof this._previousOpenModuleSettings === 'function') { - this._bridgeTarget.openModuleSettings = this - ._previousOpenModuleSettings as typeof this._bridgeTarget.openModuleSettings; - } else { - delete (this._bridgeTarget as unknown as Record)['openModuleSettings']; - } - - this._previousOpenModuleSettings = undefined; - } -} diff --git a/src/features/settings/ui/ModuleSettingsEngineFieldCatalog.ts b/src/features/settings/ui/ModuleSettingsEngineFieldCatalog.ts index 4cbd5aed..b69323f9 100644 --- a/src/features/settings/ui/ModuleSettingsEngineFieldCatalog.ts +++ b/src/features/settings/ui/ModuleSettingsEngineFieldCatalog.ts @@ -26,13 +26,17 @@ export type ImageEngineFieldGroups = { }; export class ModuleSettingsEngineFieldCatalog { - public buildComputeModeField(t: TranslateFn): EngineFieldDefinition { + public buildComputeModeField( + t: TranslateFn, + availableModes: Array<'gpu' | 'cpu'> = ['gpu', 'cpu'], + ): EngineFieldDefinition { + const options = availableModes.length > 0 ? availableModes : ['gpu', 'cpu']; return { label: t('ui.settings.engine.compute_mode', 'Compute Device'), key: 'compute_mode', type: 'select', isEngineConfig: true, - options: ['gpu', 'cpu'], + options, optionLabels: { gpu: t('ui.settings.engine.compute_gpu', 'GPU'), cpu: t('ui.settings.engine.compute_cpu', 'CPU'), diff --git a/src/features/settings/ui/ModuleSettingsEngineFieldSupport.ts b/src/features/settings/ui/ModuleSettingsEngineFieldSupport.ts index 4d4f09a1..af55c3c9 100644 --- a/src/features/settings/ui/ModuleSettingsEngineFieldSupport.ts +++ b/src/features/settings/ui/ModuleSettingsEngineFieldSupport.ts @@ -444,7 +444,7 @@ export function getEngineExtraArgDocs( appId: string, translate: ExtraArgsTranslate = (_key, fallback) => fallback, ): EngineExtraArgDocs { - if (appId === 'sdcpp' || appId === 'stable-diffusion') { + if (appId === 'sdcpp') { return { title: translate('ui.settings.engine.sdcpp_flags.title', 'Manual sd.cpp flags'), subtitle: translate( @@ -498,7 +498,7 @@ export function getEngineRecommendedExtraArgs( appId: string, context: EngineRecommendedExtraArgsContext = {}, ): string[] { - if (appId !== 'sdcpp' && appId !== 'stable-diffusion') { + if (appId !== 'sdcpp') { return ['--flash-attn']; } diff --git a/src/features/settings/ui/ModuleSettingsEngineRenderFlow.ts b/src/features/settings/ui/ModuleSettingsEngineRenderFlow.ts index ae7112c2..b91f585d 100644 --- a/src/features/settings/ui/ModuleSettingsEngineRenderFlow.ts +++ b/src/features/settings/ui/ModuleSettingsEngineRenderFlow.ts @@ -51,7 +51,10 @@ type ModuleSettingsEngineRenderOptions = { modelPlaceholder: string, isImage: boolean, ) => EngineFieldDefinition; - getComputeModeField: (translate: TranslateFn) => EngineFieldDefinition; + getComputeModeField: ( + translate: TranslateFn, + availableModes?: Array<'gpu' | 'cpu'>, + ) => EngineFieldDefinition; getImageExtraArgsField: (translate: TranslateFn) => EngineFieldDefinition; }; @@ -81,6 +84,9 @@ export class ModuleSettingsEngineRenderFlow { translate: options.translate, getCoreModelField: options.getCoreModelField, getComputeModeField: options.getComputeModeField, + ...(app.installedComputeModes !== undefined + ? { availableComputeModes: app.installedComputeModes } + : {}), getImageExtraArgsField: options.getImageExtraArgsField, getTextFields: options.getTextFields, }); @@ -106,6 +112,7 @@ export class ModuleSettingsEngineRenderFlow { isImage: boolean; modelPlaceholder: string; translate: TranslateFn; + availableComputeModes?: Array<'gpu' | 'cpu'>; getCoreModelField: ModuleSettingsEngineRenderOptions['getCoreModelField']; getComputeModeField: ModuleSettingsEngineRenderOptions['getComputeModeField']; getImageExtraArgsField: ModuleSettingsEngineRenderOptions['getImageExtraArgsField']; @@ -116,7 +123,10 @@ export class ModuleSettingsEngineRenderFlow { options.modelPlaceholder, options.isImage, ); - const computeField = options.getComputeModeField(options.translate); + const computeField = options.getComputeModeField( + options.translate, + options.availableComputeModes, + ); this._deps.renderFieldRow(options.container, { ...coreField, diff --git a/src/features/settings/ui/ModuleSettingsEngineRenderer.test.ts b/src/features/settings/ui/ModuleSettingsEngineRenderer.test.ts index 1a64d406..67b5c42d 100644 --- a/src/features/settings/ui/ModuleSettingsEngineRenderer.test.ts +++ b/src/features/settings/ui/ModuleSettingsEngineRenderer.test.ts @@ -222,7 +222,7 @@ describe('ModuleSettingsEngineRenderer', () => { type: 'select', isEngineConfig: false, options: ['Euler', 'DDIM'], - appId: 'stable-diffusion', + appId: 'sdcpp', config: null, }); diff --git a/src/features/settings/ui/ModuleSettingsEngineRenderer.ts b/src/features/settings/ui/ModuleSettingsEngineRenderer.ts index a58e451d..3c7b6595 100644 --- a/src/features/settings/ui/ModuleSettingsEngineRenderer.ts +++ b/src/features/settings/ui/ModuleSettingsEngineRenderer.ts @@ -282,7 +282,8 @@ export class ModuleSettingsEngineRenderer { getTextFields: (translate) => this._fieldCatalog.buildTextEngineFields(translate), getCoreModelField: (translate, modelPlaceholder, isImage) => this._fieldCatalog.buildCoreModelField(translate, modelPlaceholder, isImage), - getComputeModeField: (translate) => this._fieldCatalog.buildComputeModeField(translate), + getComputeModeField: (translate, availableModes) => + this._fieldCatalog.buildComputeModeField(translate, availableModes), getImageExtraArgsField: (translate) => this._fieldCatalog.buildImageExtraArgsField(translate), }); @@ -376,10 +377,14 @@ export class ModuleSettingsEngineRenderer { const buttons = new Map(); const syncDisplay = () => { - const currentValue = + let currentValue = hiddenInput.value === '' ? String(options.defaultValue ?? 'gpu') : hiddenInput.value; + if (!buttons.has(currentValue)) { + currentValue = options.options?.[0] ?? String(options.defaultValue ?? 'gpu'); + hiddenInput.value = currentValue; + } buttons.forEach((button, value) => { const selected = value === currentValue; button.classList.toggle('selected', selected); diff --git a/src/features/settings/ui/ModuleSettingsUI.ts b/src/features/settings/ui/ModuleSettingsUI.ts index 2515657a..4d2c236d 100644 --- a/src/features/settings/ui/ModuleSettingsUI.ts +++ b/src/features/settings/ui/ModuleSettingsUI.ts @@ -24,7 +24,6 @@ import { type TauriProvider } from '@/infrastructure/tauri/TauriProvider'; import { type NavigationService } from '@/infrastructure/navigation/NavigationService'; import { EngineConfigService } from '@/features/ai/services/EngineConfigService'; import { ModuleSettingsModalController } from './ModuleSettingsModalController'; -import { ModuleSettingsBridgeController } from './ModuleSettingsBridgeController'; import type { ModuleSettingsAutosaveController } from './ModuleSettingsAutosaveController'; import type { ModuleSettingsCustomUiController } from './ModuleSettingsCustomUiController'; import type { ModuleSettingsEngineRenderer } from './ModuleSettingsEngineRenderer'; @@ -57,7 +56,6 @@ export class ModuleSettingsUI { private _engineRenderer: ModuleSettingsEngineRenderer | null = null; private _schemaRenderer: ModuleSettingsSchemaRenderer | null = null; private readonly _modalController: ModuleSettingsModalController; - private readonly _bridgeController: ModuleSettingsBridgeController; private readonly _viewHelper = new ModuleSettingsViewHelper(); private _autosaveController: ModuleSettingsAutosaveController | null = null; private _customUiController: ModuleSettingsCustomUiController | null = null; @@ -86,7 +84,6 @@ export class ModuleSettingsUI { private readonly _deps: ModuleSettingsUIDeps, ) { this._engineConfigService = new EngineConfigService(_tauri, this._deps.tracer); - this._bridgeController = new ModuleSettingsBridgeController(this._deps.tracer); this._modalController = new ModuleSettingsModalController(_navigation, { closeAppSelection: () => { this._deps.closeAppSelection(); @@ -146,9 +143,6 @@ export class ModuleSettingsUI { this._loadCardWidths(); this._initCardResizer(); - this._bridgeController.install( - async (app: IApp) => await this._openModuleSettingsHelper(app), - ); this._bindGlobalEvents(); } @@ -218,7 +212,6 @@ export class ModuleSettingsUI { this._unbindGlobalEvents(); this._destroyCardResizer(); aiSettingsRenderer.destroy(); - this._bridgeController.uninstall(); this._deps.tracer.info('[ModuleSettingsUI] Destroyed.'); } @@ -245,12 +238,12 @@ export class ModuleSettingsUI { } private _bindGlobalEvents(): void { - globalThis.addEventListener('lang:changed', this._boundLangChanged); + globalThis.addEventListener('language-changed', this._boundLangChanged); } private _unbindGlobalEvents(): void { this._unbindDropdownEvents(); - globalThis.removeEventListener('lang:changed', this._boundLangChanged); + globalThis.removeEventListener('language-changed', this._boundLangChanged); } private _initCardResizer(): void { diff --git a/src/infrastructure/i18n/I18nService.test.ts b/src/infrastructure/i18n/I18nService.test.ts index b86e7edb..5d83133b 100644 --- a/src/infrastructure/i18n/I18nService.test.ts +++ b/src/infrastructure/i18n/I18nService.test.ts @@ -140,7 +140,7 @@ describe('I18nService', () => { }); describe('loadTranslations', () => { - it('should dispatch legacy DOM events and event bus notifications when translations load', async () => { + it('should dispatch DOM events and event bus notifications when translations load', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({ greeting: 'Hello' }), @@ -148,7 +148,6 @@ describe('I18nService', () => { vi.stubGlobal('fetch', fetchMock); const languageChangedHandler = vi.fn(); - const legacyLangChangedHandler = vi.fn(); const languageBusHandler = vi.fn(); const translationsLoadedHandler = vi.fn(); @@ -156,7 +155,6 @@ describe('I18nService', () => { 'language-changed', languageChangedHandler as EventListener, ); - globalThis.addEventListener('lang:changed', legacyLangChangedHandler as EventListener); testEventBus.on('i18n:language:change', languageBusHandler); testEventBus.on('i18n:translations:loaded', translationsLoadedHandler); @@ -168,10 +166,6 @@ describe('I18nService', () => { expect( (languageChangedHandler.mock.calls[0]?.[0] as CustomEvent<{ lang: string }>).detail, ).toEqual({ lang: 'ru' }); - expect(legacyLangChangedHandler).toHaveBeenCalledTimes(1); - expect( - (legacyLangChangedHandler.mock.calls[0]?.[0] as CustomEvent).detail, - ).toBe('ru'); expect(languageBusHandler).toHaveBeenCalledWith({ lang: 'ru', previousLang: 'en' }); expect(translationsLoadedHandler).toHaveBeenCalledWith({ lang: 'ru' }); @@ -179,10 +173,6 @@ describe('I18nService', () => { 'language-changed', languageChangedHandler as EventListener, ); - globalThis.removeEventListener( - 'lang:changed', - legacyLangChangedHandler as EventListener, - ); }); it('should set currentLang after loading translations', async () => { diff --git a/src/infrastructure/i18n/I18nService.ts b/src/infrastructure/i18n/I18nService.ts index f745971d..4f5a5ff0 100644 --- a/src/infrastructure/i18n/I18nService.ts +++ b/src/infrastructure/i18n/I18nService.ts @@ -172,7 +172,6 @@ export class I18nService { private _notifyLanguageChange(lang: string, previousLang: string): void { globalThis.dispatchEvent(new CustomEvent('language-changed', { detail: { lang } })); - globalThis.dispatchEvent(new CustomEvent('lang:changed', { detail: lang })); this._eventBus.emit('i18n:language:change', { lang, previousLang }); this._eventBus.emit('i18n:translations:loaded', { lang }); } diff --git a/src/infrastructure/i18n/I18nUI.ts b/src/infrastructure/i18n/I18nUI.ts index 559c45c0..3636ba3c 100644 --- a/src/infrastructure/i18n/I18nUI.ts +++ b/src/infrastructure/i18n/I18nUI.ts @@ -318,9 +318,6 @@ export class I18nUI { } } - /** - * Initializes emoji flags (legacy compatibility). - */ public initEmojiFlags(): void { this.updateSwitcherUI(); } diff --git a/src/infrastructure/logging/LoggerService.test.ts b/src/infrastructure/logging/LoggerService.test.ts index 31ecd312..b4c80ef4 100644 --- a/src/infrastructure/logging/LoggerService.test.ts +++ b/src/infrastructure/logging/LoggerService.test.ts @@ -658,7 +658,7 @@ describe('LoggerService', () => { }); it('should skip flush fallback if no transports are configured', async () => { - tracer.error('test no __TAURI__'); + tracer.error('test no Tauri internals'); // eslint-disable-next-line @typescript-eslint/no-explicit-any (tracer as any)._transport = null; (tracer as unknown as { _fallbackTransport: null })._fallbackTransport = null; diff --git a/src/infrastructure/logging/LoggerService.ts b/src/infrastructure/logging/LoggerService.ts index 88de9b7c..b1bb8cb5 100644 --- a/src/infrastructure/logging/LoggerService.ts +++ b/src/infrastructure/logging/LoggerService.ts @@ -28,7 +28,7 @@ export class LoggerService { // Flag to prevent recursive logging loops during interception private _isInternalLog = false; private _initialized = false; - /** Injected after TauriProvider is ready — avoids direct __TAURI__ access. */ + /** Injected after TauriProvider is ready. */ private _transport: ((logs: { level: string; message: string }[]) => Promise) | null = null; /** Optional early-boot transport used before the main transport is wired. */ @@ -47,7 +47,7 @@ export class LoggerService { /** * Injects the Tauri transport after TauriProvider is initialized. - * Decouples LoggerService from direct __TAURI__ access (§4.1). + * Decouples LoggerService from the concrete IPC implementation. */ public setTransport(fn: (logs: { level: string; message: string }[]) => Promise): void { this._transport = fn; diff --git a/src/infrastructure/tauri/TauriProvider.test.ts b/src/infrastructure/tauri/TauriProvider.test.ts index 9976e41a..375f0466 100644 --- a/src/infrastructure/tauri/TauriProvider.test.ts +++ b/src/infrastructure/tauri/TauriProvider.test.ts @@ -20,19 +20,6 @@ vi.mock('@tauri-apps/api/event', () => ({ import { invoke as mockedTauriInvoke } from '@tauri-apps/api/core'; import { listen as mockedTauriListen } from '@tauri-apps/api/event'; -// Full Tauri structure that TauriProvider expects (for globalThis fallback tests) -const tauriMock = { - core: { - invoke: mockedTauriInvoke, - }, - event: { - listen: mockedTauriListen, - }, -}; - -// Must set BEFORE import -(globalThis as unknown as Record)['__TAURI__'] = tauriMock; - function createTracer(): LoggerService { return { info: vi.fn(), @@ -54,17 +41,14 @@ function createListenWithPayload(payload: unknown) { function setupWebMode(): { win: Record; - origTauri: unknown; origInternals: unknown; provider: TauriProvider; } { const win = globalThis as unknown as Record; - const origTauri = win['__TAURI__']; const origInternals = win['__TAURI_INTERNALS__']; - delete win['__TAURI__']; delete win['__TAURI_INTERNALS__']; - return { win, origTauri, origInternals, provider: new TauriProvider(createTracer()) }; + return { win, origInternals, provider: new TauriProvider(createTracer()) }; } import { TauriProvider } from '@/infrastructure/tauri/TauriProvider'; @@ -74,14 +58,12 @@ describe('TauriProvider', () => { beforeEach(() => { vi.clearAllMocks(); - // Ensure Tauri is set - (globalThis as unknown as Record)['__TAURI__'] = tauriMock; + (globalThis as unknown as Record)['__TAURI_INTERNALS__'] = {}; provider = new TauriProvider(createTracer()); }); afterEach(() => { - // Restore Tauri - (globalThis as unknown as Record)['__TAURI__'] = tauriMock; + (globalThis as unknown as Record)['__TAURI_INTERNALS__'] = {}; }); // ---------------------------------------------------------- constructor @@ -93,18 +75,17 @@ describe('TauriProvider', () => { // ---------------------------------------------------------- isTauri describe('isTauri', () => { - it('should return true when __TAURI__ is present', () => { + it('should return true when Tauri internals are present', () => { expect(provider.isTauri()).toBe(true); }); - it('should return false when __TAURI__ is missing', () => { + it('should return false when Tauri internals are missing', () => { const { provider: webProvider } = setupWebMode(); expect(webProvider.isTauri()).toBe(false); }); - it('should return true when __TAURI_INTERNALS__ is present but __TAURI__ is not', () => { + it('should return true when __TAURI_INTERNALS__ is present', () => { const win = globalThis as unknown as Record; - delete win['__TAURI__']; win['__TAURI_INTERNALS__'] = {}; const p = new TauriProvider(createTracer()); @@ -129,13 +110,11 @@ describe('TauriProvider', () => { // Wait for the async handshake promise to settle await new Promise((resolve) => setTimeout(resolve, 0)); - // Remove globals so the static check on line 35 would return false. - // If isTauri() still returns true, it MUST use the cached path (line 32). - const { win, origTauri } = setupWebMode(); + const { win, origInternals } = setupWebMode(); expect(provider.isTauri()).toBe(true); - win['__TAURI__'] = origTauri; + win['__TAURI_INTERNALS__'] = origInternals; }); it('should keep Tauri mode after a failed handshake when runtime globals are present', async () => { @@ -150,7 +129,7 @@ describe('TauriProvider', () => { }); it('should return _isTauriDetected=false after failed handshake without runtime globals', async () => { - const { win, origTauri } = setupWebMode(); + const { win, origInternals } = setupWebMode(); (mockedTauriInvoke as unknown as Mock).mockRejectedValueOnce( new Error('Handshake fail'), @@ -162,8 +141,7 @@ describe('TauriProvider', () => { expect(p.isTauri()).toBe(false); }); - // Restore - win['__TAURI__'] = origTauri; + win['__TAURI_INTERNALS__'] = origInternals; }); }); @@ -200,11 +178,9 @@ describe('TauriProvider', () => { expect(mockedTauriInvoke).toHaveBeenCalledWith('simple_command', {}); }); - it('should fallback to mock when invoke fails for non-critical commands', async () => { - // Must be in non-test mode — but since we ARE in test mode, errors propagate + it('should propagate invoke failures', async () => { (mockedTauriInvoke as unknown as Mock).mockRejectedValueOnce(new Error('fail')); - // In test mode, error is propagated await expect(provider.invoke('get_settings')).rejects.toThrow('fail'); }); @@ -431,9 +407,18 @@ describe('TauriProvider', () => { }); }); - it('should log in web mode', async () => { + it('should reject when the Tauri clipboard plugin fails', async () => { + (mockedTauriInvoke as unknown as Mock).mockRejectedValueOnce(new Error('denied')); + + await expect(provider.writeToClipboard('copied text')).rejects.toThrow('denied'); + }); + + it('should reject in web mode', async () => { const { provider: webProvider } = setupWebMode(); - await webProvider.writeToClipboard('text'); // should not throw + + await expect(webProvider.writeToClipboard('text')).rejects.toThrow( + 'Clipboard write is unavailable outside Tauri', + ); }); }); @@ -476,67 +461,24 @@ describe('TauriProvider', () => { }); }); - it('should open window in web mode', async () => { + it('should reject outside Tauri', async () => { const { provider: webProvider } = setupWebMode(); - const openSpy = vi.fn(); - vi.stubGlobal('open', openSpy); - - await webProvider.openUrl('https://example.com'); - - expect(openSpy).toHaveBeenCalledWith('https://example.com', '_blank'); + await expect(webProvider.openUrl('https://example.com')).rejects.toThrow( + 'External URL opening is unavailable outside Tauri', + ); }); }); - // ---------------------------------------------------------- mock mode - describe('mock mode', () => { - it('should work without Tauri and use mock invoke', async () => { + // ---------------------------------------------------------- web mode + describe('web mode', () => { + it('should reject command invocation without Tauri', async () => { const { provider: webProvider } = setupWebMode(); expect(webProvider.isTauri()).toBe(false); - const result = await webProvider.invoke('get_settings'); - expect(result).toEqual({ - language: 'en', - theme: 'dark', - use_gpu: true, - debug_mode: false, - }); - }); - - it('should return empty object for unknown commands', async () => { - const { provider: webProvider } = setupWebMode(); - const result = await webProvider.invoke('unknown_command'); - expect(result).toEqual({}); - }); - - it('should return mock modules for get_modules', async () => { - const { provider: webProvider } = setupWebMode(); - const result = await webProvider.invoke('get_modules'); - expect(result).toEqual([]); - }); - - it('should return mock system stats', async () => { - const { provider: webProvider } = setupWebMode(); - const result = await webProvider.invoke<{ cpu: { name: string } }>('get_system_stats'); - expect(result.cpu.name).toBe('Mock CPU'); - }); - - it('should return mock translations for get_translations', async () => { - const { provider: webProvider } = setupWebMode(); - const result = await webProvider.invoke('get_translations'); - expect(result).toEqual({}); - }); - - it('should return mock config for get_config', async () => { - const { provider: webProvider } = setupWebMode(); - const result = await webProvider.invoke<{ version: string }>('get_config'); - expect(result.version).toBe('1.0.0'); - }); - - it('should return true for validate_api_key', async () => { - const { provider: webProvider } = setupWebMode(); - const result = await webProvider.invoke('validate_api_key'); - expect(result).toBe(true); + await expect(webProvider.invoke('get_settings')).rejects.toThrow( + 'Tauri IPC unavailable for command: get_settings', + ); }); }); @@ -557,7 +499,7 @@ describe('TauriProvider', () => { }); it('should set _isTauriDetected to false when handshake fails without globals', async () => { - const { win, origTauri, provider: provider3 } = setupWebMode(); + const { win, origInternals, provider: provider3 } = setupWebMode(); (mockedTauriInvoke as unknown as Mock).mockRejectedValueOnce(new Error('Fail')); provider3.init(); @@ -566,32 +508,26 @@ describe('TauriProvider', () => { expect(provider3.isTauri()).toBe(false); }); - win['__TAURI__'] = origTauri; + win['__TAURI_INTERNALS__'] = origInternals; }); }); - // ---------------------------------------------------------- _performInvoke globalThis branch (lines 62-70) - describe('_performInvoke globalThis fallback', () => { - it('should use globalThis.__TAURI__.core.invoke when tauriInvoke is falsy', async () => { - // To hit the globalInvoke branch, we need tauriInvoke to NOT be a function. - // Since tauriInvoke is always a mock function in tests, we simulate by - // making it throw (which is what _handleInvokeError deals with). - // Instead we test the branch indirectly via isTauri() + successful invoke path. + describe('_performInvoke', () => { + it('should invoke through the imported Tauri API', async () => { (mockedTauriInvoke as unknown as Mock).mockResolvedValueOnce({ result: 'ok' }); const result = await provider.invoke<{ result: string }>('some_cmd'); expect(result.result).toBe('ok'); }); - it('should fallback to mock when __TAURI__ is removed', async () => { - const { provider: webProvider, win, origTauri } = setupWebMode(); + it('should reject when Tauri internals are missing', async () => { + const { provider: webProvider, win, origInternals } = setupWebMode(); - // Without __TAURI__, isTauri() is false → _mockInvoke is used - const result = await webProvider.invoke('any_cmd'); - expect(result).toEqual({}); + await expect(webProvider.invoke('any_cmd')).rejects.toThrow( + 'Tauri IPC unavailable for command: any_cmd', + ); - // Restore - win['__TAURI__'] = origTauri; + win['__TAURI_INTERNALS__'] = origInternals; }); }); }); diff --git a/src/infrastructure/tauri/TauriProvider.ts b/src/infrastructure/tauri/TauriProvider.ts index 0bf3b20b..0c41c13e 100644 --- a/src/infrastructure/tauri/TauriProvider.ts +++ b/src/infrastructure/tauri/TauriProvider.ts @@ -1,6 +1,5 @@ import { listen } from '@tauri-apps/api/event'; import { invoke as tauriInvoke } from '@tauri-apps/api/core'; -import type * as Bindings from '@/shared/types/bindings'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; import type { IBridge } from '@/shared/types/IBridge'; @@ -55,7 +54,7 @@ export class TauriProvider implements IBridge { this._tracer.warn( this._isTauriDetected ? '[TauriProvider] Handshake failed, keeping Tauri IPC because runtime globals are present' - : '[TauriProvider] Handshake failed, operating in Mock mode', + : '[TauriProvider] Handshake failed, Tauri runtime is unavailable', ); } } @@ -86,7 +85,7 @@ export class TauriProvider implements IBridge { args: A = {} as A, ): Promise { if (!this.isTauri()) { - return this._mockInvoke(cmd, args); + return Promise.reject(new Error(`Tauri IPC unavailable for command: ${cmd}`)); } try { @@ -149,7 +148,7 @@ export class TauriProvider implements IBridge { } /** - * Internal execution of Tauri IPC with multiple fallback strategies. + * Internal execution of Tauri IPC. */ private async _performInvoke(cmd: string, args: unknown): Promise { return await tauriInvoke(cmd, args as Record); @@ -190,7 +189,7 @@ export class TauriProvider implements IBridge { }); return unlisten; } else { - this._tracer.info(`[TauriProvider] Mock Listen: ${event}`); + this._tracer.info(`[TauriProvider] Listen skipped outside Tauri: ${event}`); return () => { /* no-op */ }; @@ -203,12 +202,8 @@ export class TauriProvider implements IBridge { await this.invoke('plugin:clipboard-manager|write_text', { text }); return; } catch (error) { - try { - if (await this._writeBrowserClipboard(text)) { - return; - } - } catch { - /* Preserve the original Tauri clipboard error. */ + if (await this._writeBrowserClipboard(text)) { + return; } throw error; } @@ -328,64 +323,6 @@ export class TauriProvider implements IBridge { return { exists: false, length: 0 }; } } - - private _mockInvoke(cmd: string, args: unknown): Promise { - this._tracer.debug(`[Mock Invoke] ${cmd} ${JSON.stringify(args)}`); - - const saneDefaults: Record = { - get_settings: { - language: 'en', - theme: 'dark', - use_gpu: true, - debug_mode: false, - } as Bindings.AppSettings, - get_ui_state: {}, - get_module_settings: {}, - get_translations: {}, - get_system_language: 'en', - get_config: { - version: '1.0.0', - catalog: { ai: [], services: [], stars: [] }, - apiProviders: [], - } as Bindings.AppConfig, - get_modules: [] satisfies Bindings.Module[], - get_logs: [], - get_app_bootstrap_data: null, - get_system_stats: { - cpu: { percent: 0, cores: 0, name: 'Mock CPU' }, - ram: { percent: 0, usedGb: 0, totalGb: 16, availableGb: 16 }, - gpu: { usage: 0, memoryUsed: 0, memoryTotal: 0, temp: 0, name: 'Mock GPU' }, - vram: { percent: 0, usedGb: 0, totalGb: 8 }, - disk: { - readRate: 0, - writeRate: 0, - utilization: 0, - totalGb: 500, - usedGb: 0, - activityPercent: 0, - }, - network: { - downloadRate: 0, - uploadRate: 0, - totalReceived: 0, - totalSent: 0, - utilization: 0, - activityPercent: 0, - }, - pid: 1234, - appCpu: 0, - appMemory: 0, - } satisfies Bindings.SystemStats, - validate_api_key: true, - has_secure_key: false, - get_secure_key_meta: { exists: false, length: 0 } satisfies SecureKeyMeta, - clear_logs: null, - save_ui_state: null, - save_setting: true, - }; - - return Promise.resolve((saneDefaults[cmd] ?? {}) as unknown as T); - } } function stringifyInvokePayload(payload: unknown): string { diff --git a/src/public/templates/pages/settings.html b/src/public/templates/pages/settings.html index 9fc5d037..753c57be 100644 --- a/src/public/templates/pages/settings.html +++ b/src/public/templates/pages/settings.html @@ -1,16 +1,22 @@
    - -
    Taskbar Management
    -
    Configure tab visibility
    +
    + Sidebar Management +
    +
    + Configure sidebar page visibility +
    - -
    -
    Monitoring Management
    -
    System monitor visibility settings
    +
    +
    + Monitoring Management +
    +
    + System monitor visibility settings +
    diff --git a/src/shared/config/catalog_fallback.ts b/src/shared/config/catalog_fallback.ts deleted file mode 100644 index a3eb860b..00000000 --- a/src/shared/config/catalog_fallback.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { AppConfig } from '@/shared/types/bindings'; - -export const FALLBACK_CONFIG: AppConfig = { - version: '1.0.0', - apiProviders: [], - catalog: { - ai: [], - services: [], - stars: [], - }, -}; diff --git a/src/shared/services/CatalogLoadSnapshot.ts b/src/shared/services/CatalogLoadSnapshot.ts index 5e8f5817..934d3335 100644 --- a/src/shared/services/CatalogLoadSnapshot.ts +++ b/src/shared/services/CatalogLoadSnapshot.ts @@ -4,6 +4,7 @@ import type { IModule } from '@/shared/types/coreTypes'; export type EngineDefinition = { id: string; installed: boolean; + installed_compute_modes?: Array<'gpu' | 'cpu'>; }; export type CatalogLoadSnapshot = { diff --git a/src/shared/services/CatalogService.test.ts b/src/shared/services/CatalogService.test.ts index fb08b652..7df927e5 100644 --- a/src/shared/services/CatalogService.test.ts +++ b/src/shared/services/CatalogService.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { CatalogService } from './CatalogService'; import type { IModule } from '@/shared/types/coreTypes'; -import { FALLBACK_CONFIG } from '@/shared/config/catalog_fallback'; import { createCatalogHarness, createMockAppConfig, @@ -78,7 +77,7 @@ describe('CatalogService', () => { expect(app?.type).toBe('local'); }); - it('should fallback to FALLBACK_CONFIG if config is empty or invalid', async () => { + it('should keep an explicitly empty catalog empty', async () => { const invalidConfig = createMockAppConfig(); setupBridgeMocks(mockBridge, invalidConfig); @@ -87,9 +86,8 @@ describe('CatalogService', () => { const catalog = service.getCatalog(); - // Should be hydrated from fallback source - expect(catalog.ai.length).toBe(FALLBACK_CONFIG.catalog.ai.length); - expect(catalog.ai[0]?.id).toBe(FALLBACK_CONFIG.catalog.ai[0]?.id); + expect(catalog.ai).toHaveLength(0); + expect(catalog.services).toHaveLength(0); }); it('should inject apiProviderData for API modules', async () => { @@ -133,8 +131,7 @@ describe('CatalogService', () => { }); }); - // ---------------------------------------------------------- getCatalogCategory fallback (lines 39-40) - describe('getCatalogCategory fallback', () => { + describe('getCatalogCategory defaults', () => { it('should return empty array for unknown category', () => { // getCatalogCategory is now on GlobalBridge, not CatalogService // Test service-level method instead @@ -161,8 +158,7 @@ describe('CatalogService', () => { }); }); - // ---------------------------------------------------------- invoke fallback - describe('bridge fallback', () => { + describe('bridge failure handling', () => { it('should load config through bridge even when isTauri=false', async () => { const mockConfig = createMockAppConfig({ catalog: { ai: [{ id: 'fetched-ai', name: 'Fetched AI' }], services: [] }, @@ -178,27 +174,29 @@ describe('CatalogService', () => { expect(mockBridge.invoke).toHaveBeenCalledWith('get_config'); }); - it('should fallback to FALLBACK_CONFIG when bridge returns null config', async () => { + it('should use an empty catalog when bridge returns null config', async () => { mockBridge.isTauri.mockReturnValue(false); setupBridgeMocks(mockBridge, null); await service.loadCatalog(); const catalog = service.getCatalog(); - expect(catalog.ai.length).toBe(FALLBACK_CONFIG.catalog.ai.length); + expect(catalog.ai).toHaveLength(0); + expect(catalog.services).toHaveLength(0); }); - it('should fallback when bridge throws', async () => { + it('should use an empty catalog when bridge throws', async () => { mockBridge.isTauri.mockReturnValue(false); mockBridge.invoke.mockRejectedValue(new Error('Bridge error')); await service.loadCatalog(); const catalog = service.getCatalog(); - expect(catalog.ai.length).toBe(FALLBACK_CONFIG.catalog.ai.length); + expect(catalog.ai).toHaveLength(0); + expect(catalog.services).toHaveLength(0); }); - it('should fallback when bridge returns malformed catalog shape', async () => { + it('should use an empty catalog when bridge returns malformed catalog shape', async () => { setupBridgeMocks( mockBridge, createMockAppConfig({ @@ -210,23 +208,23 @@ describe('CatalogService', () => { await service.loadCatalog(); const catalog = service.getCatalog(); - expect(catalog.ai.length).toBe(FALLBACK_CONFIG.catalog.ai.length); - expect(catalog.services.length).toBe(FALLBACK_CONFIG.catalog.services.length); + expect(catalog.ai).toHaveLength(0); + expect(catalog.services).toHaveLength(0); expect(globalThis.dispatchEvent).toHaveBeenCalledWith( expect.objectContaining({ type: 'catalog-loaded' }), ); }); }); - // ---------------------------------------------------------- _ensureValidConfig null config (lines 278-279) describe('_ensureValidConfig null config', () => { - it('should use FALLBACK_CONFIG when bridge invoke returns null', async () => { + it('should use an empty catalog when bridge invoke returns null', async () => { setupBridgeMocks(mockBridge, null); await service.loadCatalog(); const catalog = service.getCatalog(); - expect(catalog.ai.length).toBe(FALLBACK_CONFIG.catalog.ai.length); + expect(catalog.ai).toHaveLength(0); + expect(catalog.services).toHaveLength(0); }); }); @@ -251,8 +249,7 @@ describe('CatalogService', () => { }); }); - // ---------------------------------------------------------- _ensureFallbacks api-type (L193) - describe('_ensureFallbacks api-type branch (L193)', () => { + describe('catalog hydration', () => { it('should mark api-type apps as installed=true', async () => { const config = createMockAppConfig({ catalog: { diff --git a/src/shared/services/CatalogService.ts b/src/shared/services/CatalogService.ts index d396a7af..8c168707 100644 --- a/src/shared/services/CatalogService.ts +++ b/src/shared/services/CatalogService.ts @@ -7,10 +7,18 @@ import type { IBridge } from '@/shared/types/IBridge'; import type { IApp, IModule, IConfigField, ICatalogData } from '@/shared/types/coreTypes'; import type { AppConfig, ModuleItem, ApiProvider } from '@/shared/types/bindings'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; -import { FALLBACK_CONFIG } from '@/shared/config/catalog_fallback'; import type { CatalogLoadSnapshot, EngineDefinition } from './CatalogLoadSnapshot'; type CatalogLogger = Pick; +const EMPTY_CONFIG: AppConfig = { + version: '1.0.0', + apiProviders: [], + catalog: { + ai: [], + services: [], + stars: [], + }, +}; export class CatalogService { private readonly _appData: ICatalogData = { ai: [], services: [] }; @@ -41,9 +49,6 @@ export class CatalogService { // Hydrate with schemas, providers & engine install status this._hydrateApps(snapshot.config, snapshot.installedModules, snapshot.engineDefs); - // Final check for fallbacks - this._ensureFallbacks(); - const event = new CustomEvent('catalog-loaded'); globalThis.dispatchEvent(event); } catch (e) { @@ -96,17 +101,12 @@ export class CatalogService { }; } - /** - * Loads the configuration from backend or fallback. - */ - private async _loadConfig(): Promise { + private async _loadConfig(): Promise { try { return await this._bridge.invoke('get_config'); } catch (e) { - this._tracer.warn( - `[CatalogService] Backend config failed, using fallback: ${String(e)}`, - ); - return FALLBACK_CONFIG; + this._tracer.warn(`[CatalogService] Backend config failed: ${String(e)}`); + return null; } } @@ -182,6 +182,12 @@ export class CatalogService { const installedMap = new Map(installedModules.map((m) => [m.id.toLowerCase(), m])); // Build a fast lookup for engine installation status const engineInstallMap = new Map(engineDefs.map((e) => [e.id.toLowerCase(), e.installed])); + const engineComputeModesMap = new Map( + engineDefs.map((engine) => [ + engine.id.toLowerCase(), + (engine.installed_compute_modes ?? []) as Array<'gpu' | 'cpu'>, + ]), + ); const mergeAppSchema = (app: IApp) => { const isApi = @@ -202,6 +208,7 @@ export class CatalogService { } else if (app.type === 'local' && engineInstallMap.has(app.id.toLowerCase())) { // Use real-time detection from is_engine_installed() app.installed = engineInstallMap.get(app.id.toLowerCase()) ?? false; + app.installedComputeModes = engineComputeModesMap.get(app.id.toLowerCase()) ?? []; } else if (app.type === 'local' && installedModule) { // Non-engine local modules should render as installed immediately. // Otherwise the modal first paints the "download" style and only then @@ -274,34 +281,6 @@ export class CatalogService { }; } - /** - * Ensures each category has at least one app from fallbacks if empty. - */ - private _ensureFallbacks(): void { - const fallbackAi = FALLBACK_CONFIG.catalog.ai; - const fallbackServices = FALLBACK_CONFIG.catalog.services; - - if (this._appData.ai.length === 0) { - this._tracer.warn( - `[CatalogService] AI catalog still empty (fallback source has ${String(fallbackAi.length)} items), injecting fallbacks.`, - ); - this._appData.ai = this._mapModuleItems(fallbackAi, 'ai'); - this._tracer.info( - `[CatalogService] AI catalog now has ${String(this._appData.ai.length)} items.`, - ); - } - - if (this._appData.services.length === 0) { - this._tracer.warn( - `[CatalogService] Services catalog still empty (fallback source has ${String(fallbackServices.length)} items), injecting fallbacks.`, - ); - this._appData.services = this._mapModuleItems(fallbackServices, 'services'); - this._tracer.info( - `[CatalogService] Services catalog now has ${String(this._appData.services.length)} items.`, - ); - } - } - /** * Returns the current catalog data. */ @@ -319,29 +298,15 @@ export class CatalogService { ); } - /** - * Validates the configuration and returns a fallback if invalid. - */ private _ensureValidConfig(config: AppConfig | null): AppConfig { - const fallback = FALLBACK_CONFIG; - if (!config) { - this._tracer.warn('[CatalogService] Config is null. Using FALLBACK_CONFIG.'); - return fallback; + this._tracer.warn('[CatalogService] Config is unavailable. Using empty catalog.'); + return EMPTY_CONFIG; } if (!this._hasCatalogArrays(config)) { - this._tracer.warn('[CatalogService] Config shape is invalid. Using FALLBACK_CONFIG.'); - return fallback; - } - - if (config.catalog.ai.length === 0 && config.catalog.services.length === 0) { - const aiLen = config.catalog.ai.length; - const srvLen = config.catalog.services.length; - this._tracer.warn( - `[CatalogService] Config invalid or empty (AI: ${String(aiLen)}, Services: ${String(srvLen)}). FORCING FALLBACK_CONFIG.`, - ); - return fallback; + this._tracer.warn('[CatalogService] Config shape is invalid. Using empty catalog.'); + return EMPTY_CONFIG; } return config; } diff --git a/src/shared/services/ModuleService.test.ts b/src/shared/services/ModuleService.test.ts index bb1b99b2..b0034daa 100644 --- a/src/shared/services/ModuleService.test.ts +++ b/src/shared/services/ModuleService.test.ts @@ -246,17 +246,20 @@ describe('ModuleService', () => { expect(moduleService.getDownloadState('test-module')?.status).not.toBe('error'); }); - it('should treat legacy paused errors as interrupted downloads', async () => { + it('should surface unexpected paused errors as failed downloads', async () => { mocks.invokeSafe.mockResolvedValueOnce({ status: 'error', error: { message: 'Download paused' }, }); - const result = await moduleService.downloadModule('test-module', 'url'); + await expect(moduleService.downloadModule('test-module', 'url')).rejects.toThrow( + 'Download paused', + ); - expect(result).toBe('paused'); - expect(mocks.tracer.error).not.toHaveBeenCalled(); - expect(moduleService.getDownloadState('test-module')?.status).not.toBe('error'); + expect(mocks.tracer.error).toHaveBeenCalledWith( + '[ModuleService] Download error for test-module: Download paused', + ); + expect(moduleService.getDownloadState('test-module')?.status).toBe('error'); }); }); diff --git a/src/shared/services/ModuleService.ts b/src/shared/services/ModuleService.ts index 87bd82f5..d082a94b 100644 --- a/src/shared/services/ModuleService.ts +++ b/src/shared/services/ModuleService.ts @@ -148,19 +148,11 @@ export class ModuleService { ); if (result.status === 'error') { - const interrupted = this._downloadOutcomeFromError(result.error.message); - if (interrupted !== null) { - return interrupted; - } throw new Error(result.error.message); } return this._normalizeDownloadOutcome(result.data); } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); - const interrupted = this._downloadOutcomeFromError(errorMessage); - if (interrupted !== null) { - return interrupted; - } this._tracer.error(`[ModuleService] Download error for ${moduleId}: ${errorMessage}`); this._publishDownloadProgress({ module_id: moduleId, @@ -256,17 +248,6 @@ export class ModuleService { return 'completed'; } - private _downloadOutcomeFromError(message: string): DownloadModuleOutcome | null { - const normalized = message.toLowerCase(); - if (normalized.includes('download paused')) { - return 'paused'; - } - if (normalized.includes('download cancelled')) { - return 'cancelled'; - } - return null; - } - /** * Cancels an in-progress module download. * @param moduleId - The ID of the module whose download to cancel diff --git a/src/shared/services/WindowNativeBridgeHelper.test.ts b/src/shared/services/WindowNativeBridgeHelper.test.ts index 05ba3180..39b294f6 100644 --- a/src/shared/services/WindowNativeBridgeHelper.test.ts +++ b/src/shared/services/WindowNativeBridgeHelper.test.ts @@ -4,9 +4,11 @@ import { WindowNativeBridgeHelper } from './WindowNativeBridgeHelper'; describe('WindowNativeBridgeHelper', () => { const invoke = vi.fn().mockResolvedValue(undefined); - const bridge = { invoke } as unknown as IBridge; + const isTauri = vi.fn().mockReturnValue(true); + const bridge = { invoke, isTauri } as unknown as IBridge; const runtime = { - getWindowApi: vi.fn(), + getCurrentWindow: vi.fn(), + createLogicalSize: vi.fn((width: number, height: number) => ({ width, height })), }; const helper = new WindowNativeBridgeHelper( bridge, @@ -15,6 +17,7 @@ describe('WindowNativeBridgeHelper', () => { beforeEach(() => { vi.clearAllMocks(); + isTauri.mockReturnValue(true); }); it('should set size, read maximize state and persist window state', async () => { @@ -23,21 +26,16 @@ describe('WindowNativeBridgeHelper', () => { const isMaximized = vi.fn().mockResolvedValue(false); const innerSize = vi.fn().mockResolvedValue({ width: 1280, height: 720 }); const outerPosition = vi.fn().mockResolvedValue({ x: 10, y: 20 }); - const LogicalSize = vi.fn(); - - runtime.getWindowApi.mockReturnValue({ - getCurrentWindow: () => ({ - setSize, - center, - isMaximized, - innerSize, - outerPosition, - }), - LogicalSize, + runtime.getCurrentWindow.mockReturnValue({ + setSize, + center, + isMaximized, + innerSize, + outerPosition, }); await helper.setSizeAndCenter(800, 600); - expect(setSize).toHaveBeenCalled(); + expect(setSize).toHaveBeenCalledWith({ width: 800, height: 600 }); expect(center).toHaveBeenCalled(); expect(await helper.isMaximized()).toBe(false); @@ -49,7 +47,7 @@ describe('WindowNativeBridgeHelper', () => { }); it('should gracefully no-op when runtime window api is unavailable', async () => { - runtime.getWindowApi.mockReturnValue(null); + isTauri.mockReturnValue(false); await expect(helper.setSizeAndCenter(800, 600)).resolves.toBeUndefined(); await expect(helper.isMaximized()).resolves.toBe(false); diff --git a/src/shared/services/WindowNativeBridgeHelper.ts b/src/shared/services/WindowNativeBridgeHelper.ts index 194ec9b5..e1266275 100644 --- a/src/shared/services/WindowNativeBridgeHelper.ts +++ b/src/shared/services/WindowNativeBridgeHelper.ts @@ -1,37 +1,20 @@ import type { IBridge } from '@/shared/types/IBridge'; +import { getCurrentWindow, LogicalSize } from '@tauri-apps/api/window'; -type WindowSize = { width: number; height: number }; -type WindowPosition = { x: number; y: number }; - -type TauriWindowHandle = { - setSize: (size: unknown) => Promise; - center: () => Promise; - isMaximized: () => Promise; - innerSize: () => Promise; - outerPosition: () => Promise; -}; - -type TauriWindowApi = { - getCurrentWindow: () => TauriWindowHandle; - LogicalSize: new (w: number, h: number) => unknown; -}; - -type TauriWindowGlobal = { - __TAURI__?: { - window?: TauriWindowApi; - }; -}; +type TauriWindowHandle = ReturnType; type WindowNativeRuntime = { - getWindowApi: () => TauriWindowApi | null; + getCurrentWindow: () => TauriWindowHandle; + createLogicalSize: ( + width: number, + height: number, + ) => Parameters[0]; }; function createDefaultWindowNativeRuntime(): WindowNativeRuntime { return { - getWindowApi: () => { - const globalWindow = globalThis as unknown as TauriWindowGlobal; - return globalWindow.__TAURI__?.window ?? null; - }, + getCurrentWindow, + createLogicalSize: (width, height) => new LogicalSize(width, height), }; } @@ -42,35 +25,34 @@ export class WindowNativeBridgeHelper { ) {} public isAvailable(): boolean { - return this._getWindowApi() !== null; + return this._bridge.isTauri(); } public async setSizeAndCenter(width: number, height: number): Promise { - const windowApi = this._getWindowApi(); - if (windowApi === null) { + if (!this.isAvailable()) { return; } - const appWindow = windowApi.getCurrentWindow(); - await appWindow.setSize(new windowApi.LogicalSize(width, height)); + const appWindow = this._runtime.getCurrentWindow(); + await appWindow.setSize(this._runtime.createLogicalSize(width, height)); await appWindow.center(); } public async isMaximized(): Promise { - const appWindow = this._getCurrentWindow(); - if (appWindow === null) { + if (!this.isAvailable()) { return false; } + const appWindow = this._runtime.getCurrentWindow(); return await appWindow.isMaximized(); } public async saveWindowState(): Promise { - const appWindow = this._getCurrentWindow(); - if (appWindow === null) { + if (!this.isAvailable()) { return; } + const appWindow = this._runtime.getCurrentWindow(); const maximized = await appWindow.isMaximized(); await this._bridge.invoke('save_maximized_state', { maximized }); @@ -90,17 +72,4 @@ export class WindowNativeBridgeHelper { y: position.y, }); } - - private _getCurrentWindow(): TauriWindowHandle | null { - const windowApi = this._getWindowApi(); - if (windowApi === null) { - return null; - } - - return windowApi.getCurrentWindow(); - } - - private _getWindowApi(): TauriWindowApi | null { - return this._runtime.getWindowApi(); - } } diff --git a/src/shared/services/WindowService.test.ts b/src/shared/services/WindowService.test.ts index cad0e74b..4138dbe8 100644 --- a/src/shared/services/WindowService.test.ts +++ b/src/shared/services/WindowService.test.ts @@ -6,6 +6,23 @@ import { WindowService, type IWindowConfig } from './WindowService'; import type { IBridge } from '@/shared/types/IBridge'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; +vi.mock('@tauri-apps/api/window', () => ({ + getCurrentWindow: () => { + const windowApi = ( + globalThis as unknown as { + __AXELATE_TEST_WINDOW__?: { getCurrentWindow?: () => unknown }; + } + ).__AXELATE_TEST_WINDOW__; + return windowApi?.getCurrentWindow?.(); + }, + LogicalSize: class { + public constructor( + public readonly width: number, + public readonly height: number, + ) {} + }, +})); + describe('WindowService', () => { let mockBridge: { isTauri: ReturnType; @@ -23,7 +40,6 @@ describe('WindowService', () => { let mockRuntime: { addEventListener: ReturnType; removeEventListener: ReturnType; - close: ReturnType; getScreenSize: ReturnType; getInnerSize: ReturnType; setAppZoomCss: ReturnType; @@ -74,7 +90,6 @@ describe('WindowService', () => { nativeRemoveEventListener(...args); }, ), - close: vi.fn(), getScreenSize: vi.fn().mockReturnValue({ width: 1920, height: 1080 }), getInnerSize: vi.fn().mockReturnValue({ width: 1920, height: 1080 }), setAppZoomCss: vi.fn(), @@ -205,9 +220,12 @@ describe('WindowService', () => { expect(mockBridge.invoke).toHaveBeenCalledWith('minimize_window'); }); - it('should log in web mode for minimize', async () => { + it('should skip native minimize outside Tauri', async () => { mockBridge.isTauri.mockReturnValue(false); await service.minimize(); // should not throw + expect(mockTracer.info).toHaveBeenCalledWith( + '[WindowService] Native minimize unavailable outside Tauri', + ); }); it('should call maximize_window via bridge', async () => { @@ -215,9 +233,12 @@ describe('WindowService', () => { expect(mockBridge.invoke).toHaveBeenCalledWith('maximize_window'); }); - it('should log in web mode for toggleMaximize', async () => { + it('should skip native maximize outside Tauri', async () => { mockBridge.isTauri.mockReturnValue(false); await service.toggleMaximize(); + expect(mockTracer.info).toHaveBeenCalledWith( + '[WindowService] Native maximize unavailable outside Tauri', + ); }); it('should call close_window via bridge', async () => { @@ -241,10 +262,12 @@ describe('WindowService', () => { expect(calls).toEqual(['save', 'close']); }); - it('should call globalThis.close in web mode', async () => { + it('should skip native close outside Tauri', async () => { mockBridge.isTauri.mockReturnValue(false); await service.close(); - expect(mockRuntime.close).toHaveBeenCalled(); + expect(mockTracer.info).toHaveBeenCalledWith( + '[WindowService] Native close unavailable outside Tauri', + ); }); }); @@ -266,9 +289,12 @@ describe('WindowService', () => { expect(mockBridge.invoke).toHaveBeenCalledWith('minimize_window'); }); - it('should log in web mode', async () => { + it('should skip native hide-to-tray outside Tauri', async () => { mockBridge.isTauri.mockReturnValue(false); await service.hideToTray(); // should not throw + expect(mockTracer.info).toHaveBeenCalledWith( + '[WindowService] Native hide-to-tray unavailable outside Tauri', + ); }); }); @@ -590,14 +616,12 @@ describe('WindowService', () => { const mockCenter = vi.fn().mockResolvedValue(undefined); const mockLogicalSize = vi.fn(); - vi.stubGlobal('__TAURI__', { - window: { - getCurrentWindow: () => ({ - setSize: mockSetSize, - center: mockCenter, - }), + vi.stubGlobal('__AXELATE_TEST_WINDOW__', { + getCurrentWindow: () => ({ + setSize: mockSetSize, + center: mockCenter, LogicalSize: mockLogicalSize, - }, + }), }); await service.setSize(800, 600); @@ -607,14 +631,12 @@ describe('WindowService', () => { }); it('should handle error gracefully', async () => { - vi.stubGlobal('__TAURI__', { - window: { - getCurrentWindow: () => ({ - setSize: vi.fn().mockRejectedValue(new Error('fail')), - center: vi.fn(), - }), + vi.stubGlobal('__AXELATE_TEST_WINDOW__', { + getCurrentWindow: () => ({ + setSize: vi.fn().mockRejectedValue(new Error('fail')), + center: vi.fn(), LogicalSize: vi.fn(), - }, + }), }); await service.setSize(800, 600); // should not throw @@ -625,8 +647,8 @@ describe('WindowService', () => { await service.setSize(800, 600); // no throw, no calls }); - it('should skip if __TAURI__.window is missing', async () => { - vi.stubGlobal('__TAURI__', {}); + it('should skip if the Tauri window handle is missing', async () => { + vi.stubGlobal('__AXELATE_TEST_WINDOW__', {}); await service.setSize(800, 600); // should not throw }); }); @@ -634,12 +656,10 @@ describe('WindowService', () => { // ---------------------------------------------------------- isMaximized describe('isMaximized', () => { it('should return true when Tauri window is maximized', async () => { - vi.stubGlobal('__TAURI__', { - window: { - getCurrentWindow: () => ({ - isMaximized: vi.fn().mockResolvedValue(true), - }), - }, + vi.stubGlobal('__AXELATE_TEST_WINDOW__', { + getCurrentWindow: () => ({ + isMaximized: vi.fn().mockResolvedValue(true), + }), }); const result = await service.isMaximized(); @@ -647,12 +667,10 @@ describe('WindowService', () => { }); it('should return false when Tauri window is not maximized', async () => { - vi.stubGlobal('__TAURI__', { - window: { - getCurrentWindow: () => ({ - isMaximized: vi.fn().mockResolvedValue(false), - }), - }, + vi.stubGlobal('__AXELATE_TEST_WINDOW__', { + getCurrentWindow: () => ({ + isMaximized: vi.fn().mockResolvedValue(false), + }), }); const result = await service.isMaximized(); @@ -660,20 +678,18 @@ describe('WindowService', () => { }); it('should return false on error', async () => { - vi.stubGlobal('__TAURI__', { - window: { - getCurrentWindow: () => ({ - isMaximized: vi.fn().mockRejectedValue(new Error('fail')), - }), - }, + vi.stubGlobal('__AXELATE_TEST_WINDOW__', { + getCurrentWindow: () => ({ + isMaximized: vi.fn().mockRejectedValue(new Error('fail')), + }), }); const result = await service.isMaximized(); expect(result).toBe(false); }); - it('should return false if __TAURI__.window is missing', async () => { - vi.stubGlobal('__TAURI__', {}); + it('should return false if the Tauri window handle is missing', async () => { + vi.stubGlobal('__AXELATE_TEST_WINDOW__', {}); const result = await service.isMaximized(); expect(result).toBe(false); }); @@ -706,14 +722,12 @@ describe('WindowService', () => { it('should save window state (maximized) when Tauri window resolves', async () => { const mockIsMaximized = vi.fn().mockResolvedValue(true); - vi.stubGlobal('__TAURI__', { - window: { - getCurrentWindow: () => ({ - isMaximized: mockIsMaximized, - innerSize: vi.fn().mockResolvedValue({ width: 1280, height: 720 }), - outerPosition: vi.fn().mockResolvedValue({ x: 0, y: 0 }), - }), - }, + vi.stubGlobal('__AXELATE_TEST_WINDOW__', { + getCurrentWindow: () => ({ + isMaximized: mockIsMaximized, + innerSize: vi.fn().mockResolvedValue({ width: 1280, height: 720 }), + outerPosition: vi.fn().mockResolvedValue({ x: 0, y: 0 }), + }), }); await service.init(mockWindowConfig, 1); @@ -732,14 +746,12 @@ describe('WindowService', () => { const mockInnerSize = vi.fn().mockResolvedValue({ width: 1000, height: 600 }); const mockOuterPos = vi.fn().mockResolvedValue({ x: 100, y: 50 }); - vi.stubGlobal('__TAURI__', { - window: { - getCurrentWindow: () => ({ - isMaximized: mockIsMaximized, - innerSize: mockInnerSize, - outerPosition: mockOuterPos, - }), - }, + vi.stubGlobal('__AXELATE_TEST_WINDOW__', { + getCurrentWindow: () => ({ + isMaximized: mockIsMaximized, + innerSize: mockInnerSize, + outerPosition: mockOuterPos, + }), }); await service.init(mockWindowConfig, 1); @@ -762,14 +774,12 @@ describe('WindowService', () => { it('should await immediate window state save', async () => { const mockIsMaximized = vi.fn().mockResolvedValue(true); - vi.stubGlobal('__TAURI__', { - window: { - getCurrentWindow: () => ({ - isMaximized: mockIsMaximized, - innerSize: vi.fn().mockResolvedValue({ width: 1280, height: 720 }), - outerPosition: vi.fn().mockResolvedValue({ x: 0, y: 0 }), - }), - }, + vi.stubGlobal('__AXELATE_TEST_WINDOW__', { + getCurrentWindow: () => ({ + isMaximized: mockIsMaximized, + innerSize: vi.fn().mockResolvedValue({ width: 1280, height: 720 }), + outerPosition: vi.fn().mockResolvedValue({ x: 0, y: 0 }), + }), }); await service.saveImmediate(); @@ -781,14 +791,12 @@ describe('WindowService', () => { it('should cancel pending debounced save when saving immediately', async () => { const mockIsMaximized = vi.fn().mockResolvedValue(true); - vi.stubGlobal('__TAURI__', { - window: { - getCurrentWindow: () => ({ - isMaximized: mockIsMaximized, - innerSize: vi.fn().mockResolvedValue({ width: 1280, height: 720 }), - outerPosition: vi.fn().mockResolvedValue({ x: 0, y: 0 }), - }), - }, + vi.stubGlobal('__AXELATE_TEST_WINDOW__', { + getCurrentWindow: () => ({ + isMaximized: mockIsMaximized, + innerSize: vi.fn().mockResolvedValue({ width: 1280, height: 720 }), + outerPosition: vi.fn().mockResolvedValue({ x: 0, y: 0 }), + }), }); service.scheduleSave(); @@ -818,14 +826,12 @@ describe('WindowService', () => { const mockInnerSize = vi.fn().mockResolvedValue({ width: 1000, height: 600 }); const mockOuterPos = vi.fn().mockResolvedValue({ x: 100, y: 50 }); - vi.stubGlobal('__TAURI__', { - window: { - getCurrentWindow: () => ({ - isMaximized: mockIsMaximized, - innerSize: mockInnerSize, - outerPosition: mockOuterPos, - }), - }, + vi.stubGlobal('__AXELATE_TEST_WINDOW__', { + getCurrentWindow: () => ({ + isMaximized: mockIsMaximized, + innerSize: mockInnerSize, + outerPosition: mockOuterPos, + }), }); const first = service.saveImmediate(); diff --git a/src/shared/services/WindowService.ts b/src/shared/services/WindowService.ts index f5e84c6f..92ff8b8c 100644 --- a/src/shared/services/WindowService.ts +++ b/src/shared/services/WindowService.ts @@ -57,7 +57,6 @@ const PAGE_ZOOM_PROFILES: Record = { type WindowRuntime = { addEventListener: typeof globalThis.addEventListener; removeEventListener: typeof globalThis.removeEventListener; - close: () => void; getScreenSize: () => { width: number; height: number }; getInnerSize: () => { width: number; height: number }; setAppZoomCss: (zoom: string) => void; @@ -69,9 +68,6 @@ function createDefaultWindowRuntime(): WindowRuntime { return { addEventListener: globalThis.addEventListener.bind(globalThis), removeEventListener: globalThis.removeEventListener.bind(globalThis), - close: () => { - globalThis.close(); - }, getScreenSize: () => ({ width: globalThis.screen.width, height: globalThis.screen.height, @@ -130,7 +126,6 @@ export class WindowService { this._nativeHelper = new WindowNativeBridgeHelper(_bridge); this._actions = new WindowServiceActions({ bridge: _bridge, - runtime: _runtime, tracer: this._tracer, beforeClose: () => this._beforeClose, }); @@ -199,7 +194,7 @@ export class WindowService { // Initialize persistence listeners this._persistence.initWindowListeners(); } else { - // Web Fallback: Load from localStorage or default to 1 + // Non-native test/runtime path: apply the injected/default zoom without persistence. this._currentZoom = fallbackZoom; this._runtime.setAppZoomCss(this._currentZoom.toFixed(3)); } @@ -227,7 +222,7 @@ export class WindowService { } /** - * Closes the application window or browser tab. + * Closes the native application window. */ public async close(): Promise { await this._actions.close(); diff --git a/src/shared/services/WindowServiceActions.ts b/src/shared/services/WindowServiceActions.ts index 976f834c..17e612f3 100644 --- a/src/shared/services/WindowServiceActions.ts +++ b/src/shared/services/WindowServiceActions.ts @@ -6,13 +6,8 @@ type WindowActionsLogger = { error: (message: string) => void; }; -type WindowActionsRuntime = { - close: () => void; -}; - type WindowServiceActionsDeps = { bridge: IBridge; - runtime: WindowActionsRuntime; tracer: WindowActionsLogger; beforeClose: () => (() => Promise) | null; }; @@ -24,7 +19,7 @@ export class WindowServiceActions { if (this._deps.bridge.isTauri()) { await this._deps.bridge.invoke('minimize_window'); } else { - this._deps.tracer.info('[WindowService] minimize (mock)'); + this._deps.tracer.info('[WindowService] Native minimize unavailable outside Tauri'); } } @@ -32,7 +27,7 @@ export class WindowServiceActions { if (this._deps.bridge.isTauri()) { await this._deps.bridge.invoke('maximize_window'); } else { - this._deps.tracer.info('[WindowService] toggleMaximize (mock)'); + this._deps.tracer.info('[WindowService] Native maximize unavailable outside Tauri'); } } @@ -42,7 +37,7 @@ export class WindowServiceActions { await beforeClose?.(); await this._deps.bridge.invoke('close_window'); } else { - this._deps.runtime.close(); + this._deps.tracer.info('[WindowService] Native close unavailable outside Tauri'); } } @@ -54,7 +49,7 @@ export class WindowServiceActions { await minimizeFallback(); } } else { - this._deps.tracer.info('[WindowService] hideToTray (mock)'); + this._deps.tracer.info('[WindowService] Native hide-to-tray unavailable outside Tauri'); } } diff --git a/src/shared/services/ai/AISettingsService.test.ts b/src/shared/services/ai/AISettingsService.test.ts index 67f89f1c..c5a507fa 100644 --- a/src/shared/services/ai/AISettingsService.test.ts +++ b/src/shared/services/ai/AISettingsService.test.ts @@ -22,8 +22,8 @@ describe('AISettingsService', () => { expect(service.getThinkingLevel('gemini')).toBe('off'); }); - it('should default llamacpp thinking level to low', () => { - expect(service.getThinkingLevel('llamacpp')).toBe('low'); + it('should not infer thinking defaults from provider ids', () => { + expect(service.getThinkingLevel('llamacpp')).toBe('off'); }); it('should set thinking level', () => { @@ -56,16 +56,4 @@ describe('AISettingsService', () => { service.setLocalMaxOutputTokens('llamacpp', 999999); expect(service.getLocalMaxOutputTokens('llamacpp')).toBe(32768); }); - - it('should get and set AI session ID', () => { - expect(service.getAiSessionId()).toBeNull(); - service.setAiSessionId('sess-123'); - expect(service.getAiSessionId()).toBe('sess-123'); - }); - - it('should set AI session ID to null', () => { - service.setAiSessionId('sess-123'); - service.setAiSessionId(null); - expect(service.getAiSessionId()).toBeNull(); - }); }); diff --git a/src/shared/services/ai/AISettingsService.ts b/src/shared/services/ai/AISettingsService.ts index a80c5102..ab912ccc 100644 --- a/src/shared/services/ai/AISettingsService.ts +++ b/src/shared/services/ai/AISettingsService.ts @@ -1,6 +1,5 @@ import type { UiStateStore, ThinkingLevel } from '../state/UiStateStore'; -const LOCAL_LOW_THINKING_DEFAULTS = new Set(['llamacpp']); const DEFAULT_LOCAL_MAX_OUTPUT_TOKENS = 384; export class AISettingsService { @@ -20,7 +19,7 @@ export class AISettingsService { return savedLevel; } - return LOCAL_LOW_THINKING_DEFAULTS.has(appId) ? 'low' : 'off'; + return 'off'; } public setThinkingLevel(appId: string, level: ThinkingLevel): void { @@ -53,12 +52,4 @@ export class AISettingsService { const normalized = Math.max(1, Math.min(Math.trunc(tokens), 32768)); this._store.updateNestedState('local_max_output_tokens', appId, normalized); } - - public getAiSessionId(): string | null { - return this._store.getState().ai_session_id; - } - - public setAiSessionId(sessionId: string | null): void { - this._store.updateState({ ai_session_id: sessionId }); - } } diff --git a/src/shared/services/modules/ModuleSettingsService.test.ts b/src/shared/services/modules/ModuleSettingsService.test.ts index d6182461..91d2dbbe 100644 --- a/src/shared/services/modules/ModuleSettingsService.test.ts +++ b/src/shared/services/modules/ModuleSettingsService.test.ts @@ -20,7 +20,7 @@ function createMockStore(): UiStateStore { ai_thinking_level: {}, ai_web_search_enabled: {}, local_max_output_tokens: {}, - ai_session_id: null, + integration_import_last_directory: null, pending_chat_reveal: false, }; diff --git a/src/shared/services/state/UiStateStore.test.ts b/src/shared/services/state/UiStateStore.test.ts index a1ede941..6a21b32d 100644 --- a/src/shared/services/state/UiStateStore.test.ts +++ b/src/shared/services/state/UiStateStore.test.ts @@ -15,8 +15,6 @@ describe('UiStateStore', () => { let bridge: IBridge; let store: UiStateStore; let tracer: Pick; - let storage: Pick; - let storageState: Record; beforeEach(() => { vi.useFakeTimers(); @@ -26,14 +24,7 @@ describe('UiStateStore', () => { warn: vi.fn(), error: vi.fn(), }; - storageState = {}; - storage = { - getItem: vi.fn((key: string) => storageState[key] ?? null), - setItem: vi.fn((key: string, value: string) => { - storageState[key] = value; - }), - }; - store = new UiStateStore(bridge, tracer, storage); + store = new UiStateStore(bridge, tracer); }); afterEach(() => { @@ -48,7 +39,6 @@ describe('UiStateStore', () => { expect(state.sidebar_width).toBe(280); expect(state.zoom_level).toBe(1); expect(state.sound_enabled).toBe(true); - expect(state.ai_session_id).toBeNull(); }); it('should merge state via setState', () => { @@ -114,42 +104,38 @@ describe('UiStateStore', () => { (bridge.invoke as ReturnType).mockResolvedValue({ sidebar_width: 500, }); - store = new UiStateStore(bridge, tracer, storage); + store = new UiStateStore(bridge, tracer); const result = await store.loadState(); expect(bridge.invoke).toHaveBeenCalledWith('get_ui_state'); expect(result.sidebar_width).toBe(500); }); - it('should load from localStorage in non-Tauri environment', async () => { - storageState['axelate_ui_state'] = JSON.stringify({ sidebar_width: 350 }); - store = new UiStateStore(bridge, tracer, storage); - + it('should keep defaults in non-Tauri environment', async () => { const result = await store.loadState(); - expect(result.sidebar_width).toBe(350); + expect(result.sidebar_width).toBe(280); + expect(bridge.invoke).not.toHaveBeenCalled(); }); - it('should clamp legacy zoom values when loading state', async () => { - storageState['axelate_ui_state'] = JSON.stringify({ + it('should clamp out-of-range zoom values when setting state', () => { + store.setState({ zoom_level: 3, resolution_zoom: { '1920x1080': 3.2, '2560x1440': 2.4 }, }); - store = new UiStateStore(bridge, tracer, storage); - const result = await store.loadState(); + const result = store.getState(); expect(result.zoom_level).toBe(2.6); expect(result.resolution_zoom['1920x1080']).toBe(2.6); expect(result.resolution_zoom['2560x1440']).toBe(2.4); }); - it('should keep legacy partial state instead of dropping it when map fields are missing', async () => { - storageState['axelate_ui_state'] = JSON.stringify({ + it('should keep partial state instead of dropping it when map fields are missing', () => { + store.setState({ sidebar_width: 360, zoom_level: 1.25, }); - store = new UiStateStore(bridge, tracer, storage); - const result = await store.loadState(); + const result = store.getState(); expect(result.sidebar_width).toBe(360); expect(result.zoom_level).toBe(1.25); @@ -158,17 +144,28 @@ describe('UiStateStore', () => { expect(tracer.warn).not.toHaveBeenCalled(); }); - it('should normalize malformed map fields while preserving valid entries', async () => { - storageState['axelate_ui_state'] = JSON.stringify({ - resolution_zoom: null, - selected_ai_models: { gpt: 'gpt-5.5', bad: 42 }, - ai_thinking_level: { gpt: 'high', bad: 'fast' }, - ai_web_search_enabled: { gpt: true, bad: 'yes' }, - local_max_output_tokens: { llamacpp: 8192, bad: 'many' }, + it('should normalize malformed map fields while preserving valid entries', () => { + store.setState({ + resolution_zoom: null as unknown as Record, + selected_ai_models: { gpt: 'gpt-5.5', bad: 42 } as unknown as Record< + string, + string + >, + ai_thinking_level: { gpt: 'high', bad: 'fast' } as unknown as Record< + string, + 'off' | 'low' | 'medium' | 'high' + >, + ai_web_search_enabled: { gpt: true, bad: 'yes' } as unknown as Record< + string, + boolean + >, + local_max_output_tokens: { llamacpp: 8192, bad: 'many' } as unknown as Record< + string, + number + >, }); - store = new UiStateStore(bridge, tracer, storage); - const result = await store.loadState(); + const result = store.getState(); expect(result.resolution_zoom).toEqual({}); expect(result.selected_ai_models).toEqual({ gpt: 'gpt-5.5' }); @@ -177,20 +174,12 @@ describe('UiStateStore', () => { expect(result.local_max_output_tokens).toEqual({ llamacpp: 8192 }); }); - it('should use defaults when localStorage is null (L67)', async () => { - delete storageState['axelate_ui_state']; - store = new UiStateStore(bridge, tracer, storage); - - const result = await store.loadState(); - expect(result.sidebar_width).toBe(280); // default - }); - it('should handle load errors and return defaults', async () => { bridge = createMockBridge(true); (bridge.invoke as ReturnType).mockRejectedValue( new Error('Backend fail'), ); - store = new UiStateStore(bridge, tracer, storage); + store = new UiStateStore(bridge, tracer); const result = await store.loadState(); // Should fall back to defaults without throwing @@ -201,7 +190,7 @@ describe('UiStateStore', () => { describe('saveAsync', () => { it('should save to backend in Tauri environment when dirty', async () => { bridge = createMockBridge(true); - store = new UiStateStore(bridge, tracer, storage); + store = new UiStateStore(bridge, tracer); store.updateState({ sidebar_width: 999 }); await store.saveAsync(); @@ -210,15 +199,11 @@ describe('UiStateStore', () => { }); }); - it('should save to localStorage in non-Tauri environment when dirty', async () => { + it('should not persist in non-Tauri environment', async () => { store.updateState({ sidebar_width: 450 }); await store.saveAsync(); - const stored = JSON.parse(storageState['axelate_ui_state'] ?? '{}') as Record< - string, - unknown - >; - expect(stored['sidebar_width']).toBe(450); + expect(bridge.invoke).not.toHaveBeenCalled(); }); it('should not save when not dirty', async () => { @@ -229,7 +214,7 @@ describe('UiStateStore', () => { it('should handle save errors gracefully', async () => { bridge = createMockBridge(true); (bridge.invoke as ReturnType).mockRejectedValue(new Error('Save fail')); - store = new UiStateStore(bridge, tracer, storage); + store = new UiStateStore(bridge, tracer); store.updateState({ sidebar_width: 123 }); // Should not throw @@ -247,7 +232,7 @@ describe('UiStateStore', () => { }), ) .mockResolvedValue(undefined); - store = new UiStateStore(bridge, tracer, storage); + store = new UiStateStore(bridge, tracer); store.updateState({ sidebar_width: 111 }); const firstSave = store.saveAsync(); @@ -264,25 +249,21 @@ describe('UiStateStore', () => { }); describe('saveImmediate', () => { - it('should save synchronously to localStorage when dirty', async () => { + it('should not persist synchronously in non-Tauri environment', async () => { store.updateState({ zoom_level: 1.5 }); await store.saveImmediate(); - const stored = JSON.parse(storageState['axelate_ui_state'] ?? '{}') as Record< - string, - unknown - >; - expect(stored['zoom_level']).toBe(1.5); + expect(bridge.invoke).not.toHaveBeenCalled(); }); it('should not save when not dirty', async () => { await store.saveImmediate(); - expect(storageState['axelate_ui_state']).toBeUndefined(); + expect(bridge.invoke).not.toHaveBeenCalled(); }); it('should save to backend in Tauri environment', async () => { bridge = createMockBridge(true); - store = new UiStateStore(bridge, tracer, storage); + store = new UiStateStore(bridge, tracer); store.updateState({ sidebar_width: 777 }); await store.saveImmediate(); expect(bridge.invoke).toHaveBeenCalledWith('save_ui_state', { @@ -295,7 +276,7 @@ describe('UiStateStore', () => { (bridge.invoke as ReturnType) .mockRejectedValueOnce(new Error('Save failed')) .mockResolvedValue(undefined); - store = new UiStateStore(bridge, tracer, storage); + store = new UiStateStore(bridge, tracer); store.updateState({ sidebar_width: 888 }); await expect(store.saveImmediate()).rejects.toThrow('Save failed'); @@ -311,7 +292,7 @@ describe('UiStateStore', () => { describe('Debounced auto-save', () => { it('should debounce saves on updateState', async () => { bridge = createMockBridge(true); - store = new UiStateStore(bridge, tracer, storage); + store = new UiStateStore(bridge, tracer); store.updateState({ sidebar_width: 100 }); store.updateState({ sidebar_width: 200 }); @@ -337,7 +318,7 @@ describe('UiStateStore', () => { (bridge.invoke as ReturnType).mockImplementation(() => { throw new Error('Invoke crashed'); }); - store = new UiStateStore(bridge, tracer, storage); + store = new UiStateStore(bridge, tracer); store.updateState({ sidebar_width: 123 }); await expect(store.saveImmediate()).rejects.toThrow('Invoke crashed'); @@ -347,7 +328,7 @@ describe('UiStateStore', () => { describe('Auto-save events', () => { it('should save on visibilitychange when hidden', () => { bridge = createMockBridge(true); - store = new UiStateStore(bridge, tracer, storage); + store = new UiStateStore(bridge, tracer); store.updateState({ sidebar_width: 400 }); // Simulate document becoming hidden @@ -364,7 +345,7 @@ describe('UiStateStore', () => { it('should not save on visibilitychange when NOT hidden (L160)', () => { bridge = createMockBridge(true); - store = new UiStateStore(bridge, tracer, storage); + store = new UiStateStore(bridge, tracer); store.updateState({ sidebar_width: 400 }); // Simulate document becoming visible (not hidden) @@ -380,23 +361,21 @@ describe('UiStateStore', () => { }); it('should saveImmediate synchronously', async () => { - bridge = createMockBridge(); - store = new UiStateStore(bridge, tracer, storage); + bridge = createMockBridge(true); + store = new UiStateStore(bridge, tracer); store.updateState({ sidebar_width: 500 }); // beforeunload is now handled by StateManager; test saveImmediate directly await store.saveImmediate(); - const stored = JSON.parse(storageState['axelate_ui_state'] ?? '{}') as Record< - string, - unknown - >; - expect(stored['sidebar_width']).toBe(500); + expect(bridge.invoke).toHaveBeenCalledWith('save_ui_state', { + state: expect.objectContaining({ sidebar_width: 500 }) as unknown, + }); }); it('should remove auto-save timer on destroy', async () => { - bridge = createMockBridge(); - store = new UiStateStore(bridge, tracer, storage); + bridge = createMockBridge(true); + store = new UiStateStore(bridge, tracer); store.updateState({ sidebar_width: 640 }); store.destroy(); @@ -404,11 +383,9 @@ describe('UiStateStore', () => { // saveImmediate should still work (no event listeners to remove) await store.saveImmediate(); - const stored = JSON.parse(storageState['axelate_ui_state'] ?? '{}') as Record< - string, - unknown - >; - expect(stored['sidebar_width']).toBe(640); + expect(bridge.invoke).toHaveBeenCalledWith('save_ui_state', { + state: expect.objectContaining({ sidebar_width: 640 }) as unknown, + }); }); }); }); diff --git a/src/shared/services/state/UiStateStore.ts b/src/shared/services/state/UiStateStore.ts index 0e6b0c31..044a87be 100644 --- a/src/shared/services/state/UiStateStore.ts +++ b/src/shared/services/state/UiStateStore.ts @@ -31,13 +31,11 @@ export interface IUIState { ai_thinking_level: Record; ai_web_search_enabled: Record; local_max_output_tokens: Record; - ai_session_id: string | null; + integration_import_last_directory: string | null; preferred_language?: string | null; pending_chat_reveal: boolean; } -type UiStateStorage = Pick; - const DEFAULT_UI_STATE: IUIState = { sidebar_collapsed: false, sidebar_manual_override: false, @@ -55,7 +53,7 @@ const DEFAULT_UI_STATE: IUIState = { ai_thinking_level: {}, ai_web_search_enabled: {}, local_max_output_tokens: {}, - ai_session_id: null, + integration_import_last_directory: null, preferred_language: null, pending_chat_reveal: false, }; @@ -68,13 +66,11 @@ export class UiStateStore { private _isDirty = false; private _revision = 0; private _autoSaveTimer: ReturnType | null = null; - private readonly _STORAGE_KEY = 'axelate_ui_state'; private _isDestroyed = false; constructor( private readonly _bridge: IBridge, private readonly _tracer: UiStateStoreLogger, - private readonly _storage: UiStateStorage | null = globalThis.localStorage, ) {} public async loadState(): Promise { @@ -83,12 +79,6 @@ export class UiStateStore { const loaded = await this._bridge.invoke('get_ui_state'); this.setState(loaded); this._tracer.info('[UiStateStore] Loaded from backend'); - } else { - const stored = this._storage?.getItem(this._STORAGE_KEY) ?? null; - if (stored !== null) { - this.setState(JSON.parse(stored) as Partial); - this._tracer.info('[UiStateStore] Loaded from browser storage'); - } } } catch (e) { this._tracer.warn(`[UiStateStore] Failed to load, using defaults: ${String(e)}`); @@ -160,6 +150,15 @@ export class UiStateStore { this.removeNestedState('selected_modules', category); } + public getIntegrationImportLastDirectory(): string | null { + return this._state.integration_import_last_directory; + } + + public setIntegrationImportLastDirectory(path: string | null): void { + const normalized = typeof path === 'string' ? path.trim() : ''; + this.updateState({ integration_import_last_directory: normalized || null }); + } + private _debouncedSave(): void { if (this._autoSaveTimer !== null) { globalThis.clearTimeout(this._autoSaveTimer); @@ -176,8 +175,6 @@ export class UiStateStore { try { if (this._bridge.isTauri()) { await this._bridge.invoke('save_ui_state', { state }); - } else { - this._storage?.setItem(this._STORAGE_KEY, JSON.stringify(state)); } if (this._revision === revision) { this._isDirty = false; @@ -197,11 +194,6 @@ export class UiStateStore { if (this._revision === revision) { this._isDirty = false; } - } else { - this._storage?.setItem(this._STORAGE_KEY, JSON.stringify(state)); - if (this._revision === revision) { - this._isDirty = false; - } } } catch (e) { this._tracer.error(`[UiStateStore] Save immediate failed: ${String(e)}`); @@ -247,6 +239,9 @@ export class UiStateStore { state.local_max_output_tokens, DEFAULT_UI_STATE.local_max_output_tokens, ), + integration_import_last_directory: this._normalizeNullableString( + state.integration_import_last_directory, + ), zoom_level: this._clampZoom(state.zoom_level), }; } @@ -282,6 +277,15 @@ export class UiStateStore { ); } + private _normalizeNullableString(value: unknown): string | null { + if (typeof value !== 'string') { + return null; + } + + const trimmed = value.trim(); + return trimmed === '' ? null : trimmed; + } + private _normalizeBooleanRecord(value: unknown): Record { if (value === null || typeof value !== 'object' || Array.isArray(value)) { return {}; diff --git a/src/shared/shell/AppUI.test.ts b/src/shared/shell/AppUI.test.ts index ff1306f2..9f222f7c 100644 --- a/src/shared/shell/AppUI.test.ts +++ b/src/shared/shell/AppUI.test.ts @@ -102,6 +102,8 @@ describe('AppUI lifecycle', () => { ) => void )(category, moduleData); }, + getIntegrationImportLastDirectory: () => null, + setIntegrationImportLastDirectory: vi.fn(), }, launchApp: async (category: string, app: IApp) => { await ( diff --git a/src/shared/shell/AppUI.ts b/src/shared/shell/AppUI.ts index ae7d7b37..0764bc1a 100644 --- a/src/shared/shell/AppUI.ts +++ b/src/shared/shell/AppUI.ts @@ -25,6 +25,8 @@ import { type NavigationService } from '@/infrastructure/navigation/NavigationSe type AppUIStateDeps = { removeSelectedModule: (category: string) => void; setSelectedModule: (category: string, moduleData: Partial) => void; + getIntegrationImportLastDirectory: () => string | null; + setIntegrationImportLastDirectory: (path: string | null) => void; }; type AppUIDeps = { @@ -136,6 +138,10 @@ export class AppUI { modalManager: this._modalManager, getCatalogApps: (category) => this._getCatalogApps(category), getSelectedAppId: (category) => this._selectionState.get(category)?.id ?? null, + getIntegrationImportLastDirectory: () => + this._deps.uiState.getIntegrationImportLastDirectory(), + setIntegrationImportLastDirectory: (path) => + this._deps.uiState.setIntegrationImportLastDirectory(path), clearModuleCard: (category) => this.clearModuleCard(category), markSlotCardAsInstalled: (card, app) => this._dashboardSupport.markSlotCardAsInstalled(card, app), diff --git a/src/shared/shell/SidebarUI.ts b/src/shared/shell/SidebarUI.ts index 7075164d..8787b9e8 100644 --- a/src/shared/shell/SidebarUI.ts +++ b/src/shared/shell/SidebarUI.ts @@ -175,7 +175,6 @@ export class SidebarUI extends BaseComponent { this._isCollapsed = !this._isCollapsed; this._hasManualSidebarOverride = false; } - this._startSnappingAnimation(); this._updateAutoCompactState(); this._applySidebarWidth(); this._persistSidebarPreferenceState(); diff --git a/src/shared/shell/ui/AppUiModuleFlow.test.ts b/src/shared/shell/ui/AppUiModuleFlow.test.ts index 2de4612d..6f32bacc 100644 --- a/src/shared/shell/ui/AppUiModuleFlow.test.ts +++ b/src/shared/shell/ui/AppUiModuleFlow.test.ts @@ -52,12 +52,14 @@ describe('AppUiModuleFlow', () => { const translate = vi.fn((_key: string, fallback: string) => fallback); const reloadCatalog = vi.fn().mockResolvedValue(undefined); const openExternalUrl = vi.fn().mockResolvedValue(undefined); + const getIntegrationImportLastDirectory = vi.fn<() => string | null>(); + const setIntegrationImportLastDirectory = vi.fn(); let flow: AppUiModuleFlow; beforeEach(() => { vi.clearAllMocks(); - localStorage.clear(); + getIntegrationImportLastDirectory.mockReturnValue(null); document.body.innerHTML = ''; flow = new AppUiModuleFlow({ platformService: platformService as never, @@ -70,6 +72,8 @@ describe('AppUiModuleFlow', () => { modalManager, getCatalogApps, getSelectedAppId, + getIntegrationImportLastDirectory, + setIntegrationImportLastDirectory, clearModuleCard, markSlotCardAsInstalled, showToast, @@ -226,10 +230,11 @@ describe('AppUiModuleFlow', () => { expect(platformService.importIntegrationPath).toHaveBeenCalledWith( 'C:\\Users\\FORLE\\Downloads\\Parser', ); - expect(localStorage.getItem('axelate.integrationImport.lastDirectory')).toBe( + expect(setIntegrationImportLastDirectory).toHaveBeenCalledWith( 'C:\\Users\\FORLE\\Downloads\\Parser', ); + getIntegrationImportLastDirectory.mockReturnValue('C:\\Users\\FORLE\\Downloads\\Parser'); vi.mocked(open).mockResolvedValue(null); await flow.handleIntegrationImport('local'); @@ -251,6 +256,33 @@ describe('AppUiModuleFlow', () => { expect(showToast).not.toHaveBeenCalled(); }); + it('imports integration archives with archive filters and remembers the archive folder', async () => { + vi.mocked(downloadDir).mockResolvedValue('C:\\Users\\FORLE\\Downloads'); + vi.mocked(open).mockResolvedValue('C:\\Users\\FORLE\\Downloads\\Parser.zip'); + platformService.importIntegrationPath.mockResolvedValue('telegram-parser'); + + await flow.handleIntegrationImport('archive'); + + expect(open).toHaveBeenCalledWith( + expect.objectContaining({ + directory: false, + defaultPath: 'C:\\Users\\FORLE\\Downloads', + filters: [ + { + name: 'Archive', + extensions: ['zip', 'tar', 'gz', 'tgz', 'xz', 'txz', '7z'], + }, + ], + }), + ); + expect(platformService.importIntegrationPath).toHaveBeenCalledWith( + 'C:\\Users\\FORLE\\Downloads\\Parser.zip', + ); + expect(setIntegrationImportLastDirectory).toHaveBeenCalledWith( + 'C:\\Users\\FORLE\\Downloads', + ); + }); + it('refreshes the open integrations modal after local integration import', async () => { vi.mocked(downloadDir).mockResolvedValue('C:\\Users\\FORLE\\Downloads'); vi.mocked(open).mockResolvedValue('C:\\Users\\FORLE\\Downloads\\Parser'); diff --git a/src/shared/shell/ui/AppUiModuleFlow.ts b/src/shared/shell/ui/AppUiModuleFlow.ts index f6286c62..14d9e261 100644 --- a/src/shared/shell/ui/AppUiModuleFlow.ts +++ b/src/shared/shell/ui/AppUiModuleFlow.ts @@ -10,7 +10,6 @@ import { downloadDir } from '@tauri-apps/api/path'; const CUSTOM_INTEGRATION_GUIDE_URL = 'https://github.com/F0RLE/Axelate/blob/nightly/docs/en/CUSTOM_INTEGRATIONS.md'; -const INTEGRATION_IMPORT_LAST_DIR_KEY = 'axelate.integrationImport.lastDirectory'; type ModalBridge = { isAppSelectionOpen(): boolean; @@ -28,6 +27,8 @@ type AppUiModuleFlowDeps = { modalManager: ModalBridge; getCatalogApps: (category: string) => IApp[]; getSelectedAppId: (category: string) => string | null; + getIntegrationImportLastDirectory: () => string | null; + setIntegrationImportLastDirectory: (path: string | null) => void; clearModuleCard: (category: string) => void; markSlotCardAsInstalled: (card: HTMLElement, app: IApp) => void; showToast: (message: string, type?: string) => void; @@ -256,6 +257,13 @@ export class AppUiModuleFlow { : await this._deps.platformService.importIntegrationPath(path); } + if (action === 'archive') { + const path = await this._openArchiveIntegrationSource(); + return path === null + ? null + : await this._deps.platformService.importIntegrationPath(path); + } + const url = await this._openIntegrationUrlSource(); return url === null ? null : await this._deps.platformService.importIntegrationUrl(url); } @@ -289,13 +297,42 @@ export class AppUiModuleFlow { }), ); if (selectedPath !== null) { - saveIntegrationImportLastDirectory(selectedPath); + this._deps.setIntegrationImportLastDirectory(selectedPath); + } + return selectedPath; + } + + private async _openArchiveIntegrationSource(): Promise { + const selectedPath = normalizeDialogPath( + await open({ + directory: false, + defaultPath: await this._getIntegrationImportDefaultPath(), + multiple: false, + title: this._deps.translate( + 'ui.launcher.integrations.import.archive_title', + 'Choose integration archive', + ), + filters: [ + { + name: this._deps.translate( + 'ui.launcher.integrations.import.archive', + 'Archive', + ), + extensions: ['zip', 'tar', 'gz', 'tgz', 'xz', 'txz', '7z'], + }, + ], + }), + ); + if (selectedPath !== null) { + this._deps.setIntegrationImportLastDirectory( + getParentDirectory(selectedPath) ?? selectedPath, + ); } return selectedPath; } private async _getIntegrationImportDefaultPath(): Promise { - const savedPath = loadIntegrationImportLastDirectory(); + const savedPath = this._deps.getIntegrationImportLastDirectory(); if (savedPath !== null) { return savedPath; } @@ -347,19 +384,13 @@ function normalizeDialogPath(value: string | string[] | null): string | null { return null; } -function loadIntegrationImportLastDirectory(): string | null { - try { - const value = localStorage.getItem(INTEGRATION_IMPORT_LAST_DIR_KEY); - return value !== null && value.trim().length > 0 ? value : null; - } catch { +function getParentDirectory(path: string): string | null { + const normalized = path.replace(/\\/gu, '/'); + const lastSeparator = normalized.lastIndexOf('/'); + if (lastSeparator <= 0) { return null; } -} -function saveIntegrationImportLastDirectory(path: string): void { - try { - localStorage.setItem(INTEGRATION_IMPORT_LAST_DIR_KEY, path); - } catch { - // Dialog still works if storage is unavailable. - } + const parent = path.slice(0, lastSeparator); + return parent.trim().length === 0 ? null : parent; } diff --git a/src/shared/shell/ui/DownloadSelectionDialog.test.ts b/src/shared/shell/ui/DownloadSelectionDialog.test.ts new file mode 100644 index 00000000..b05ff4f4 --- /dev/null +++ b/src/shared/shell/ui/DownloadSelectionDialog.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { openDownloadSelectionDialog } from './DownloadSelectionDialog'; +import type { ReleaseDownloadOptions } from '@/shared/types/coreTypes'; + +describe('openDownloadSelectionDialog', () => { + it('allows selecting both CPU and GPU packages', async () => { + vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((callback) => { + callback(0); + return 0; + }); + Element.prototype.scrollIntoView = vi.fn(); + + const options: ReleaseDownloadOptions = { + module_id: 'llamacpp', + versions: [ + { + tag_name: 'v1.0.0', + published_at: '2026-05-06T00:00:00Z', + recommended: 'gpu', + gpu: { + compute_target: 'gpu', + assets: ['gpu.zip'], + total_size: 1024, + }, + cpu: { + compute_target: 'cpu', + assets: ['cpu.zip'], + total_size: 2048, + }, + }, + ], + }; + + const resultPromise = openDownloadSelectionDialog({ + app: { id: 'llamacpp', name: 'llama.cpp', icon: 'L' }, + loadOptions: () => Promise.resolve(options), + translate: (_key, fallback) => fallback, + }); + + await vi.waitFor(() => { + expect(document.querySelectorAll('[data-download-target]')).toHaveLength(3); + }); + + const bothButton = document.querySelector( + '[data-download-target="both"]', + ); + expect(bothButton).not.toBeNull(); + if (bothButton === null) { + throw new Error('Both package button not found'); + } + bothButton.click(); + + const confirmButton = document.querySelector( + '.download-selection-confirm', + ); + expect(confirmButton).not.toBeNull(); + if (confirmButton === null) { + throw new Error('Download confirm button not found'); + } + confirmButton.click(); + + await expect(resultPromise).resolves.toEqual({ + tag_name: 'v1.0.0', + compute_target: 'both', + }); + }); +}); diff --git a/src/shared/shell/ui/DownloadSelectionDialog.ts b/src/shared/shell/ui/DownloadSelectionDialog.ts index 12b1a1f9..ab217f1b 100644 --- a/src/shared/shell/ui/DownloadSelectionDialog.ts +++ b/src/shared/shell/ui/DownloadSelectionDialog.ts @@ -15,7 +15,7 @@ type DownloadSelectionDialogOptions = { translate: TranslateFn; }; -const TARGETS: Array> = ['gpu', 'cpu']; +const TARGETS: Array> = ['gpu', 'cpu', 'both']; export function openDownloadSelectionDialog({ app, @@ -177,7 +177,7 @@ export function openDownloadSelectionDialog({ button.addEventListener('click', () => { const target = button.dataset['downloadTarget']; if (selectedVersion === null) return; - if (target !== 'gpu' && target !== 'cpu') return; + if (target !== 'gpu' && target !== 'cpu' && target !== 'both') return; if (getVariant(selectedVersion, target) === null) return; selectedTarget = target; render(); @@ -252,7 +252,9 @@ function renderTargetButton( const label = target === 'gpu' ? translate('ui.download.gpu_package', 'GPU') - : translate('ui.download.cpu_package', 'CPU'); + : target === 'cpu' + ? translate('ui.download.cpu_package', 'CPU') + : translate('ui.download.both_packages', 'CPU + GPU'); const meta = variant === null ? translate('ui.download.unavailable', 'Unavailable') @@ -297,6 +299,15 @@ function normalizeTarget( target: ReleaseComputeTarget, version: ReleaseDownloadVersion, ): Exclude { + if ( + target === 'both' && + version.gpu !== null && + version.gpu !== undefined && + version.cpu !== null && + version.cpu !== undefined + ) { + return 'both'; + } if ( (target === 'gpu' || target === 'auto') && version.gpu !== null && @@ -316,9 +327,28 @@ function getVariant( ): ReleaseDownloadVariant | null { if (target === 'cpu') return version.cpu ?? null; if (target === 'gpu') return version.gpu ?? null; + if (target === 'both') return getCombinedVariant(version); return version.gpu ?? version.cpu ?? null; } +function getCombinedVariant(version: ReleaseDownloadVersion): ReleaseDownloadVariant | null { + if (version.cpu === null || version.cpu === undefined) return null; + if (version.gpu === null || version.gpu === undefined) return null; + + const assets = [...version.gpu.assets]; + version.cpu.assets.forEach((asset) => { + if (!assets.includes(asset)) { + assets.push(asset); + } + }); + + return { + compute_target: 'both', + assets, + total_size: version.cpu.total_size + version.gpu.total_size, + }; +} + function formatBytes(bytes: number): string { if (!Number.isFinite(bytes) || bytes <= 0) return '0 MB'; const units = ['B', 'KB', 'MB', 'GB']; diff --git a/src/shared/shell/ui/ModalManager.test.ts b/src/shared/shell/ui/ModalManager.test.ts index d74005ec..87966b12 100644 --- a/src/shared/shell/ui/ModalManager.test.ts +++ b/src/shared/shell/ui/ModalManager.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ModalManager } from './ModalManager'; import { ModuleCardRenderer } from './ModuleCardRenderer'; import { ModalSelectionPolicy } from './ModalSelectionPolicy'; +import type { IntegrationImportAction } from './ModalManagerSupport'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; import type { NavigationService } from '@/infrastructure/navigation/NavigationService'; import type { IApp } from '../../types/coreTypes'; @@ -86,7 +87,7 @@ describe('ModalManager lifecycle', () => { function createManager( onFilterChange?: (capability: 'text' | 'image') => string | null, - onIntegrationImport?: (action: 'local' | 'url' | 'guide') => void, + onIntegrationImport?: (action: IntegrationImportAction) => void, ) { return new ModalManager( new ModuleCardRenderer({ translate: (_key, fallback) => fallback, tracer }), @@ -151,7 +152,7 @@ describe('ModalManager lifecycle', () => { expect(importSpy).toHaveBeenNthCalledWith(1, 'local'); expect(importSpy).toHaveBeenNthCalledWith(2, 'url'); expect(importSpy).toHaveBeenNthCalledWith(3, 'guide'); - expect(importSpy).toHaveBeenNthCalledWith(4, 'local'); + expect(importSpy).toHaveBeenNthCalledWith(4, 'archive'); }); it('should rerender services selection when refresh receives an empty app list', () => { diff --git a/src/shared/shell/ui/ModalManagerSupport.ts b/src/shared/shell/ui/ModalManagerSupport.ts index 9a8dfd50..163161cb 100644 --- a/src/shared/shell/ui/ModalManagerSupport.ts +++ b/src/shared/shell/ui/ModalManagerSupport.ts @@ -15,7 +15,7 @@ type AppInteractionHandler = (event: MouseEvent, app: IApp, category: string) => type DownloadHandler = (app: IApp, action: ModuleCardDownloadAction) => void; type ProgressEventHandler = (event: Event) => void; type TranslateFunc = (key: string, fallback: string) => string; -export type IntegrationImportAction = 'local' | 'url' | 'guide'; +export type IntegrationImportAction = 'local' | 'archive' | 'url' | 'guide'; export async function cancelModalDownload(options: { app: IApp; @@ -248,7 +248,7 @@ function createIntegrationImportCard( translate('ui.launcher.integrations.import.card_title', 'Add integration'), ); card.addEventListener('click', () => { - onIntegrationImport?.('local'); + onIntegrationImport?.('archive'); }); card.addEventListener('keydown', (event) => { if (event.key !== 'Enter' && event.key !== ' ') { @@ -256,7 +256,7 @@ function createIntegrationImportCard( } event.preventDefault(); - onIntegrationImport?.('local'); + onIntegrationImport?.('archive'); }); const help = createIntegrationHelpBadge(translate, onIntegrationImport); @@ -280,7 +280,7 @@ function createIntegrationImportCard( actions.append( createIntegrationImportButton( 'local', - translate('ui.launcher.integrations.import.open', 'Open'), + translate('ui.launcher.integrations.import.folder', 'Folder'), onIntegrationImport, ), createIntegrationImportButton( diff --git a/src/shared/types/global.d.ts b/src/shared/types/global.d.ts index fe077ff7..27a9ff15 100644 --- a/src/shared/types/global.d.ts +++ b/src/shared/types/global.d.ts @@ -1,30 +1,8 @@ -import { type IApp } from '@/shared/types/coreTypes'; - export {}; declare global { var __APP_VERSION__: string; - var __TAURI__: { - core: { - invoke: (_cmd: string, _args?: Record) => Promise; - }; - invoke: (_cmd: string, _args?: Record) => Promise; - event: { - listen: ( - _event: string, - _handler: (_event: { payload: T }) => void, - ) => Promise<() => void>; - }; - window: { - getCurrentWindow: () => { - isMaximized: () => Promise; - setSize: (_size: { width: number; height: number }) => Promise; - center: () => Promise; - }; - LogicalSize: new (_width: number, _height: number) => { width: number; height: number }; - }; - }; var __TAURI_INTERNALS__: | { invoke?: (_cmd: string, _args?: Record) => Promise; @@ -32,11 +10,7 @@ declare global { } | undefined; - var openModuleSettings: (_app: IApp) => void; - interface Window { - __TAURI__: typeof __TAURI__; __TAURI_INTERNALS__: typeof __TAURI_INTERNALS__; - openModuleSettings: typeof openModuleSettings; } } diff --git a/src/shared/types/global_bridge_types.ts b/src/shared/types/global_bridge_types.ts deleted file mode 100644 index 46d91a2e..00000000 --- a/src/shared/types/global_bridge_types.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { ITauriInstance } from './coreTypes'; - -/** - * Minimal bridge-facing types kept only for modules that still inspect runtime globals. - */ -export type TTranslateFunction = (key: string, defaultValue?: string) => string; - -export interface IGlobalRuntime { - __TAURI__?: ITauriInstance; - __TAURI_INTERNALS__?: unknown; -} - -export type TGlobalWin = typeof globalThis & IGlobalRuntime; diff --git a/src/shared/utils/providerSupport.ts b/src/shared/utils/providerSupport.ts index c3f05e8e..490f2fb7 100644 --- a/src/shared/utils/providerSupport.ts +++ b/src/shared/utils/providerSupport.ts @@ -18,30 +18,10 @@ const CLOUD_PROVIDER_IDS = new Set([ CUSTOM_IMAGE_PROVIDER_ID, ]); -const IMAGE_PROVIDER_IDS = new Set([ - 'sdcpp', - 'stable-diffusion', - 'comfyui', - 'gemini-image', - 'gpt-image', - 'seedream-image', - CUSTOM_IMAGE_PROVIDER_ID, -]); - -const MANAGED_LOCAL_IMAGE_PROVIDER_IDS = new Set(['sdcpp', 'stable-diffusion']); - export function isCloudProviderId(providerId: string): boolean { return CLOUD_PROVIDER_IDS.has(providerId); } -export function isImageProviderId(providerId: string): boolean { - return IMAGE_PROVIDER_IDS.has(providerId); -} - -export function isManagedLocalImageProviderId(providerId: string): boolean { - return MANAGED_LOCAL_IMAGE_PROVIDER_IDS.has(providerId); -} - export function getSharedCloudSecretService(): string { return `${SHARED_CLOUD_KEY_PROVIDER_ID}_api_key`; } diff --git a/src/styles/features/chat-page.css b/src/styles/features/chat-page.css index 92381ed3..98429f72 100644 --- a/src/styles/features/chat-page.css +++ b/src/styles/features/chat-page.css @@ -946,6 +946,7 @@ #chat-messages::-webkit-scrollbar { width: 8px; + height: 8px; } #chat-messages::-webkit-scrollbar-track { @@ -953,25 +954,19 @@ } #chat-messages::-webkit-scrollbar-thumb { - background: transparent; + background: transparent !important; border-radius: 999px; border: 2px solid transparent; background-clip: padding-box; } -#chat-messages:hover, -#chat-messages:focus-within { +#chat-container.has-messages:hover #chat-messages { + scrollbar-width: thin; scrollbar-color: rgba(255, 255, 255, 0.14) transparent; } -#chat-messages:hover::-webkit-scrollbar, -#chat-messages:focus-within::-webkit-scrollbar { - width: 8px; -} - -#chat-messages:hover::-webkit-scrollbar-thumb, -#chat-messages:focus-within::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.14); +#chat-container.has-messages:hover #chat-messages::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.14) !important; } #chat-messages.has-messages { diff --git a/src/styles/features/console-page.css b/src/styles/features/console-page.css index 5086e2f6..dd8bb498 100644 --- a/src/styles/features/console-page.css +++ b/src/styles/features/console-page.css @@ -135,7 +135,7 @@ .console-workspace { display: grid; - grid-template-columns: minmax(0, 1fr) 112px; + grid-template-columns: minmax(0, 1fr) 104px; gap: 0.45rem; flex: 1; min-height: 0; @@ -144,10 +144,10 @@ .console-controls-panel { display: flex; flex-direction: column; - gap: 0.52rem; + gap: 0.44rem; min-width: 0; min-height: 0; - padding: 0.48rem; + padding: 0.42rem; border: 1px solid rgba(255, 255, 255, 0.04); border-radius: 12px; background: rgba(255, 255, 255, 0.018); @@ -163,7 +163,8 @@ .console-controls-label { color: var(--text-muted); font-size: 0.58rem; - line-height: 1; + line-height: 1.1; + text-align: center; text-transform: uppercase; } @@ -182,15 +183,18 @@ display: inline-flex; align-items: center; justify-content: center; + width: 100%; min-width: 0; - height: 28px; - padding: 0 0.48rem; + height: 26px; + padding: 0 0.36rem; border: 1px solid var(--module-button-border); border-radius: 8px; background: var(--module-button-bg-soft); color: var(--module-button-text); font-family: var(--app-font-family); - font-size: 0.66rem; + font-size: 0.62rem; + line-height: 1; + text-align: center; cursor: pointer; white-space: nowrap; transition: @@ -226,7 +230,7 @@ align-items: center; justify-content: center; width: 100%; - height: 28px; + height: 26px; padding: 0; border: 1px solid var(--module-button-border); border-radius: 8px; @@ -279,6 +283,9 @@ font-family: var(--app-font-family); font-size: 0.88rem; line-height: 1.42; + scrollbar-gutter: stable; + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.08) transparent; user-select: text !important; -webkit-user-select: text !important; cursor: text !important; @@ -520,7 +527,7 @@ } .console-logs-area::-webkit-scrollbar { - width: 8px; + width: 12px; } .console-logs-area::-webkit-scrollbar-track { @@ -529,6 +536,8 @@ .console-logs-area::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.08); + background-clip: content-box; + border: 3px solid transparent; border-radius: 999px; } diff --git a/src/styles/features/downloads-page.css b/src/styles/features/downloads-page.css index 63048195..d3ed4194 100644 --- a/src/styles/features/downloads-page.css +++ b/src/styles/features/downloads-page.css @@ -276,6 +276,7 @@ align-items: center; justify-content: center; cursor: pointer; + -webkit-app-region: no-drag; transition: transform 0.18s ease, background-color 0.18s ease, @@ -285,6 +286,13 @@ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05); } +.downloads-action-btn *, +.downloads-action-btn svg, +.downloads-action-btn use { + cursor: pointer; + pointer-events: none; +} + .downloads-action-btn:hover { transform: translateY(-1px); } @@ -326,8 +334,10 @@ } .downloads-action-icon { - width: 13px; - height: 13px; + display: block; + width: 14px; + height: 14px; + flex-shrink: 0; } .downloads-progress-section { diff --git a/src/styles/features/home-page-and-module-cards.css b/src/styles/features/home-page-and-module-cards.css index c4d2d393..7143f722 100644 --- a/src/styles/features/home-page-and-module-cards.css +++ b/src/styles/features/home-page-and-module-cards.css @@ -652,7 +652,8 @@ input[type='password']::-webkit-credentials-auto-fill-button { .app-delete-badge:hover .badge-text { opacity: 1; - max-width: 100px; + max-width: 10rem; + padding-left: 6px; padding-right: 10px; } @@ -809,7 +810,8 @@ input[type='password']::-webkit-credentials-auto-fill-button { .module-action-badge:hover .badge-text { opacity: 1; - max-width: 100px; + max-width: 10rem; + padding-left: 6px; padding-right: 10px; } @@ -829,7 +831,7 @@ input[type='password']::-webkit-credentials-auto-fill-button { .module-action-badge.right:hover .badge-text { padding-left: 10px; - padding-right: 0; + padding-right: 6px; } /* Settings variant */ diff --git a/src/styles/features/module-selection-modal.css b/src/styles/features/module-selection-modal.css index e087eb1e..58a24eb8 100644 --- a/src/styles/features/module-selection-modal.css +++ b/src/styles/features/module-selection-modal.css @@ -995,11 +995,12 @@ body.integration-import-open #main-area { } .download-selection-view { + --download-selection-zoom: var(--app-zoom, 1); position: fixed; - top: var(--header-height, 60px); + top: calc(var(--header-height, 60px) * var(--download-selection-zoom)); right: 0; bottom: 0; - left: var(--sidebar-width, 280px); + left: calc(var(--sidebar-width, 280px) * var(--download-selection-zoom)); z-index: 1700; display: flex; align-items: center; @@ -1016,8 +1017,9 @@ body.integration-import-open #main-area { } .download-selection-panel { - width: min(500px, calc(100% - 32px)); - max-height: min(680px, calc(100% - 24px)); + width: min(680px, 100%); + max-height: min(760px, 100%); + min-height: 0; display: flex; flex-direction: column; gap: 0.85rem; @@ -1025,10 +1027,8 @@ body.integration-import-open #main-area { border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 16px; background: rgb(18, 17, 24); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.06), - 0 20px 54px rgba(0, 0, 0, 0.42); - overflow-y: auto; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.035); + overflow: hidden; pointer-events: auto; } @@ -1071,7 +1071,7 @@ body.integration-import-open #main-area { .download-selection-targets { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.55rem; } @@ -1152,7 +1152,9 @@ body.integration-import-open #main-area { .download-selection-version { display: grid; + flex: 1 1 auto; gap: 0.42rem; + min-height: 0; color: var(--text-secondary); font-size: 0.78rem; font-weight: 700; @@ -1178,24 +1180,25 @@ body.integration-import-open #main-area { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.38rem; - max-height: 204px; + min-height: 0; + max-height: min(320px, 38vh); overflow-y: auto; padding: 0.4rem; scroll-padding: 0.4rem; border: 1px solid rgba(255, 255, 255, 0.05); border-radius: 12px; - background: rgb(25, 24, 32); + background: rgba(255, 255, 255, 0.014); } .download-selection-version-item { display: grid; min-width: 0; - min-height: 48px; + min-height: 58px; gap: 0.18rem; padding: 0.5rem 0.6rem; - border: 1px solid transparent; + border: 1px solid rgba(255, 255, 255, 0.05); border-radius: 10px; - background: rgb(31, 29, 39); + background: rgb(29, 27, 36); color: var(--text-primary); font-family: var(--app-font-family); text-align: left; @@ -1203,13 +1206,15 @@ body.integration-import-open #main-area { } .download-selection-version-item:hover { - background: rgba(255, 255, 255, 0.055); + border-color: rgba(255, 255, 255, 0.085); + background: rgba(255, 255, 255, 0.04); } .download-selection-version-item.selected { - border-color: rgba(var(--primary-raw), 0.52); - background: rgba(var(--primary-raw), 0.22); - box-shadow: inset 0 0 0 1px rgba(var(--primary-raw), 0.16); + border-color: var(--premium-purple-border); + background: var(--premium-purple-bg); + color: #ffffff; + box-shadow: none; } .download-selection-version-item span, @@ -1221,12 +1226,12 @@ body.integration-import-open #main-area { } .download-selection-version-item span { - font-size: 0.78rem; + font-size: 0.9rem; } .download-selection-version-item small { color: var(--text-secondary); - font-size: 0.68rem; + font-size: 0.74rem; } .download-selection-summary { diff --git a/src/styles/layouts/app-sidebar.css b/src/styles/layouts/app-sidebar.css index ba81931e..dc2a9877 100644 --- a/src/styles/layouts/app-sidebar.css +++ b/src/styles/layouts/app-sidebar.css @@ -20,9 +20,9 @@ -webkit-app-region: no-drag; z-index: auto; box-sizing: border-box; - transition: - width 0.28s var(--ease-smooth), - padding 0.28s var(--ease-smooth); + contain: layout paint style; + will-change: width; + transition: width 0.22s var(--ease-smooth); } /* ... */ @@ -44,9 +44,7 @@ padding: 0; transition: background 0.2s ease, - opacity 0.2s ease, - padding 0.28s var(--ease-smooth), - margin 0.28s var(--ease-smooth); + opacity 0.2s ease; } .logo-icon-wrapper { @@ -258,9 +256,10 @@ overflow: hidden; white-space: nowrap; margin: 0; + transform: translateX(-4px); transition: - width 0.3s var(--ease-smooth), - opacity 0.2s ease; + opacity 0.16s ease, + transform 0.16s var(--ease-smooth); } #sidebar.collapsed .sidebar-title-wrapper { @@ -330,7 +329,10 @@ opacity: 0; visibility: hidden; transform: translateX(-10px); - transition: all 0.2s; + transition: + opacity 0.2s ease, + transform 0.2s ease, + visibility 0.2s ease; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); z-index: 10; } @@ -369,12 +371,7 @@ #sidebar .main-menu, #sidebar .bottom-menu, #sidebar .nav-section { - transition: - margin 0.28s var(--ease-smooth), - padding 0.28s var(--ease-smooth), - width 0.28s var(--ease-smooth), - gap 0.28s var(--ease-smooth), - transform 0.28s var(--ease-smooth); + transition: none; } #system-monitor { @@ -415,17 +412,12 @@ position: relative; overflow: hidden; transition: - height 0.3s cubic-bezier(0.4, 0, 0.2, 1), - max-height 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1), - margin 0.3s cubic-bezier(0.4, 0, 0.2, 1), - padding 0.3s cubic-bezier(0.4, 0, 0.2, 1), - gap 0.3s cubic-bezier(0.4, 0, 0.2, 1), - justify-content 0.3s cubic-bezier(0.4, 0, 0.2, 1), background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease, - color 0.2s ease; + color 0.2s ease, + transform 0.2s ease; height: 44px; max-height: 44px; outline: none; @@ -525,26 +517,21 @@ white-space: nowrap; text-overflow: ellipsis; transform-origin: left center; + transform: translateX(0); transition: - opacity 0.24s var(--ease-smooth), - width 0.28s var(--ease-smooth), - margin 0.28s var(--ease-smooth), - transform 0.28s var(--ease-smooth), - visibility 0.28s; + opacity 0.18s ease, + transform 0.18s var(--ease-smooth); } body.snapping #sidebar { - transition: - width 0.3s var(--ease-smooth), - padding 0.3s var(--ease-smooth); + transition: width 0.22s var(--ease-smooth); } body.snapping .nav-btn span, body.snapping #sidebar.collapsed .nav-btn span { transition: - opacity 0.3s var(--ease-smooth), - width 0.3s var(--ease-smooth), - visibility 0.3s; + opacity 0.16s ease, + transform 0.16s var(--ease-smooth); } #sidebar:not(.collapsed).monitor-hidden .nav-btn { diff --git a/src/test/integration/CatalogService.integration.test.ts b/src/test/integration/CatalogService.integration.test.ts index e3163c5f..f987baaf 100644 --- a/src/test/integration/CatalogService.integration.test.ts +++ b/src/test/integration/CatalogService.integration.test.ts @@ -1,13 +1,12 @@ /** * @module test/integration/CatalogService.integration.test.ts * @description Integration tests for CatalogService — verifies full lifecycle - * from bridge calls through catalog hydration to globalThis sync. + * from backend calls through catalog hydration to update events. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { CatalogService } from '@/shared/services/CatalogService'; import type { IModule } from '@/shared/types/coreTypes'; -import { FALLBACK_CONFIG } from '@/shared/config/catalog_fallback'; import { createCatalogHarness, createMockAppConfig, @@ -54,15 +53,15 @@ describe('CatalogService Integration', () => { expect(catalog.services.at(0)?.id).toBe('my-worker'); }); - it('should handle Tauri backend failure and use fallback', async () => { + it('should handle Tauri backend failure with an empty catalog', async () => { mockBridge.isTauri.mockReturnValue(true); mockBridge.invoke.mockResolvedValue(null); await service.loadCatalog(); const catalog = service.getCatalog(); - // Fallback config should populate the catalog - expect(catalog.ai.length).toBe(FALLBACK_CONFIG.catalog.ai.length); + expect(catalog.ai).toHaveLength(0); + expect(catalog.services).toHaveLength(0); }); it('should dispatch catalog-loaded event after successful load', async () => { @@ -79,7 +78,7 @@ describe('CatalogService Integration', () => { ); }); - it('should handle empty config and keep empty catalog (fallback is also empty)', async () => { + it('should handle empty config and keep empty catalog', async () => { const mockConfig = createMockAppConfig({ catalog: { ai: [], services: [] }, }); @@ -89,7 +88,6 @@ describe('CatalogService Integration', () => { await service.loadCatalog(); const catalog = service.getCatalog(); - // FALLBACK_CONFIG is also empty, so catalog stays empty expect(catalog.ai).toHaveLength(0); expect(catalog.services).toHaveLength(0); }); @@ -117,13 +115,13 @@ describe('CatalogService Integration', () => { expect(catalog.ai.at(0)?.installed).toBe(true); // API modules always installed }); - it('should use bundled fallback when Tauri bridge is unavailable', async () => { + it('should use an empty catalog when Tauri bridge is unavailable', async () => { mockBridge.isTauri.mockReturnValue(false); await service.loadCatalog(); const catalog = service.getCatalog(); - expect(catalog.ai.length).toBe(FALLBACK_CONFIG.catalog.ai.length); - expect(catalog.services.length).toBe(FALLBACK_CONFIG.catalog.services.length); + expect(catalog.ai).toHaveLength(0); + expect(catalog.services).toHaveLength(0); }); }); diff --git a/src/test/integration/CoreContainer.test.ts b/src/test/integration/CoreContainer.test.ts index 3529e4ca..1465ed8a 100644 --- a/src/test/integration/CoreContainer.test.ts +++ b/src/test/integration/CoreContainer.test.ts @@ -1,7 +1,7 @@ /** * @module test/integration/CoreContainer.test.ts * @description Integration tests for CoreContainer — verifies service registration, - * backward compat with globalThis, and container lifecycle. + * catalog access, and container lifecycle. */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; diff --git a/src/test/mocks/mockUiStateStore.ts b/src/test/mocks/mockUiStateStore.ts index 5772ab1f..a6bc24b5 100644 --- a/src/test/mocks/mockUiStateStore.ts +++ b/src/test/mocks/mockUiStateStore.ts @@ -19,7 +19,7 @@ export function createMockStore(initial?: Partial): UiStateStore { ai_thinking_level: {}, ai_web_search_enabled: {}, local_max_output_tokens: {}, - ai_session_id: null, + integration_import_last_directory: null, pending_chat_reveal: false, ...initial, }; diff --git a/src/test/setup.test.ts b/src/test/setup.test.ts index 2967c985..48990845 100644 --- a/src/test/setup.test.ts +++ b/src/test/setup.test.ts @@ -9,10 +9,6 @@ describe('Testing Setup', () => { it('should have mocked Tauri invoke', () => { const win = globalThis as unknown as Record; - expect(win['__TAURI__']).toBeDefined(); - expect(typeof (win['__TAURI__'] as { core: { invoke: unknown } }).core.invoke).toBe( - 'function', - ); expect(typeof (win['__TAURI_INTERNALS__'] as { invoke: unknown }).invoke).toBe('function'); }); diff --git a/src/test/setup.ts b/src/test/setup.ts index cba834f9..494fa676 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -25,14 +25,10 @@ Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock }); function installDefaultTauriGlobals(): void { const win = globalThis as unknown as Record; - win['__TAURI__'] = { - invoke: async () => {}, - core: { invoke: async () => {} }, - event: { listen: async () => () => {} }, - }; win['__TAURI_INTERNALS__'] = { invoke: async () => {}, transformCallback: () => 0, + eventListen: async () => () => {}, }; win['t'] = vi.fn((key: string, fallback?: string) => fallback ?? key); } @@ -41,13 +37,10 @@ function installDefaultTauriGlobals(): void { vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn().mockImplementation((cmd: string, args?: unknown) => { const win = globalThis as unknown as Record; - const tauri = win['__TAURI__'] as Record | undefined; + const internals = win['__TAURI_INTERNALS__'] as Record | undefined; - if (tauri && typeof tauri['invoke'] === 'function') { - return tauri['invoke'](cmd, args); - } - if (typeof tauri?.['core']?.['invoke'] === 'function') { - return tauri['core']['invoke'](cmd, args); + if (typeof internals?.['invoke'] === 'function') { + return internals['invoke'](cmd, args); } return Promise.resolve(); }), @@ -57,10 +50,10 @@ vi.mock('@tauri-apps/api/core', () => ({ vi.mock('@tauri-apps/api/event', () => ({ listen: vi.fn().mockImplementation((event: string, callback: (payload: any) => void) => { const win = globalThis as unknown as Record; - const tauri = win['__TAURI__'] as Record | undefined; + const internals = win['__TAURI_INTERNALS__'] as Record | undefined; - if (typeof tauri?.['event']?.['listen'] === 'function') { - return tauri['event']['listen'](event, callback); + if (typeof internals?.['eventListen'] === 'function') { + return internals['eventListen'](event, callback); } return Promise.resolve(() => {}); }), From a4e2cc578173dfeab0a62d97ca3907955cdbfa8c Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 7 May 2026 14:55:40 +0300 Subject: [PATCH 104/126] chore(lint): report unused disable directives --- src/eslint.config.js | 2 +- src/features/ai/services/AIBridge.test.ts | 14 +++++++------- src/infrastructure/i18n/I18nService.test.ts | 1 - src/infrastructure/logging/LoggerService.test.ts | 1 - src/infrastructure/navigation/NavigationService.ts | 1 - src/infrastructure/tauri/TauriProvider.ts | 1 - src/shared/services/ErrorHandler.test.ts | 2 -- src/shared/services/ModuleService.test.ts | 1 - src/shared/services/ModuleService.ts | 1 - 9 files changed, 8 insertions(+), 16 deletions(-) diff --git a/src/eslint.config.js b/src/eslint.config.js index 68954fc8..0c932a8c 100644 --- a/src/eslint.config.js +++ b/src/eslint.config.js @@ -21,7 +21,7 @@ export default [ }, { linterOptions: { - reportUnusedDisableDirectives: 'off', + reportUnusedDisableDirectives: 'error', }, }, js.configs.recommended, diff --git a/src/features/ai/services/AIBridge.test.ts b/src/features/ai/services/AIBridge.test.ts index 5088db11..a9493a86 100644 --- a/src/features/ai/services/AIBridge.test.ts +++ b/src/features/ai/services/AIBridge.test.ts @@ -150,7 +150,7 @@ describe('AIBridge', () => { mockCore.catalog.getCatalog.mockClear(); localStorage.clear(); aiBridge = new AIBridge(mockTracer); - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument + // eslint-disable-next-line @typescript-eslint/no-explicit-any aiBridge.setCore(mockCore as any); // Mock session ID for init @@ -191,7 +191,7 @@ describe('AIBridge', () => { it('should clean up transport state when initialization fails', async () => { const bridge2 = new AIBridge(mockTracer); - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument + // eslint-disable-next-line @typescript-eslint/no-explicit-any bridge2.setCore(mockCore as any); // eslint-disable-next-line @typescript-eslint/no-explicit-any const transportDestroySpy = vi.spyOn((bridge2 as any)._transport, 'destroy'); @@ -207,7 +207,7 @@ describe('AIBridge', () => { it('should broadcast chunks and thoughts via transport callbacks', async () => { const bridge2 = new AIBridge(mockTracer); - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument + // eslint-disable-next-line @typescript-eslint/no-explicit-any bridge2.setCore(mockCore as any); mockInvoke.mockResolvedValueOnce('session-id'); @@ -806,7 +806,7 @@ describe('AIBridge', () => { mockCore.tauriProvider.isTauri.mockReturnValue(false); const bridge2 = new AIBridge(mockTracer); - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument + // eslint-disable-next-line @typescript-eslint/no-explicit-any bridge2.setCore(mockCore as any); mockInvoke.mockResolvedValueOnce('session-id'); await bridge2.init(); // should not throw when IPC streaming is unavailable @@ -818,7 +818,7 @@ describe('AIBridge', () => { it('should handle IPC initialization failure gracefully (line 86)', async () => { // Make onStream throw to trigger the catch block const bridge2 = new AIBridge(mockTracer); - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument + // eslint-disable-next-line @typescript-eslint/no-explicit-any bridge2.setCore(mockCore as any); // eslint-disable-next-line @typescript-eslint/no-explicit-any vi.spyOn((bridge2 as any)._transport, 'onStream').mockImplementation(() => { @@ -948,7 +948,7 @@ describe('AIBridge', () => { const tempBridge = new AIBridge(mockTracer); // eslint-disable-next-line @typescript-eslint/no-explicit-any (tempBridge as any)._transport = { setCore: vi.fn() }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument + // eslint-disable-next-line @typescript-eslint/no-explicit-any tempBridge.setCore(mockCore as any); // Should not throw and Should not call setCore on the plain object since it fails instanceof }); @@ -959,7 +959,7 @@ describe('AIBridge', () => { (import.meta.env as any).DEV = false; const tempBridge = new AIBridge(mockTracer); - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument + // eslint-disable-next-line @typescript-eslint/no-explicit-any tempBridge.setCore(mockCore as any); mockInvoke.mockResolvedValueOnce('session'); await tempBridge.init(); diff --git a/src/infrastructure/i18n/I18nService.test.ts b/src/infrastructure/i18n/I18nService.test.ts index 5d83133b..a34f6b98 100644 --- a/src/infrastructure/i18n/I18nService.test.ts +++ b/src/infrastructure/i18n/I18nService.test.ts @@ -277,7 +277,6 @@ describe('I18nService', () => { .mockResolvedValue({ ok: true, json: () => Promise.resolve({ hello: 'Hi' }) }), // mock translations ); // Force the method to reject to hit the `.catch` block - // eslint-disable-next-line @typescript-eslint/no-explicit-any const syncSpy = vi // eslint-disable-next-line @typescript-eslint/no-explicit-any .spyOn(i18n as any, '_syncToBackend') diff --git a/src/infrastructure/logging/LoggerService.test.ts b/src/infrastructure/logging/LoggerService.test.ts index b4c80ef4..82e9a92e 100644 --- a/src/infrastructure/logging/LoggerService.test.ts +++ b/src/infrastructure/logging/LoggerService.test.ts @@ -375,7 +375,6 @@ describe('LoggerService', () => { }); it('should use [Unstringifiable Object] when fallbackStringify inner catch fires', () => { - // eslint-disable-next-line prefer-arrow-callback const fnObj = Object.assign(function noop() { /* poisoned getter test */ }, {}); diff --git a/src/infrastructure/navigation/NavigationService.ts b/src/infrastructure/navigation/NavigationService.ts index 0c42c4e9..2477bce6 100644 --- a/src/infrastructure/navigation/NavigationService.ts +++ b/src/infrastructure/navigation/NavigationService.ts @@ -148,7 +148,6 @@ export class NavigationService { public popBackAction(): boolean { if (this._actionStack.length > 0) { const actionInfo = this._actionStack.pop(); - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (actionInfo) { this._tracer.debug(`[NavigationService] Executing back action: ${actionInfo.id}`); diff --git a/src/infrastructure/tauri/TauriProvider.ts b/src/infrastructure/tauri/TauriProvider.ts index 0c41c13e..63d47cbf 100644 --- a/src/infrastructure/tauri/TauriProvider.ts +++ b/src/infrastructure/tauri/TauriProvider.ts @@ -180,7 +180,6 @@ export class TauriProvider implements IBridge { return Promise.reject(new Error(String(e))); } - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters public async listen(event: string, callback: (payload: T) => void): Promise<() => void> { if (this.isTauri()) { // Using imported listen for robust IPC diff --git a/src/shared/services/ErrorHandler.test.ts b/src/shared/services/ErrorHandler.test.ts index 63b6b056..ed3e56dd 100644 --- a/src/shared/services/ErrorHandler.test.ts +++ b/src/shared/services/ErrorHandler.test.ts @@ -115,7 +115,6 @@ describe('ErrorHandler', () => { }); it('should capture error and return undefined on failure', async () => { - // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression const result = await errorHandler.wrapAsync(async () => { await Promise.resolve(); // Ensure async throw new Error('Async error'); @@ -166,7 +165,6 @@ describe('ErrorHandler', () => { describe('wrapAsync edge cases', () => { it('should handle non-Error throw with no context (L189)', async () => { - // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression const result = await errorHandler.wrapAsync(async () => { await Promise.resolve(); throw 42; // eslint-disable-line no-throw-literal, @typescript-eslint/only-throw-error diff --git a/src/shared/services/ModuleService.test.ts b/src/shared/services/ModuleService.test.ts index b0034daa..5191374c 100644 --- a/src/shared/services/ModuleService.test.ts +++ b/src/shared/services/ModuleService.test.ts @@ -77,7 +77,6 @@ describe('ModuleService', () => { mocks.commands.importIntegrationPath.mockReturnValue('import-path-promise'); mocks.commands.importIntegrationUrl.mockReturnValue('import-url-promise'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument moduleService = new ModuleService(mocks.tauriProvider as any, mocks.tracer); }); diff --git a/src/shared/services/ModuleService.ts b/src/shared/services/ModuleService.ts index d082a94b..2b364917 100644 --- a/src/shared/services/ModuleService.ts +++ b/src/shared/services/ModuleService.ts @@ -319,7 +319,6 @@ export class ModuleService { } this._deletedModules.add(moduleId); - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete this._downloadState[moduleId]; return true; } catch (e) { From 50d2931fdb3ec3419b5fa7f964dd5df32d11bf98 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 7 May 2026 15:06:47 +0300 Subject: [PATCH 105/126] chore(lint): include test and script sources --- src/eslint.config.js | 6 +-- .../CatalogService.integration.test.ts | 2 +- src/test/setup.ts | 38 +++++++++++++------ 3 files changed, 28 insertions(+), 18 deletions(-) diff --git a/src/eslint.config.js b/src/eslint.config.js index 0c932a8c..ac0cbf64 100644 --- a/src/eslint.config.js +++ b/src/eslint.config.js @@ -12,11 +12,7 @@ export default [ '*.config.js', '*.config.ts', 'vite.config.ts', - 'test/**', - '**/test/**', '**/bindings.ts', - 'scripts/**', - '**/scripts/**', ], }, { @@ -107,7 +103,7 @@ export default [ }, }, { - files: ['scripts/**/*.js'], + files: ['scripts/**/*.{js,mjs}'], languageOptions: { globals: { ...globals.node, diff --git a/src/test/integration/CatalogService.integration.test.ts b/src/test/integration/CatalogService.integration.test.ts index f987baaf..f85283d6 100644 --- a/src/test/integration/CatalogService.integration.test.ts +++ b/src/test/integration/CatalogService.integration.test.ts @@ -5,7 +5,7 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { CatalogService } from '@/shared/services/CatalogService'; +import type { CatalogService } from '@/shared/services/CatalogService'; import type { IModule } from '@/shared/types/coreTypes'; import { createCatalogHarness, diff --git a/src/test/setup.ts b/src/test/setup.ts index 494fa676..2acd2d8f 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1,5 +1,11 @@ import { beforeEach, vi } from 'vitest'; +type TauriInternalsMock = { + invoke: (cmd?: string, args?: unknown) => Promise; + transformCallback: () => number; + eventListen: (event?: string, callback?: (payload: unknown) => void) => Promise<() => void>; +}; + // Mock localStorage for JSDOM const localStorageMock = (() => { let store: Record = {}; @@ -25,22 +31,31 @@ Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock }); function installDefaultTauriGlobals(): void { const win = globalThis as unknown as Record; - win['__TAURI_INTERNALS__'] = { - invoke: async () => {}, + const internals: TauriInternalsMock = { + invoke: () => Promise.resolve(), transformCallback: () => 0, - eventListen: async () => () => {}, + eventListen: () => Promise.resolve(() => {}), }; + win['__TAURI_INTERNALS__'] = internals; win['t'] = vi.fn((key: string, fallback?: string) => fallback ?? key); } +function getTauriInternals(): Partial | null { + const win = globalThis as unknown as Record; + const internals = win['__TAURI_INTERNALS__']; + if (internals === null || typeof internals !== 'object') { + return null; + } + return internals as Partial; +} + // Mock Tauri APIs vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn().mockImplementation((cmd: string, args?: unknown) => { - const win = globalThis as unknown as Record; - const internals = win['__TAURI_INTERNALS__'] as Record | undefined; + const internals = getTauriInternals(); - if (typeof internals?.['invoke'] === 'function') { - return internals['invoke'](cmd, args); + if (typeof internals?.invoke === 'function') { + return internals.invoke(cmd, args); } return Promise.resolve(); }), @@ -48,12 +63,11 @@ vi.mock('@tauri-apps/api/core', () => ({ })); vi.mock('@tauri-apps/api/event', () => ({ - listen: vi.fn().mockImplementation((event: string, callback: (payload: any) => void) => { - const win = globalThis as unknown as Record; - const internals = win['__TAURI_INTERNALS__'] as Record | undefined; + listen: vi.fn().mockImplementation((event: string, callback: (payload: unknown) => void) => { + const internals = getTauriInternals(); - if (typeof internals?.['eventListen'] === 'function') { - return internals['eventListen'](event, callback); + if (typeof internals?.eventListen === 'function') { + return internals.eventListen(event, callback); } return Promise.resolve(() => {}); }), From c21ab9a9a2d84fa692971c7683a9ed7f0bc4b7b8 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 7 May 2026 15:16:40 +0300 Subject: [PATCH 106/126] ci: lint repository tooling and trim rust cache --- .github/scripts/workflow.mjs | 22 +++++++++++++++++++--- .github/workflows/ci.yml | 2 ++ .github/workflows/release.yml | 1 + src/eslint.config.js | 5 ++++- 4 files changed, 26 insertions(+), 4 deletions(-) diff --git a/.github/scripts/workflow.mjs b/.github/scripts/workflow.mjs index dd072365..113d41f4 100644 --- a/.github/scripts/workflow.mjs +++ b/.github/scripts/workflow.mjs @@ -774,11 +774,27 @@ function verifyProject() { ensureFrontendDependencies(); run('npm', ['run', 'format:check'], { cwd: srcDir }); run('npm', ['run', 'typecheck'], { cwd: srcDir }); - run('npm', ['run', 'lint'], { cwd: srcDir }); + lintProject(); run('npm', ['run', 'test'], { cwd: srcDir }); run('npm', ['run', 'build:bundle'], { cwd: srcDir }); } +function lintProject() { + run('npm', ['--prefix', 'src', 'run', 'lint']); + run('npm', [ + '--prefix', + 'src', + 'exec', + '--', + 'eslint', + '--config', + 'src/eslint.config.js', + '.github/scripts', + '.github/commitlint.config.js', + '--no-ignore', + ]); +} + function setupProject() { runDoctor(); run('npm', ['ci'], { cwd: srcDir }); @@ -805,7 +821,7 @@ Tasks: release:checksums Generate SHA256 checksums for release bundles release:verify-hardening Validate release hardening settings run Launch the built app artifact - lint Run frontend lint checks + lint Run frontend and repository tooling lint checks format Format frontend files format:check Check frontend formatting test Run frontend tests @@ -897,7 +913,7 @@ Tasks: runReleaseBinary(); }, lint() { - run('npm', ['--prefix', 'src', 'run', 'lint']); + lintProject(); }, format() { run('npm', ['--prefix', 'src', 'run', 'format']); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b39fc7ff..05eb59fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,7 @@ jobs: uses: Swatinem/rust-cache@919333daf4640a6fca9dd8b87468dc776e46b44b # node24 with: workspaces: "src-tauri -> target" + cache-targets: false - name: Install Dependencies run: | @@ -92,6 +93,7 @@ jobs: uses: Swatinem/rust-cache@919333daf4640a6fca9dd8b87468dc776e46b44b # node24 with: workspaces: "src-tauri -> target" + cache-targets: false - name: Clippy (Strict Linting) run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b2eb99fe..d23223fb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -74,6 +74,7 @@ jobs: uses: Swatinem/rust-cache@919333daf4640a6fca9dd8b87468dc776e46b44b # node24 with: workspaces: "src-tauri -> target" + cache-targets: false - name: Install frontend dependencies run: | diff --git a/src/eslint.config.js b/src/eslint.config.js index ac0cbf64..f4e9c40c 100644 --- a/src/eslint.config.js +++ b/src/eslint.config.js @@ -103,12 +103,15 @@ export default [ }, }, { - files: ['scripts/**/*.{js,mjs}'], + files: ['scripts/**/*.{js,mjs}', '../.github/**/*.{js,mjs}', '.github/**/*.{js,mjs}'], languageOptions: { globals: { ...globals.node, }, }, + rules: { + 'no-console': 'off', + }, }, eslintConfigPrettier, ]; From 4e5eb2c07230cfd7f4ca4ac2a96aae95d76ea462 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 14 May 2026 18:57:19 +0300 Subject: [PATCH 107/126] fix(core): clean up unsplit rebase regressions --- .../domain/modules/controller/lifecycle.rs | 6 +--- src/features/chat/chat.test.ts | 3 +- .../chat/controllers/ChatSendController.ts | 1 - .../ui/ModuleSettingsEngineRenderFlow.ts | 34 ------------------- .../tauri/TauriProvider.test.ts | 28 +++++++++------ 5 files changed, 21 insertions(+), 51 deletions(-) diff --git a/src-tauri/src/domain/modules/controller/lifecycle.rs b/src-tauri/src/domain/modules/controller/lifecycle.rs index 8e36955b..130458ff 100644 --- a/src-tauri/src/domain/modules/controller/lifecycle.rs +++ b/src-tauri/src/domain/modules/controller/lifecycle.rs @@ -337,6 +337,7 @@ impl<'a> LifecycleExecutor<'a> { let lifecycle_lock = module_lifecycle_lock(&self.module_id).await; let _lifecycle_guard = lifecycle_lock.lock().await; tracing::info!("Stopping module: {}", self.module_id); + let script_entry_path = self.resolve_script_entry_path(manifest)?; // 1. Run stop script if exists if let Some(stop_cmd) = manifest.lifecycle.as_ref().and_then(|l| l.stop.clone()) { @@ -441,11 +442,6 @@ impl<'a> LifecycleExecutor<'a> { tokio::time::sleep(Duration::from_millis(500)).await; } - let script_entry_path = self.resolve_script_entry_path(manifest)?; - if let Some(entry_path) = script_entry_path.as_ref() { - self.kill_matching_script_processes(entry_path).await?; - } - if self .controller .is_running(&self.module_id, self.module_path) diff --git a/src/features/chat/chat.test.ts b/src/features/chat/chat.test.ts index 10394b11..b7ce809c 100644 --- a/src/features/chat/chat.test.ts +++ b/src/features/chat/chat.test.ts @@ -318,7 +318,7 @@ describe('ChatController', () => { _state: { isSending: boolean }; }; - controller.init(); + const initPromise = controller.init(); internals._state.isSending = true; resolvePreview({ data_url: 'data:image/png;base64,abc', @@ -329,6 +329,7 @@ describe('ChatController', () => { speed: null, eta_relative: null, }); + await initPromise; await Promise.resolve(); await Promise.resolve(); diff --git a/src/features/chat/controllers/ChatSendController.ts b/src/features/chat/controllers/ChatSendController.ts index b45808b1..af86083c 100644 --- a/src/features/chat/controllers/ChatSendController.ts +++ b/src/features/chat/controllers/ChatSendController.ts @@ -179,7 +179,6 @@ export class ChatSendController { let streamingHandle: StreamingMessageHandle | null = null; let imageHandle: ImageGenerationHandle | null = null; - let streamingHandle: StreamingMessageHandle | null = null; let shouldStopImageEngine = false; try { diff --git a/src/features/settings/ui/ModuleSettingsEngineRenderFlow.ts b/src/features/settings/ui/ModuleSettingsEngineRenderFlow.ts index b91f585d..4dc3f87e 100644 --- a/src/features/settings/ui/ModuleSettingsEngineRenderFlow.ts +++ b/src/features/settings/ui/ModuleSettingsEngineRenderFlow.ts @@ -213,38 +213,4 @@ export class ModuleSettingsEngineRenderFlow { options.config, ); } - - private _renderTextFields(options: { - container: HTMLElement; - appId: string; - config: EngineConfig | null; - translate: TranslateFn; - getTextFields: ModuleSettingsEngineRenderOptions['getTextFields']; - }): void { - const fieldTargets: Record = { - context_size: `#local-engine-context-${options.appId}`, - llamacpp_system_prompt: `#local-engine-system-prompt-${options.appId}`, - }; - - options.getTextFields(options.translate).forEach((field) => { - const targetSelector = fieldTargets[field.key]; - if (targetSelector === undefined) { - // eslint-disable-next-line no-console - console.warn( - `[ModuleSettingsEngineRenderFlow] Missing target for text field "${field.key}" in ${options.appId}`, - ); - return; - } - const target = options.container.querySelector(targetSelector); - if (!(target instanceof HTMLElement)) { - return; - } - - this._deps.renderFieldRow(target, { - ...field, - appId: options.appId, - config: options.config, - }); - }); - } } diff --git a/src/infrastructure/tauri/TauriProvider.test.ts b/src/infrastructure/tauri/TauriProvider.test.ts index 375f0466..ec65388a 100644 --- a/src/infrastructure/tauri/TauriProvider.test.ts +++ b/src/infrastructure/tauri/TauriProvider.test.ts @@ -43,12 +43,22 @@ function setupWebMode(): { win: Record; origInternals: unknown; provider: TauriProvider; + openExternal: ReturnType; } { const win = globalThis as unknown as Record; const origInternals = win['__TAURI_INTERNALS__']; delete win['__TAURI_INTERNALS__']; + const openExternal = vi.fn(); - return { win, origInternals, provider: new TauriProvider(createTracer()) }; + return { + win, + origInternals, + openExternal, + provider: new TauriProvider(createTracer(), { + hasTauriGlobals: () => false, + openExternal, + }), + }; } import { TauriProvider } from '@/infrastructure/tauri/TauriProvider'; @@ -413,12 +423,11 @@ describe('TauriProvider', () => { await expect(provider.writeToClipboard('copied text')).rejects.toThrow('denied'); }); - it('should reject in web mode', async () => { + it('should use browser clipboard fallback in web mode', async () => { const { provider: webProvider } = setupWebMode(); - await expect(webProvider.writeToClipboard('text')).rejects.toThrow( - 'Clipboard write is unavailable outside Tauri', - ); + await expect(webProvider.writeToClipboard('text')).resolves.toBeUndefined(); + expect(mockedTauriInvoke).not.toHaveBeenCalled(); }); }); @@ -461,12 +470,11 @@ describe('TauriProvider', () => { }); }); - it('should reject outside Tauri', async () => { - const { provider: webProvider } = setupWebMode(); + it('should use the runtime fallback outside Tauri', async () => { + const { provider: webProvider, openExternal } = setupWebMode(); - await expect(webProvider.openUrl('https://example.com')).rejects.toThrow( - 'External URL opening is unavailable outside Tauri', - ); + await expect(webProvider.openUrl('https://example.com')).resolves.toBeUndefined(); + expect(openExternal).toHaveBeenCalledWith('https://example.com'); }); }); From 6a5784ecded5d3b52ea7e634256556812cc9d56b Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 14 May 2026 20:38:03 +0300 Subject: [PATCH 108/126] ci: make Rust cache non-blocking --- .github/workflows/ci.yml | 2 ++ .github/workflows/release.yml | 1 + 2 files changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05eb59fe..58902867 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,7 @@ jobs: - name: Rust Cache uses: Swatinem/rust-cache@919333daf4640a6fca9dd8b87468dc776e46b44b # node24 + continue-on-error: true with: workspaces: "src-tauri -> target" cache-targets: false @@ -91,6 +92,7 @@ jobs: - name: Rust Cache uses: Swatinem/rust-cache@919333daf4640a6fca9dd8b87468dc776e46b44b # node24 + continue-on-error: true with: workspaces: "src-tauri -> target" cache-targets: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d23223fb..fbea2f44 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -72,6 +72,7 @@ jobs: - name: Rust Cache uses: Swatinem/rust-cache@919333daf4640a6fca9dd8b87468dc776e46b44b # node24 + continue-on-error: true with: workspaces: "src-tauri -> target" cache-targets: false From e380b57746f4bd07ee7473f146e14d2f501a794d Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 14 May 2026 20:55:12 +0300 Subject: [PATCH 109/126] chore: align Vitest package lock --- src/package-lock.json | 136 +++++++++++++++++------------------------- src/package.json | 4 +- 2 files changed, 56 insertions(+), 84 deletions(-) diff --git a/src/package-lock.json b/src/package-lock.json index a0f2fb40..c03786af 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -27,7 +27,7 @@ "@typescript-eslint/parser": "^8.59.3", "@typescript-eslint/utils": "^8.59.3", "@vitest/coverage-v8": "^4.1.6", - "@vitest/ui": "^4.1.5", + "@vitest/ui": "^4.1.6", "eslint": "^10.3.0", "eslint-config-prettier": "^10.1.8", "fonteditor-core": "^2.6.3", @@ -39,7 +39,7 @@ "typescript": "^6.0.3", "typescript-eslint": "^8.59.3", "vite": "^8.0.12", - "vitest": "^4.1.5" + "vitest": "^4.1.6" }, "engines": { "node": ">=20.0.0" @@ -1789,45 +1789,17 @@ } } }, - "node_modules/@vitest/coverage-v8/node_modules/@vitest/pretty-format": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz", - "integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/coverage-v8/node_modules/@vitest/utils": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz", - "integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.6", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@vitest/expect": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", - "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz", + "integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/spy": "4.1.6", + "@vitest/utils": "4.1.6", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -1836,13 +1808,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", - "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz", + "integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.5", + "@vitest/spy": "4.1.6", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1863,9 +1835,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", - "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz", + "integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==", "dev": true, "license": "MIT", "dependencies": { @@ -1876,13 +1848,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", - "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz", + "integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.5", + "@vitest/utils": "4.1.6", "pathe": "^2.0.3" }, "funding": { @@ -1890,14 +1862,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", - "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz", + "integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/pretty-format": "4.1.6", + "@vitest/utils": "4.1.6", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1906,9 +1878,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", - "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz", + "integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==", "dev": true, "license": "MIT", "funding": { @@ -1916,13 +1888,13 @@ } }, "node_modules/@vitest/ui": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.5.tgz", - "integrity": "sha512-3Z9HNFiV0IF1fk0JPiK+7kE1GcaIPefQQIBYur6PM5yFIq6agys3uqP/0t966e1wXfmjbRCHDe7qW236Xjwnag==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.6.tgz", + "integrity": "sha512-wiu5em68DfGv/2HFvI1Njr7JI2CHcBlQvereSzVG8my53PRxjTNOCsD9VOkRKrsJBDHmyuXvosxWZw7T91a2mw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.5", + "@vitest/utils": "4.1.6", "fflate": "^0.8.2", "flatted": "^3.4.2", "pathe": "^2.0.3", @@ -1934,17 +1906,17 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "4.1.5" + "vitest": "4.1.6" } }, "node_modules/@vitest/utils": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", - "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz", + "integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.5", + "@vitest/pretty-format": "4.1.6", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -2399,9 +2371,9 @@ } }, "node_modules/es-module-lexer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", "dev": true, "license": "MIT" }, @@ -4416,19 +4388,19 @@ } }, "node_modules/vitest": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", - "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz", + "integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.5", - "@vitest/mocker": "4.1.5", - "@vitest/pretty-format": "4.1.5", - "@vitest/runner": "4.1.5", - "@vitest/snapshot": "4.1.5", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/expect": "4.1.6", + "@vitest/mocker": "4.1.6", + "@vitest/pretty-format": "4.1.6", + "@vitest/runner": "4.1.6", + "@vitest/snapshot": "4.1.6", + "@vitest/spy": "4.1.6", + "@vitest/utils": "4.1.6", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -4456,12 +4428,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.5", - "@vitest/browser-preview": "4.1.5", - "@vitest/browser-webdriverio": "4.1.5", - "@vitest/coverage-istanbul": "4.1.5", - "@vitest/coverage-v8": "4.1.5", - "@vitest/ui": "4.1.5", + "@vitest/browser-playwright": "4.1.6", + "@vitest/browser-preview": "4.1.6", + "@vitest/browser-webdriverio": "4.1.6", + "@vitest/coverage-istanbul": "4.1.6", + "@vitest/coverage-v8": "4.1.6", + "@vitest/ui": "4.1.6", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/src/package.json b/src/package.json index f652bfff..8c42e108 100644 --- a/src/package.json +++ b/src/package.json @@ -33,7 +33,7 @@ "@typescript-eslint/parser": "^8.59.3", "@typescript-eslint/utils": "^8.59.3", "@vitest/coverage-v8": "^4.1.6", - "@vitest/ui": "^4.1.5", + "@vitest/ui": "^4.1.6", "eslint": "^10.3.0", "eslint-config-prettier": "^10.1.8", "fonteditor-core": "^2.6.3", @@ -45,7 +45,7 @@ "typescript": "^6.0.3", "typescript-eslint": "^8.59.3", "vite": "^8.0.12", - "vitest": "^4.1.5" + "vitest": "^4.1.6" }, "dependencies": { "@tauri-apps/api": "~2.11.0", From 8fa59aa943c74ac88ba7163fd1166de69acef35d Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 14 May 2026 21:12:19 +0300 Subject: [PATCH 110/126] chore: move project tooling to Node 26 --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/security-audit.yml | 2 +- .nvmrc | 1 + package.json | 2 +- src/package-lock.json | 2 +- src/package.json | 2 +- 7 files changed, 7 insertions(+), 6 deletions(-) create mode 100644 .nvmrc diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58902867..dfffdc81 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: "22" + node-version: "26.1.0" cache: "npm" cache-dependency-path: src/package-lock.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fbea2f44..17805a79 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -61,7 +61,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: "22" + node-version: "26.1.0" cache: "npm" cache-dependency-path: src/package-lock.json diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml index 4662ddc4..c747dc0c 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -24,7 +24,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: "22" + node-version: "26.1.0" cache: "npm" cache-dependency-path: src/package-lock.json diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..2aaedf99 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +26.1.0 diff --git a/package.json b/package.json index 712d735e..4e6b4835 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,6 @@ "check-size": "node .github/scripts/workflow.mjs check-size" }, "engines": { - "node": ">=20.0.0" + "node": ">=26.1.0" } } diff --git a/src/package-lock.json b/src/package-lock.json index c03786af..a0a7d608 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -42,7 +42,7 @@ "vitest": "^4.1.6" }, "engines": { - "node": ">=20.0.0" + "node": ">=26.1.0" } }, "node_modules/@asamuzakjp/css-color": { diff --git a/src/package.json b/src/package.json index 8c42e108..0aff5584 100644 --- a/src/package.json +++ b/src/package.json @@ -58,6 +58,6 @@ "wawoff2": "^2.0.1" }, "engines": { - "node": ">=20.0.0" + "node": ">=26.1.0" } } From 5cd5385eaa0b1f77bcff85f68c3cabd4a11a4da8 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 14 May 2026 22:12:26 +0300 Subject: [PATCH 111/126] chore: refresh toolchain and dependencies --- .github/workflows/ci.yml | 4 +- .github/workflows/codeql.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/security-audit.yml | 2 +- README.md | 4 +- docs/localization/en/GETTING_STARTED.md | 4 +- rust-toolchain.toml | 2 +- src-tauri/Cargo.lock | 56 +++++++++++-------- src-tauri/Cargo.toml | 4 +- src-tauri/src/domain/ai/ai_service.rs | 2 +- src-tauri/src/domain/ai/image_cloud.rs | 2 +- src-tauri/src/domain/ai/image_comfyui.rs | 4 +- .../src/domain/modules/downloader_transfer.rs | 2 +- .../domain/modules/settings_ui_protocol.rs | 2 +- src/package-lock.json | 4 +- src/package.json | 4 +- 16 files changed, 56 insertions(+), 44 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dfffdc81..a6ced52e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: - name: Setup Rust uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable with: - toolchain: 1.94.1 + toolchain: 1.95.0 - name: Rust Cache uses: Swatinem/rust-cache@919333daf4640a6fca9dd8b87468dc776e46b44b # node24 @@ -87,7 +87,7 @@ jobs: - name: Setup Rust uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable with: - toolchain: 1.94.1 + toolchain: 1.95.0 components: clippy, llvm-tools-preview - name: Rust Cache diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index b6955429..ed6bc8ab 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -39,7 +39,7 @@ jobs: if: matrix.language == 'rust' uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 with: - toolchain: 1.94.1 + toolchain: 1.95.0 - name: Initialize CodeQL uses: github/codeql-action/init@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 17805a79..ad0f8749 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -68,7 +68,7 @@ jobs: - name: Setup Rust uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable with: - toolchain: 1.94.1 + toolchain: 1.95.0 - name: Rust Cache uses: Swatinem/rust-cache@919333daf4640a6fca9dd8b87468dc776e46b44b # node24 diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml index c747dc0c..c285e414 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -49,7 +49,7 @@ jobs: - name: Setup Rust uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 with: - toolchain: 1.94.1 + toolchain: 1.95.0 - name: Install cargo-audit run: cargo install cargo-audit --locked --quiet diff --git a/README.md b/README.md index 42230307..490b82b2 100644 --- a/README.md +++ b/README.md @@ -48,8 +48,8 @@ It is not yet a finished marketplace, managed platform, or polished MCP-first op Install on Windows first: -- Node.js 20+ -- npm 10+ +- Node.js 26.1.0+ +- npm 11+ - Rust via `rustup` (`rust-toolchain.toml` pins the tested version) - WebView2 Runtime - Windows SDK diff --git a/docs/localization/en/GETTING_STARTED.md b/docs/localization/en/GETTING_STARTED.md index dc073f6b..aa8bcf5b 100644 --- a/docs/localization/en/GETTING_STARTED.md +++ b/docs/localization/en/GETTING_STARTED.md @@ -8,8 +8,8 @@ For day-to-day contributor work after setup, continue with [Development Workflow ## Requirements -- Node.js 20+ -- npm 10+ +- Node.js 26.1.0+ +- npm 11+ - Rust via `rustup` (`rust-toolchain.toml` pins the tested version) - Windows: Visual Studio Build Tools, Windows SDK, and WebView2 Runtime diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 48701262..8934eb4a 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "1.94.1" +channel = "1.95.0" components = ["clippy", "rustfmt"] profile = "minimal" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index fe282021..794d38d4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1135,7 +1135,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1342,7 +1342,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2809,7 +2809,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2910,7 +2910,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3133,6 +3133,17 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-open-directory" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb82bed227edf5201dfedf072bba4015a33d3d4a98519837295a90f0a23f676d" +dependencies = [ + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -3280,7 +3291,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3852,7 +3863,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4328,7 +4339,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4550,15 +4561,16 @@ dependencies = [ [[package]] name = "sysinfo" -version = "0.38.4" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" +checksum = "a4deba334e1190ba7cb498327affa11e5ece10d26a30ab2f27fcf09504b8d8b6" dependencies = [ "libc", "memchr", "ntapi", "objc2-core-foundation", "objc2-io-kit", + "objc2-open-directory", "windows 0.62.2", ] @@ -5023,7 +5035,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5260,7 +5272,7 @@ dependencies = [ "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow 1.0.2", + "winnow 1.0.3", ] [[package]] @@ -5323,7 +5335,7 @@ dependencies = [ "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.2", + "winnow 1.0.3", ] [[package]] @@ -5332,7 +5344,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.2", + "winnow 1.0.3", ] [[package]] @@ -5498,7 +5510,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5544,7 +5556,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6060,7 +6072,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6542,9 +6554,9 @@ checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" [[package]] name = "winnow" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ "memchr", ] @@ -6854,7 +6866,7 @@ dependencies = [ "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow 1.0.2", + "winnow 1.0.3", "zbus_macros", "zbus_names", "zvariant", @@ -6882,7 +6894,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" dependencies = [ "serde", - "winnow 1.0.2", + "winnow 1.0.3", "zvariant", ] @@ -7069,7 +7081,7 @@ dependencies = [ "endi", "enumflags2", "serde", - "winnow 1.0.2", + "winnow 1.0.3", "zvariant_derive", "zvariant_utils", ] @@ -7097,5 +7109,5 @@ dependencies = [ "quote", "serde", "syn 2.0.117", - "winnow 1.0.2", + "winnow 1.0.3", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e5d71896..0a1a56fd 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -8,7 +8,7 @@ version = "0.1.5" description = "Axelate: Windows-first AI Workstation." authors = ["F0RLE"] edition = "2024" -rust-version = "1.94.1" +rust-version = "1.95.0" license = "Apache-2.0" repository = "https://github.com/F0RLE/Axelate" homepage = "https://github.com/F0RLE/Axelate" @@ -58,7 +58,7 @@ futures-util = "0.3.32" scraper = "0.27.0" # --- System, Hardware & Low Level --- -sysinfo = "0.38.4" +sysinfo = "0.39.1" nvml-wrapper = "0.12.1" # NVIDIA Management Library wmi = "0.18.4" # Windows Management Instrumentation machine-uid = "0.6.0" # Hardware ID generation diff --git a/src-tauri/src/domain/ai/ai_service.rs b/src-tauri/src/domain/ai/ai_service.rs index e6f91f72..ab5d3801 100644 --- a/src-tauri/src/domain/ai/ai_service.rs +++ b/src-tauri/src/domain/ai/ai_service.rs @@ -18,7 +18,7 @@ pub use super::types::{ }; const CLOUD_AI_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(90); -const LOCAL_AI_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30 * 60); +const LOCAL_AI_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(30); struct PreparedRequestExecution { provider: OpenAiCompatibleProvider, diff --git a/src-tauri/src/domain/ai/image_cloud.rs b/src-tauri/src/domain/ai/image_cloud.rs index 46d32586..ebe615d5 100644 --- a/src-tauri/src/domain/ai/image_cloud.rs +++ b/src-tauri/src/domain/ai/image_cloud.rs @@ -22,7 +22,7 @@ pub(super) async fn process_cloud_image_request( .filter(|value| !value.trim().is_empty()) .ok_or_else(|| AppError::Validation("OpenRouter API key is missing".to_string()))?; - let client = build_image_client(Duration::from_secs(180))?; + let client = build_image_client(Duration::from_mins(3))?; let response = client .post("https://openrouter.ai/api/v1/chat/completions") .header(reqwest::header::AUTHORIZATION, format!("Bearer {api_key}")) diff --git a/src-tauri/src/domain/ai/image_comfyui.rs b/src-tauri/src/domain/ai/image_comfyui.rs index 21469498..fe296a36 100644 --- a/src-tauri/src/domain/ai/image_comfyui.rs +++ b/src-tauri/src/domain/ai/image_comfyui.rs @@ -36,7 +36,7 @@ pub(super) async fn process_comfyui_request( settings_service: &SettingsService, ) -> Result, AppError> { let settings_context = load_image_request_settings_context(request, settings_service).await?; - let client = build_image_client(Duration::from_secs(120))?; + let client = build_image_client(Duration::from_mins(2))?; let comfyui = build_comfyui_request_context(request, &settings_context, &client).await?; let workflow = build_comfyui_workflow( &request.prompt, @@ -443,7 +443,7 @@ async fn wait_for_comfyui_images( prompt_id: &str, image_generation_state: &ImageGenerationState, ) -> Result, AppError> { - let deadline = Instant::now() + Duration::from_secs(600); + let deadline = Instant::now() + Duration::from_mins(10); loop { if image_generation_state diff --git a/src-tauri/src/domain/modules/downloader_transfer.rs b/src-tauri/src/domain/modules/downloader_transfer.rs index 1d0bea1b..be3e6c48 100644 --- a/src-tauri/src/domain/modules/downloader_transfer.rs +++ b/src-tauri/src/domain/modules/downloader_transfer.rs @@ -178,7 +178,7 @@ pub(super) fn build_client(module_id: &str) -> Result fn construct_client_builder() -> reqwest::ClientBuilder { reqwest::Client::builder() .user_agent(format!("Axelate/1.0.0 (Tauri; {})", std::env::consts::OS)) - .timeout(std::time::Duration::from_secs(600)) + .timeout(std::time::Duration::from_mins(10)) } pub(super) fn build_public_client() -> Result { diff --git a/src-tauri/src/domain/modules/settings_ui_protocol.rs b/src-tauri/src/domain/modules/settings_ui_protocol.rs index a82e5e54..12dc77e9 100644 --- a/src-tauri/src/domain/modules/settings_ui_protocol.rs +++ b/src-tauri/src/domain/modules/settings_ui_protocol.rs @@ -20,7 +20,7 @@ type ModuleSettingsPayload = HashMap; const MODULE_SETTINGS_SCHEME: &str = "module-settings"; const MODULE_SETTINGS_LABEL_PREFIX: &str = "module-settings"; -const MODULE_SETTINGS_SESSION_TTL: Duration = Duration::from_secs(60 * 60); +const MODULE_SETTINGS_SESSION_TTL: Duration = Duration::from_hours(1); const MODULE_SETTINGS_MAX_SESSIONS: usize = 128; const HOST_INDEX_HTML: &str = include_str!("../../../resources/module_settings_host/index.html"); const HOST_SCRIPT: &str = include_str!("../../../resources/module_settings_host/host.js"); diff --git a/src/package-lock.json b/src/package-lock.json index a0a7d608..850935a2 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -22,7 +22,7 @@ "@commitlint/config-conventional": "^21.0.1", "@eslint/js": "^10.0.1", "@tauri-apps/cli": "~2.11.1", - "@types/node": "^25.7.0", + "@types/node": "^25.8.0", "@typescript-eslint/eslint-plugin": "^8.59.3", "@typescript-eslint/parser": "^8.59.3", "@typescript-eslint/utils": "^8.59.3", @@ -38,7 +38,7 @@ "terser": "^5.47.1", "typescript": "^6.0.3", "typescript-eslint": "^8.59.3", - "vite": "^8.0.12", + "vite": "^8.0.13", "vitest": "^4.1.6" }, "engines": { diff --git a/src/package.json b/src/package.json index 0aff5584..3b9b5f3f 100644 --- a/src/package.json +++ b/src/package.json @@ -28,7 +28,7 @@ "@commitlint/config-conventional": "^21.0.1", "@eslint/js": "^10.0.1", "@tauri-apps/cli": "~2.11.1", - "@types/node": "^25.7.0", + "@types/node": "^25.8.0", "@typescript-eslint/eslint-plugin": "^8.59.3", "@typescript-eslint/parser": "^8.59.3", "@typescript-eslint/utils": "^8.59.3", @@ -44,7 +44,7 @@ "terser": "^5.47.1", "typescript": "^6.0.3", "typescript-eslint": "^8.59.3", - "vite": "^8.0.12", + "vite": "^8.0.13", "vitest": "^4.1.6" }, "dependencies": { From ac6d5a4e122a429d463057041462d142e85a29a4 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Fri, 15 May 2026 00:55:05 +0300 Subject: [PATCH 112/126] chore: upgrade specta bindings stack --- src-tauri/Cargo.lock | 58 +- src-tauri/Cargo.toml | 6 +- src-tauri/src/lib.rs | 205 +++--- src/shared/types/bindings.ts | 1234 +++++++++++++++++----------------- 4 files changed, 753 insertions(+), 750 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 794d38d4..2ea1e2be 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1135,7 +1135,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1342,7 +1342,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2809,7 +2809,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2910,7 +2910,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3291,7 +3291,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3863,7 +3863,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4339,7 +4339,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4392,9 +4392,9 @@ dependencies = [ [[package]] name = "specta" -version = "2.0.0-rc.24" +version = "2.0.0-rc.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f320c7dd82008b6958f43f6257c95319c407d1c17ade43686e50ea520c28bb26" +checksum = "38f9a30cbcbb7011f1da7d73483983bf838af123883e45f2b36ed76328df9c50" dependencies = [ "paste", "rustc_version", @@ -4404,9 +4404,9 @@ dependencies = [ [[package]] name = "specta-macros" -version = "2.0.0-rc.24" +version = "2.0.0-rc.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "153f185d0051a64d81977bab5012809d5c9d9db8792406a0997352e05494f711" +checksum = "2ce14957ecc2897f1f848b8255b6531d13ddf49cbcf506b7c2c9fb1d005593bb" dependencies = [ "Inflector", "proc-macro2", @@ -4416,30 +4416,28 @@ dependencies = [ [[package]] name = "specta-serde" -version = "0.0.11" +version = "0.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a46349e2c3fb3f3de4b78daa19632c32813262e1ab0966896b64ca91e0926e" +checksum = "ee8a72b755ddb8949fd8f17c5db43f0e8a806ea587d9bc602ee3f73240c00029" dependencies = [ "specta", "specta-macros", ] [[package]] -name = "specta-tags" -version = "0.0.0" +name = "specta-typescript" +version = "0.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3026b7e2f8c76dcab90ec27b7f3e6a0b7d646501a3a8aa5d739d09cb9ee59871" +checksum = "639404ee95557f2f8b7e4cb773ffefd45304c7ab8ba21ac83b69051595e083c0" dependencies = [ - "serde", - "serde_json", "specta", ] [[package]] -name = "specta-typescript" -version = "0.0.11" +name = "specta-util" +version = "0.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea586e1709619c9f4cb0c9115ff29c278f331c0450aff266c2913c639708b299" +checksum = "29b1fc02b446f7244a92924fe68c0555921209f1d342990cd1539e9138e69502" dependencies = [ "specta", ] @@ -4947,17 +4945,17 @@ dependencies = [ [[package]] name = "tauri-specta" -version = "2.0.0-rc.24" +version = "2.0.0-rc.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9191951c8d3aefce8fb9818271545c61b441a3c1095d8443637b36572e0346e4" +checksum = "ee080f36d2ac17ce2f3a82fb53f02d664e8345457de51b56dad3c394dacc41a2" dependencies = [ "heck 0.5.0", "serde", "serde_json", "specta", "specta-serde", - "specta-tags", "specta-typescript", + "specta-util", "tauri", "tauri-specta-macros", "thiserror 2.0.18", @@ -4965,9 +4963,9 @@ dependencies = [ [[package]] name = "tauri-specta-macros" -version = "2.0.0-rc.24" +version = "2.0.0-rc.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e23657d20f2b5508d5eca5ee6bb98d77e4c13127b9049e102b5db6a63bc73665" +checksum = "6a59dfdce06c98d8d211619bea5fdb39486d8a8c558e12b2d2ce255972320012" dependencies = [ "darling 0.23.0", "heck 0.5.0", @@ -5035,7 +5033,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5510,7 +5508,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5556,7 +5554,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6072,7 +6070,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0a1a56fd..049943e9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -44,9 +44,9 @@ tauri-plugin-global-shortcut = "~2.3" tauri-plugin-clipboard-manager = "~2.3" # --- Frontend Bridge (Types & Serialization) --- -specta = { version = "2.0.0-rc.24", features = ["derive", "serde_json"] } -tauri-specta = { version = "2.0.0-rc.24", features = ["typescript", "derive"] } -specta-typescript = "0.0.11" +specta = { version = "2.0.0-rc.25", features = ["derive", "serde_json"] } +tauri-specta = { version = "2.0.0-rc.25", features = ["typescript", "derive"] } +specta-typescript = "0.0.12" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" toml = "1.1" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f14581b6..d03c305b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -77,7 +77,7 @@ use infrastructure::{ persistence::json_store::JsonStore, }; -use specta_typescript::Typescript; +use specta_typescript::{Typescript, define, semantic::Configuration}; use std::path::{Path, PathBuf}; use std::sync::atomic::Ordering; use tauri::Manager; @@ -89,104 +89,111 @@ const TYPESCRIPT_BINDINGS_HEADER: &str = "// @ts-nocheck\n/* eslint-disable @typ /// Creates and configures the Specta builder with all application commands. pub fn create_specta_builder() -> Builder { - Builder::::new().commands(collect_commands![ - health::get_health, - config::get_config, - settings::get_settings, - settings::save_settings, - settings::save_setting, - settings::get_module_settings, - settings::save_module_settings, - settings::get_system_language, - logs::get_logs, - logs::get_console_logs, - logs::get_console_overview, - logs::clear_logs, - logs::clear_console_logs, - logs::get_log_dir, - logs::open_log_dir, - logs::open_console_log_target, - logs::add_log, - logs::log_batch, - downloader::download_module, - downloader::get_release_download_options, - downloader::import_integration_folder, - downloader::import_integration_archive, - downloader::import_integration_path, - downloader::import_integration_url, - downloader::resume_download, - downloader::check_module_installed, - downloader::get_module_path, - downloader::delete_module, - downloader::list_module_files, - downloader::set_download_settings, - downloader::cancel_download, - downloader::pause_download, - system::get_system_stats, - system::get_gpu_info, - system::set_monitoring_paused, - modules::get_modules, - modules::control_module, - modules::get_module_status, - modules::create_module_settings_session, - window::minimize_window, - window::maximize_window, - window::close_window, - window::show_window, - window::hide_window, - translations::get_translations, - theme::get_theme_colors, - window_settings::get_window_settings, - window_settings::save_window_size, - window_settings::save_window_position, - window_settings::save_maximized_state, - window_settings::save_zoom_level, - window_settings::set_webview_zoom, - window_settings::save_current_resolution_zoom, - window_settings::get_webview_zoom, - window_settings::get_resolution_zoom, - window_settings::get_window_config, - window_settings::get_window_policy, - ui_state::get_ui_state, - ui_state::save_ui_state, - bootstrap::get_app_bootstrap_data, - secure::save_secure_key, - secure::remove_secure_key, - secure::get_secure_key, - secure::has_secure_key, - secure::get_secure_key_meta, - ai::send_chat_message, - ai::cancel_chat_generation, - ai::validate_api_key, - ai::validate_stored_api_key, - ai::clear_chat_history, - ai::get_chat_history, - ai::rewind_last_turn, - ai::count_tokens, - ai::generate_image, - ai::cancel_image_generation, - ai::get_image_generation_preview, - ai::delete_chat_image, - ai::open_chat_image_location, - ai::save_chat_image_default, - voice::recognize_voice_once, - voice::cancel_voice_recognition, - voice::open_voice_privacy_settings, - custom_model_service::get_custom_models, - custom_model_service::add_custom_model, - custom_model_service::remove_custom_model, - file_service::process_file_content, - engine::start_engine, - engine::stop_engine, - engine::stop_engine_slot, - engine::get_engine_state, - engine::check_engine_installed, - engine::delete_engine, - engine::get_engine_definitions, - engine::get_engine_config, - engine::get_engine_settings_payload, - engine::set_engine_config, - ]) + let semantic_types = Configuration::default() + .enable_lossless_floats() + .define::(|_| define("unknown").into(), None, None); + + Builder::::new() + .dangerously_cast_bigints_to_number() + .semantic_types(semantic_types) + .commands(collect_commands![ + health::get_health, + config::get_config, + settings::get_settings, + settings::save_settings, + settings::save_setting, + settings::get_module_settings, + settings::save_module_settings, + settings::get_system_language, + logs::get_logs, + logs::get_console_logs, + logs::get_console_overview, + logs::clear_logs, + logs::clear_console_logs, + logs::get_log_dir, + logs::open_log_dir, + logs::open_console_log_target, + logs::add_log, + logs::log_batch, + downloader::download_module, + downloader::get_release_download_options, + downloader::import_integration_folder, + downloader::import_integration_archive, + downloader::import_integration_path, + downloader::import_integration_url, + downloader::resume_download, + downloader::check_module_installed, + downloader::get_module_path, + downloader::delete_module, + downloader::list_module_files, + downloader::set_download_settings, + downloader::cancel_download, + downloader::pause_download, + system::get_system_stats, + system::get_gpu_info, + system::set_monitoring_paused, + modules::get_modules, + modules::control_module, + modules::get_module_status, + modules::create_module_settings_session, + window::minimize_window, + window::maximize_window, + window::close_window, + window::show_window, + window::hide_window, + translations::get_translations, + theme::get_theme_colors, + window_settings::get_window_settings, + window_settings::save_window_size, + window_settings::save_window_position, + window_settings::save_maximized_state, + window_settings::save_zoom_level, + window_settings::set_webview_zoom, + window_settings::save_current_resolution_zoom, + window_settings::get_webview_zoom, + window_settings::get_resolution_zoom, + window_settings::get_window_config, + window_settings::get_window_policy, + ui_state::get_ui_state, + ui_state::save_ui_state, + bootstrap::get_app_bootstrap_data, + secure::save_secure_key, + secure::remove_secure_key, + secure::get_secure_key, + secure::has_secure_key, + secure::get_secure_key_meta, + ai::send_chat_message, + ai::cancel_chat_generation, + ai::validate_api_key, + ai::validate_stored_api_key, + ai::clear_chat_history, + ai::get_chat_history, + ai::rewind_last_turn, + ai::count_tokens, + ai::generate_image, + ai::cancel_image_generation, + ai::get_image_generation_preview, + ai::delete_chat_image, + ai::open_chat_image_location, + ai::save_chat_image_default, + voice::recognize_voice_once, + voice::cancel_voice_recognition, + voice::open_voice_privacy_settings, + custom_model_service::get_custom_models, + custom_model_service::add_custom_model, + custom_model_service::remove_custom_model, + file_service::process_file_content, + engine::start_engine, + engine::stop_engine, + engine::stop_engine_slot, + engine::get_engine_state, + engine::check_engine_installed, + engine::delete_engine, + engine::get_engine_definitions, + engine::get_engine_config, + engine::get_engine_settings_payload, + engine::set_engine_config, + ]) } /// Exports Specta TypeScript bindings and normalizes generated whitespace. diff --git a/src/shared/types/bindings.ts b/src/shared/types/bindings.ts index 26751993..024321da 100644 --- a/src/shared/types/bindings.ts +++ b/src/shared/types/bindings.ts @@ -9,656 +9,654 @@ import { invoke as __TAURI_INVOKE, Channel } from "@tauri-apps/api/core"; /** Commands */ export const commands = { - // Checks backend health status + /** Checks backend health status */ getHealth: () => typedError(__TAURI_INVOKE("get_health")), - // Loads application configuration with module installation status - getConfig: () => typedError(__TAURI_INVOKE("get_config")), - // Retrieves application settings (theme, language, GPU, debug) + /** Loads application configuration with module installation status */ + getConfig: () => typedError(__TAURI_INVOKE("get_config")).then((v) => ((v.status === "ok" ? { ...v, data: ({...v.data,apiProviders:v.data.apiProviders.map(i=>({...i,models:i.models==null?i.models:i.models.map(i=>({...i,pricing:i.pricing==null?i.pricing:({...i.pricing,input:i.pricing.input==null?i.pricing.input:i.pricing.input,output:i.pricing.output==null?i.pricing.output:i.pricing.output})}))})),catalog:({...v.data.catalog,ai:v.data.catalog.ai.map(i=>({...i,configSchema:i.configSchema==null?i.configSchema:i.configSchema})),services:v.data.catalog.services.map(i=>({...i,configSchema:i.configSchema==null?i.configSchema:i.configSchema}))})}) } : v) as typeof v)), + /** Retrieves application settings (theme, language, GPU, debug) */ getSettings: () => typedError(__TAURI_INVOKE("get_settings")), - // Saves application settings + /** Saves application settings */ saveSettings: (settings: AppSettings) => typedError(__TAURI_INVOKE("save_settings", { settings })), - // Saves a single setting by key-value pair + /** Saves a single setting by key-value pair */ saveSetting: (key: string, value: string) => typedError(__TAURI_INVOKE("save_setting", { key, value })), - // Retrieves persisted settings for a specific module. - getModuleSettings: (moduleId: string) => typedError<{ [key in string]: "Null" | ({ Bool: boolean }) & { Array?: never; Number?: never; Object?: never; String?: never } | ({ Number: ({ f64: number }) & { i64?: never; u64?: never } | ({ i64: number }) & { f64?: never; u64?: never } | ({ u64: number }) & { f64?: never; i64?: never } }) & { Array?: never; Bool?: never; Object?: never; String?: never } | ({ String: string }) & { Array?: never; Bool?: never; Number?: never; Object?: never } | ({ Array: Value[] }) & { Bool?: never; Number?: never; Object?: never; String?: never } | ({ Object: { [key in string]: Value } }) & { Array?: never; Bool?: never; Number?: never; String?: never } }, AppError>(__TAURI_INVOKE("get_module_settings", { moduleId })), - // Saves persisted settings for a specific module. - saveModuleSettings: (moduleId: string, settings: { [key in string]: "Null" | ({ Bool: boolean }) & { Array?: never; Number?: never; Object?: never; String?: never } | ({ Number: ({ f64: number }) & { i64?: never; u64?: never } | ({ i64: number }) & { f64?: never; u64?: never } | ({ u64: number }) & { f64?: never; i64?: never } }) & { Array?: never; Bool?: never; Object?: never; String?: never } | ({ String: string }) & { Array?: never; Bool?: never; Number?: never; Object?: never } | ({ Array: Value[] }) & { Bool?: never; Number?: never; Object?: never; String?: never } | ({ Object: { [key in string]: Value } }) & { Array?: never; Bool?: never; Number?: never; String?: never } }) => typedError(__TAURI_INVOKE("save_module_settings", { moduleId, settings })), - // Detects and returns the current system language code + /** Retrieves persisted settings for a specific module. */ + getModuleSettings: (moduleId: string) => typedError<{ [key in string]: unknown }, AppError>(__TAURI_INVOKE("get_module_settings", { moduleId })).then((v) => ((v.status === "ok" ? { ...v, data: Object.fromEntries(Object.entries(v.data).map(([k,v])=>[k,v])) } : v) as typeof v)), + /** Saves persisted settings for a specific module. */ + saveModuleSettings: (moduleId: string, settings: { [key in string]: unknown }) => typedError(__TAURI_INVOKE("save_module_settings", { moduleId, settings: Object.fromEntries(Object.entries(settings).map(([k,v])=>[k,v])) })), + /** Detects and returns the current system language code */ getSystemLanguage: () => typedError(__TAURI_INVOKE("get_system_language")), - // Retrieves log entries since a given timestamp - getLogs: (since: number) => typedError(__TAURI_INVOKE("get_logs", { since })), - // Retrieves log entries for a single console view since a given timestamp. - getConsoleLogs: (viewId: string, since: number) => typedError(__TAURI_INVOKE("get_console_logs", { viewId, since })), - // Returns aggregated console metadata for views and runtime statuses. + /** Retrieves log entries since a given timestamp */ + getLogs: (since: number) => typedError(__TAURI_INVOKE("get_logs", { since })).then((v) => ((v.status === "ok" ? { ...v, data: v.data.map(i=>i) } : v) as typeof v)), + /** Retrieves log entries for a single console view since a given timestamp. */ + getConsoleLogs: (viewId: string, since: number) => typedError(__TAURI_INVOKE("get_console_logs", { viewId, since })).then((v) => ((v.status === "ok" ? { ...v, data: v.data.map(i=>i) } : v) as typeof v)), + /** Returns aggregated console metadata for views and runtime statuses. */ getConsoleOverview: () => typedError(__TAURI_INVOKE("get_console_overview")), - // Clears all stored log entries + /** Clears all stored log entries */ clearLogs: () => typedError(__TAURI_INVOKE("clear_logs")), - // Clears log entries and files for a single console view. + /** Clears log entries and files for a single console view. */ clearConsoleLogs: (viewId: string) => typedError(__TAURI_INVOKE("clear_console_logs", { viewId })), - // Returns the root folder where launcher logs are stored. + /** Returns the root folder where launcher logs are stored. */ getLogDir: () => typedError(__TAURI_INVOKE("get_log_dir")), - // Opens the root folder where launcher logs are stored. + /** Opens the root folder where launcher logs are stored. */ openLogDir: () => typedError(__TAURI_INVOKE("open_log_dir")), - // Opens the log folder for a single console view. + /** Opens the log folder for a single console view. */ openConsoleLogTarget: (viewId: string) => typedError(__TAURI_INVOKE("open_console_log_target", { viewId })), - // Adds a single log entry to the log store + /** Adds a single log entry to the log store */ addLog: (msg: string, source: string, level: string) => typedError(__TAURI_INVOKE("add_log", { msg, source, level })), - // Adds multiple log entries in batch from frontend + /** Adds multiple log entries in batch from frontend */ logBatch: (logs: BatchLogEntry[]) => typedError(__TAURI_INVOKE("log_batch", { logs })), - // Downloads and verifies a module from a Git repository + /** Downloads and verifies a module from a Git repository */ downloadModule: (moduleId: string, repoUrl: string, expectedHash: string | null, dlType: string | null, releaseSelection: { - // GitHub release tag to download. `None` means the newest compatible release. + /** GitHub release tag to download. `None` means the newest compatible release. */ tag_name: string | null, - // Compute target selected by the user. + /** Compute target selected by the user. */ compute_target?: ReleaseComputeTarget, } | null) => typedError(__TAURI_INVOKE("download_module", { moduleId, repoUrl, expectedHash, dlType, releaseSelection })), - // Lists compatible release versions and CPU/GPU package choices for a module. + /** Lists compatible release versions and CPU/GPU package choices for a module. */ getReleaseDownloadOptions: (moduleId: string, repoUrl: string) => typedError(__TAURI_INVOKE("get_release_download_options", { moduleId, repoUrl })), - // Imports an integration from a local folder containing `axelate-module.toml`. + /** Imports an integration from a local folder containing `axelate-module.toml`. */ importIntegrationFolder: (path: string) => typedError(__TAURI_INVOKE("import_integration_folder", { path })), - // Imports an integration from a local `.zip`, `.tar.gz`, `.tgz`, or `.7z` archive. + /** Imports an integration from a local `.zip`, `.tar.gz`, `.tgz`, or `.7z` archive. */ importIntegrationArchive: (path: string) => typedError(__TAURI_INVOKE("import_integration_archive", { path })), - // Imports an integration from a local folder or archive, auto-detected by path type. + /** Imports an integration from a local folder or archive, auto-detected by path type. */ importIntegrationPath: (path: string) => typedError(__TAURI_INVOKE("import_integration_path", { path })), - // Downloads and imports an integration from a repository or archive URL. + /** Downloads and imports an integration from a repository or archive URL. */ importIntegrationUrl: (sourceUrl: string) => typedError(__TAURI_INVOKE("import_integration_url", { sourceUrl })), - // Resumes a paused module download using backend-owned request metadata. + /** Resumes a paused module download using backend-owned request metadata. */ resumeDownload: (moduleId: string) => typedError(__TAURI_INVOKE("resume_download", { moduleId })), - // Checks if a module is already installed locally + /** Checks if a module is already installed locally */ checkModuleInstalled: (moduleId: string) => typedError(__TAURI_INVOKE("check_module_installed", { moduleId })), - // Retrieves the filesystem path to a module's directory + /** Retrieves the filesystem path to a module's directory */ getModulePath: (moduleId: string) => typedError(__TAURI_INVOKE("get_module_path", { moduleId })), - // Deletes a module from local storage + /** Deletes a module from local storage */ deleteModule: (moduleId: string) => typedError(__TAURI_INVOKE("delete_module", { moduleId })), - // Lists all files in a module's directory + /** Lists all files in a module's directory */ listModuleFiles: (moduleId: string) => typedError(__TAURI_INVOKE("list_module_files", { moduleId })), - // Configures download bandwidth limits + /** Configures download bandwidth limits */ setDownloadSettings: (enabled: boolean, maxSpeed: number) => __TAURI_INVOKE("set_download_settings", { enabled, maxSpeed }), - // Cancels an in-progress module download + /** Cancels an in-progress module download */ cancelDownload: (moduleId: string) => __TAURI_INVOKE("cancel_download", { moduleId }), - // Pauses an in-progress module download while preserving partial files for resume + /** Pauses an in-progress module download while preserving partial files for resume */ pauseDownload: (moduleId: string) => __TAURI_INVOKE("pause_download", { moduleId }), - // Retrieves real-time system statistics (CPU, RAM, GPU, disk, network) - getSystemStats: () => typedError(__TAURI_INVOKE("get_system_stats")), - // Retrieves GPU information and preferred runtime backend hint + /** Retrieves real-time system statistics (CPU, RAM, GPU, disk, network) */ + getSystemStats: () => typedError(__TAURI_INVOKE("get_system_stats")).then((v) => ((v.status === "ok" ? { ...v, data: ({...v.data,gpu:v.data.gpu==null?v.data.gpu:v.data.gpu,vram:v.data.vram==null?v.data.vram:v.data.vram}) } : v) as typeof v)), + /** Retrieves GPU information and preferred runtime backend hint */ getGpuInfo: () => typedError(__TAURI_INVOKE("get_gpu_info")), - // Pauses or resumes system monitoring + /** Pauses or resumes system monitoring */ setMonitoringPaused: (paused: boolean) => typedError(__TAURI_INVOKE("set_monitoring_paused", { paused })), - // Retrieves list of all available modules (AI and services) - getModules: () => typedError(__TAURI_INVOKE("get_modules")), - // Controls a module (start, stop, restart) + /** Retrieves list of all available modules (AI and services) */ + getModules: () => typedError(__TAURI_INVOKE("get_modules")).then((v) => ((v.status === "ok" ? { ...v, data: v.data.map(i=>({...i,config:Object.fromEntries(Object.entries(i.config).map(([k,v])=>[k,v])),configSchema:i.configSchema==null?i.configSchema:Object.fromEntries(Object.entries(i.configSchema).map(([k,v])=>[k,({...v,default:v.default==null?v.default:v.default,min:v.min==null?v.min:v.min,max:v.max==null?v.max:v.max,step:v.step==null?v.step:v.step})]))})) } : v) as typeof v)), + /** Controls a module (start, stop, restart) */ controlModule: (request: ControlRequest) => typedError(__TAURI_INVOKE("control_module", { request })), - // Retrieves runtime status of a specific module + /** Retrieves runtime status of a specific module */ getModuleStatus: (moduleId: string) => typedError(__TAURI_INVOKE("get_module_status", { moduleId })), - // Creates a scoped settings-session token for a module-owned custom settings UI. + /** Creates a scoped settings-session token for a module-owned custom settings UI. */ createModuleSettingsSession: (moduleId: string) => typedError(__TAURI_INVOKE("create_module_settings_session", { moduleId })), - // Minimizes the application window + /** Minimizes the application window */ minimizeWindow: () => typedError(__TAURI_INVOKE("minimize_window")), - // Maximizes or unmaximizes the window + /** Maximizes or unmaximizes the window */ maximizeWindow: () => typedError(__TAURI_INVOKE("maximize_window")), - // Closes the window gracefully (app remains in tray) + /** Closes the window gracefully (app remains in tray) */ closeWindow: () => typedError(__TAURI_INVOKE("close_window")), - // Shows and focuses the window + /** Shows and focuses the window */ showWindow: () => typedError(__TAURI_INVOKE("show_window")), - // Hides the window + /** Hides the window */ hideWindow: () => typedError(__TAURI_INVOKE("hide_window")), - // Retrieves translation strings for the specified language - getTranslations: (lang: string) => typedError<"Null" | ({ Bool: boolean }) & { Array?: never; Number?: never; Object?: never; String?: never } | ({ Number: ({ f64: number }) & { i64?: never; u64?: never } | ({ i64: number }) & { f64?: never; u64?: never } | ({ u64: number }) & { f64?: never; i64?: never } }) & { Array?: never; Bool?: never; Object?: never; String?: never } | ({ String: string }) & { Array?: never; Bool?: never; Number?: never; Object?: never } | ({ Array: Value[] }) & { Bool?: never; Number?: never; Object?: never; String?: never } | ({ Object: { [key in string]: Value } }) & { Array?: never; Bool?: never; Number?: never; String?: never }, AppError>(__TAURI_INVOKE("get_translations", { lang })), - // Retrieves current theme color palette + /** Retrieves translation strings for the specified language */ + getTranslations: (lang: string) => typedError(__TAURI_INVOKE("get_translations", { lang })), + /** Retrieves current theme color palette */ getThemeColors: () => typedError<{ [key in string]: string }, AppError>(__TAURI_INVOKE("get_theme_colors")), - // Retrieves persisted window settings (size, position, maximized state) + /** Retrieves persisted window settings (size, position, maximized state) */ getWindowSettings: () => typedError(__TAURI_INVOKE("get_window_settings")), - // Saves window dimensions to disk + /** Saves window dimensions to disk */ saveWindowSize: (width: number, height: number) => typedError(__TAURI_INVOKE("save_window_size", { width, height })), - // Saves window screen position to disk + /** Saves window screen position to disk */ saveWindowPosition: (x: number, y: number) => typedError(__TAURI_INVOKE("save_window_position", { x, y })), - // Saves maximized/unmaximized state to disk + /** Saves maximized/unmaximized state to disk */ saveMaximizedState: (maximized: boolean) => typedError(__TAURI_INVOKE("save_maximized_state", { maximized })), - // Saves global zoom level to UI state + /** Saves global zoom level to UI state */ saveZoomLevel: (zoom: number) => typedError(__TAURI_INVOKE("save_zoom_level", { zoom })), /** * Set `WebView` zoom level and persist for current resolution. * Uses native WebView zoom so layout metrics stay consistent with the rendered size. */ setWebviewZoom: (zoom: number) => typedError(__TAURI_INVOKE("set_webview_zoom", { zoom })), - // Persist zoom for the active monitor resolution without touching the WebView. + /** Persist zoom for the active monitor resolution without touching the WebView. */ saveCurrentResolutionZoom: (zoom: number) => typedError(__TAURI_INVOKE("save_current_resolution_zoom", { zoom })), - // Retrieves current global `WebView` zoom level + /** Retrieves current global `WebView` zoom level */ getWebviewZoom: () => typedError(__TAURI_INVOKE("get_webview_zoom")), /** * Get the effective zoom for the current monitor resolution. * Read-only — never auto-saves, so "user set" is always distinguishable from "defaulted". */ getResolutionZoom: () => typedError(__TAURI_INVOKE("get_resolution_zoom")), - // Retrieves window configuration settings + /** Retrieves window configuration settings */ getWindowConfig: () => __TAURI_INVOKE("get_window_config"), - // Calculates window layout policy based on screen size and zoom + /** Calculates window layout policy based on screen size and zoom */ getWindowPolicy: () => typedError(__TAURI_INVOKE("get_window_policy")), - // Retrieves persisted UI state (sidebar, zoom, selected modules) - getUiState: () => typedError(__TAURI_INVOKE("get_ui_state")), - // Saves UI state to persistent storage - saveUiState: (state: UIState) => typedError(__TAURI_INVOKE("save_ui_state", { state })), - // Retrieves all application state and configuration during app startup - getAppBootstrapData: () => typedError(__TAURI_INVOKE("get_app_bootstrap_data")), - // Saves anAPI key securely to system credential storage + /** Retrieves persisted UI state (sidebar, zoom, selected modules) */ + getUiState: () => typedError(__TAURI_INVOKE("get_ui_state")).then((v) => ((v.status === "ok" ? { ...v, data: ({...v.data,resolution_zoom:Object.fromEntries(Object.entries(v.data.resolution_zoom).map(([k,v])=>[k,v]))}) } : v) as typeof v)), + /** Saves UI state to persistent storage */ + saveUiState: (state: UIState) => typedError(__TAURI_INVOKE("save_ui_state", { state: ({...state,resolution_zoom:Object.fromEntries(Object.entries(state.resolution_zoom).map(([k,v])=>[k,v]))}) })), + /** Retrieves all application state and configuration during app startup */ + getAppBootstrapData: () => typedError(__TAURI_INVOKE("get_app_bootstrap_data")).then((v) => ((v.status === "ok" ? { ...v, data: ({...v.data,uiState:({...v.data.uiState,resolution_zoom:Object.fromEntries(Object.entries(v.data.uiState.resolution_zoom).map(([k,v])=>[k,v]))})}) } : v) as typeof v)), + /** Saves anAPI key securely to system credential storage */ saveSecureKey: (service: string, key: string) => typedError(__TAURI_INVOKE("save_secure_key", { service, key })), - // Removes a frontend-managed secret from system credential storage + /** Removes a frontend-managed secret from system credential storage */ removeSecureKey: (service: string) => typedError(__TAURI_INVOKE("remove_secure_key", { service })), - // Retrieves a frontend-managed secret from system credential storage + /** Retrieves a frontend-managed secret from system credential storage */ getSecureKey: (service: string) => typedError(__TAURI_INVOKE("get_secure_key", { service })), - // Checks whether a non-empty API key exists in secure storage + /** Checks whether a non-empty API key exists in secure storage */ hasSecureKey: (service: string) => typedError(__TAURI_INVOKE("has_secure_key", { service })), - // Returns non-sensitive metadata for a stored key without exposing the secret. + /** Returns non-sensitive metadata for a stored key without exposing the secret. */ getSecureKeyMeta: (service: string) => typedError(__TAURI_INVOKE("get_secure_key_meta", { service })), - // Sends a chat message to the AI provider and streams the response - sendChatMessage: (request: ChatRequest, chatChannel: Channel, thoughtChannel: Channel) => typedError(__TAURI_INVOKE("send_chat_message", { request, chatChannel, thoughtChannel })), - // Cancels an active streamed chat request by request identifier. + /** Sends a chat message to the AI provider and streams the response */ + sendChatMessage: (request: ChatRequest, chatChannel: Channel, thoughtChannel: Channel) => typedError(__TAURI_INVOKE("send_chat_message", { request: ({...request,messages:request.messages.map(i=>i)}), chatChannel, thoughtChannel })), + /** Cancels an active streamed chat request by request identifier. */ cancelChatGeneration: (requestId: string) => __TAURI_INVOKE("cancel_chat_generation", { requestId }), - // Validates an API key for the specified provider + /** Validates an API key for the specified provider */ validateApiKey: (provider: string, key: string) => typedError(__TAURI_INVOKE("validate_api_key", { provider, key })), - // Validates the stored provider key without exposing it to the frontend + /** Validates the stored provider key without exposing it to the frontend */ validateStoredApiKey: (provider: string) => typedError(__TAURI_INVOKE("validate_stored_api_key", { provider })), - // Clears chat history for a specific session + /** Clears chat history for a specific session */ clearChatHistory: (sessionId: string) => typedError(__TAURI_INVOKE("clear_chat_history", { sessionId })), - // Retrieves chat history for a specific session - getChatHistory: (sessionId: string) => typedError(__TAURI_INVOKE("get_chat_history", { sessionId })), - // Removes the latest user turn and any following assistant replies from a session. + /** Retrieves chat history for a specific session */ + getChatHistory: (sessionId: string) => typedError(__TAURI_INVOKE("get_chat_history", { sessionId })).then((v) => ((v.status === "ok" ? { ...v, data: v.data.map(i=>i) } : v) as typeof v)), + /** Removes the latest user turn and any following assistant replies from a session. */ rewindLastTurn: (sessionId: string) => typedError(__TAURI_INVOKE("rewind_last_turn", { sessionId })), - // Counts tokens in text for the specified model + /** Counts tokens in text for the specified model */ countTokens: (text: string, model: string | null) => typedError(__TAURI_INVOKE("count_tokens", { text, model })), - // Sends an image generation request to the connected AI provider - generateImage: (request: ImageGenerationRequest) => typedError(__TAURI_INVOKE("generate_image", { request })), - // Cancels the current image generation request for the selected provider. + /** Sends an image generation request to the connected AI provider */ + generateImage: (request: ImageGenerationRequest) => typedError(__TAURI_INVOKE("generate_image", { request: ({...request,cfg_scale:request.cfg_scale==null?request.cfg_scale:request.cfg_scale,denoising_strength:request.denoising_strength==null?request.denoising_strength:request.denoising_strength}) })), + /** Cancels the current image generation request for the selected provider. */ cancelImageGeneration: (provider: string) => typedError(__TAURI_INVOKE("cancel_image_generation", { provider })), - // Returns the latest image-generation preview when the local image engine writes one. + /** Returns the latest image-generation preview when the local image engine writes one. */ getImageGenerationPreview: () => typedError<{ - // Data URL of the latest preview image. + /** Data URL of the latest preview image. */ data_url: string, - // File modification timestamp in Unix milliseconds. + /** File modification timestamp in Unix milliseconds. */ updated_at_ms: number, - // Current image-generation progress, normalized to 0.0..1.0 when the engine exposes it. + /** Current image-generation progress, normalized to 0.0..1.0 when the engine exposes it. */ progress: number | null, - // Current sampling step when available. + /** Current sampling step when available. */ step: number | null, - // Total sampling steps when available. + /** Total sampling steps when available. */ total: number | null, - // Latest reported generation speed when available, for example `1.07s/it`. + /** Latest reported generation speed when available, for example `1.07s/it`. */ speed: string | null, - // Estimated remaining seconds when the engine exposes it. + /** Estimated remaining seconds when the engine exposes it. */ eta_relative: number | null, -} | null, AppError>(__TAURI_INVOKE("get_image_generation_preview")), - // Deletes a previously saved chat image from disk. +} | null, AppError>(__TAURI_INVOKE("get_image_generation_preview")).then((v) => ((v.status === "ok" ? { ...v, data: v.data==null?v.data:({...v.data,progress:v.data.progress==null?v.data.progress:v.data.progress,eta_relative:v.data.eta_relative==null?v.data.eta_relative:v.data.eta_relative}) } : v) as typeof v)), + /** Deletes a previously saved chat image from disk. */ deleteChatImage: (filePath: string) => typedError(__TAURI_INVOKE("delete_chat_image", { filePath })), - // Opens the saved chat image folder in the system file manager. + /** Opens the saved chat image folder in the system file manager. */ openChatImageLocation: (filePath: string, folderPath: string) => typedError(__TAURI_INVOKE("open_chat_image_location", { filePath, folderPath })), - // Saves a chat image to the default Pictures/axelate directory and returns the final path. + /** Saves a chat image to the default Pictures/axelate directory and returns the final path. */ saveChatImageDefault: (base64Data: string, mimeType: string) => typedError(__TAURI_INVOKE("save_chat_image_default", { base64Data, mimeType })), - // Captures one voice utterance with the native platform recognizer. + /** Captures one voice utterance with the native platform recognizer. */ recognizeVoiceOnce: (request: VoiceRecognitionRequest) => typedError(__TAURI_INVOKE("recognize_voice_once", { request })), - // Cancels the active native voice recognition request, if one is running. + /** Cancels the active native voice recognition request, if one is running. */ cancelVoiceRecognition: () => typedError(__TAURI_INVOKE("cancel_voice_recognition")), - // Opens the native Windows speech privacy settings page. + /** Opens the native Windows speech privacy settings page. */ openVoicePrivacySettings: () => typedError(__TAURI_INVOKE("open_voice_privacy_settings")), - // Retrieves all custom AI models configured by the user - getCustomModels: () => typedError(__TAURI_INVOKE("get_custom_models")), - // Adds a new custom AI model configuration + /** Retrieves all custom AI models configured by the user */ + getCustomModels: () => typedError(__TAURI_INVOKE("get_custom_models")).then((v) => ((v.status === "ok" ? { ...v, data: v.data.map(i=>i) } : v) as typeof v)), + /** Adds a new custom AI model configuration */ addCustomModel: (providerId: string, id: string, name: string, baseModelId: string) => typedError(__TAURI_INVOKE("add_custom_model", { providerId, id, name, baseModelId })), - // Removes a custom AI model by ID + /** Removes a custom AI model by ID */ removeCustomModel: (id: string) => typedError(__TAURI_INVOKE("remove_custom_model", { id })), - // Processes file content for AI context (extracts text from files and archives) + /** Processes file content for AI context (extracts text from files and archives) */ processFileContent: (name: string, data: number[]) => typedError(__TAURI_INVOKE("process_file_content", { name, data })), - // Starts a local engine. Hot-swaps if another engine is active. + /** Starts a local engine. Hot-swaps if another engine is active. */ startEngine: (config: EngineConfig) => typedError(__TAURI_INVOKE("start_engine", { config })), - // Stops all running engines. + /** Stops all running engines. */ stopEngine: () => typedError(__TAURI_INVOKE("stop_engine")), - // Stops the engine in a specific capability slot (text, image, vision). + /** Stops the engine in a specific capability slot (text, image, vision). */ stopEngineSlot: (capability: Capability) => typedError(__TAURI_INVOKE("stop_engine_slot", { capability })), - // Gets the current engine state (idle, starting, ready, error). + /** Gets the current engine state (idle, starting, ready, error). */ getEngineState: () => typedError(__TAURI_INVOKE("get_engine_state")), - // Checks if an engine binary is present (in ENGINES_DIR or system PATH). + /** Checks if an engine binary is present (in ENGINES_DIR or system PATH). */ checkEngineInstalled: (engineId: string, binaryName: string | null) => __TAURI_INVOKE("check_engine_installed", { engineId, binaryName }), - // Deletes an Axelate-managed engine from local storage. + /** Deletes an Axelate-managed engine from local storage. */ deleteEngine: (engineId: string) => typedError(__TAURI_INVOKE("delete_engine", { engineId })), - // Returns all registered engine definitions with real-time installation status. - getEngineDefinitions: () => typedError(__TAURI_INVOKE("get_engine_definitions")), - // Returns the persisted user config for an engine, or defaults if none saved yet. + /** Returns all registered engine definitions with real-time installation status. */ + getEngineDefinitions: () => typedError(__TAURI_INVOKE("get_engine_definitions")).then((v) => ((v.status === "ok" ? { ...v, data: v.data.map(i=>({...i,config_schema:i.config_schema==null?i.config_schema:i.config_schema})) } : v) as typeof v)), + /** Returns the persisted user config for an engine, or defaults if none saved yet. */ getEngineConfig: (engineId: string) => typedError(__TAURI_INVOKE("get_engine_config", { engineId })), - // Returns the local engine modal payload in a single backend round-trip. + /** Returns the local engine modal payload in a single backend round-trip. */ getEngineSettingsPayload: (engineId: string) => typedError(__TAURI_INVOKE("get_engine_settings_payload", { engineId })), - // Persists user engine config (compute mode, context_size, model_path, extra_args). + /** Persists user engine config (compute mode, context_size, model_path, extra_args). */ setEngineConfig: (config: EngineConfig) => typedError(__TAURI_INVOKE("set_engine_config", { config })), }; /* Types */ -// Complete AI model definition +/** Complete AI model definition */ export type AiModel = { - // Model ID (moved from dict key) + /** Model ID (moved from dict key) */ id: string, - // Localization key for description + /** Localization key for description */ descKey?: string, - // Display name + /** Display name */ name: string, - // Human-readable description + /** Human-readable description */ desc: string, - // Tier classification (Weak < Medium < Strong) + /** Tier classification (Weak < Medium < Strong) */ tier: ModelTier, - // Model size classification (optional) + /** Model size classification (optional) */ modelSize: string | null, - // Release date string (YYYY-MM) + /** Release date string (YYYY-MM) */ releaseDate: string | null, - // Context window size in tokens + /** Context window size in tokens */ contextWindow: number | null, - // Maximum output tokens allowed + /** Maximum output tokens allowed */ maxOutputTokens: number | null, - // Pricing configuration + /** Pricing configuration */ pricing: PricingConfig | null, - // Performance statistics + /** Performance statistics */ stats: ModelStats, - // Capabilities + /** Capabilities */ capabilities: ModelCapabilities | null, - // API model identifiers (mapped to `apiModels` in JSON) + /** API model identifiers (mapped to `apiModels` in JSON) */ apiModels: ApiModelConfig | null, }; -// API model identifiers for different capabilities +/** API model identifiers for different capabilities */ export type ApiModelConfig = { - // Model ID for text generation + /** Model ID for text generation */ text: string | null, - // Model ID for image generation + /** Model ID for image generation */ image: string | null, }; -// Configuration for an AI API provider (OpenAI, Gemini, Claude, etc.) +/** Configuration for an AI API provider (OpenAI, Gemini, Claude, etc.) */ export type ApiProvider = { - // Unique identifier (e.g., "gpt", "gemini") + /** Unique identifier (e.g., "gpt", "gemini") */ id: string, - // Display name (e.g., "GPT", "Gemini") + /** Display name (e.g., "GPT", "Gemini") */ name: string, - // Localization key for description + /** Localization key for description */ descKey?: string | null, - // Direct description text + /** Direct description text */ description?: string | null, - // Icon/emoji for UI display + /** Icon/emoji for UI display */ icon?: string | null, - // Provider type + /** Provider type */ type: ProviderType | null, - // Base URL for API endpoints + /** Base URL for API endpoints */ baseUrl?: string | null, - // Environment variable name for API key + /** Environment variable name for API key */ apiKeyEnv?: string | null, - // Available models configuration + /** Available models configuration */ models?: AiModel[] | null, - // Provider output capabilities exposed in the launcher catalog + /** Provider output capabilities exposed in the launcher catalog */ capabilities?: string[] | null, - // Model aliases (UI name → API ID mappings) + /** Model aliases (UI name → API ID mappings) */ modelAliases?: { [key in string]: string } | null, }; -// Orchestrated application configuration +/** Orchestrated application configuration */ export type AppConfig = AppConfig_Serialize | AppConfig_Deserialize; -// Orchestrated application configuration +/** Orchestrated application configuration */ export type AppConfig_Deserialize = { - // Configuration version + /** Configuration version */ version: string, - // Available AI providers (loaded from resources/api_providers) + /** Available AI providers (loaded from resources/api_providers) */ apiProviders: ApiProvider[], - // Catalog of available apps/services (local + cloud virtual modules) + /** Catalog of available apps/services (local + cloud virtual modules) */ catalog: ConfigCatalog_Deserialize, }; -// Orchestrated application configuration +/** Orchestrated application configuration */ export type AppConfig_Serialize = { - // Configuration version + /** Configuration version */ version: string, - // Available AI providers (loaded from resources/api_providers) + /** Available AI providers (loaded from resources/api_providers) */ apiProviders: ApiProvider[], - // Catalog of available apps/services (local + cloud virtual modules) + /** Catalog of available apps/services (local + cloud virtual modules) */ catalog: ConfigCatalog_Serialize, }; -// Application-level errors +/** Application-level errors */ export type AppError = -// Validation error (invalid input, malformed data) +/** Validation error (invalid input, malformed data) */ ({ Validation: string }) & { Config?: never; External?: never; FrontendSecretForbidden?: never; Internal?: never; Io?: never; NotFound?: never; PermissionDenied?: never; Serialization?: never } | -// Resource not found error +/** Resource not found error */ ({ NotFound: string }) & { Config?: never; External?: never; FrontendSecretForbidden?: never; Internal?: never; Io?: never; PermissionDenied?: never; Serialization?: never; Validation?: never } | -// Permission denied or unauthorized access +/** Permission denied or unauthorized access */ ({ PermissionDenied: string }) & { Config?: never; External?: never; FrontendSecretForbidden?: never; Internal?: never; Io?: never; NotFound?: never; Serialization?: never; Validation?: never } | -// Frontend tried to access a secret outside the managed allowlist +/** Frontend tried to access a secret outside the managed allowlist */ ({ FrontendSecretForbidden: string }) & { Config?: never; External?: never; Internal?: never; Io?: never; NotFound?: never; PermissionDenied?: never; Serialization?: never; Validation?: never } | -// File system I/O error +/** File system I/O error */ ({ Io: string }) & { Config?: never; External?: never; FrontendSecretForbidden?: never; Internal?: never; NotFound?: never; PermissionDenied?: never; Serialization?: never; Validation?: never } | -// JSON serialization/deserialization error +/** JSON serialization/deserialization error */ ({ Serialization: string }) & { Config?: never; External?: never; FrontendSecretForbidden?: never; Internal?: never; Io?: never; NotFound?: never; PermissionDenied?: never; Validation?: never } | -// Configuration loading or parsing error +/** Configuration loading or parsing error */ ({ Config: string }) & { External?: never; FrontendSecretForbidden?: never; Internal?: never; Io?: never; NotFound?: never; PermissionDenied?: never; Serialization?: never; Validation?: never } | -// External service or API error +/** External service or API error */ ({ External: { - // Unique request identifier for tracing + /** Unique request identifier for tracing */ request_id: string | null, - // error message + /** error message */ message: string, } }) & { Config?: never; FrontendSecretForbidden?: never; Internal?: never; Io?: never; NotFound?: never; PermissionDenied?: never; Serialization?: never; Validation?: never } | -// Internal server error (unexpected failures) +/** Internal server error (unexpected failures) */ ({ Internal: { - // Unique request identifier for tracing + /** Unique request identifier for tracing */ request_id: string | null, - // error message + /** error message */ message: string, } }) & { Config?: never; External?: never; FrontendSecretForbidden?: never; Io?: never; NotFound?: never; PermissionDenied?: never; Serialization?: never; Validation?: never }; -// Global application settings +/** Global application settings */ export type AppSettings = { - // UI theme ("dark" or "light") + /** UI theme ("dark" or "light") */ theme: string, - // Interface language code (e.g., "en", "ru", "zh") + /** Interface language code (e.g., "en", "ru", "zh") */ language: string, - // Enable GPU acceleration for monitoring + /** Enable GPU acceleration for monitoring */ use_gpu: boolean, - // Enable debug mode and logging + /** Enable debug mode and logging */ debug_mode: boolean, -} & -// Dynamic extra settings (module-specific, etc.) -({ [key in string]: string }); +} & { [key in string]: string }; -// Batch log entry from frontend +/** Batch log entry from frontend */ export type BatchLogEntry = { - // Log level ("info", "warn", "error") + /** Log level ("info", "warn", "error") */ level: string, - // Log message content + /** Log message content */ message: string, }; -// Application bootstrap data sent to frontend during initialization +/** Application bootstrap data sent to frontend during initialization */ export type BootstrapData = { - // Persisted UI state + /** Persisted UI state */ uiState: UIState, - // Window configuration settings + /** Window configuration settings */ windowConfig: WindowConfig, - // Detected system language + /** Detected system language */ systemLanguage: string, - // Effective zoom for the current monitor resolution + /** Effective zoom for the current monitor resolution */ initialZoom: number, }; -// Breakpoints configuration. +/** Breakpoints configuration. */ export type Breakpoints = { - // Width for compact layout. + /** Width for compact layout. */ compact: number, - // Width for medium layout. + /** Width for medium layout. */ medium: number, - // Width for large layout. + /** Width for large layout. */ large: number, }; -// What an engine can do +/** What an engine can do */ export type Capability = -// Text generation (LLM) +/** Text generation (LLM) */ "text" | -// Image generation (diffusion) +/** Image generation (diffusion) */ "image" | -// Image understanding (multimodal LLM) +/** Image understanding (multimodal LLM) */ "vision"; -// AI chat message with role and content +/** AI chat message with role and content */ export type ChatMessage = { - // Unique message identifier (UUID v4) + /** Unique message identifier (UUID v4) */ id?: string, - // Role ("user", "assistant", "system") + /** Role ("user", "assistant", "system") */ role: string, - // Message content (text or structured data) - content: "Null" | ({ Bool: boolean }) & { Array?: never; Number?: never; Object?: never; String?: never } | ({ Number: ({ f64: number }) & { i64?: never; u64?: never } | ({ i64: number }) & { f64?: never; u64?: never } | ({ u64: number }) & { f64?: never; i64?: never } }) & { Array?: never; Bool?: never; Object?: never; String?: never } | ({ String: string }) & { Array?: never; Bool?: never; Number?: never; Object?: never } | ({ Array: Value[] }) & { Bool?: never; Number?: never; Object?: never; String?: never } | ({ Object: { [key in string]: Value } }) & { Array?: never; Bool?: never; Number?: never; String?: never }, - // Optional signature for extended thinking + /** Message content (text or structured data) */ + content: unknown, + /** Optional signature for extended thinking */ thought_signature: string | null, }; -// AI reply content +/** AI reply content */ export type ChatReply = { - // Reply text + /** Reply text */ text: string, - // Role (typically "assistant") + /** Role (typically "assistant") */ role: string, }; -// AI chat request parameters +/** AI chat request parameters */ export type ChatRequest = { - // AI provider ("openai", "gemini", "local") - largely ignored now as we route via OpenRouter + /** AI provider ("openai", "gemini", "local") - largely ignored now as we route via OpenRouter */ provider: string, - // Model identifier + /** Model identifier */ model: string, - // Chat history and new message + /** Chat history and new message */ messages: ChatMessage[], - // Optional API key + /** Optional API key */ api_key: string | null, - // Thinking level ("low", "medium", "high") + /** Thinking level ("low", "medium", "high") */ thinking_level: string | null, - // Optional max output tokens + /** Optional max output tokens */ max_tokens: number | null, - // Client-generated request identifier for stream isolation + /** Client-generated request identifier for stream isolation */ request_id: string | null, - // Session identifier for history tracking + /** Session identifier for history tracking */ session_id: string | null, - // Optional web search controls for cloud/API providers + /** Optional web search controls for cloud/API providers */ web_search?: WebSearchOptions | null, }; -// AI chat response +/** AI chat response */ export type ChatResponse = { - // Corresponding request identifier + /** Corresponding request identifier */ id?: string, - // Whether request was successful + /** Whether request was successful */ ok: boolean, - // AI reply content + /** AI reply content */ reply: ChatReply | null, - // Error message if failed + /** Error message if failed */ error: string | null, - // Model used + /** Model used */ model: string | null, - // Thinking signature + /** Thinking signature */ thought_signature: string | null, - // Token usage metrics + /** Token usage metrics */ usage: TokenUsage | null, }; -// Application catalog containing available modules and services +/** Application catalog containing available modules and services */ export type ConfigCatalog = ConfigCatalog_Serialize | ConfigCatalog_Deserialize; -// Application catalog containing available modules and services +/** Application catalog containing available modules and services */ export type ConfigCatalog_Deserialize = { - // AI generation modules (text, images, `LocalAI`) + /** AI generation modules (text, images, `LocalAI`) */ ai: ModuleItem_Deserialize[], - // Service integrations and external automation + /** Service integrations and external automation */ services: ModuleItem_Deserialize[], - // Starred/Favorite module IDs + /** Starred/Favorite module IDs */ stars: string[], }; -// Application catalog containing available modules and services +/** Application catalog containing available modules and services */ export type ConfigCatalog_Serialize = { - // AI generation modules (text, images, `LocalAI`) + /** AI generation modules (text, images, `LocalAI`) */ ai: ModuleItem_Serialize[], - // Service integrations and external automation + /** Service integrations and external automation */ services: ModuleItem_Serialize[], - // Starred/Favorite module IDs + /** Starred/Favorite module IDs */ stars: string[], }; -// Configuration field schema for module settings +/** Configuration field schema for module settings */ export type ConfigField = { - // Field type ("text", "password", "select", "checkbox") + /** Field type ("text", "password", "select", "checkbox") */ fieldType: string, - // Display label in UI + /** Display label in UI */ label: string, - // Optional field description/help text shown under the control. + /** Optional field description/help text shown under the control. */ description?: string | null, - // Optional placeholder text for text inputs and textareas. + /** Optional placeholder text for text inputs and textareas. */ placeholder?: string | null, - // Default value - default: "Null" | ({ Bool: boolean }) & { Array?: never; Number?: never; Object?: never; String?: never } | ({ Number: ({ f64: number }) & { i64?: never; u64?: never } | ({ i64: number }) & { f64?: never; u64?: never } | ({ u64: number }) & { f64?: never; i64?: never } }) & { Array?: never; Bool?: never; Object?: never; String?: never } | ({ String: string }) & { Array?: never; Bool?: never; Number?: never; Object?: never } | ({ Array: Value[] }) & { Bool?: never; Number?: never; Object?: never; String?: never } | ({ Object: { [key in string]: Value } }) & { Array?: never; Bool?: never; Number?: never; String?: never } | null, - // Whether field is required + /** Default value */ + default: unknown | null, + /** Whether field is required */ required: boolean, - // Optional minimum numeric value for number and range controls. + /** Optional minimum numeric value for number and range controls. */ min?: number | null, - // Optional maximum numeric value for number and range controls. + /** Optional maximum numeric value for number and range controls. */ max?: number | null, - // Optional numeric step for number and range controls. + /** Optional numeric step for number and range controls. */ step?: number | null, - // Preferred row count for multiline textareas. + /** Preferred row count for multiline textareas. */ rows?: number | null, - // Optional section/group label for form grouping. + /** Optional section/group label for form grouping. */ section?: string | null, - // Optional ordering hint inside a form or section. + /** Optional ordering hint inside a form or section. */ order?: number | null, - // Available options for "select" fields. + /** Available options for "select" fields. */ options?: string[] | null, }; -// Console log view metadata for frontend tabs. +/** Console log view metadata for frontend tabs. */ export type ConsoleLogView = { - // Stable view identifier. + /** Stable view identifier. */ id: string, - // Human-readable label. + /** Human-readable label. */ label: string, }; -// Aggregated console metadata payload. +/** Aggregated console metadata payload. */ export type ConsoleOverview = { - // Available log views including the default general tab. + /** Available log views including the default general tab. */ views: ConsoleLogView[], - // Runtime status rows for engines and modules. + /** Runtime status rows for engines and modules. */ status_items: ConsoleStatusItem[], }; -// Runtime status used by the console overview. +/** Runtime status used by the console overview. */ export type ConsoleRuntimeStatus = -// Process is currently running. +/** Process is currently running. */ "running" | -// Process is starting or switching. +/** Process is starting or switching. */ "starting" | -// Process failed or status lookup failed. +/** Process failed or status lookup failed. */ "failed" | -// Process is stopped. +/** Process is stopped. */ "stopped"; -// Console status row for engines or modules. +/** Console status row for engines or modules. */ export type ConsoleStatusItem = { - // Stable item identifier. + /** Stable item identifier. */ id: string, - // Human-readable label. + /** Human-readable label. */ label: string, - // Status category discriminator. + /** Status category discriminator. */ kind: string, - // Runtime status. + /** Runtime status. */ status: ConsoleRuntimeStatus, - // Additional detail text. + /** Additional detail text. */ detail: string, }; -// Module control request from frontend +/** Module control request from frontend */ export type ControlRequest = { - // Module identifier (optional for global actions) + /** Module identifier (optional for global actions) */ module_id: string | null, - // Control action ("start", "stop", "restart") + /** Control action ("start", "stop", "restart") */ action: string, }; -// Module control response to frontend +/** Module control response to frontend */ export type ControlResponse = { - // Whether the operation succeeded + /** Whether the operation succeeded */ success: boolean, - // Human-readable result message + /** Human-readable result message */ message: string, - // Current module status after operation + /** Current module status after operation */ status: string | null, }; -// CPU (Central Processing Unit) statistics +/** CPU (Central Processing Unit) statistics */ export type CpuStats = { - // CPU usage percentage (0-100) + /** CPU usage percentage (0-100) */ percent: number, - // Number of logical cores + /** Number of logical cores */ cores: number, - // CPU model name + /** CPU model name */ name: string, }; -// User-created fine-tuned or custom AI model +/** User-created fine-tuned or custom AI model */ export type CustomModel = { - // Unique identifier + /** Unique identifier */ id: string, - // Display name + /** Display name */ name: string, - // Provider ID (e.g., "gpt", "deepseek") + /** Provider ID (e.g., "gpt", "deepseek") */ provider_id: string, - // Base model identifier (e.g., "ft:gpt-3.5-turbo:...") + /** Base model identifier (e.g., "ft:gpt-3.5-turbo:...") */ base_model_id: string, - // Creation timestamp (Unix epoch) + /** Creation timestamp (Unix epoch) */ created_at: number, }; -// Disk I/O (Input/Output) statistics +/** Disk I/O (Input/Output) statistics */ export type DiskStats = { - // Read speed (bytes/sec) + /** Read speed (bytes/sec) */ readRate: number, - // Write speed (bytes/sec) + /** Write speed (bytes/sec) */ writeRate: number, - // Disk utilization percentage (0-100) + /** Disk utilization percentage (0-100) */ utilization: number, - // Total disk capacity (GB) + /** Total disk capacity (GB) */ totalGb: number, - // Disk space currently used (GB) + /** Disk space currently used (GB) */ usedGb: number, - // Disk activity percentage (0-100) + /** Disk activity percentage (0-100) */ activityPercent: number, }; -// Preferred compute backend for a local engine. +/** Preferred compute backend for a local engine. */ export type EngineComputeMode = -// Let the engine use available GPU devices automatically. +/** Let the engine use available GPU devices automatically. */ "gpu" | -// Force CPU execution and disable GPU offload. +/** Force CPU execution and disable GPU offload. */ "cpu"; -// Runtime configuration for starting an engine +/** Runtime configuration for starting an engine */ export type EngineConfig = { - // Engine identifier (matches EngineDefinition.id) + /** Engine identifier (matches EngineDefinition.id) */ engine_id: string, - // Preferred compute backend. + /** Preferred compute backend. */ compute_mode?: EngineComputeMode, - // Context window size + /** Context window size */ context_size?: number, - // Path to model file + /** Path to model file */ model_path: string | null, - // Extra CLI arguments + /** Extra CLI arguments */ extra_args?: string[], }; -// Static engine definition (from local_modules.json) +/** Static engine definition (from local_modules.json) */ export type EngineDefinition = { - // Unique identifier (e.g. "llamacpp") + /** Unique identifier (e.g. "llamacpp") */ id: string, - // Display name + /** Display name */ name: string, - // Description + /** Description */ desc?: string, - // Icon emoji + /** Icon emoji */ icon?: string, - // What this engine can do + /** What this engine can do */ capabilities?: Capability[], - // Binary name for local engines + /** Binary name for local engines */ binary?: string | null, - // GitHub repository URL (for releases/downloads) + /** GitHub repository URL (for releases/downloads) */ repo_url?: string | null, - // Current version + /** Current version */ version?: string, - // Default port (extracted from configSchema.port.default) + /** Default port (extracted from configSchema.port.default) */ default_port?: number, - // Default context window size (extracted from configSchema.contextSize.default) + /** Default context window size (extracted from configSchema.contextSize.default) */ default_context_size?: number, - // Raw configuration schema for UI rendering (kept for frontend) - config_schema?: "Null" | ({ Bool: boolean }) & { Array?: never; Number?: never; Object?: never; String?: never } | ({ Number: ({ f64: number }) & { i64?: never; u64?: never } | ({ i64: number }) & { f64?: never; u64?: never } | ({ u64: number }) & { f64?: never; i64?: never } }) & { Array?: never; Bool?: never; Object?: never; String?: never } | ({ String: string }) & { Array?: never; Bool?: never; Number?: never; Object?: never } | ({ Array: Value[] }) & { Bool?: never; Number?: never; Object?: never; String?: never } | ({ Object: { [key in string]: Value } }) & { Array?: never; Bool?: never; Number?: never; String?: never } | null, - // Whether the engine binary is currently installed (populated at runtime, not from JSON) + /** Raw configuration schema for UI rendering (kept for frontend) */ + config_schema?: unknown | null, + /** Whether the engine binary is currently installed (populated at runtime, not from JSON) */ installed?: boolean, /** * Compute modes present in the Axelate-managed install metadata. @@ -666,704 +664,704 @@ export type EngineDefinition = { * Empty means unknown, usually a system PATH install or an older install without metadata. */ installed_compute_modes?: EngineComputeMode[], - // True when the launcher connects to a user-managed external engine instead of installing it + /** True when the launcher connects to a user-managed external engine instead of installing it */ managed_externally?: boolean, }; -// Aggregated payload for the local engine settings modal. +/** Aggregated payload for the local engine settings modal. */ export type EngineSettingsPayload = { - // Fully merged engine config for the selected engine. + /** Fully merged engine config for the selected engine. */ config: EngineConfig, }; -// Engine lifecycle state (for frontend) +/** Engine lifecycle state (for frontend) */ export type EngineState = -// No engine loaded +/** No engine loaded */ "idle" | -// Engine is starting up +/** Engine is starting up */ ({ starting: { - // ID of the engine being started + /** ID of the engine being started */ engine_id: string, } }) & { error?: never; ready?: never; swapping?: never } | -// Swapping from one engine to another within a slot +/** Swapping from one engine to another within a slot */ ({ swapping: { - // ID of the engine being stopped + /** ID of the engine being stopped */ from: string, - // ID of the engine being started + /** ID of the engine being started */ to: string, } }) & { error?: never; ready?: never; starting?: never } | -// One or more engines are running +/** One or more engines are running */ ({ ready: { - // Active slots (one per capability) + /** Active slots (one per capability) */ slots: SlotStatus[], } }) & { error?: never; starting?: never; swapping?: never } | -// Engine encountered an error +/** Engine encountered an error */ ({ error: { - // ID of the failed engine + /** ID of the failed engine */ engine_id: string, - // Error description + /** Error description */ message: string, } }) & { ready?: never; starting?: never; swapping?: never }; -// Currently running engine +/** Currently running engine */ export type EngineStatus = { - // Engine identifier + /** Engine identifier */ id: string, - // Display name + /** Display name */ name: string, - // Capabilities + /** Capabilities */ capabilities: Capability[], - // HTTP endpoint (e.g. "http://localhost:8081") + /** HTTP endpoint (e.g. "http://localhost:8081") */ endpoint: string, - // Is the engine healthy and ready + /** Is the engine healthy and ready */ healthy: boolean, }; -// Public system GPU probe result used by the frontend and downloader. +/** Public system GPU probe result used by the frontend and downloader. */ export type GpuInfo = { - // Whether a usable GPU adapter was detected. + /** Whether a usable GPU adapter was detected. */ detected: boolean, - // Human-readable adapter name. + /** Human-readable adapter name. */ name: string, - // Whether CUDA-capable NVIDIA hardware was detected. + /** Whether CUDA-capable NVIDIA hardware was detected. */ cuda: boolean, - // Preferred runtime backend hint (`cuda`, `vulkan`, `cpu`, `hip`, `sycl`). + /** Preferred runtime backend hint (`cuda`, `vulkan`, `cpu`, `hip`, `sycl`). */ backend: string, - // Total GPU memory in megabytes, when available. + /** Total GPU memory in megabytes, when available. */ memory: number, - // CUDA driver major version, when available. + /** CUDA driver major version, when available. */ cuda_driver_major: number | null, - // CUDA driver minor version, when available. + /** CUDA driver minor version, when available. */ cuda_driver_minor: number | null, }; -// GPU (Graphics Processing Unit) statistics +/** GPU (Graphics Processing Unit) statistics */ export type GpuStats = { - // GPU usage percentage (0-100) + /** GPU usage percentage (0-100) */ usage: number, - // Memory currently used (bytes) + /** Memory currently used (bytes) */ memoryUsed: number, - // Total available memory (bytes) + /** Total available memory (bytes) */ memoryTotal: number, - // GPU temperature (Celsius) + /** GPU temperature (Celsius) */ temp: number, - // GPU model name + /** GPU model name */ name: string, }; -// Live preview payload for in-progress image generation. +/** Live preview payload for in-progress image generation. */ export type ImageGenerationPreview = { - // Data URL of the latest preview image. + /** Data URL of the latest preview image. */ data_url: string, - // File modification timestamp in Unix milliseconds. + /** File modification timestamp in Unix milliseconds. */ updated_at_ms: number, - // Current image-generation progress, normalized to 0.0..1.0 when the engine exposes it. + /** Current image-generation progress, normalized to 0.0..1.0 when the engine exposes it. */ progress: number | null, - // Current sampling step when available. + /** Current sampling step when available. */ step: number | null, - // Total sampling steps when available. + /** Total sampling steps when available. */ total: number | null, - // Latest reported generation speed when available, for example `1.07s/it`. + /** Latest reported generation speed when available, for example `1.07s/it`. */ speed: string | null, - // Estimated remaining seconds when the engine exposes it. + /** Estimated remaining seconds when the engine exposes it. */ eta_relative: number | null, }; -// Image generation request parameters +/** Image generation request parameters */ export type ImageGenerationRequest = { - // AI provider or local engine ID + /** AI provider or local engine ID */ provider: string, - // The text prompt for generation + /** The text prompt for generation */ prompt: string, - // Original user text before UI prompt prefixes + /** Original user text before UI prompt prefixes */ original_prompt: string | null, - // Model identifier + /** Model identifier */ model: string, - // Optional settings namespace key when UI-selected module differs from provider id + /** Optional settings namespace key when UI-selected module differs from provider id */ settings_key: string | null, - // Session identifier for history tracking + /** Session identifier for history tracking */ session_id: string | null, - // Number of inference steps + /** Number of inference steps */ steps: number | null, - // Guidance scale (CFG) + /** Guidance scale (CFG) */ cfg_scale: number | null, - // Denoising strength for image-to-image capable backends + /** Denoising strength for image-to-image capable backends */ denoising_strength: number | null, - // Image width in pixels + /** Image width in pixels */ width: number | null, - // Image height in pixels + /** Image height in pixels */ height: number | null, - // Sampler algorithm + /** Sampler algorithm */ sampler: string | null, - // Random seed + /** Random seed */ seed: number | null, - // Clip skip + /** Clip skip */ clip_skip: number | null, - // Optional negative prompt + /** Optional negative prompt */ negative_prompt: string | null, - // Number of images to generate (batch size) + /** Number of images to generate (batch size) */ batch_size: number | null, - // Scheduler algorithm + /** Scheduler algorithm */ scheduler: string | null, }; -// Image generation response +/** Image generation response */ export type ImageGenerationResponse = { - // Base64 encoded images or URLs + /** Base64 encoded images or URLs */ images: string[], - // Whether request was successful + /** Whether request was successful */ ok: boolean, - // Error message if failed + /** Error message if failed */ error: string | null, }; -// Log entry for frontend display +/** Log entry for frontend display */ export type LogEntry = { - // Unix timestamp + /** Unix timestamp */ timestamp: number, - // Log source component + /** Log source component */ source: string, - // Log level ("info", "warn", "error") + /** Log level ("info", "warn", "error") */ level: string, - // Log message + /** Log message */ message: string, - // Resolved module/runtime identifier when the log belongs to a module. + /** Resolved module/runtime identifier when the log belongs to a module. */ module_id: string | null, - // Parsed time component extracted from the message when present. + /** Parsed time component extracted from the message when present. */ display_time: string | null, - // Normalized level used by the console UI. + /** Normalized level used by the console UI. */ normalized_level: string | null, - // Parsed scope segment when present. + /** Parsed scope segment when present. */ scope: string | null, - // Precomputed summary message for console rendering. + /** Precomputed summary message for console rendering. */ summary_message: string | null, - // Human-friendly source label for console rendering. + /** Human-friendly source label for console rendering. */ source_label: string | null, - // CSS-friendly source class for console rendering. + /** CSS-friendly source class for console rendering. */ source_class: string | null, - // Page identifier extracted from navigation logs. + /** Page identifier extracted from navigation logs. */ page: string | null, - // Action extracted from module control logs. + /** Action extracted from module control logs. */ action: string | null, - // Expected manifest or artifact hint from error logs. + /** Expected manifest or artifact hint from error logs. */ expected: string | null, }; -// Model capability flags (JSON Compatible) +/** Model capability flags (JSON Compatible) */ export type ModelCapabilities = { - // Supports reasoning/thinking steps + /** Supports reasoning/thinking steps */ reasoning?: boolean, - // Supports image input/vision + /** Supports image input/vision */ vision?: boolean, - // Supports multimodal input (audio/video) + /** Supports multimodal input (audio/video) */ multimodal?: boolean, - // Supports large context windows (>128k) + /** Supports large context windows (>128k) */ longContext?: boolean, - // Supports token streaming + /** Supports token streaming */ streaming?: boolean, - // Supports function/tool calling + /** Supports function/tool calling */ functionCalling?: boolean, }; -// Performance characteristics of an AI model (0-10 scale) +/** Performance characteristics of an AI model (0-10 scale) */ export type ModelStats = { - // Response speed rating + /** Response speed rating */ speed: number, - // Logical reasoning capability + /** Logical reasoning capability */ logic: number, - // Creative output quality + /** Creative output quality */ creative: number, }; -// Tier classification for AI models +/** Tier classification for AI models */ export type ModelTier = -// Entry-level or fast models +/** Entry-level or fast models */ "weak" | -// Balanced models +/** Balanced models */ "medium" | -// Flagship or reasoning-heavy models +/** Flagship or reasoning-heavy models */ "strong"; -// Complete module metadata and state +/** Complete module metadata and state */ export type Module = { - // Unique module identifier + /** Unique module identifier */ id: string, - // Display name + /** Display name */ name: string, - // User-facing description + /** User-facing description */ description: string, - // Semantic version (e.g., "1.0.0") + /** Semantic version (e.g., "1.0.0") */ version: string, - // Author username or organization + /** Author username or organization */ author: string, - // Category ("ai" or "service") + /** Category ("ai" or "service") */ category: string, - // Icon/emoji for UI display + /** Icon/emoji for UI display */ icon: string, - // Module-owned card preview metadata. + /** Module-owned card preview metadata. */ preview?: ModulePreview | null, - // Absolute filesystem path to module directory + /** Absolute filesystem path to module directory */ path: string, - // Whether module files are present locally + /** Whether module files are present locally */ installed: boolean, - // Whether module is user-installed (vs. built-in) + /** Whether module is user-installed (vs. built-in) */ local: boolean, - // Whether module is enabled for auto-start + /** Whether module is enabled for auto-start */ enabled: boolean, - // Current runtime status ("running", "stopped", "error") + /** Current runtime status ("running", "stopped", "error") */ status: string | null, - // Whether module can be deleted by user + /** Whether module can be deleted by user */ isDeletable: boolean, - // Current configuration values - config: { [key in string]: "Null" | ({ Bool: boolean }) & { Array?: never; Number?: never; Object?: never; String?: never } | ({ Number: ({ f64: number }) & { i64?: never; u64?: never } | ({ i64: number }) & { f64?: never; u64?: never } | ({ u64: number }) & { f64?: never; i64?: never } }) & { Array?: never; Bool?: never; Object?: never; String?: never } | ({ String: string }) & { Array?: never; Bool?: never; Number?: never; Object?: never } | ({ Array: Value[] }) & { Bool?: never; Number?: never; Object?: never; String?: never } | ({ Object: { [key in string]: Value } }) & { Array?: never; Bool?: never; Number?: never; String?: never } }, - // Configuration schema definition + /** Current configuration values */ + config: { [key in string]: unknown }, + /** Configuration schema definition */ configSchema: { [key in string]: ConfigField } | null, - // Relative path to the module-owned settings UI entry file. + /** Relative path to the module-owned settings UI entry file. */ settingsUi: string | null, }; -// Catalog item for downloadable modules +/** Catalog item for downloadable modules */ export type ModuleItem = ModuleItem_Serialize | ModuleItem_Deserialize; -// Catalog item for downloadable modules +/** Catalog item for downloadable modules */ export type ModuleItem_Deserialize = { - // Unique module identifier + /** Unique module identifier */ id: string, - // Localization key for name + /** Localization key for name */ nameKey: string, - // Localization key for description + /** Localization key for description */ descKey: string, - // Display name + /** Display name */ name: string, - // Description text + /** Description text */ desc: string, - // Icon/emoji + /** Icon/emoji */ icon: string, - // Optional module-owned card preview metadata. + /** Optional module-owned card preview metadata. */ preview?: ModulePreview | null, - // Module type ("api" or "service") + /** Module type ("api" or "service") */ type: string, - // Download type ("source" or "release") + /** Download type ("source" or "release") */ dlType?: string | null, - // Engine capabilities (e.g. `["text"]`, `["image"]`) + /** Engine capabilities (e.g. `["text"]`, `["image"]`) */ capabilities?: string[], - // Binary executable name for local engines (e.g. "llama-server") + /** Binary executable name for local engines (e.g. "llama-server") */ binary?: string | null, - // GitHub repository URL + /** GitHub repository URL */ repoUrl: string | null, - // SHA-256 hash for integrity verification + /** SHA-256 hash for integrity verification */ expectedHash: string | null, - // Marks catalog entries that should render as placeholders and not be launchable yet + /** Marks catalog entries that should render as placeholders and not be launchable yet */ comingSoon?: boolean, - // True when the launcher should treat this engine as user-managed and skip install checks + /** True when the launcher should treat this engine as user-managed and skip install checks */ managedExternally?: boolean, - // Semantic version (e.g., "1.0.0") + /** Semantic version (e.g., "1.0.0") */ version?: string, - // Raw configSchema from JSON (used by engine registry to extract typed defaults) - configSchema?: "Null" | ({ Bool: boolean }) & { Array?: never; Number?: never; Object?: never; String?: never } | ({ Number: ({ f64: number }) & { i64?: never; u64?: never } | ({ i64: number }) & { f64?: never; u64?: never } | ({ u64: number }) & { f64?: never; i64?: never } }) & { Array?: never; Bool?: never; Object?: never; String?: never } | ({ String: string }) & { Array?: never; Bool?: never; Number?: never; Object?: never } | ({ Array: Value[] }) & { Bool?: never; Number?: never; Object?: never; String?: never } | ({ Object: { [key in string]: Value } }) & { Array?: never; Bool?: never; Number?: never; String?: never } | null, + /** Raw configSchema from JSON (used by engine registry to extract typed defaults) */ + configSchema?: unknown | null, }; -// Catalog item for downloadable modules +/** Catalog item for downloadable modules */ export type ModuleItem_Serialize = { - // Unique module identifier + /** Unique module identifier */ id: string, - // Localization key for name + /** Localization key for name */ nameKey: string, - // Localization key for description + /** Localization key for description */ descKey: string, - // Display name + /** Display name */ name: string, - // Description text + /** Description text */ desc: string, - // Icon/emoji + /** Icon/emoji */ icon: string, - // Optional module-owned card preview metadata. + /** Optional module-owned card preview metadata. */ preview: ModulePreview | null, - // Module type ("api" or "service") + /** Module type ("api" or "service") */ type: string, - // Download type ("source" or "release") + /** Download type ("source" or "release") */ dlType: string | null, - // Engine capabilities (e.g. `["text"]`, `["image"]`) + /** Engine capabilities (e.g. `["text"]`, `["image"]`) */ capabilities: string[], - // Binary executable name for local engines (e.g. "llama-server") + /** Binary executable name for local engines (e.g. "llama-server") */ binary: string | null, - // GitHub repository URL + /** GitHub repository URL */ repoUrl: string | null, - // SHA-256 hash for integrity verification + /** SHA-256 hash for integrity verification */ expectedHash: string | null, - // Marks catalog entries that should render as placeholders and not be launchable yet + /** Marks catalog entries that should render as placeholders and not be launchable yet */ comingSoon: boolean, - // True when the launcher should treat this engine as user-managed and skip install checks + /** True when the launcher should treat this engine as user-managed and skip install checks */ managedExternally: boolean, - // Semantic version (e.g., "1.0.0") + /** Semantic version (e.g., "1.0.0") */ version: string, - // Whether module is currently installed (runtime only) + /** Whether module is currently installed (runtime only) */ installed: boolean, - // Raw configSchema from JSON (used by engine registry to extract typed defaults) - configSchema: "Null" | ({ Bool: boolean }) & { Array?: never; Number?: never; Object?: never; String?: never } | ({ Number: ({ f64: number }) & { i64?: never; u64?: never } | ({ i64: number }) & { f64?: never; u64?: never } | ({ u64: number }) & { f64?: never; i64?: never } }) & { Array?: never; Bool?: never; Object?: never; String?: never } | ({ String: string }) & { Array?: never; Bool?: never; Number?: never; Object?: never } | ({ Array: Value[] }) & { Bool?: never; Number?: never; Object?: never; String?: never } | ({ Object: { [key in string]: Value } }) & { Array?: never; Bool?: never; Number?: never; String?: never } | null, - // Configuration schema definition (runtime only, built from raw_config_schema) + /** Raw configSchema from JSON (used by engine registry to extract typed defaults) */ + configSchema: unknown | null, + /** Configuration schema definition (runtime only, built from raw_config_schema) */ configSchema: { [key in string]: ConfigField } | null, }; -// Module-owned card preview metadata. +/** Module-owned card preview metadata. */ export type ModulePreview = { - // Optional card title override. + /** Optional card title override. */ title?: string | null, - // Optional card description override. + /** Optional card description override. */ description?: string | null, - // Optional emoji/text sticker shown when no image is provided. + /** Optional emoji/text sticker shown when no image is provided. */ sticker?: string | null, - // Optional image URL or data URL for the card preview. + /** Optional image URL or data URL for the card preview. */ image?: string | null, - // Optional directory with localized preview JSON files. + /** Optional directory with localized preview JSON files. */ i18n?: string | null, }; -// Network I/O statistics +/** Network I/O statistics */ export type NetworkStats = { - // Download speed (bytes/sec) + /** Download speed (bytes/sec) */ downloadRate: number, - // Upload speed (bytes/sec) + /** Upload speed (bytes/sec) */ uploadRate: number, - // Total bytes received since boot + /** Total bytes received since boot */ totalReceived: number, - // Total bytes sent since boot + /** Total bytes sent since boot */ totalSent: number, - // Network utilization percentage (0-100) + /** Network utilization percentage (0-100) */ utilization: number, - // Network activity percentage (0-100) + /** Network activity percentage (0-100) */ activityPercent: number, }; -// Pricing configuration for a model +/** Pricing configuration for a model */ export type PricingConfig = { - // Input-side cost or score shown in the launcher UI + /** Input-side cost or score shown in the launcher UI */ input: number | null, - // Output-side cost or score shown in the launcher UI + /** Output-side cost or score shown in the launcher UI */ output: number | null, - // Currency code + /** Currency code */ currency: string | null, - // Additional notes + /** Additional notes */ notes: string | null, }; -// Processed file content result +/** Processed file content result */ export type ProcessedFile = { - // File name + /** File name */ name: string, - // Extracted text content + /** Extracted text content */ content: string, - // Whether file was a ZIP archive + /** Whether file was a ZIP archive */ is_archive: boolean, - // Processing error if any + /** Processing error if any */ error: string | null, - // Estimated token count for extracted text content. + /** Estimated token count for extracted text content. */ token_estimate: number, }; -// Type of AI provider +/** Type of AI provider */ export type ProviderType = -// Standard OpenAI API +/** Standard OpenAI API */ "openai" | -// Google Gemini API +/** Google Gemini API */ "google" | -// Anthropic Claude API (via OpenRouter or direct) +/** Anthropic Claude API (via OpenRouter or direct) */ "anthropic" | -// OpenAI-compatible local or cloud API +/** OpenAI-compatible local or cloud API */ "openai-compatible" | -// Generic API provider +/** Generic API provider */ "api" | -// Local module inference +/** Local module inference */ "local"; -// RAM (Random Access Memory) statistics +/** RAM (Random Access Memory) statistics */ export type RamStats = { - // RAM usage percentage (0-100) + /** RAM usage percentage (0-100) */ percent: number, - // RAM currently used (GB) + /** RAM currently used (GB) */ usedGb: number, - // Total RAM capacity (GB) + /** Total RAM capacity (GB) */ totalGb: number, - // RAM available for allocation (GB) + /** RAM available for allocation (GB) */ availableGb: number, }; -// User-facing compute target for release package selection. +/** User-facing compute target for release package selection. */ export type ReleaseComputeTarget = -// Let Axelate choose the best compatible package for this machine. +/** Let Axelate choose the best compatible package for this machine. */ "auto" | -// Prefer a GPU package, for example CUDA, Vulkan, HIP, or SYCL. +/** Prefer a GPU package, for example CUDA, Vulkan, HIP, or SYCL. */ "gpu" | -// Prefer a CPU package. +/** Prefer a CPU package. */ "cpu" | -// Download both CPU and GPU packages when both are compatible. +/** Download both CPU and GPU packages when both are compatible. */ "both"; -// User-visible release download options for a single module. +/** User-visible release download options for a single module. */ export type ReleaseDownloadOptions = { - // Module identifier these options belong to. + /** Module identifier these options belong to. */ module_id: string, - // GitHub release versions in newest-first order. + /** GitHub release versions in newest-first order. */ versions: ReleaseDownloadVersion[], }; -// Explicit release package selection passed from the frontend. +/** Explicit release package selection passed from the frontend. */ export type ReleaseDownloadSelection = { - // GitHub release tag to download. `None` means the newest compatible release. + /** GitHub release tag to download. `None` means the newest compatible release. */ tag_name: string | null, - // Compute target selected by the user. + /** Compute target selected by the user. */ compute_target?: ReleaseComputeTarget, }; -// User-visible package variant for one compute target. +/** User-visible package variant for one compute target. */ export type ReleaseDownloadVariant = { - // Compute target represented by this variant. + /** Compute target represented by this variant. */ compute_target: ReleaseComputeTarget, - // Asset filenames that will be downloaded. + /** Asset filenames that will be downloaded. */ assets: string[], - // Combined download size in bytes. + /** Combined download size in bytes. */ total_size: number, }; -// User-visible package choices for a GitHub release version. +/** User-visible package choices for a GitHub release version. */ export type ReleaseDownloadVersion = { - // GitHub release tag. + /** GitHub release tag. */ tag_name: string, - // GitHub release publish timestamp when available. + /** GitHub release publish timestamp when available. */ published_at: string | null, - // CPU package choice for this release, when compatible. + /** CPU package choice for this release, when compatible. */ cpu: ReleaseDownloadVariant | null, - // GPU package choice for this release, when compatible. + /** GPU package choice for this release, when compatible. */ gpu: ReleaseDownloadVariant | null, - // Recommended package target for this machine. + /** Recommended package target for this machine. */ recommended: ReleaseComputeTarget, }; -// Result of saving a generated chat image to disk. +/** Result of saving a generated chat image to disk. */ export type SavedChatImage = { - // Absolute path to the saved image file. + /** Absolute path to the saved image file. */ file_path: string, - // Absolute path to the folder containing the saved image. + /** Absolute path to the folder containing the saved image. */ folder_path: string, }; -// Non-sensitive metadata for a securely stored key. +/** Non-sensitive metadata for a securely stored key. */ export type SecureKeyMeta = { - // Whether a non-empty key exists for the requested service. + /** Whether a non-empty key exists for the requested service. */ exists: boolean, - // Character length of the stored key, if present. + /** Character length of the stored key, if present. */ length: number, }; -// Currently selected module in UI +/** Currently selected module in UI */ export type SelectedModule = { - // Module identifier + /** Module identifier */ id: string, - // Display name + /** Display name */ name: string, - // Localization key for name + /** Localization key for name */ nameKey: string | null, - // Icon/emoji + /** Icon/emoji */ icon: string, - // Module type + /** Module type */ type: string, - // Localization key for description + /** Localization key for description */ descKey: string | null, - // Description text + /** Description text */ desc: string, }; -// Status of a single capability slot +/** Status of a single capability slot */ export type SlotStatus = { - // Which capability this slot serves + /** Which capability this slot serves */ capability: Capability, - // Engine running in this slot + /** Engine running in this slot */ engine: EngineStatus, }; -// Streaming payload delivered from the backend to the frontend chat channels. +/** Streaming payload delivered from the backend to the frontend chat channels. */ export type StreamChunkPayload = { - // Correlates the chunk with the originating frontend request. + /** Correlates the chunk with the originating frontend request. */ request_id: string, - // Identifies the assistant message currently being streamed. + /** Identifies the assistant message currently being streamed. */ message_id: string, - // Describes how the frontend should handle this stream event. + /** Describes how the frontend should handle this stream event. */ kind: StreamPayloadKind, - // The incremental text fragment emitted by the model. + /** The incremental text fragment emitted by the model. */ content: string, }; -// Kind of streaming payload delivered to frontend chat channels. +/** Kind of streaming payload delivered to frontend chat channels. */ export type StreamPayloadKind = -// A visible assistant text fragment. +/** A visible assistant text fragment. */ "chat_chunk" | -// A reasoning/thinking text fragment. +/** A reasoning/thinking text fragment. */ "thought_chunk" | -// End-of-stream marker after all chunks have been delivered. +/** End-of-stream marker after all chunks have been delivered. */ "done"; -// Complete system statistics snapshot +/** Complete system statistics snapshot */ export type SystemStats = { - // CPU usage and information + /** CPU usage and information */ cpu: CpuStats, - // RAM usage and availability + /** RAM usage and availability */ ram: RamStats, - // GPU usage (if available) + /** GPU usage (if available) */ gpu: GpuStats | null, - // VRAM usage (if GPU present) + /** VRAM usage (if GPU present) */ vram: VramStats | null, - // Disk I/O statistics + /** Disk I/O statistics */ disk: DiskStats, - // Network I/O statistics + /** Network I/O statistics */ network: NetworkStats, - // Current process ID + /** Current process ID */ pid: number, - // CPU usage of the current process (0-100) + /** CPU usage of the current process (0-100) */ appCpu: number, - // Memory used by the current process (bytes) + /** Memory used by the current process (bytes) */ appMemory: number, }; -// Thresholds configuration. +/** Thresholds configuration. */ export type Thresholds = { - // Warning threshold width. + /** Warning threshold width. */ warningWidth: number, - // Warning threshold height. + /** Warning threshold height. */ warningHeight: number, - // Small screen threshold width. + /** Small screen threshold width. */ smallScreenWidth: number, - // Small screen threshold height. + /** Small screen threshold height. */ smallScreenHeight: number, }; -// Token usage statistics +/** Token usage statistics */ export type TokenUsage = { - // Tokens in the prompt + /** Tokens in the prompt */ prompt_tokens: number, - // Tokens in the completion + /** Tokens in the completion */ completion_tokens: number, - // Total tokens used + /** Total tokens used */ total_tokens: number, }; -// UI State that persists across sessions +/** UI State that persists across sessions */ export type UIState = { - // Sidebar collapsed state + /** Sidebar collapsed state */ sidebar_collapsed: boolean, - // User manually overrode responsive sidebar compaction + /** User manually overrode responsive sidebar compaction */ sidebar_manual_override?: boolean, - // Sidebar width in pixels + /** Sidebar width in pixels */ sidebar_width: number, - // Hidden navigation items (page IDs) + /** Hidden navigation items (page IDs) */ hidden_nav_items: string[], - // Hidden system monitor items + /** Hidden system monitor items */ hidden_monitors: string[], - // Card widths map (`card_id` -> "full" | "half") + /** Card widths map (`card_id` -> "full" | "half") */ card_widths: { [key in string]: string }, - // Download settings + /** Download settings */ download_limit_enabled: boolean, - // Maximum download speed in MB/s + /** Maximum download speed in MB/s */ download_max_speed: number, - // Selected modules by category + /** Selected modules by category */ selected_modules: { [key in string]: SelectedModule }, - // Global Zoom Level + /** Global Zoom Level */ zoom_level: number, - // Selected AI Models (`AppID` -> `ModelKey`) + /** Selected AI Models (`AppID` -> `ModelKey`) */ selected_ai_models: { [key in string]: string }, - // Last visited page ID + /** Last visited page ID */ last_page: string | null, /** * Per-resolution zoom levels ("WxH" -> value) * Per-resolution zoom levels (e.g., "1920x1080" -> 1.2) */ resolution_zoom: { [key in string]: number }, - // Sound effects enabled state + /** Sound effects enabled state */ sound_enabled: boolean, - // Selected reasoning level by AI provider + /** Selected reasoning level by AI provider */ ai_thinking_level?: { [key in string]: string }, - // Enables provider-side internet search by AI provider + /** Enables provider-side internet search by AI provider */ ai_web_search_enabled?: { [key in string]: boolean }, - // Per-provider local model output token limits. + /** Per-provider local model output token limits. */ local_max_output_tokens?: { [key in string]: number }, - // Last directory used by the custom integration import dialog. + /** Last directory used by the custom integration import dialog. */ integration_import_last_directory?: string | null, - // Preferred launcher interface language + /** Preferred launcher interface language */ preferred_language?: string | null, - // Request to reopen the chat and reveal the latest message after background work. + /** Request to reopen the chat and reveal the latest message after background work. */ pending_chat_reveal?: boolean, }; -// One-shot voice recognition request. +/** One-shot voice recognition request. */ export type VoiceRecognitionRequest = { - // Preferred UI language code, for example `en`, `ru`, or `ru-RU`. + /** Preferred UI language code, for example `en`, `ru`, or `ru-RU`. */ language: string | null, }; -// One-shot voice recognition response. +/** One-shot voice recognition response. */ export type VoiceRecognitionResponse = { - // Recognized final text. + /** Recognized final text. */ text: string, - // Native recognizer status. + /** Native recognizer status. */ status: string, - // Native confidence bucket when available. + /** Native confidence bucket when available. */ confidence: string | null, }; -// VRAM (Video RAM) statistics +/** VRAM (Video RAM) statistics */ export type VramStats = { - // VRAM usage percentage (0-100) + /** VRAM usage percentage (0-100) */ percent: number, - // VRAM currently used (GB) + /** VRAM currently used (GB) */ usedGb: number, - // Total VRAM capacity (GB) + /** Total VRAM capacity (GB) */ totalGb: number, }; -// Optional web search configuration for provider-backed chat requests. +/** Optional web search configuration for provider-backed chat requests. */ export type WebSearchOptions = { - // Enables provider-side web search. + /** Enables provider-side web search. */ enabled?: boolean, - // Search engine preference (`auto`, `native`, `exa`, ...). + /** Search engine preference (`auto`, `native`, `exa`, ...). */ engine?: string | null, - // Maximum results per search call. + /** Maximum results per search call. */ max_results?: number | null, - // Maximum results across all search calls in one request. + /** Maximum results across all search calls in one request. */ max_total_results?: number | null, - // Search context size (`low`, `medium`, `high`). + /** Search context size (`low`, `medium`, `high`). */ search_context_size?: string | null, - // Optional allow-list of domains. + /** Optional allow-list of domains. */ allowed_domains?: string[], - // Optional deny-list of domains. + /** Optional deny-list of domains. */ excluded_domains?: string[], }; -// Overall window configuration combining breakpoints and thresholds. +/** Overall window configuration combining breakpoints and thresholds. */ export type WindowConfig = { - // Breakpoint settings. + /** Breakpoint settings. */ breakpoints: Breakpoints, - // Threshold settings. + /** Threshold settings. */ thresholds: Thresholds, }; -// Layout policy based on screen size and current window dimensions. +/** Layout policy based on screen size and current window dimensions. */ export type WindowPolicy = { - // True if the screen is considered "small" (mobile/tablet/small laptop). + /** True if the screen is considered "small" (mobile/tablet/small laptop). */ isSmallScreen: boolean, - // True if a layout warning should be shown. + /** True if a layout warning should be shown. */ showWarning: boolean, }; -// Persistent window state. +/** Persistent window state. */ export type WindowSettings = { - // Window width. + /** Window width. */ width: number, - // Window height. + /** Window height. */ height: number, - // Horizontal screen position. + /** Horizontal screen position. */ x: number | null, - // Vertical screen position. + /** Vertical screen position. */ y: number | null, - // True if the window is maximized. + /** True if the window is maximized. */ maximized: boolean, }; From 2733c78caadcfb0cf33c9532061831aecf6fad47 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Fri, 15 May 2026 02:25:51 +0300 Subject: [PATCH 113/126] docs: align workflow docs with current bindings stack --- CONTRIBUTING.md | 24 +++++++++---------- README.md | 24 +++++++++---------- SUPPORT.md | 6 ++--- docs/localization/en/ARCHITECTURE.md | 7 ++++++ docs/localization/en/CURRENT_STATE.md | 6 ++--- docs/localization/en/DEVELOPMENT_WORKFLOW.md | 10 +++++++- .../ru/INTEGRATION_DEVELOPMENT.md | 2 +- 7 files changed, 47 insertions(+), 32 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index edcf16ed..8522e54d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,10 +2,10 @@ Use these documents first: -- [Getting Started](docs/en/GETTING_STARTED.md) -- [Development Workflow](docs/en/DEVELOPMENT_WORKFLOW.md) -- [Releases](docs/en/RELEASES.md) -- [Current State](docs/en/CURRENT_STATE.md) +- [Getting Started](docs/localization/en/GETTING_STARTED.md) +- [Development Workflow](docs/localization/en/DEVELOPMENT_WORKFLOW.md) +- [Releases](docs/localization/en/RELEASES.md) +- [Current State](docs/localization/en/CURRENT_STATE.md) ## Working Rules @@ -43,7 +43,7 @@ Use these documents first: ## Releases -- Read [Releases](docs/en/RELEASES.md) before tagging. +- Read [Releases](docs/localization/en/RELEASES.md) before tagging. - Tags must start with `v`. - Tag versions must match `package.json`, `src/package.json`, and `src-tauri/Cargo.toml`. - Release tags must point to a commit that is already reachable from `main`. @@ -55,15 +55,15 @@ Use these documents first: These files should describe the repository as it works today: - `README.md` -- `docs/en/GETTING_STARTED.md` -- `docs/en/DEVELOPMENT_WORKFLOW.md` -- `docs/en/RELEASES.md` -- `docs/en/CURRENT_STATE.md` -- `docs/en/TRUST_MODEL.md` +- `docs/localization/en/GETTING_STARTED.md` +- `docs/localization/en/DEVELOPMENT_WORKFLOW.md` +- `docs/localization/en/RELEASES.md` +- `docs/localization/en/CURRENT_STATE.md` +- `docs/localization/en/TRUST_MODEL.md` These files are planning documents and should not be used as current feature inventory: -- `docs/en/VISION.md` -- `docs/en/ROADMAP.md` +- `docs/localization/en/VISION.md` +- `docs/localization/en/ROADMAP.md` Move future ideas into the planning documents instead of mixing them into current onboarding docs. diff --git a/README.md b/README.md index 490b82b2..bbdb9d60 100644 --- a/README.md +++ b/README.md @@ -94,30 +94,30 @@ git tag v0.1.5 git push origin v0.1.5 ``` -For the full release checklist, see [Releases](docs/en/RELEASES.md). +For the full release checklist, see [Releases](docs/localization/en/RELEASES.md). ## Docs Start here: -- [User Guide](docs/en/USER_GUIDE.md) -- [Getting Started](docs/en/GETTING_STARTED.md) -- [Development Workflow](docs/en/DEVELOPMENT_WORKFLOW.md) -- [Architecture](docs/en/ARCHITECTURE.md) -- [Integration Development](docs/en/INTEGRATION_DEVELOPMENT.md) -- [Releases](docs/en/RELEASES.md) -- [Current State](docs/en/CURRENT_STATE.md) +- [User Guide](docs/localization/en/USER_GUIDE.md) +- [Getting Started](docs/localization/en/GETTING_STARTED.md) +- [Development Workflow](docs/localization/en/DEVELOPMENT_WORKFLOW.md) +- [Architecture](docs/localization/en/ARCHITECTURE.md) +- [Integration Development](docs/localization/en/INTEGRATION_DEVELOPMENT.md) +- [Releases](docs/localization/en/RELEASES.md) +- [Current State](docs/localization/en/CURRENT_STATE.md) - [Contributing](CONTRIBUTING.md) Current reference: -- [Trust Model](docs/en/TRUST_MODEL.md) -- Integration Development: [RU](docs/ru/INTEGRATION_DEVELOPMENT.md) · [ZH](docs/zh/INTEGRATION_DEVELOPMENT.md) +- [Trust Model](docs/localization/en/TRUST_MODEL.md) +- Integration Development: [RU](docs/localization/ru/INTEGRATION_DEVELOPMENT.md) · [ZH](docs/localization/zh/INTEGRATION_DEVELOPMENT.md) Planning only: -- [Vision](docs/en/VISION.md) -- [Roadmap](docs/en/ROADMAP.md) +- [Vision](docs/localization/en/VISION.md) +- [Roadmap](docs/localization/en/ROADMAP.md) `Vision` and `Roadmap` are planning documents. They are not setup guides and should not be read as a promise that those features already ship today. diff --git a/SUPPORT.md b/SUPPORT.md index e0153691..a2e7f0de 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -5,8 +5,8 @@ Axelate is currently an active development project. Use GitHub Issues for reproducible bugs and scoped feature requests. For setup and workflow questions, start with: - `README.md` -- `docs/en/GETTING_STARTED.md` -- `docs/en/DEVELOPMENT_WORKFLOW.md` -- `docs/en/CURRENT_STATE.md` +- `docs/localization/en/GETTING_STARTED.md` +- `docs/localization/en/DEVELOPMENT_WORKFLOW.md` +- `docs/localization/en/CURRENT_STATE.md` For security issues, follow `SECURITY.md` instead of opening a public issue. diff --git a/docs/localization/en/ARCHITECTURE.md b/docs/localization/en/ARCHITECTURE.md index fc2f3c7d..df884fd8 100644 --- a/docs/localization/en/ARCHITECTURE.md +++ b/docs/localization/en/ARCHITECTURE.md @@ -11,6 +11,8 @@ Axelate is a Tauri 2 desktop app: - `src-tauri/` is the Rust backend and Tauri host. - Rust commands are exported to TypeScript through Specta bindings in `src/shared/types/bindings.ts`. +- The current binding stack is `specta` `2.0.0-rc.25`, + `tauri-specta` `2.0.0-rc.25`, and `specta-typescript` `0.0.12`. - Runtime assets, built-in module manifests, and locales live under `src-tauri/resources/`. @@ -87,6 +89,11 @@ Use this sequence when changing a frontend-visible backend contract: If bindings are out of date, `npm --prefix src run bindings:check` should fail. +Dynamic JSON fields such as provider payloads, module settings, config schemas, +and chat content are exported as TypeScript `unknown`. Treat that as an +intentional trust boundary: narrow the value at the frontend use site, or replace +the Rust field with a typed DTO when the shape becomes stable. + ## Cross-Platform Rule The app is Windows-first today, but new architecture should keep Linux and macOS diff --git a/docs/localization/en/CURRENT_STATE.md b/docs/localization/en/CURRENT_STATE.md index 334b2880..4545dee4 100644 --- a/docs/localization/en/CURRENT_STATE.md +++ b/docs/localization/en/CURRENT_STATE.md @@ -33,7 +33,7 @@ Confirmed by the repository: - backend: Rust - desktop runtime: Tauri v2 - frontend: vanilla TypeScript -- shared contract generation: Specta +- shared contract generation: Specta rc.25 with generated TypeScript bindings - async runtime: Tokio - HTTP client: reqwest - target operating system: Windows-first @@ -43,7 +43,7 @@ Confirmed repository posture: - Rust owns domain logic and secure state - TypeScript owns desktop composition and UI orchestration - Tauri commands are used as thin adapters -- frontend-visible bindings are generated from Rust types +- frontend-visible bindings are generated from Rust types and validated by the exporter ## Current Repository Shape @@ -52,7 +52,7 @@ Top-level areas: - `.github/` workflow runner, scripts, automation support - `src/` frontend app and shell - `src-tauri/` Rust backend, domain logic, config, commands -- `docs/` canonical English product documentation +- `docs/localization/en/` canonical English product documentation Current backend top-level areas: diff --git a/docs/localization/en/DEVELOPMENT_WORKFLOW.md b/docs/localization/en/DEVELOPMENT_WORKFLOW.md index 4e305cf9..8d90458a 100644 --- a/docs/localization/en/DEVELOPMENT_WORKFLOW.md +++ b/docs/localization/en/DEVELOPMENT_WORKFLOW.md @@ -25,7 +25,7 @@ The repository currently splits responsibilities this way: - `.github/scripts/workflow.mjs`: root task runner for setup, dev, build, release, and verification - `src/`: vanilla TypeScript frontend, shell, tests, and frontend tooling - `src-tauri/`: Rust backend, domain logic, secure state, and build pipeline -- `docs/en/`: current docs plus separate planning docs +- `docs/localization/en/`: current English docs plus separate planning docs Rust toolchain policy: @@ -144,6 +144,14 @@ Frontend bindings are generated from Rust. The intended workflow is: If bindings are out of date, `typecheck` and `verify` should fail instead of silently rewriting files. +Current Specta policy: + +- the binding stack is `specta` `2.0.0-rc.25`, `tauri-specta` `2.0.0-rc.25`, and `specta-typescript` `0.0.12` +- `serde_json::Value` is exported to TypeScript as `unknown` because it is dynamic JSON, not a stable typed contract +- Rust integer shapes that cross the Tauri JSON boundary are exported as TypeScript `number`; do not introduce frontend `bigint` unless the IPC path is changed deliberately +- floating-point DTO fields use lossless-float generation so metrics and settings remain typed as numbers on the frontend +- validate binding changes with `npm run bindings:check`, `npm run typecheck`, and the smallest relevant Rust/frontend tests + ## Git Hooks `npm run setup` configures `core.hooksPath` to use `.github/.husky`. diff --git a/docs/localization/ru/INTEGRATION_DEVELOPMENT.md b/docs/localization/ru/INTEGRATION_DEVELOPMENT.md index 4b21bf96..22752cde 100644 --- a/docs/localization/ru/INTEGRATION_DEVELOPMENT.md +++ b/docs/localization/ru/INTEGRATION_DEVELOPMENT.md @@ -29,7 +29,7 @@ npm run integration:doctor -- ./my-integration iframe-протокола custom settings UI. Главный контракт все равно описан в [Integration API](../en/INTEGRATION_API.md) -(англ., в `docs/en/INTEGRATION_API.md`): это локальный HTTP API лаунчера. +(англ., в `docs/localization/en/INTEGRATION_API.md`): это локальный HTTP API лаунчера. ## Структура интеграции From 864f82a5ff6bacb8044c962c1b6768033074d41e Mon Sep 17 00:00:00 2001 From: F0RLE Date: Fri, 15 May 2026 02:30:38 +0300 Subject: [PATCH 114/126] docs: update integration guide links --- .github/PULL_REQUEST_TEMPLATE.md | 2 +- src/shared/shell/ui/AppUiModuleFlow.test.ts | 2 +- src/shared/shell/ui/AppUiModuleFlow.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index cc3c4a22..78d5e5af 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -9,6 +9,6 @@ ## Checklist - [ ] The PR targets `nightly`, unless this is a release PR to `main`. -- [ ] User-facing behavior is documented in `README.md` or `docs/en` when needed. +- [ ] User-facing behavior is documented in `README.md` or `docs/localization/en` when needed. - [ ] Rust-exported frontend bindings were regenerated with `npm run bindings:sync` when Rust types changed. - [ ] No generated build output, cache files, local runtime data, or secrets are committed. diff --git a/src/shared/shell/ui/AppUiModuleFlow.test.ts b/src/shared/shell/ui/AppUiModuleFlow.test.ts index 6f32bacc..b6b87a4a 100644 --- a/src/shared/shell/ui/AppUiModuleFlow.test.ts +++ b/src/shared/shell/ui/AppUiModuleFlow.test.ts @@ -305,7 +305,7 @@ describe('AppUiModuleFlow', () => { await flow.handleIntegrationImport('guide'); expect(openExternalUrl).toHaveBeenCalledWith( - 'https://github.com/F0RLE/Axelate/blob/nightly/docs/en/CUSTOM_INTEGRATIONS.md', + 'https://github.com/F0RLE/Axelate/blob/nightly/docs/localization/en/CUSTOM_INTEGRATIONS.md', ); expect(platformService.importIntegrationPath).not.toHaveBeenCalled(); expect(platformService.importIntegrationUrl).not.toHaveBeenCalled(); diff --git a/src/shared/shell/ui/AppUiModuleFlow.ts b/src/shared/shell/ui/AppUiModuleFlow.ts index 14d9e261..3cb7fa91 100644 --- a/src/shared/shell/ui/AppUiModuleFlow.ts +++ b/src/shared/shell/ui/AppUiModuleFlow.ts @@ -9,7 +9,7 @@ import { open } from '@tauri-apps/plugin-dialog'; import { downloadDir } from '@tauri-apps/api/path'; const CUSTOM_INTEGRATION_GUIDE_URL = - 'https://github.com/F0RLE/Axelate/blob/nightly/docs/en/CUSTOM_INTEGRATIONS.md'; + 'https://github.com/F0RLE/Axelate/blob/nightly/docs/localization/en/CUSTOM_INTEGRATIONS.md'; type ModalBridge = { isAppSelectionOpen(): boolean; From dec2ef7a33e93e9d61ac1f0dc909012703b146e2 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Fri, 15 May 2026 02:39:37 +0300 Subject: [PATCH 115/126] refactor: align chat controller filename --- src/app/CoreChatFactory.ts | 2 +- src/app/CoreContainer.ts | 2 +- src/app/CoreLifecycleController.ts | 2 +- src/app/CoreRuntimeSupport.ts | 2 +- src/app/CoreUiFactory.ts | 2 +- src/app/events.ts | 2 +- src/features/chat/{chat.test.ts => ChatController.test.ts} | 2 +- src/features/chat/{chat.ts => ChatController.ts} | 2 +- src/features/chat/index.ts | 2 +- src/features/chat/services/ChatControllerFactory.ts | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) rename src/features/chat/{chat.test.ts => ChatController.test.ts} (99%) rename src/features/chat/{chat.ts => ChatController.ts} (99%) diff --git a/src/app/CoreChatFactory.ts b/src/app/CoreChatFactory.ts index 02b8177b..4b1d705e 100644 --- a/src/app/CoreChatFactory.ts +++ b/src/app/CoreChatFactory.ts @@ -1,5 +1,5 @@ import type { AIBridge } from '@/features/ai/services/AIBridge'; -import { ChatController } from '@/features/chat/chat'; +import { ChatController } from '@/features/chat/ChatController'; import type { I18nService } from '@/infrastructure/i18n/I18nService'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; import type { TauriProvider } from '@/infrastructure/tauri/TauriProvider'; diff --git a/src/app/CoreContainer.ts b/src/app/CoreContainer.ts index 39115ef8..e1e1e4fe 100644 --- a/src/app/CoreContainer.ts +++ b/src/app/CoreContainer.ts @@ -18,7 +18,7 @@ import type { ModuleSettingsService } from '@/shared/services/modules/ModuleSett import type { MonitoringService } from '@/features/monitoring/services/MonitoringService'; import type { ConsoleLogService } from '@/features/console/services/ConsoleLogService'; import type { SettingsService } from '@/features/settings/services/SettingsService'; -import type { ChatController } from '@/features/chat/chat'; +import type { ChatController } from '@/features/chat/ChatController'; import type { ModulePlatformService } from '@/shared/services/ModulePlatformService'; import type { AIBridge } from '@/features/ai/services/AIBridge'; import type { AppUI } from '@/shared/shell/AppUI'; diff --git a/src/app/CoreLifecycleController.ts b/src/app/CoreLifecycleController.ts index c793d33c..f997aaf0 100644 --- a/src/app/CoreLifecycleController.ts +++ b/src/app/CoreLifecycleController.ts @@ -1,5 +1,5 @@ import type { AIBridge } from '@/features/ai/services/AIBridge'; -import type { ChatController } from '@/features/chat/chat'; +import type { ChatController } from '@/features/chat/ChatController'; import type { DownloadUI } from '@/features/downloads/ui/DownloadUI'; import type { MonitoringService } from '@/features/monitoring/services/MonitoringService'; import type { SettingsService } from '@/features/settings/services/SettingsService'; diff --git a/src/app/CoreRuntimeSupport.ts b/src/app/CoreRuntimeSupport.ts index d1de0bec..028c22e1 100644 --- a/src/app/CoreRuntimeSupport.ts +++ b/src/app/CoreRuntimeSupport.ts @@ -1,4 +1,4 @@ -import type { ChatController } from '@/features/chat/chat'; +import type { ChatController } from '@/features/chat/ChatController'; import type { DownloadUI } from '@/features/downloads/ui/DownloadUI'; import type { I18nService } from '@/infrastructure/i18n/I18nService'; import type { I18nUI } from '@/infrastructure/i18n/I18nUI'; diff --git a/src/app/CoreUiFactory.ts b/src/app/CoreUiFactory.ts index 8d82b246..f40d6cc9 100644 --- a/src/app/CoreUiFactory.ts +++ b/src/app/CoreUiFactory.ts @@ -1,5 +1,5 @@ import type { AIBridge } from '@/features/ai/services/AIBridge'; -import type { ChatController } from '@/features/chat/chat'; +import type { ChatController } from '@/features/chat/ChatController'; import { DownloadUI } from '@/features/downloads/ui/DownloadUI'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; import { NavigationUI } from '@/infrastructure/navigation/NavigationUI'; diff --git a/src/app/events.ts b/src/app/events.ts index 09b635ef..75d1ca71 100644 --- a/src/app/events.ts +++ b/src/app/events.ts @@ -4,7 +4,7 @@ */ import type { AppUI } from '@/shared/shell/AppUI'; -import type { ChatController } from '@/features/chat/chat'; +import type { ChatController } from '@/features/chat/ChatController'; import type { DownloadUI } from '@/features/downloads/ui/DownloadUI'; import type { I18nUI } from '@/infrastructure/i18n/I18nUI'; import type { NavigationUI } from '@/infrastructure/navigation/NavigationUI'; diff --git a/src/features/chat/chat.test.ts b/src/features/chat/ChatController.test.ts similarity index 99% rename from src/features/chat/chat.test.ts rename to src/features/chat/ChatController.test.ts index b7ce809c..776a6514 100644 --- a/src/features/chat/chat.test.ts +++ b/src/features/chat/ChatController.test.ts @@ -99,7 +99,7 @@ vi.mock('./services/ChatFileHandler', () => ({ }, })); -import { ChatController } from './chat'; +import { ChatController } from './ChatController'; import { ChatContentHelper } from './services/ChatContentHelper'; import { ChatUiStateHelper } from './services/ChatUiStateHelper'; import { EventBus } from '@/shared/services/EventBus'; diff --git a/src/features/chat/chat.ts b/src/features/chat/ChatController.ts similarity index 99% rename from src/features/chat/chat.ts rename to src/features/chat/ChatController.ts index 21c9d208..3cf77a3c 100644 --- a/src/features/chat/chat.ts +++ b/src/features/chat/ChatController.ts @@ -1,5 +1,5 @@ /** - * @module chat/chat + * @module chat/ChatController * @description Main controller for the Chat module. * Composes VoiceController and FilePickerController for SRP compliance. */ diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts index 184d4bb5..e635af48 100644 --- a/src/features/chat/index.ts +++ b/src/features/chat/index.ts @@ -7,5 +7,5 @@ export type * from './types/chatTypes'; export * from './ui/ChatUI'; export * from './services/ChatService'; // Services -export { ChatController } from './chat'; +export { ChatController } from './ChatController'; export { ChatFileHandler } from './services/ChatFileHandler'; diff --git a/src/features/chat/services/ChatControllerFactory.ts b/src/features/chat/services/ChatControllerFactory.ts index 031b1fdf..722cee6e 100644 --- a/src/features/chat/services/ChatControllerFactory.ts +++ b/src/features/chat/services/ChatControllerFactory.ts @@ -8,7 +8,7 @@ import type { AIBridge } from '@/features/ai/services/AIBridge'; import type { I18nService } from '@/infrastructure/i18n/I18nService'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; import type { EventBus } from '@/shared/services/EventBus'; -import type { PendingChatRevealStore } from '../chat'; +import type { PendingChatRevealStore } from '../ChatController'; import type { ChatContent } from '@/features/ai/types/aiTypes'; import type { IApp } from '@/shared/types/coreTypes'; import type { IChatAttachment, IChatMessage, IChatResponse } from '../types/chatTypes'; From 7ed5e5d6b416b69820ac06485bf9c627d9f30fd9 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Fri, 15 May 2026 02:42:03 +0300 Subject: [PATCH 116/126] chore: remove local nvm pin --- .gitignore | 1 + .nvmrc | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 .nvmrc diff --git a/.gitignore b/.gitignore index 7887e78b..20c69365 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ /.history/ /.playwright-cli/ /build/ +/.nvmrc __pycache__/ *.py[cod] diff --git a/.nvmrc b/.nvmrc deleted file mode 100644 index 2aaedf99..00000000 --- a/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -26.1.0 From 7e3964463fd35f12124cb69d5553e7779e001c67 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Fri, 15 May 2026 02:46:59 +0300 Subject: [PATCH 117/126] feat: open links from actionable toasts --- src/shared/shell/AppUI.test.ts | 41 +++++++++++++++++++++++++++++++++- src/shared/shell/AppUI.ts | 29 +++++++++++++++++++++++- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/src/shared/shell/AppUI.test.ts b/src/shared/shell/AppUI.test.ts index 9f222f7c..91ddabb2 100644 --- a/src/shared/shell/AppUI.test.ts +++ b/src/shared/shell/AppUI.test.ts @@ -19,6 +19,7 @@ describe('AppUI lifecycle', () => { let openModuleSettingsMock: ReturnType; let stopAiProviderMock: ReturnType; let reloadCatalogMock: ReturnType Promise>>; + let openExternalUrlMock: ReturnType Promise>>; let getCatalogCategoryMock: ReturnType; let tracerMock: LoggerService; let platformServiceMock: { @@ -44,6 +45,7 @@ describe('AppUI lifecycle', () => { openModuleSettingsMock = vi.fn(); stopAiProviderMock = vi.fn(); reloadCatalogMock = vi.fn<() => Promise>().mockResolvedValue(undefined); + openExternalUrlMock = vi.fn<(_url: string) => Promise>().mockResolvedValue(undefined); getCatalogCategoryMock = vi.fn().mockReturnValue([]); tracerMock = { info: vi.fn(), @@ -122,7 +124,7 @@ describe('AppUI lifecycle', () => { reloadCatalog: async () => { await reloadCatalogMock(); }, - openExternalUrl: vi.fn().mockResolvedValue(undefined), + openExternalUrl: openExternalUrlMock, }, ); } @@ -168,6 +170,43 @@ describe('AppUI lifecycle', () => { expect(testEventBus.listenerCount('page:change')).toBe(initialCount); }); + it('opens the first URL when an error toast is clicked', () => { + appUI = createAppUI(); + + appUI.showToast( + 'Error 402: Payment Required. Please check your balance at https://openrouter.ai/settings/credits.', + 'error', + ); + + const toast = document.querySelector('.toast'); + if (!(toast instanceof HTMLElement)) { + throw new Error('Toast was not created'); + } + + expect(toast.classList.contains('toast--actionable')).toBe(true); + + toast.click(); + + expect(openExternalUrlMock).toHaveBeenCalledWith('https://openrouter.ai/settings/credits'); + }); + + it('keeps explicitly provided toast actions ahead of URL auto-actions', () => { + appUI = createAppUI(); + const onClick = vi.fn(); + + appUI.showToast('Open https://example.com', 'info', 3000, null, null, onClick); + + const toast = document.querySelector('.toast'); + if (!(toast instanceof HTMLElement)) { + throw new Error('Toast was not created'); + } + + toast.click(); + + expect(onClick).toHaveBeenCalledOnce(); + expect(openExternalUrlMock).not.toHaveBeenCalled(); + }); + it('should remove language-changed listener on destroy', () => { appUI = createAppUI(); const refreshSpy = vi.spyOn( diff --git a/src/shared/shell/AppUI.ts b/src/shared/shell/AppUI.ts index 0764bc1a..8f17999c 100644 --- a/src/shared/shell/AppUI.ts +++ b/src/shared/shell/AppUI.ts @@ -47,6 +47,8 @@ type AppUIDeps = { // Note: Window interface extensions are defined in core.ts export class AppUI { + private static readonly _urlPattern = /https?:\/\/[^\s<>"')\]]+/iu; + private readonly _chrome: AppUiChrome; private readonly _toastManager: ToastManager; private readonly _modalManager: ModalManager; @@ -266,7 +268,32 @@ export class AppUI { id: string | null = null, onClick: (() => void) | null = null, ): void { - this._toastManager.show(message, type, duration, title, id, onClick); + this._toastManager.show( + message, + type, + duration, + title, + id, + onClick ?? this._createLinkToastAction(message), + ); + } + + private _createLinkToastAction(message: string): (() => void) | null { + const url = AppUI._extractFirstUrl(message); + if (url === null) { + return null; + } + + return () => { + void this._deps.openExternalUrl(url).catch((error: unknown) => { + this._deps.tracer.warn(`[AppUI] Failed to open toast link: ${String(error)}`); + }); + }; + } + + private static _extractFirstUrl(message: string): string | null { + const match = AppUI._urlPattern.exec(message); + return match?.[0].replace(/[.,!?;:]+$/u, '') ?? null; } // --- Action Feedback --- From f847ab4a804c16ddf53d7e69dbfcd1d0039f0c10 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Fri, 15 May 2026 02:59:29 +0300 Subject: [PATCH 118/126] fix: roll back failed chat sends --- src-tauri/resources/config/local_modules.json | 2 +- src/features/chat/ChatController.ts | 5 +++ .../controllers/ChatSendController.test.ts | 40 ++++++++++++++++++- .../chat/controllers/ChatSendController.ts | 38 ++++++++++++++++++ .../chat/services/ChatControllerFactory.ts | 4 ++ src/shared/utils/customProviderSupport.ts | 4 +- 6 files changed, 89 insertions(+), 4 deletions(-) diff --git a/src-tauri/resources/config/local_modules.json b/src-tauri/resources/config/local_modules.json index 740c03b0..135f9a21 100644 --- a/src-tauri/resources/config/local_modules.json +++ b/src-tauri/resources/config/local_modules.json @@ -62,7 +62,7 @@ "descKey": "ui.launcher.engine.comfyui.desc", "name": "ComfyUI", "desc": "Node-based image workflow engine for maximum quality and control.", - "icon": "🧩", + "icon": "🕸️", "type": "local", "dlType": "release", "comingSoon": true, diff --git a/src/features/chat/ChatController.ts b/src/features/chat/ChatController.ts index 3cf77a3c..757dd3bf 100644 --- a/src/features/chat/ChatController.ts +++ b/src/features/chat/ChatController.ts @@ -345,6 +345,11 @@ export class ChatController { appendUserMessage: (text, attachments, tokens) => { this._ui.appendMessage('user', text, { attachments, tokens }); }, + rollbackOptimisticSend: (historySnapshot, inputText) => { + this._historyController.restoreLocalHistorySnapshot(historySnapshot); + this._inputCoordinator.restore(inputText); + void this._syncContextTokensFromHistory(historySnapshot); + }, getSelectedModule: (category) => deps.getSelectedModule(category), getPreferredAiCategory: () => deps.getPreferredAiCategory(), isForceImageGeneration: () => this._forceImageGeneration, diff --git a/src/features/chat/controllers/ChatSendController.test.ts b/src/features/chat/controllers/ChatSendController.test.ts index c4afe54d..4aa72fa6 100644 --- a/src/features/chat/controllers/ChatSendController.test.ts +++ b/src/features/chat/controllers/ChatSendController.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ChatSendController } from './ChatSendController'; +import type { IChatMessage } from '../types/chatTypes'; describe('ChatSendController', () => { const createController = () => { @@ -42,7 +43,7 @@ describe('ChatSendController', () => { service: { sendMessage, } as never, - getHistory: vi.fn(() => []), + getHistory: vi.fn<() => IChatMessage[]>(() => []), estimateTokens: vi.fn((text: string) => Promise.resolve(Math.ceil(text.length / 4))), pushUserMessage: vi.fn(), createStreamingHandle: vi.fn(() => streamingHandle), @@ -53,6 +54,7 @@ describe('ChatSendController', () => { clearInput: vi.fn(), addContextTokens: vi.fn(), appendUserMessage: vi.fn(), + rollbackOptimisticSend: vi.fn(), getSelectedModule: vi.fn(), getPreferredAiCategory: vi.fn(() => 'ai_text' as const), isForceImageGeneration: vi.fn(() => false), @@ -163,6 +165,42 @@ describe('ChatSendController', () => { expect(options.pushUserMessage).not.toHaveBeenCalled(); expect(sendMessage).not.toHaveBeenCalled(); expect(options.handleError).toHaveBeenCalled(); + expect(options.rollbackOptimisticSend).not.toHaveBeenCalled(); + }); + + it('rolls back the optimistic user turn when the provider returns an error', async () => { + const { controller, options, sendMessage, streamingHandle } = createController(); + const existingHistory: IChatMessage[] = [{ role: 'assistant', content: 'previous' }]; + options.getHistory.mockReturnValue(existingHistory); + sendMessage.mockResolvedValueOnce({ ok: false, error: 'provider failed' }); + const input = document.createElement('textarea'); + input.value = 'hello'; + + const result = await controller.sendChat(input); + + expect(result).toBe(true); + expect(options.handleResponse).toHaveBeenCalledWith( + { ok: false, error: 'provider failed' }, + streamingHandle, + null, + ); + expect(options.rollbackOptimisticSend).toHaveBeenCalledWith(existingHistory, 'hello'); + expect(options.handleError).not.toHaveBeenCalled(); + }); + + it('rolls back the optimistic user turn when sending throws after render', async () => { + const { controller, options, sendMessage } = createController(); + const existingHistory: IChatMessage[] = [{ role: 'assistant', content: 'previous' }]; + options.getHistory.mockReturnValue(existingHistory); + sendMessage.mockRejectedValueOnce(new Error('network failed')); + const input = document.createElement('textarea'); + input.value = 'hello'; + + const result = await controller.sendChat(input); + + expect(result).toBe(false); + expect(options.rollbackOptimisticSend).toHaveBeenCalledWith(existingHistory, 'hello'); + expect(options.handleError).toHaveBeenCalledWith(new Error('network failed')); }); it('uses distinct stream listener ids when sends start in the same millisecond', async () => { diff --git a/src/features/chat/controllers/ChatSendController.ts b/src/features/chat/controllers/ChatSendController.ts index af86083c..0afd6125 100644 --- a/src/features/chat/controllers/ChatSendController.ts +++ b/src/features/chat/controllers/ChatSendController.ts @@ -53,6 +53,7 @@ type ChatSendControllerOptions = { clearInput: () => void; addContextTokens: (count: number) => void; appendUserMessage: (text: string, attachments: IChatAttachment[], tokens: number) => void; + rollbackOptimisticSend: (historySnapshot: IChatMessage[], inputText: string) => void; getSelectedModule: (category: 'ai_text' | 'ai_image') => Partial | undefined; getPreferredAiCategory: () => 'ai_text' | 'ai_image'; isForceImageGeneration: () => boolean; @@ -180,6 +181,8 @@ export class ChatSendController { let streamingHandle: StreamingMessageHandle | null = null; let imageHandle: ImageGenerationHandle | null = null; let shouldStopImageEngine = false; + let optimisticUserAppended = false; + const historySnapshot = this._cloneHistory(this._options.getHistory()); try { const sendPlan = await this._sendFlow.prepare(text); @@ -218,6 +221,7 @@ export class ChatSendController { this._options.addContextTokens(sendPlan.tokenCount); this._options.appendUserMessage(text, sendPlan.attachments, sendPlan.tokenCount); this._options.pushUserMessage(sendPlan.userContent); + optimisticUserAppended = true; const ensureStreamingHandle = (): StreamingMessageHandle => { streamingHandle ??= this._options.createStreamingHandle(typingId); @@ -267,6 +271,9 @@ export class ChatSendController { } await this._options.handleResponse(response, streamingHandle, imageHandle); + if (!response.ok) { + this._rollbackOptimisticSend(historySnapshot, text); + } return true; } catch (error: unknown) { this._cleanupStreamingState(listenerId, typingId); @@ -276,6 +283,9 @@ export class ChatSendController { return false; } if (!this._wasDestroyed()) { + if (optimisticUserAppended) { + this._rollbackOptimisticSend(historySnapshot, text); + } this._options.handleError(error); } else { this._cancelStreamingHandle(streamingHandle); @@ -384,6 +394,34 @@ export class ChatSendController { return `${Date.now().toString(36)}-${this._sendSequence.toString(36)}`; } + private _rollbackOptimisticSend(historySnapshot: IChatMessage[], inputText: string): void { + this._options.rollbackOptimisticSend(this._cloneHistory(historySnapshot), inputText); + } + + private _cloneHistory(history: IChatMessage[]): IChatMessage[] { + return history.map((message) => ({ + ...message, + content: this._cloneContent(message.content), + })); + } + + private _cloneContent(content: IChatMessage['content']): IChatMessage['content'] { + if (typeof content === 'string') { + return content; + } + + return content.map((part) => { + if (part.type === 'image_url') { + return { + ...part, + image_url: { ...part.image_url }, + }; + } + + return { ...part }; + }); + } + public async tryAutoStartAi(prompt?: string): Promise { return await this._autoStartHelper.startSelectedModule(prompt); } diff --git a/src/features/chat/services/ChatControllerFactory.ts b/src/features/chat/services/ChatControllerFactory.ts index 722cee6e..562a9153 100644 --- a/src/features/chat/services/ChatControllerFactory.ts +++ b/src/features/chat/services/ChatControllerFactory.ts @@ -123,6 +123,7 @@ type ChatSendFactoryDeps = { clearInput: () => void; addContextTokens: (count: number) => void; appendUserMessage: (text: string, attachments: IChatAttachment[], tokens: number) => void; + rollbackOptimisticSend: (historySnapshot: IChatMessage[], inputText: string) => void; getSelectedModule: (category: 'ai_text' | 'ai_image') => Partial | undefined; getPreferredAiCategory: () => 'ai_text' | 'ai_image'; isForceImageGeneration: () => boolean; @@ -288,6 +289,9 @@ export class ChatControllerFactory { appendUserMessage: (text, attachments, tokens) => { deps.appendUserMessage(text, attachments, tokens); }, + rollbackOptimisticSend: (historySnapshot, inputText) => { + deps.rollbackOptimisticSend(historySnapshot, inputText); + }, getSelectedModule: (category) => deps.getSelectedModule(category), getPreferredAiCategory: () => deps.getPreferredAiCategory(), isForceImageGeneration: () => deps.isForceImageGeneration(), diff --git a/src/shared/utils/customProviderSupport.ts b/src/shared/utils/customProviderSupport.ts index b606006c..c45e87a7 100644 --- a/src/shared/utils/customProviderSupport.ts +++ b/src/shared/utils/customProviderSupport.ts @@ -23,7 +23,7 @@ const CUSTOM_PROVIDER_SPECS: readonly CustomProviderSpec[] = [ nameKey: 'ui.launcher.app.custom_text.name', desc: 'Use any OpenRouter text model by pasting its model ID manually.', descKey: 'ui.launcher.app.custom_text.desc', - icon: '🧩', + icon: '🔤', }, { id: CUSTOM_IMAGE_PROVIDER_ID, @@ -33,7 +33,7 @@ const CUSTOM_PROVIDER_SPECS: readonly CustomProviderSpec[] = [ nameKey: 'ui.launcher.app.custom_image.name', desc: 'Use any OpenRouter image model by pasting its model ID manually.', descKey: 'ui.launcher.app.custom_image.desc', - icon: '🎛️', + icon: '🪄', }, ]; From a0942ec6146ab2c5aaff8d904aca77e98177acb0 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Fri, 15 May 2026 12:17:29 +0300 Subject: [PATCH 119/126] fix: tighten chat and integration flows --- .../python-ai-tool/axelate-module.toml | 2 +- docs/localization/en/CUSTOM_INTEGRATIONS.md | 2 +- docs/localization/en/DEVELOPMENT_WORKFLOW.md | 27 +++++++++++++++++++ .../en/INTEGRATION_DEVELOPMENT.md | 2 +- .../ru/INTEGRATION_DEVELOPMENT.md | 2 +- .../zh/INTEGRATION_DEVELOPMENT.md | 2 +- .../controllers/ChatSendController.test.ts | 2 +- .../chat/controllers/ChatSendController.ts | 1 + src/shared/services/CatalogService.test.ts | 23 ++++++++++++++++ src/shared/services/CatalogService.ts | 8 ++++++ 10 files changed, 65 insertions(+), 6 deletions(-) diff --git a/docs/examples/integrations/python-ai-tool/axelate-module.toml b/docs/examples/integrations/python-ai-tool/axelate-module.toml index 781fd147..44d5e448 100644 --- a/docs/examples/integrations/python-ai-tool/axelate-module.toml +++ b/docs/examples/integrations/python-ai-tool/axelate-module.toml @@ -4,7 +4,7 @@ name = "Python AI Tool" version = "0.1.0" description = "Example integration that calls Axelate AI and stores settings." author = "Axelate" -category = "service" +type = "service" icon = "⚙" readme = "README.md" settings_ui = "settings-ui/index.html" diff --git a/docs/localization/en/CUSTOM_INTEGRATIONS.md b/docs/localization/en/CUSTOM_INTEGRATIONS.md index c21c6526..54fdbc3a 100644 --- a/docs/localization/en/CUSTOM_INTEGRATIONS.md +++ b/docs/localization/en/CUSTOM_INTEGRATIONS.md @@ -28,7 +28,7 @@ name = "My Integration" version = "0.1.0" description = "Connects my product to Axelate." author = "Your Name" -category = "service" +type = "service" icon = "⚙" readme = "README.md" settings_ui = "settings-ui/index.html" diff --git a/docs/localization/en/DEVELOPMENT_WORKFLOW.md b/docs/localization/en/DEVELOPMENT_WORKFLOW.md index 8d90458a..460132af 100644 --- a/docs/localization/en/DEVELOPMENT_WORKFLOW.md +++ b/docs/localization/en/DEVELOPMENT_WORKFLOW.md @@ -71,6 +71,33 @@ Recommended use: - `typecheck`: read-only binding validation plus TypeScript checks - `verify`: full local release gate before handoff, release work, or a pull request +## Manual Smoke Check + +Before merging a large PR to `nightly` or promoting `nightly` toward `main`, run +the smallest manual desktop pass that touches the real Tauri runtime: + +- startup and shutdown: launch with `npm run dev`, close the app, relaunch, and + confirm the previous UI state restores without stale modals or stuck loading + states +- chat text flow: send a normal message, cancel a streaming response, retry or + regenerate the last turn, and confirm provider errors appear as toasts rather + than persistent assistant messages +- chat image flow: send an image-generation request, cancel one in progress, and + confirm generated images restore from history with image actions intact +- provider settings: save, validate, remove, and relaunch after deleting an API + key; the key should stay removed after restart +- local modules: open version selection, download a CPU or GPU package, start, + stop, restart, remove, and confirm the selection modal refreshes after disk + changes +- integrations: import a folder or archive, run it, open settings, delete it + from the launcher, then delete or restore the folder externally and confirm + the integrations modal refreshes +- console and downloads: filter log levels, pause/resume/cancel an active + download, and confirm controls stay clickable under hover + +If one of these checks fails, fix the product flow first and only update docs if +the intended behavior changed. + Current dev-server behavior: - the Tauri dev flow expects the frontend on `http://localhost:1420` diff --git a/docs/localization/en/INTEGRATION_DEVELOPMENT.md b/docs/localization/en/INTEGRATION_DEVELOPMENT.md index 940ee6d2..cf125393 100644 --- a/docs/localization/en/INTEGRATION_DEVELOPMENT.md +++ b/docs/localization/en/INTEGRATION_DEVELOPMENT.md @@ -49,7 +49,7 @@ api_version = "1" id = "my-integration" name = "My Integration" version = "0.1.0" -category = "service" +type = "service" settings_ui = "settings-ui/index.html" [runtime] diff --git a/docs/localization/ru/INTEGRATION_DEVELOPMENT.md b/docs/localization/ru/INTEGRATION_DEVELOPMENT.md index 22752cde..26c175f8 100644 --- a/docs/localization/ru/INTEGRATION_DEVELOPMENT.md +++ b/docs/localization/ru/INTEGRATION_DEVELOPMENT.md @@ -50,7 +50,7 @@ api_version = "1" id = "my-integration" name = "My Integration" version = "0.1.0" -category = "service" +type = "service" settings_ui = "settings-ui/index.html" [runtime] diff --git a/docs/localization/zh/INTEGRATION_DEVELOPMENT.md b/docs/localization/zh/INTEGRATION_DEVELOPMENT.md index c6cad1fa..5ea32eb2 100644 --- a/docs/localization/zh/INTEGRATION_DEVELOPMENT.md +++ b/docs/localization/zh/INTEGRATION_DEVELOPMENT.md @@ -47,7 +47,7 @@ api_version = "1" id = "my-integration" name = "My Integration" version = "0.1.0" -category = "service" +type = "service" settings_ui = "settings-ui/index.html" [runtime] diff --git a/src/features/chat/controllers/ChatSendController.test.ts b/src/features/chat/controllers/ChatSendController.test.ts index 4aa72fa6..a770b443 100644 --- a/src/features/chat/controllers/ChatSendController.test.ts +++ b/src/features/chat/controllers/ChatSendController.test.ts @@ -178,7 +178,7 @@ describe('ChatSendController', () => { const result = await controller.sendChat(input); - expect(result).toBe(true); + expect(result).toBe(false); expect(options.handleResponse).toHaveBeenCalledWith( { ok: false, error: 'provider failed' }, streamingHandle, diff --git a/src/features/chat/controllers/ChatSendController.ts b/src/features/chat/controllers/ChatSendController.ts index 0afd6125..9253a54d 100644 --- a/src/features/chat/controllers/ChatSendController.ts +++ b/src/features/chat/controllers/ChatSendController.ts @@ -273,6 +273,7 @@ export class ChatSendController { await this._options.handleResponse(response, streamingHandle, imageHandle); if (!response.ok) { this._rollbackOptimisticSend(historySnapshot, text); + return false; } return true; } catch (error: unknown) { diff --git a/src/shared/services/CatalogService.test.ts b/src/shared/services/CatalogService.test.ts index 7df927e5..7fac3c3f 100644 --- a/src/shared/services/CatalogService.test.ts +++ b/src/shared/services/CatalogService.test.ts @@ -391,6 +391,29 @@ describe('CatalogService', () => { expect(service.getAppById('parser')).toBeUndefined(); expect(mockBridge.listen).toHaveBeenCalledTimes(1); }); + + it('should unsubscribe the integration watcher if destroy runs while binding is pending', async () => { + let resolveListen: (unlisten: () => void) => void = () => { + throw new Error('listen promise was not started'); + }; + const unlisten = vi.fn(); + + setupBridgeMocks(mockBridge, createMockAppConfig()); + mockBridge.isTauri.mockReturnValue(true); + mockBridge.listen.mockReturnValue( + new Promise((resolve) => { + resolveListen = resolve; + }), + ); + + const loadPromise = service.loadCatalog(); + service.destroy(); + resolveListen(unlisten); + await loadPromise; + await Promise.resolve(); + + expect(unlisten).toHaveBeenCalledTimes(1); + }); }); describe('_initGlobalExposures DEV branch (L29)', () => { diff --git a/src/shared/services/CatalogService.ts b/src/shared/services/CatalogService.ts index 8c168707..54251f4a 100644 --- a/src/shared/services/CatalogService.ts +++ b/src/shared/services/CatalogService.ts @@ -24,6 +24,7 @@ export class CatalogService { private readonly _appData: ICatalogData = { ai: [], services: [] }; private _integrationWatcherUnlisten: (() => void) | null = null; private _integrationWatcherBinding = false; + private _destroyed = false; constructor( private readonly _bridge: IBridge, @@ -34,6 +35,7 @@ export class CatalogService { * Asynchronously loads the application catalog from the Tauri backend. */ public async loadCatalog(): Promise { + this._destroyed = false; this._bindIntegrationWatcher(); const snapshot = await this._loadSnapshot(); @@ -57,6 +59,7 @@ export class CatalogService { } public destroy(): void { + this._destroyed = true; this._integrationWatcherUnlisten?.(); this._integrationWatcherUnlisten = null; this._integrationWatcherBinding = false; @@ -77,6 +80,11 @@ export class CatalogService { void this.loadCatalog(); }) .then((unlisten) => { + if (this._destroyed) { + unlisten(); + this._integrationWatcherBinding = false; + return; + } this._integrationWatcherUnlisten = unlisten; }) .catch((error: unknown) => { From 556b2ca8a953ba1dcd10dfaab80a43fb2f47fe7a Mon Sep 17 00:00:00 2001 From: F0RLE Date: Fri, 15 May 2026 12:24:35 +0300 Subject: [PATCH 120/126] fix: rely on backend module install state --- src/shared/services/ModuleService.test.ts | 11 +++++----- src/shared/services/ModuleService.ts | 8 ------- src/shared/shell/AppUI.ts | 2 -- .../shell/ui/ModuleCardRenderer.test.ts | 21 ------------------- src/shared/shell/ui/ModuleCardRenderer.ts | 1 - 5 files changed, 6 insertions(+), 37 deletions(-) diff --git a/src/shared/services/ModuleService.test.ts b/src/shared/services/ModuleService.test.ts index 5191374c..a317ab22 100644 --- a/src/shared/services/ModuleService.test.ts +++ b/src/shared/services/ModuleService.test.ts @@ -367,7 +367,7 @@ describe('ModuleService', () => { ).rejects.toThrow('Import available only in desktop app'); }); - it('should clear deleted-module cache after importing the same module id', async () => { + it('should ask backend after importing the same module id', async () => { mocks.invokeSafe.mockResolvedValueOnce({ status: 'ok', data: null }); await moduleService.deleteModule('restored-module'); @@ -628,13 +628,14 @@ describe('ModuleService', () => { expect(result).toBe(false); }); - it('should return false for deleted module', async () => { - // First delete the module + it('should ask backend after deleting a module so external restores are detected', async () => { mocks.invokeSafe.mockResolvedValueOnce({ status: 'ok' }); await moduleService.deleteModule('del-mod'); - // checkInstalled should short-circuit + + mocks.invokeSafe.mockResolvedValueOnce({ status: 'ok', data: true }); const result = await moduleService.checkInstalled('del-mod'); - expect(result).toBe(false); + expect(result).toBe(true); + expect(mocks.commands.checkModuleInstalled).toHaveBeenCalledWith('del-mod'); }); }); diff --git a/src/shared/services/ModuleService.ts b/src/shared/services/ModuleService.ts index 2b364917..462a8dd7 100644 --- a/src/shared/services/ModuleService.ts +++ b/src/shared/services/ModuleService.ts @@ -18,7 +18,6 @@ export type DownloadModuleOutcome = 'completed' | 'paused' | 'cancelled'; export class ModuleService { private readonly _downloadState: Record = {}; - private readonly _deletedModules = new Set(); private readonly _lastLoggedDownloadPhase = new Map(); private _downloadProgressUnlisten: (() => void) | null = null; private _initialized = false; @@ -73,7 +72,6 @@ export class ModuleService { */ public async checkInstalled(moduleId: string): Promise { if (!this._bridge.isTauri()) return false; - if (this._deletedModules.has(moduleId)) return false; try { // Updated to use new API layer @@ -131,7 +129,6 @@ export class ModuleService { } try { - this._deletedModules.delete(moduleId); // Sanitize expectedHash: pass null if empty string or undefined to ensure rust gets None const hashToPass = expectedHash !== undefined && expectedHash.trim() !== '' ? expectedHash : null; @@ -198,7 +195,6 @@ export class ModuleService { if (result.status === 'error') { throw new Error(result.error.message); } - this._deletedModules.delete(result.data); return result.data; } @@ -211,7 +207,6 @@ export class ModuleService { if (result.status === 'error') { throw new Error(result.error.message); } - this._deletedModules.delete(result.data); return result.data; } @@ -224,7 +219,6 @@ export class ModuleService { if (result.status === 'error') { throw new Error(result.error.message); } - this._deletedModules.delete(result.data); return result.data; } @@ -237,7 +231,6 @@ export class ModuleService { if (result.status === 'error') { throw new Error(result.error.message); } - this._deletedModules.delete(result.data); return result.data; } @@ -318,7 +311,6 @@ export class ModuleService { return false; } - this._deletedModules.add(moduleId); delete this._downloadState[moduleId]; return true; } catch (e) { diff --git a/src/shared/shell/AppUI.ts b/src/shared/shell/AppUI.ts index 8f17999c..7a2ccb1a 100644 --- a/src/shared/shell/AppUI.ts +++ b/src/shared/shell/AppUI.ts @@ -81,8 +81,6 @@ export class AppUI { this._chrome = new AppUiChrome(this._translate, this._deps.tracer); this._toastManager = new ToastManager(); this._cardRenderer = new ModuleCardRenderer({ - checkInstalled: async (moduleId) => - await this._platformService.checkInstalled(moduleId), translate: this._translate, tracer: this._deps.tracer, openModuleSettings: (app) => { diff --git a/src/shared/shell/ui/ModuleCardRenderer.test.ts b/src/shared/shell/ui/ModuleCardRenderer.test.ts index 4507370c..2b53bcfc 100644 --- a/src/shared/shell/ui/ModuleCardRenderer.test.ts +++ b/src/shared/shell/ui/ModuleCardRenderer.test.ts @@ -11,12 +11,10 @@ import { ModuleCardRenderer } from './ModuleCardRenderer'; describe('ModuleCardRenderer', () => { let renderer: ModuleCardRenderer; - let checkInstalled: (moduleId: string) => Promise; let openModuleSettingsSpy: ReturnType; let tracer: LoggerService; beforeEach(() => { - checkInstalled = vi.fn<(_: string) => Promise>(() => Promise.resolve(false)); openModuleSettingsSpy = vi.fn(); tracer = { info: vi.fn(), @@ -25,7 +23,6 @@ describe('ModuleCardRenderer', () => { debug: vi.fn(), } as unknown as LoggerService; renderer = new ModuleCardRenderer({ - checkInstalled, translate: (key, fallback) => `${key}:${fallback}`, tracer, openModuleSettings: (app) => { @@ -104,7 +101,6 @@ describe('ModuleCardRenderer', () => { it('restores active download state when a card is recreated', () => { renderer = new ModuleCardRenderer({ - checkInstalled, translate: (key, fallback) => `${key}:${fallback}`, tracer, getDownloadState: (moduleId) => @@ -292,23 +288,6 @@ describe('ModuleCardRenderer', () => { expect(openModuleSettingsSpy).not.toHaveBeenCalled(); }); - it('does not run per-card install checks while rendering the modal', async () => { - const onClick = vi.fn(); - (checkInstalled as ReturnType).mockResolvedValue(true); - - renderer.createSelectionCard( - { id: 'late-install', name: 'Later', desc: 'Desc', installed: false } as never, - 'services', - false, - onClick, - ); - - await Promise.resolve(); - await Promise.resolve(); - - expect(checkInstalled).not.toHaveBeenCalled(); - }); - it('updates dashboard card content and marks cards as installed', () => { const card = document.createElement('div'); card.innerHTML = ` diff --git a/src/shared/shell/ui/ModuleCardRenderer.ts b/src/shared/shell/ui/ModuleCardRenderer.ts index 2cc842f0..f487dd3a 100644 --- a/src/shared/shell/ui/ModuleCardRenderer.ts +++ b/src/shared/shell/ui/ModuleCardRenderer.ts @@ -15,7 +15,6 @@ import { import { ModuleCardPresentationHelper } from './ModuleCardPresentationHelper'; type ModuleCardRendererDeps = { - checkInstalled?: (moduleId: string) => Promise; translate?: (key: string, fallback: string) => string; openModuleSettings?: (app: IApp) => void; getDownloadState?: (moduleId: string) => IModuleDownloadState | undefined; From 57330fcb25b0a65f803a5dcf000b0c26ea9b9a09 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Fri, 15 May 2026 12:29:55 +0300 Subject: [PATCH 121/126] fix: refresh catalog after module downloads --- src/shared/shell/ui/AppUiModuleFlow.test.ts | 28 ++++++++++++++++--- src/shared/shell/ui/AppUiModuleFlow.ts | 14 ++++++++-- .../shell/ui/ModuleCardDownloadProgress.ts | 3 +- .../shell/ui/ModuleCardRenderer.test.ts | 2 +- 4 files changed, 38 insertions(+), 9 deletions(-) diff --git a/src/shared/shell/ui/AppUiModuleFlow.test.ts b/src/shared/shell/ui/AppUiModuleFlow.test.ts index b6b87a4a..71adff71 100644 --- a/src/shared/shell/ui/AppUiModuleFlow.test.ts +++ b/src/shared/shell/ui/AppUiModuleFlow.test.ts @@ -119,7 +119,7 @@ describe('AppUiModuleFlow', () => { ); }); - it('refreshes modal selection after successful download', () => { + it('reloads catalog and refreshes modal selection after successful download', async () => { const app = { id: 'svc', name: 'Service', installed: false } as IApp; const card = document.createElement('div'); card.className = 'app-card'; @@ -130,27 +130,47 @@ describe('AppUiModuleFlow', () => { getSelectedAppId.mockReturnValue('svc'); modalManager.isViewingCategory.mockReturnValue(true); - flow.onModalDownloadSuccess(btn, app, 'services'); + await flow.onModalDownloadSuccess(btn, app, 'services'); expect(app.installed).toBe(true); expect(btn.classList.contains('downloading')).toBe(false); expect(markSlotCardAsInstalled).toHaveBeenCalledWith(card, app); + expect(reloadCatalog).toHaveBeenCalledOnce(); expect(modalManager.refreshCurrentSelection).toHaveBeenCalledWith([app], 'svc'); }); - it('does not refresh modal selection after download success in another category', () => { + it('does not refresh modal selection after download success in another category', async () => { const app = { id: 'svc', name: 'Service', installed: false } as IApp; const btn = document.createElement('button'); btn.className = 'download-btn downloading indeterminate'; modalManager.isViewingCategory.mockReturnValue(false); - flow.onModalDownloadSuccess(btn, app, 'services'); + await flow.onModalDownloadSuccess(btn, app, 'services'); expect(app.installed).toBe(true); expect(btn.classList.contains('downloading')).toBe(false); + expect(reloadCatalog).toHaveBeenCalledOnce(); expect(modalManager.refreshCurrentSelection).not.toHaveBeenCalled(); }); + it('keeps successful download UI when catalog refresh fails', async () => { + const app = { id: 'svc', name: 'Service', installed: false } as IApp; + const card = document.createElement('div'); + card.className = 'app-card'; + const btn = document.createElement('button'); + btn.className = 'download-btn downloading indeterminate'; + card.appendChild(btn); + reloadCatalog.mockRejectedValueOnce(new Error('catalog unavailable')); + modalManager.isViewingCategory.mockReturnValue(true); + + await flow.onModalDownloadSuccess(btn, app, 'services'); + + expect(app.installed).toBe(true); + expect(markSlotCardAsInstalled).toHaveBeenCalledWith(card, app); + expect(showToast).not.toHaveBeenCalled(); + expect(modalManager.refreshCurrentSelection).toHaveBeenCalled(); + }); + it('resets download button and shows a toast after download errors', () => { const btn = document.createElement('button'); btn.className = 'download-btn downloading indeterminate'; diff --git a/src/shared/shell/ui/AppUiModuleFlow.ts b/src/shared/shell/ui/AppUiModuleFlow.ts index 3cb7fa91..7092f820 100644 --- a/src/shared/shell/ui/AppUiModuleFlow.ts +++ b/src/shared/shell/ui/AppUiModuleFlow.ts @@ -91,7 +91,7 @@ export class AppUiModuleFlow { this.onModalDownloadInterrupted(activeButton, outcome); return; } - this.onModalDownloadSuccess(activeButton, app, category); + await this.onModalDownloadSuccess(activeButton, app, category); } catch (err: unknown) { this.onModalDownloadError(activeButton, err); } @@ -166,7 +166,11 @@ export class AppUiModuleFlow { } } - public onModalDownloadSuccess(btn: HTMLElement | null, app: IApp, category: string): void { + public async onModalDownloadSuccess( + btn: HTMLElement | null, + app: IApp, + category: string, + ): Promise { app.installed = true; this.resetDownloadButton(btn); @@ -177,6 +181,12 @@ export class AppUiModuleFlow { this._deps.markSlotCardAsInstalled(card, app); } + try { + await this._deps.reloadCatalog(); + } catch (error) { + this._deps.tracer.warn('[AppUI] Catalog refresh after download failed:', error); + } + if (this._deps.modalManager.isViewingCategory(category)) { this._deps.modalManager.refreshCurrentSelection( this._deps.getCatalogApps(this._toRawCategory(category)), diff --git a/src/shared/shell/ui/ModuleCardDownloadProgress.ts b/src/shared/shell/ui/ModuleCardDownloadProgress.ts index 3a5d0614..e19bf0d4 100644 --- a/src/shared/shell/ui/ModuleCardDownloadProgress.ts +++ b/src/shared/shell/ui/ModuleCardDownloadProgress.ts @@ -28,8 +28,7 @@ export function setModuleCardDownloadProgress( const label = btn.querySelector('.download-label'); if (label) { - const cardEl = label.closest('.app-card'); - const extractingLabel = (cardEl?.dataset['translateExtracting'] ?? 'Extracting').replace( + const extractingLabel = (btn.dataset['translateExtracting'] ?? 'Extracting').replace( /\.+$/, '', ); diff --git a/src/shared/shell/ui/ModuleCardRenderer.test.ts b/src/shared/shell/ui/ModuleCardRenderer.test.ts index 2b53bcfc..5d981c2a 100644 --- a/src/shared/shell/ui/ModuleCardRenderer.test.ts +++ b/src/shared/shell/ui/ModuleCardRenderer.test.ts @@ -83,7 +83,7 @@ describe('ModuleCardRenderer', () => { ModuleCardRenderer.setDownloadProgress(card, 73, 'extracting'); expect((card.querySelector('.download-label') as HTMLElement).textContent).toContain( - 'Extracting', + 'ui.launcher.module.extracting:Extracting', ); expect((card.querySelector('.download-pct') as HTMLElement).textContent).toBe('73%'); expect(card.querySelector('.download-btn')?.classList.contains('indeterminate')).toBe( From b15756ed2a2188fd4815ea1ad647df18ddc76b0d Mon Sep 17 00:00:00 2001 From: F0RLE Date: Fri, 15 May 2026 12:33:21 +0300 Subject: [PATCH 122/126] fix: reconcile missing ai selections --- src/shared/shell/AppUI.test.ts | 27 +++++++++++++++++++++++++++ src/shared/shell/AppUI.ts | 2 ++ 2 files changed, 29 insertions(+) diff --git a/src/shared/shell/AppUI.test.ts b/src/shared/shell/AppUI.test.ts index 91ddabb2..4ceee4b4 100644 --- a/src/shared/shell/AppUI.test.ts +++ b/src/shared/shell/AppUI.test.ts @@ -904,6 +904,33 @@ describe('AppUI lifecycle', () => { ); }); + it('should clear selected AI slots when their module disappears from catalog', () => { + appUI = createAppUI(); + document.body.innerHTML = ` +
    +
    +
    +
    +
    + `; + const missingApp = { + id: 'local-text-engine', + name: 'Local Text Engine', + installed: true, + type: 'local', + capability: 'text', + } as IApp; + appUI.updateModuleCard('ai_text', missingApp); + getCatalogCategoryMock.mockImplementation((category: string) => + category === 'ai' ? [] : [], + ); + + globalThis.dispatchEvent(new Event('catalog-loaded')); + + expect(uiStateMocks.removeSelectedModule).toHaveBeenCalledWith('ai_text'); + expect(document.getElementById('ai-module-card')?.classList.contains('empty')).toBe(true); + }); + it('should resolve app by id from injected catalog resolver', () => { appUI = createAppUI(); getCatalogCategoryMock.mockImplementation((category: string) => diff --git a/src/shared/shell/AppUI.ts b/src/shared/shell/AppUI.ts index 7a2ccb1a..aacfa615 100644 --- a/src/shared/shell/AppUI.ts +++ b/src/shared/shell/AppUI.ts @@ -483,6 +483,8 @@ export class AppUI { } private _reconcileSelectionsWithCatalog(): void { + this._clearMissingSelection(CategoryKey.AI_TEXT); + this._clearMissingSelection(CategoryKey.AI_IMAGE); this._clearMissingSelection(CategoryKey.SERVICES); } From 98cf6c4c65d71df773f46c0b79fa59ddf2ecaeed Mon Sep 17 00:00:00 2001 From: F0RLE Date: Fri, 15 May 2026 12:48:04 +0300 Subject: [PATCH 123/126] fix: harden integration scaffold client --- .github/scripts/integration/scaffold.mjs | 39 ++++++++++++++++-------- 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/.github/scripts/integration/scaffold.mjs b/.github/scripts/integration/scaffold.mjs index 792a27b5..b8cd8b6c 100644 --- a/.github/scripts/integration/scaffold.mjs +++ b/.github/scripts/integration/scaffold.mjs @@ -116,16 +116,27 @@ import urllib.error import urllib.request +def required_env(name: str) -> str: + value = os.environ.get(name) + if value is None or value.strip() == "": + raise RuntimeError(f"Missing required environment variable: {name}") + return value + + def validate_base_url(value: str) -> str: parsed = urllib.parse.urlparse(value) - if parsed.scheme not in {"http", "https"}: - raise ValueError("AXELATE_HTTP_API_BASE must use http or https.") + if parsed.scheme not in {"http", "https"} or parsed.hostname not in { + "localhost", + "127.0.0.1", + "::1", + }: + raise ValueError("AXELATE_HTTP_API_BASE must be an http(s) loopback URL.") return value.rstrip("/") -BASE_URL = validate_base_url(os.environ["AXELATE_HTTP_API_BASE"]) -TOKEN = os.environ["AXELATE_HTTP_API_TOKEN"] -MODULE_ID = os.environ["AXELATE_MODULE_ID"] +BASE_URL = validate_base_url(required_env("AXELATE_HTTP_API_BASE")) +TOKEN = required_env("AXELATE_HTTP_API_TOKEN") +MODULE_ID = required_env("AXELATE_MODULE_ID") MODULE_PATH_ID = urllib.parse.quote(MODULE_ID) @@ -140,8 +151,16 @@ def request(method: str, path: str, payload: dict | None = None) -> dict: "Content-Type": "application/json", }, ) - with urllib.request.urlopen(req, timeout=120) as response: - return json.loads(response.read().decode("utf-8")) + try: + with urllib.request.urlopen(req, timeout=120) as response: + text = response.read().decode("utf-8") + except urllib.error.HTTPError as error: + body = error.read().decode("utf-8", errors="replace") + raise RuntimeError(f"{method} {path} failed with HTTP {error.code}: {body}") from error + except urllib.error.URLError as error: + raise RuntimeError(f"{method} {path} failed: {error.reason}") from error + + return json.loads(text) if text.strip() else {} def main() -> None: @@ -157,11 +176,7 @@ def main() -> None: if __name__ == "__main__": - try: - main() - except urllib.error.HTTPError as error: - print(error.read().decode("utf-8")) - raise + main() `, ); From b4a63b22ab09b5c155735963837f026e7f917554 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Fri, 15 May 2026 12:54:52 +0300 Subject: [PATCH 124/126] docs: tighten integration developer flow --- .github/scripts/integration/scaffold.mjs | 2 +- .../integrations/python-ai-tool/src/main.py | 36 ++++++++++++------- .../sdk/javascript/axelate-client.mjs | 29 +++++++++++---- docs/localization/en/CUSTOM_INTEGRATIONS.md | 6 ++++ docs/localization/en/INTEGRATION_API.md | 7 ++-- .../en/INTEGRATION_DEVELOPMENT.md | 6 ++++ .../ru/INTEGRATION_DEVELOPMENT.md | 6 ++++ .../zh/INTEGRATION_DEVELOPMENT.md | 8 +++-- 8 files changed, 76 insertions(+), 24 deletions(-) diff --git a/.github/scripts/integration/scaffold.mjs b/.github/scripts/integration/scaffold.mjs index b8cd8b6c..ec7b0e7a 100644 --- a/.github/scripts/integration/scaffold.mjs +++ b/.github/scripts/integration/scaffold.mjs @@ -137,7 +137,7 @@ def validate_base_url(value: str) -> str: BASE_URL = validate_base_url(required_env("AXELATE_HTTP_API_BASE")) TOKEN = required_env("AXELATE_HTTP_API_TOKEN") MODULE_ID = required_env("AXELATE_MODULE_ID") -MODULE_PATH_ID = urllib.parse.quote(MODULE_ID) +MODULE_PATH_ID = urllib.parse.quote(MODULE_ID, safe="") def request(method: str, path: str, payload: dict | None = None) -> dict: diff --git a/docs/examples/integrations/python-ai-tool/src/main.py b/docs/examples/integrations/python-ai-tool/src/main.py index 2f97ab64..991d587d 100644 --- a/docs/examples/integrations/python-ai-tool/src/main.py +++ b/docs/examples/integrations/python-ai-tool/src/main.py @@ -6,17 +6,26 @@ import urllib.parse import urllib.request +LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"} + + +def required_env(name: str) -> str: + value = os.environ.get(name) + if value is None or value.strip() == "": + raise RuntimeError(f"Missing required Axelate integration env var: {name}") + return value + def validate_base_url(value: str) -> str: parsed = urllib.parse.urlparse(value) - if parsed.scheme not in {"http", "https"}: - raise ValueError("AXELATE_HTTP_API_BASE must use http or https.") + if parsed.scheme not in {"http", "https"} or parsed.hostname not in LOOPBACK_HOSTS: + raise ValueError("AXELATE_HTTP_API_BASE must be an http(s) loopback URL.") return value.rstrip("/") -BASE_URL = validate_base_url(os.environ["AXELATE_HTTP_API_BASE"]) -TOKEN = os.environ["AXELATE_HTTP_API_TOKEN"] -MODULE_ID = os.environ["AXELATE_MODULE_ID"] +BASE_URL = validate_base_url(required_env("AXELATE_HTTP_API_BASE")) +TOKEN = required_env("AXELATE_HTTP_API_TOKEN") +MODULE_ID = required_env("AXELATE_MODULE_ID") MODULE_PATH_ID = urllib.parse.quote(MODULE_ID, safe="") @@ -31,8 +40,15 @@ def request(method: str, path: str, payload: dict | None = None) -> dict: "Content-Type": "application/json", }, ) - with urllib.request.urlopen(req, timeout=120) as response: - return json.loads(response.read().decode("utf-8")) + try: + with urllib.request.urlopen(req, timeout=120) as response: + body = response.read().decode("utf-8") + return json.loads(body) if body.strip() else {} + except urllib.error.HTTPError as error: + body = error.read().decode("utf-8", errors="replace") + raise RuntimeError(f"{method} {path} failed with HTTP {error.code}: {body}") from error + except urllib.error.URLError as error: + raise RuntimeError(f"{method} {path} failed: {error.reason}") from error def main() -> None: @@ -58,8 +74,4 @@ def main() -> None: if __name__ == "__main__": - try: - main() - except urllib.error.HTTPError as error: - print(error.read().decode("utf-8")) - raise + main() diff --git a/docs/examples/sdk/javascript/axelate-client.mjs b/docs/examples/sdk/javascript/axelate-client.mjs index abfcc922..0b5916a2 100644 --- a/docs/examples/sdk/javascript/axelate-client.mjs +++ b/docs/examples/sdk/javascript/axelate-client.mjs @@ -1,12 +1,8 @@ export class AxelateClient { constructor(env = globalThis.process?.env ?? {}) { - this.baseUrl = String(env.AXELATE_HTTP_API_BASE ?? '').replace(/\/$/u, ''); - this.token = String(env.AXELATE_HTTP_API_TOKEN ?? ''); - this.moduleId = String(env.AXELATE_MODULE_ID ?? ''); - - if (!this.baseUrl || !this.token || !this.moduleId) { - throw new Error('Axelate integration environment is missing.'); - } + this.baseUrl = validateBaseUrl(requiredEnv(env, 'AXELATE_HTTP_API_BASE')).replace(/\/$/u, ''); + this.token = requiredEnv(env, 'AXELATE_HTTP_API_TOKEN'); + this.moduleId = requiredEnv(env, 'AXELATE_MODULE_ID'); } async request(method, path, payload) { @@ -62,6 +58,25 @@ export class AxelateClient { } } +function requiredEnv(env, name) { + const value = String(env[name] ?? ''); + if (value.trim().length === 0) { + throw new Error(`Missing required Axelate integration env var: ${name}`); + } + + return value; +} + +function validateBaseUrl(value) { + const url = new URL(value); + const allowedHosts = new Set(['localhost', '127.0.0.1', '[::1]']); + if (!['http:', 'https:'].includes(url.protocol) || !allowedHosts.has(url.hostname)) { + throw new Error('AXELATE_HTTP_API_BASE must be an http(s) loopback URL.'); + } + + return value; +} + async function readResponseBody(response) { if (response.status === 204) { return {}; diff --git a/docs/localization/en/CUSTOM_INTEGRATIONS.md b/docs/localization/en/CUSTOM_INTEGRATIONS.md index 54fdbc3a..2d41a57a 100644 --- a/docs/localization/en/CUSTOM_INTEGRATIONS.md +++ b/docs/localization/en/CUSTOM_INTEGRATIONS.md @@ -5,6 +5,12 @@ launcher can import a folder, a local archive, or a GitHub repository/archive UR Archives may be `.zip`, `.tar.gz`, `.tgz`, or `.7z`. For a guided development flow, use [Integration Development](INTEGRATION_DEVELOPMENT.md). +For a new integration, prefer the scaffold first: + +```bash +npm run integration:new -- ./my-integration --id my-integration --name "My Integration" +npm run integration:doctor -- ./my-integration +``` ## Minimal Layout diff --git a/docs/localization/en/INTEGRATION_API.md b/docs/localization/en/INTEGRATION_API.md index a44be017..4ae458a8 100644 --- a/docs/localization/en/INTEGRATION_API.md +++ b/docs/localization/en/INTEGRATION_API.md @@ -125,7 +125,8 @@ const response = await fetch(`${baseUrl}/v1/ai/text`, { const result = await response.json(); -const settingsResponse = await fetch(`${baseUrl}/v1/modules/${moduleId}/settings`, { +const modulePathId = encodeURIComponent(moduleId); +const settingsResponse = await fetch(`${baseUrl}/v1/modules/${modulePathId}/settings`, { headers: { Authorization: `Bearer ${token}` }, }); const { settings } = await settingsResponse.json(); @@ -135,11 +136,13 @@ const { settings } = await settingsResponse.json(); ```python import os +import urllib.parse import requests base_url = os.environ["AXELATE_HTTP_API_BASE"] token = os.environ["AXELATE_HTTP_API_TOKEN"] module_id = os.environ["AXELATE_MODULE_ID"] +module_path_id = urllib.parse.quote(module_id, safe="") headers = {"Authorization": f"Bearer {token}"} response = requests.post( @@ -154,7 +157,7 @@ response = requests.post( result = response.json() settings = requests.get( - f"{base_url}/v1/modules/{module_id}/settings", + f"{base_url}/v1/modules/{module_path_id}/settings", headers=headers, timeout=30, ).json()["settings"] diff --git a/docs/localization/en/INTEGRATION_DEVELOPMENT.md b/docs/localization/en/INTEGRATION_DEVELOPMENT.md index cf125393..3a5c5bdd 100644 --- a/docs/localization/en/INTEGRATION_DEVELOPMENT.md +++ b/docs/localization/en/INTEGRATION_DEVELOPMENT.md @@ -14,6 +14,12 @@ npm run integration:doctor -- ./my-integration Then import the folder in the launcher integrations screen and launch it. +For an existing app, keep the app code in your integration folder, declare its +entrypoint in `axelate-module.toml`, and use the launcher-provided environment +variables at process start. Store generated state in +`AXELATE_MODULE_RUNTIME_DIR`, call `/v1/ai/text` or `/v1/ai/image` through the +local API, then run `integration:doctor` before importing the folder. + ## Repository Helpers - `npm run integration:new -- ` creates a minimal Python integration. diff --git a/docs/localization/ru/INTEGRATION_DEVELOPMENT.md b/docs/localization/ru/INTEGRATION_DEVELOPMENT.md index 26c175f8..15982dd5 100644 --- a/docs/localization/ru/INTEGRATION_DEVELOPMENT.md +++ b/docs/localization/ru/INTEGRATION_DEVELOPMENT.md @@ -15,6 +15,12 @@ npm run integration:doctor -- ./my-integration После этого импортируй папку на странице интеграций в лаунчере и запусти карточку. +Для существующего приложения оставь код приложения внутри папки интеграции, +укажи entrypoint в `axelate-module.toml` и читай переменные окружения лаунчера +при старте процесса. Сгенерированное состояние пиши в +`AXELATE_MODULE_RUNTIME_DIR`, AI вызывай через `/v1/ai/text` или `/v1/ai/image`, +после этого запускай `integration:doctor` и импортируй папку. + ## Инструменты в репозитории - `npm run integration:new -- ` создает минимальную Python-интеграцию. diff --git a/docs/localization/zh/INTEGRATION_DEVELOPMENT.md b/docs/localization/zh/INTEGRATION_DEVELOPMENT.md index 5ea32eb2..11c1446a 100644 --- a/docs/localization/zh/INTEGRATION_DEVELOPMENT.md +++ b/docs/localization/zh/INTEGRATION_DEVELOPMENT.md @@ -14,6 +14,11 @@ npm run integration:doctor -- ./my-integration 然后在 Axelate 的 Integrations 页面导入这个文件夹并启动卡片。 +如果要接入已有应用,把应用代码放在集成目录里,在 `axelate-module.toml` +中声明入口文件,并在进程启动时读取启动器提供的环境变量。生成的状态写入 +`AXELATE_MODULE_RUNTIME_DIR`,通过 `/v1/ai/text` 或 `/v1/ai/image` 调用 AI, +然后运行 `integration:doctor`,再导入该文件夹。 + ## 仓库工具 - `npm run integration:new -- ` 创建一个最小 Python 集成。 @@ -124,5 +129,4 @@ iframe 协议: ## 信任规则 -导入的集成是用户选择运行的本地代码。目前它们不是经过 review、签名或 sandbox -隔离的 packages。 +导入的集成是用户选择运行的本地代码。目前它们不是经过审查、签名或沙箱隔离的包。 From 89b65f0cf59b1f21f8af8f30ef4189648c7fff87 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Fri, 15 May 2026 13:03:34 +0300 Subject: [PATCH 125/126] feat: scaffold javascript integrations --- .github/scripts/integration/scaffold.mjs | 179 +++++++++++++++++- .github/scripts/workflow.mjs | 2 +- docs/localization/en/CUSTOM_INTEGRATIONS.md | 2 + .../en/INTEGRATION_DEVELOPMENT.md | 3 + .../ru/INTEGRATION_DEVELOPMENT.md | 3 + .../zh/INTEGRATION_DEVELOPMENT.md | 2 + 6 files changed, 181 insertions(+), 10 deletions(-) diff --git a/.github/scripts/integration/scaffold.mjs b/.github/scripts/integration/scaffold.mjs index ec7b0e7a..6a7a84e1 100644 --- a/.github/scripts/integration/scaffold.mjs +++ b/.github/scripts/integration/scaffold.mjs @@ -9,7 +9,7 @@ const targetArg = args.find((arg) => !arg.startsWith('--')); if (!targetArg) { console.error( - 'Usage: npm run integration:new -- [--id my-id] [--name "My Integration"]', + 'Usage: npm run integration:new -- [--id my-id] [--name "My Integration"] [--runtime python|node|bun]', ); process.exit(1); } @@ -56,10 +56,20 @@ function validateName(value) { return trimmed; } +function validateRuntime(value) { + const runtime = String(value).trim().toLowerCase(); + if (!['python', 'node', 'bun'].includes(runtime)) { + fail('Integration runtime must be one of: python, node, bun.'); + } + + return runtime; +} + const target = path.resolve(targetArg); const defaultId = slugFromName(path.basename(target)); const id = validateId(optionValue('--id', defaultId)); const name = validateName(optionValue('--name', id.replace(/[-_]+/gu, ' '))); +const runtime = validateRuntime(optionValue('--runtime', 'python')); if (existsSync(target)) { console.error(`Target already exists: ${target}`); @@ -69,6 +79,7 @@ if (existsSync(target)) { mkdirSync(path.join(target, 'src'), { recursive: true }); mkdirSync(path.join(target, 'settings-ui'), { recursive: true }); +const runtimeManifest = buildRuntimeManifest(runtime); writeFileSync( path.join(target, 'axelate-module.toml'), `api_version = "1" @@ -83,9 +94,7 @@ readme = "README.md" settings_ui = "settings-ui/index.html" [runtime] -kind = "python" -version = "3.11" -entry = "src/main.py" +${runtimeManifest} `, ); @@ -95,6 +104,8 @@ writeFileSync( Axelate integration scaffold. +Runtime: ${runtime} + ## Run 1. Import this folder in Axelate. @@ -105,9 +116,52 @@ Use \`npm run integration:doctor -- ${target}\` from the Axelate repository to v `, ); -writeFileSync( - path.join(target, 'src', 'main.py'), - `from __future__ import annotations +if (runtime === 'python') { + writeFileSync( + path.join(target, 'src', 'main.py'), + buildPythonMain(), + ); +} else { + writeFileSync(path.join(target, 'package.json'), buildPackageJson(id, name, runtime)); + writeFileSync(path.join(target, 'src', 'axelate-client.mjs'), buildJavaScriptClient()); + writeFileSync(path.join(target, 'src', 'main.mjs'), buildJavaScriptMain()); +} + +function buildRuntimeManifest(selectedRuntime) { + if (selectedRuntime === 'python') { + return `kind = "python" +version = "3.11" +entry = "src/main.py"`; + } + + return `kind = "${selectedRuntime}" +version = "system" +entry = "src/main.mjs" +dependencies = "package.json" +package_manager = "${selectedRuntime === 'bun' ? 'bun' : 'npm'}"`; +} + +function buildPackageJson(packageId, packageName, selectedRuntime) { + return `${JSON.stringify( + { + name: packageId, + version: '0.1.0', + private: true, + description: `Connects ${packageName} to Axelate AI.`, + type: 'module', + scripts: { + start: `${selectedRuntime === 'bun' ? 'bun' : 'node'} src/main.mjs`, + }, + dependencies: {}, + }, + null, + 2, + )} +`; +} + +function buildPythonMain() { + return `from __future__ import annotations import json import os @@ -177,8 +231,115 @@ def main() -> None: if __name__ == "__main__": main() -`, -); +`; +} + +function buildJavaScriptClient() { + return `export class AxelateClient { + constructor(env = globalThis.process?.env ?? {}) { + this.baseUrl = validateBaseUrl(requiredEnv(env, "AXELATE_HTTP_API_BASE")).replace(/\\/$/u, ""); + this.token = requiredEnv(env, "AXELATE_HTTP_API_TOKEN"); + this.moduleId = requiredEnv(env, "AXELATE_MODULE_ID"); + this.modulePathId = encodeURIComponent(this.moduleId); + } + + async request(method, path, payload) { + const response = await fetch(\`\${this.baseUrl}\${path}\`, { + method, + headers: { + Authorization: \`Bearer \${this.token}\`, + "Content-Type": "application/json", + }, + body: payload === undefined ? undefined : JSON.stringify(payload), + }); + + const body = await readResponseBody(response); + if (!response.ok) { + const message = + body && typeof body === "object" && "error" in body + ? body.error + : \`Axelate request failed: \${response.status}\`; + throw new Error(String(message)); + } + + return body; + } + + settings() { + return this.request("GET", \`/v1/modules/\${this.modulePathId}/settings\`).then( + (body) => body.settings ?? {}, + ); + } + + stage(stage, label, progress) { + const payload = { stage, label }; + if (progress !== undefined) { + payload.progress = progress; + } + return this.request("POST", \`/v1/modules/\${this.modulePathId}/stage\`, payload); + } + + aiText(prompt, options = {}) { + return this.request("POST", "/v1/ai/text", { + prompt, + sessionId: this.moduleId, + ...options, + }); + } +} + +function requiredEnv(env, name) { + const value = String(env[name] ?? ""); + if (value.trim().length === 0) { + throw new Error(\`Missing required Axelate integration env var: \${name}\`); + } + + return value; +} + +function validateBaseUrl(value) { + const url = new URL(value); + const allowedHosts = new Set(["localhost", "127.0.0.1", "[::1]"]); + if (!["http:", "https:"].includes(url.protocol) || !allowedHosts.has(url.hostname)) { + throw new Error("AXELATE_HTTP_API_BASE must be an http(s) loopback URL."); + } + + return value; +} + +async function readResponseBody(response) { + if (response.status === 204) { + return {}; + } + + const contentType = response.headers.get("content-type") ?? ""; + const text = await response.text(); + if (text.length === 0) { + return {}; + } + + if (contentType.includes("application/json")) { + return JSON.parse(text); + } + + return { text }; +} +`; +} + +function buildJavaScriptMain() { + return `import { AxelateClient } from "./axelate-client.mjs"; + +const client = new AxelateClient(); +const settings = await client.settings(); +const prompt = settings.prompt ?? "Write a short status update."; + +await client.stage("ai.request", "Calling Axelate AI", 0.5); +const result = await client.aiText(prompt); + +console.log(JSON.stringify(result, null, 2)); +`; +} writeFileSync( path.join(target, 'settings-ui', 'axelate-settings-bridge.js'), diff --git a/.github/scripts/workflow.mjs b/.github/scripts/workflow.mjs index 113d41f4..dfb7a6b3 100644 --- a/.github/scripts/workflow.mjs +++ b/.github/scripts/workflow.mjs @@ -837,7 +837,7 @@ Tasks: setup Validate prerequisites, install frontend deps, and configure hooks install-deps Install frontend dependencies integration:doctor Validate an Axelate integration folder - integration:new Scaffold a minimal Python integration folder + integration:new Scaffold a minimal Python, Node, or Bun integration folder update Update npm and cargo dependencies, then verify prepare Configure Git hooks check-size Print a frontend bundle size report diff --git a/docs/localization/en/CUSTOM_INTEGRATIONS.md b/docs/localization/en/CUSTOM_INTEGRATIONS.md index 2d41a57a..40df20d7 100644 --- a/docs/localization/en/CUSTOM_INTEGRATIONS.md +++ b/docs/localization/en/CUSTOM_INTEGRATIONS.md @@ -12,6 +12,8 @@ npm run integration:new -- ./my-integration --id my-integration --name "My Integ npm run integration:doctor -- ./my-integration ``` +Use `--runtime node` or `--runtime bun` for JavaScript integrations. + ## Minimal Layout ```text diff --git a/docs/localization/en/INTEGRATION_DEVELOPMENT.md b/docs/localization/en/INTEGRATION_DEVELOPMENT.md index 3a5c5bdd..21d60324 100644 --- a/docs/localization/en/INTEGRATION_DEVELOPMENT.md +++ b/docs/localization/en/INTEGRATION_DEVELOPMENT.md @@ -13,6 +13,8 @@ npm run integration:doctor -- ./my-integration ``` Then import the folder in the launcher integrations screen and launch it. +Use `--runtime node` or `--runtime bun` when the integration entrypoint is +JavaScript instead of Python. For an existing app, keep the app code in your integration folder, declare its entrypoint in `axelate-module.toml`, and use the launcher-provided environment @@ -23,6 +25,7 @@ local API, then run `integration:doctor` before importing the folder. ## Repository Helpers - `npm run integration:new -- ` creates a minimal Python integration. + Add `--runtime node` or `--runtime bun` for JavaScript runtimes. - `npm run integration:doctor -- ` validates `axelate-module.toml`, entry files, settings UI, dependency paths, and common generated folders that should not be shipped. diff --git a/docs/localization/ru/INTEGRATION_DEVELOPMENT.md b/docs/localization/ru/INTEGRATION_DEVELOPMENT.md index 15982dd5..103ff974 100644 --- a/docs/localization/ru/INTEGRATION_DEVELOPMENT.md +++ b/docs/localization/ru/INTEGRATION_DEVELOPMENT.md @@ -14,6 +14,8 @@ npm run integration:doctor -- ./my-integration После этого импортируй папку на странице интеграций в лаунчере и запусти карточку. +Если entrypoint интеграции на JavaScript, используй `--runtime node` или +`--runtime bun`. Для существующего приложения оставь код приложения внутри папки интеграции, укажи entrypoint в `axelate-module.toml` и читай переменные окружения лаунчера @@ -24,6 +26,7 @@ npm run integration:doctor -- ./my-integration ## Инструменты в репозитории - `npm run integration:new -- ` создает минимальную Python-интеграцию. + Для JavaScript runtime добавь `--runtime node` или `--runtime bun`. - `npm run integration:doctor -- ` проверяет `axelate-module.toml`, entry-файлы, settings UI, dependency paths и типичные сгенерированные папки, которые нельзя поставлять. diff --git a/docs/localization/zh/INTEGRATION_DEVELOPMENT.md b/docs/localization/zh/INTEGRATION_DEVELOPMENT.md index 11c1446a..b2dccc25 100644 --- a/docs/localization/zh/INTEGRATION_DEVELOPMENT.md +++ b/docs/localization/zh/INTEGRATION_DEVELOPMENT.md @@ -13,6 +13,7 @@ npm run integration:doctor -- ./my-integration ``` 然后在 Axelate 的 Integrations 页面导入这个文件夹并启动卡片。 +如果集成入口是 JavaScript,可以使用 `--runtime node` 或 `--runtime bun`。 如果要接入已有应用,把应用代码放在集成目录里,在 `axelate-module.toml` 中声明入口文件,并在进程启动时读取启动器提供的环境变量。生成的状态写入 @@ -22,6 +23,7 @@ npm run integration:doctor -- ./my-integration ## 仓库工具 - `npm run integration:new -- ` 创建一个最小 Python 集成。 + JavaScript runtime 可添加 `--runtime node` 或 `--runtime bun`。 - `npm run integration:doctor -- ` 检查 `axelate-module.toml`、入口文件、 settings UI、依赖路径,以及不应该随包发布的生成目录。 - `docs/examples/integrations/python-ai-tool/` 是最小可运行示例。 From 5c6bb11ac85a8c1de1b2773d60467e25ab32662a Mon Sep 17 00:00:00 2001 From: F0RLE Date: Fri, 15 May 2026 13:13:10 +0300 Subject: [PATCH 126/126] chore: prepare nightly merge configuration --- .coderabbit.yaml | 20 ++++++++++---------- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/app/window.rs | 2 +- src-tauri/tauri.conf.json | 2 +- src/index.html | 2 +- 6 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 65231bdf..ee412d8e 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -25,14 +25,13 @@ reviews: - label: "release" instructions: "Use for versioning, tagging, packaging, checksums, installers, release notes, or release workflow changes." path_filters: - - "!docs/**" - - "!src/**" - - "!src-tauri/resources/**" - - "!src-tauri/Cargo.lock" + - "!src/dist/**" + - "!src/node_modules/**" + - "!src-tauri/target/**" + - "!src-tauri/gen/**" + - "!.cache/**" - "!.github/ISSUE_TEMPLATE/**" - - "!.github/PULL_REQUEST_TEMPLATE.md" - - "!.github/CODEOWNERS" - - "!**/*.md" + - "!**/.DS_Store" path_instructions: - path: "src-tauri/src/**/*.rs" instructions: "Review as a Windows-first Tauri backend. Prioritize IPC boundary validation, path traversal, archive extraction safety, process spawning, cancellation, secure storage, logging, and error mapping. Check that frontend-callable commands never expose raw secrets and keep user data scoped to app directories." @@ -74,6 +73,7 @@ knowledge_base: - "README.md" - "CONTRIBUTING.md" - "SECURITY.md" - - "docs/en/GETTING_STARTED.md" - - "docs/en/DEVELOPMENT_WORKFLOW.md" - - "docs/en/TRUST_MODEL.md" + - "AGENTS.md" + - "docs/localization/en/DEVELOPMENT_WORKFLOW.md" + - "docs/localization/en/TRUST_MODEL.md" + - "docs/localization/en/ARCHITECTURE.md" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2ea1e2be..b226bdf9 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -300,7 +300,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "axelate" -version = "0.1.5" +version = "0.2.0" dependencies = [ "aes-gcm", "async-trait", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 049943e9..a5db7570 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -4,7 +4,7 @@ # ============================================================================== [package] name = "axelate" -version = "0.1.5" +version = "0.2.0" description = "Axelate: Windows-first AI Workstation." authors = ["F0RLE"] edition = "2024" diff --git a/src-tauri/src/app/window.rs b/src-tauri/src/app/window.rs index 69ed355a..94cf53a6 100644 --- a/src-tauri/src/app/window.rs +++ b/src-tauri/src/app/window.rs @@ -123,7 +123,7 @@ pub fn create_main_window(app: &tauri::AppHandle) -> Option - Axelate (Beta) + Axelate (Nightly)