diff --git a/docs/ErrorDuringRelease.md b/docs/ErrorDuringRelease.md new file mode 100644 index 00000000..2e89d20c --- /dev/null +++ b/docs/ErrorDuringRelease.md @@ -0,0 +1,18 @@ +# Deal with errors during release build + +## `EBUSY: resource busy or locked` during make + +```log +Error: EBUSY: resource busy or locked, unlink 'i:\Temp\...\tidgi.0.13.0-prerelease18.nupkg' +``` + +esbuild process doesn't exit properly after packaging, holding file handles to temp files. + +Solution: kill background **esbuild** process + +```powershell +Get-Process | Where-Object { $_.ProcessName -match "esbuild|electron" } | Stop-Process -Force +Remove-Item "$env:TEMP\si-*" -Recurse -Force -ErrorAction SilentlyContinue +``` + +Also check if there are any open explorer folders, closing them may help. diff --git a/features/crossWindowSync.feature b/features/crossWindowSync.feature new file mode 100644 index 00000000..e47d5d5a --- /dev/null +++ b/features/crossWindowSync.feature @@ -0,0 +1,42 @@ +Feature: Cross-Window Synchronization + As a user + I want changes made in the main window to sync to new windows + So that I can view consistent content across all windows + + Background: + Given I cleanup test wiki so it could create a new one on start + And I launch the TidGi application + And I wait for the page to load completely + Then I should see a "default wiki workspace" element with selector "div[data-testid^='workspace-']:has-text('wiki')" + When I click on a "default wiki workspace button" element with selector "div[data-testid^='workspace-']:has-text('wiki')" + Then the browser view should be loaded and visible + And I wait for "SSE backend ready" log marker "[test-id-SSE_READY]" + + @crossWindowSync @crossWindowSync-basic + Scenario: Changes in main window should sync to file system and be loadable in new window + # Open workspace in a new window to test cross-window sync + When I open workspace "wiki" in a new window + And I switch to the newest window + And I wait for the page to load completely + # Open Index in the new window + # Switch back to main window and edit the Index tiddler + When I switch to "main" window + # Edit the Index tiddler in the main window (using TiddlyWiki API to trigger IPC save) + When I execute TiddlyWiki code in browser view: "$tw.wiki.addTiddler(new $tw.Tiddler($tw.wiki.getTiddler('Index'), {text: 'CrossWindowSyncTestContent123'}))" + # Switch to the new window to verify the change was synced + When I switch to the newest window + # Verify content is visible in the new window (proving SSE push works) + Then I should see "CrossWindowSyncTestContent123" in the browser view content + + @crossWindowSync @crossWindowSync-reverse + Scenario: Changes in new window should sync back to main window via SSE + # Open workspace in a new window + When I open workspace "wiki" in a new window + And I switch to the newest window + And I wait for the page to load completely + # Edit the Index tiddler in the NEW window (using TiddlyWiki API to trigger IPC save) + When I execute TiddlyWiki code in browser view: "$tw.wiki.addTiddler(new $tw.Tiddler($tw.wiki.getTiddler('Index'), {text: 'ReverseWindowSyncTestContent456'}))" + # Switch back to main window to verify the change was synced + When I switch to "main" window + # Verify content is visible in the main window (proving SSE push works in reverse direction) + Then I should see "ReverseWindowSyncTestContent456" in the browser view content diff --git a/features/cucumber.config.js b/features/cucumber.config.js index 092cec4e..3f9bc5ec 100644 --- a/features/cucumber.config.js +++ b/features/cucumber.config.js @@ -1,7 +1,15 @@ +const isCI = Boolean(process.env.CI); + +// Debug: Log CI detection for troubleshooting timeout issues +console.log('[Cucumber Config] CI environment variable:', process.env.CI); +console.log('[Cucumber Config] isCI:', isCI); +console.log('[Cucumber Config] Timeout will be:', isCI ? 25000 : 5000, 'ms'); + module.exports = { default: { require: [ 'ts-node/register', + 'features/supports/timeout-config.ts', // Must be loaded first to set global timeout 'features/stepDefinitions/**/*.ts', ], requireModule: ['ts-node/register'], @@ -10,6 +18,8 @@ module.exports = { snippetInterface: 'async-await', }, paths: ['features/*.feature'], + // Note: Global timeout is set via setDefaultTimeout() in features/supports/timeout-config.ts + // NOT via the 'timeout' config option here (which is for Cucumber's own operations) // Parallel execution disabled due to OOM issues on Windows // Each scenario still gets isolated test-artifacts/{scenarioSlug}/ directory // parallel: 2, diff --git a/features/defaultWiki.feature b/features/defaultWiki.feature index 4faa20f9..f3fd1076 100644 --- a/features/defaultWiki.feature +++ b/features/defaultWiki.feature @@ -60,6 +60,31 @@ Feature: TidGi Default Wiki # Verify TiddlyWiki content is displayed in the new workspace Then I should see "我的 TiddlyWiki" in the browser view content + @wiki @root-tiddler + Scenario: Configure root tiddler to use lazy-load and verify content still loads + # Wait for browser view to be fully loaded first + And the browser view should be loaded and visible + And I should see "我的 TiddlyWiki" in the browser view content + # Now modify Index tiddler with unique test content before configuring root tiddler + When I modify file "wiki-test/wiki/tiddlers/Index.tid" to contain "Test content for lazy-all verification after restart" + # before restart, should not see the new content from fs yet (watch-fs is off by default) + And I should not see "Test content for lazy-all verification after restart" in the browser view content + # Update rootTiddler setting via API to use lazy-all, and ensure watch-fs is disabled + When I update workspace "wiki" settings: + | property | value | + | rootTiddler | $:/core/save/lazy-all | + | enableFileSystemWatch | false | + # Wait for config to be written + Then I wait for "config file written" log marker "[test-id-TIDGI_CONFIG_WRITTEN]" + # Restart the workspace to apply the rootTiddler configuration + When I restart workspace "wiki" + # Verify browser view is loaded and visible after restart + And the browser view should be loaded and visible + # Verify Index tiddler element exists (confirms rootTiddler=lazy-all config is applied) + Then I should see a "Index tiddler" element in browser view with selector "div[data-tiddler-title='Index']" + # Verify the actual content is displayed (confirms lazy-all loaded the file content on restart) + And I should see "Test content for lazy-all verification after restart" in the browser view content + @wiki @move-workspace Scenario: Move workspace to a new location # Enable file system watch for testing (default is false in production) diff --git a/features/newAgent.feature b/features/newAgent.feature index 702ed35e..42cbad5d 100644 --- a/features/newAgent.feature +++ b/features/newAgent.feature @@ -32,7 +32,8 @@ Feature: Create New Agent Workflow | agent name input field | [data-testid='agent-name-input-field'] | # Step 3: Select template to advance to step 2 When I click on a "search input" element with selector ".aa-Input" - # Immediately click on the Example Agent template (don't wait or panel will close) + # Wait for autocomplete panel to load with templates (async operation in CI) + And I should see an "autocomplete panel" element with selector ".aa-Panel" # Using description text to select specific agent, more precise than just name When I click on a "Example Agent template" element with selector '.aa-Item[role="option"]:has-text("Example agent with prompt processing")' # Fill in agent name while still in step 1 diff --git a/features/stepDefinitions/agent.ts b/features/stepDefinitions/agent.ts index dd459cb0..17f035b1 100644 --- a/features/stepDefinitions/agent.ts +++ b/features/stepDefinitions/agent.ts @@ -8,6 +8,7 @@ import path from 'path'; import type { ISettingFile } from '../../src/services/database/interface'; import { MockOpenAIServer } from '../supports/mockOpenAI'; import { getSettingsPath } from '../supports/paths'; +import { PLAYWRIGHT_SHORT_TIMEOUT } from '../supports/timeouts'; import type { ApplicationWorld } from './application'; // Backoff configuration for retries @@ -202,7 +203,7 @@ Then('I should see {int} messages in chat history', async function(this: Applica await backOff( async () => { // Wait for at least one message to exist - await currentWindow.waitForSelector(messageSelector, { timeout: 5000 }); + await currentWindow.waitForSelector(messageSelector, { timeout: PLAYWRIGHT_SHORT_TIMEOUT }); // Count current messages const messages = currentWindow.locator(messageSelector); diff --git a/features/stepDefinitions/application.ts b/features/stepDefinitions/application.ts index 1416fbbc..70c16fad 100644 --- a/features/stepDefinitions/application.ts +++ b/features/stepDefinitions/application.ts @@ -8,6 +8,7 @@ import { windowDimension, WindowNames } from '../../src/services/windows/WindowP import { MockOAuthServer } from '../supports/mockOAuthServer'; import { MockOpenAIServer } from '../supports/mockOpenAI'; import { getPackedAppPath, makeSlugPath } from '../supports/paths'; +import { PLAYWRIGHT_TIMEOUT } from '../supports/timeouts'; import { captureScreenshot } from '../supports/webContentsViewHelper'; /** @@ -301,7 +302,7 @@ AfterStep(async function(this: ApplicationWorld, { pickle, pickleStep, result }) // Timeout is a symptom, not the disease. Fix the root cause. // Read docs/Testing.md section "Key E2E Testing Patterns" point 6 before attempting any changes. // Maximum allowed timeouts: Local 5s, CI 10s (exactly 2x local, no more) -When('I launch the TidGi application', { timeout: process.env.CI ? 10000 : 5000 }, async function(this: ApplicationWorld) { +When('I launch the TidGi application', async function(this: ApplicationWorld) { // For E2E tests on dev mode, use the packaged test version with NODE_ENV environment variable baked in const packedAppPath = getPackedAppPath(); @@ -362,13 +363,10 @@ When('I launch the TidGi application', { timeout: process.env.CI ? 10000 : 5000 }, // Set cwd to repo root; scenario isolation is handled via --test-scenario argument cwd: process.cwd(), - // Align Electron launch timeout with step definition (max 10s in CI, 5s locally) - timeout: process.env.CI ? 10000 : 5000, + timeout: PLAYWRIGHT_TIMEOUT, }); - // Wait longer for window in CI environment - const windowTimeout = process.env.CI ? 45000 : 10000; - this.mainWindow = await this.app.firstWindow({ timeout: windowTimeout }); + this.mainWindow = await this.app.firstWindow({ timeout: PLAYWRIGHT_TIMEOUT }); this.currentWindow = this.mainWindow; } catch (error) { throw new Error( @@ -400,3 +398,22 @@ When('I prepare to select directory in dialog {string}', async function(this: Ap }; }, targetPath); }); + +When('I set file {string} to file input with selector {string}', async function(this: ApplicationWorld, filePath: string, selector: string) { + const page = this.currentWindow; + if (!page) { + throw new Error('No current window available'); + } + + // Resolve the file path relative to project root + const targetPath = path.resolve(process.cwd(), filePath); + + // Verify the file exists + if (!await fs.pathExists(targetPath)) { + throw new Error(`File does not exist: ${targetPath}`); + } + + // Use Playwright's setInputFiles to directly set file to the input element + // This works even for hidden inputs + await page.locator(selector).setInputFiles(targetPath); +}); diff --git a/features/stepDefinitions/browserView.ts b/features/stepDefinitions/browserView.ts index 5d08444f..ebb9f964 100644 --- a/features/stepDefinitions/browserView.ts +++ b/features/stepDefinitions/browserView.ts @@ -71,7 +71,7 @@ Then('I should see {string} in the browser view DOM', async function(this: Appli }); }); -Then('the browser view should be loaded and visible', { timeout: 15000 }, async function(this: ApplicationWorld) { +Then('the browser view should be loaded and visible', async function(this: ApplicationWorld) { if (!this.app) { throw new Error('Application not launched'); } @@ -87,7 +87,7 @@ Then('the browser view should be loaded and visible', { timeout: 15000 }, async throw new Error('Browser view not loaded'); } }, - { ...BACKOFF_OPTIONS, numOfAttempts: 15 }, + { ...BACKOFF_OPTIONS, numOfAttempts: 30 }, ).catch(() => { throw new Error('Browser view is not loaded or visible after multiple attempts'); }); @@ -150,7 +150,7 @@ When('I click on {string} elements in browser view with selectors:', async funct } }); -Then('I wait for {string} element in browser view with selector {string}', { timeout: 15000 }, async function( +Then('I wait for {string} element in browser view with selector {string}', async function( this: ApplicationWorld, elementComment: string, selector: string, @@ -314,7 +314,7 @@ When('I open tiddler {string} in browser view', async function(this: Application * Create a new tiddler with title and optional tags via TiddlyWiki UI. * This step handles all the UI interactions: click add button, set title, add tags, and confirm. */ -When('I create a tiddler {string} with tag {string} in browser view', { timeout: 20000 }, async function( +When('I create a tiddler {string} with tag {string} in browser view', async function( this: ApplicationWorld, tiddlerTitle: string, tagName: string, @@ -361,7 +361,7 @@ When('I create a tiddler {string} with tag {string} in browser view', { timeout: /** * Create a new tiddler with title and custom field via TiddlyWiki UI. */ -When('I create a tiddler {string} with field {string} set to {string} in browser view', { timeout: 20000 }, async function( +When('I create a tiddler {string} with field {string} set to {string} in browser view', async function( this: ApplicationWorld, tiddlerTitle: string, fieldName: string, @@ -407,3 +407,21 @@ When('I create a tiddler {string} with field {string} set to {string} in browser await clickElement(this.app, 'button:has(.tc-image-done-button)'); await new Promise(resolve => setTimeout(resolve, 500)); }); + +/** + * Execute TiddlyWiki code in browser view + * Useful for programmatic wiki operations + */ +When('I execute TiddlyWiki code in browser view: {string}', async function(this: ApplicationWorld, code: string) { + if (!this.app) { + throw new Error('Application not launched'); + } + + try { + // Wrap the code to avoid returning non-serializable objects + const wrappedCode = `(function() { ${code}; return true; })()`; + await executeTiddlyWikiCode(this.app, wrappedCode); + } catch (error) { + throw new Error(`Failed to execute TiddlyWiki code in browser view: ${error as Error}`); + } +}); diff --git a/features/stepDefinitions/cleanup.ts b/features/stepDefinitions/cleanup.ts index bb35df59..f4f64daf 100644 --- a/features/stepDefinitions/cleanup.ts +++ b/features/stepDefinitions/cleanup.ts @@ -1,4 +1,4 @@ -import { After, AfterAll, Before } from '@cucumber/cucumber'; +import { After, Before } from '@cucumber/cucumber'; import fs from 'fs-extra'; import path from 'path'; import { makeSlugPath } from '../supports/paths'; @@ -128,13 +128,3 @@ After(async function(this: ApplicationWorld, { pickle }) { // Scenario-specific logs are already in the right place, no need to move them }); - -// Force exit after all tests complete to prevent hanging -AfterAll({ timeout: 5000 }, async function() { - // Give a short grace period for any final cleanup - await new Promise((resolve) => setTimeout(resolve, 1000)); - - // Force exit the process - // This is necessary because sometimes Electron/Playwright resources don't fully clean up - process.exit(0); -}); diff --git a/features/stepDefinitions/ui.ts b/features/stepDefinitions/ui.ts index be89eaa1..45a7ac49 100644 --- a/features/stepDefinitions/ui.ts +++ b/features/stepDefinitions/ui.ts @@ -1,6 +1,7 @@ import { DataTable, Then, When } from '@cucumber/cucumber'; import { parseDataTableRows } from '../supports/dataTable'; import { getWikiTestRootPath } from '../supports/paths'; +import { PLAYWRIGHT_SHORT_TIMEOUT, PLAYWRIGHT_TIMEOUT } from '../supports/timeouts'; import type { ApplicationWorld } from './application'; When('I wait for {float} seconds', async function(seconds: number) { @@ -17,13 +18,13 @@ When('I wait for {float} seconds for {string}', async function(seconds: number, When('I wait for the page to load completely', async function(this: ApplicationWorld) { const currentWindow = this.currentWindow; - await currentWindow?.waitForLoadState('networkidle', { timeout: 30000 }); + await currentWindow?.waitForLoadState('networkidle', { timeout: PLAYWRIGHT_TIMEOUT }); }); Then('I should see a(n) {string} element with selector {string}', async function(this: ApplicationWorld, elementComment: string, selector: string) { const currentWindow = this.currentWindow; try { - await currentWindow?.waitForSelector(selector, { timeout: 10000 }); + await currentWindow?.waitForSelector(selector, { timeout: PLAYWRIGHT_TIMEOUT }); const isVisible = await currentWindow?.isVisible(selector); if (!isVisible) { throw new Error(`Element "${elementComment}" with selector "${selector}" is not visible`); @@ -50,7 +51,7 @@ Then('I should see {string} elements with selectors:', async function(this: Appl // Check all elements in parallel for better performance await Promise.all(dataRows.map(async ([elementComment, selector]) => { try { - await currentWindow.waitForSelector(selector, { timeout: 10000 }); + await currentWindow.waitForSelector(selector, { timeout: PLAYWRIGHT_TIMEOUT }); const isVisible = await currentWindow.isVisible(selector); if (!isVisible) { errors.push(`Element "${elementComment}" with selector "${selector}" is not visible`); @@ -148,7 +149,7 @@ When('I click on a(n) {string} element with selector {string}', async function(t } try { - await targetWindow.waitForSelector(selector, { timeout: 10000 }); + await targetWindow.waitForSelector(selector, { timeout: PLAYWRIGHT_TIMEOUT }); const isVisible = await targetWindow.isVisible(selector); if (!isVisible) { throw new Error(`Element "${elementComment}" with selector "${selector}" is not visible`); @@ -177,7 +178,7 @@ When('I click on {string} elements with selectors:', async function(this: Applic // Click elements sequentially (not in parallel) to maintain order and avoid race conditions for (const [elementComment, selector] of dataRows) { try { - await targetWindow.waitForSelector(selector, { timeout: 10000 }); + await targetWindow.waitForSelector(selector, { timeout: PLAYWRIGHT_TIMEOUT }); const isVisible = await targetWindow.isVisible(selector); if (!isVisible) { errors.push(`Element "${elementComment}" with selector "${selector}" is not visible`); @@ -202,7 +203,7 @@ When('I right-click on a(n) {string} element with selector {string}', async func } try { - await targetWindow.waitForSelector(selector, { timeout: 10000 }); + await targetWindow.waitForSelector(selector, { timeout: PLAYWRIGHT_TIMEOUT }); const isVisible = await targetWindow.isVisible(selector); if (!isVisible) { throw new Error(`Element "${elementComment}" with selector "${selector}" is not visible`); @@ -244,7 +245,7 @@ When('I type {string} in {string} element with selector {string}', async functio const actualText = text.replace('{tmpDir}', getWikiTestRootPath(this)); try { - await currentWindow.waitForSelector(selector, { timeout: 10000 }); + await currentWindow.waitForSelector(selector, { timeout: PLAYWRIGHT_TIMEOUT }); const element = currentWindow.locator(selector); await element.fill(actualText); } catch (error) { @@ -276,7 +277,7 @@ When('I type in {string} elements with selectors:', async function(this: Applica const actualText = text.replace('{tmpDir}', getWikiTestRootPath(this)); try { - await currentWindow.waitForSelector(selector, { timeout: 10000 }); + await currentWindow.waitForSelector(selector, { timeout: PLAYWRIGHT_TIMEOUT }); const element = currentWindow.locator(selector); await element.fill(actualText); } catch (error) { @@ -296,7 +297,7 @@ When('I clear text in {string} element with selector {string}', async function(t } try { - await currentWindow.waitForSelector(selector, { timeout: 10000 }); + await currentWindow.waitForSelector(selector, { timeout: PLAYWRIGHT_TIMEOUT }); const element = currentWindow.locator(selector); await element.clear(); } catch (error) { @@ -428,7 +429,7 @@ When('I select {string} from MUI Select with test id {string}', async function(t try { // Find the hidden input element with the test-id const inputSelector = `input[data-testid="${testId}"]`; - await currentWindow.waitForSelector(inputSelector, { timeout: 10000 }); + await currentWindow.waitForSelector(inputSelector, { timeout: PLAYWRIGHT_TIMEOUT }); // Try to click using Playwright's click on the div with role="combobox" // According to your HTML structure, the combobox is a sibling of the input @@ -464,7 +465,7 @@ When('I select {string} from MUI Select with test id {string}', async function(t await currentWindow.waitForTimeout(500); // Wait for the menu to appear - await currentWindow.waitForSelector('[role="listbox"]', { timeout: 5000 }); + await currentWindow.waitForSelector('[role="listbox"]', { timeout: PLAYWRIGHT_SHORT_TIMEOUT }); // Try to click on the option with the specified value (data-value attribute) // If not found, try to find by text content @@ -505,7 +506,7 @@ When('I select {string} from MUI Select with test id {string}', async function(t } // Wait for the menu to close - await currentWindow.waitForSelector('[role="listbox"]', { state: 'hidden', timeout: 5000 }); + await currentWindow.waitForSelector('[role="listbox"]', { state: 'hidden', timeout: PLAYWRIGHT_SHORT_TIMEOUT }); } catch (error) { throw new Error(`Failed to select option "${optionValue}" from MUI Select with test id "${testId}": ${String(error)}`); } @@ -535,7 +536,7 @@ When('I print DOM structure of element with selector {string}', async function(t } try { - await currentWindow.waitForSelector(selector, { timeout: 5000 }); + await currentWindow.waitForSelector(selector, { timeout: PLAYWRIGHT_SHORT_TIMEOUT }); const elementInfo = await currentWindow.evaluate((sel) => { const element = document.querySelector(sel); diff --git a/features/stepDefinitions/wiki.ts b/features/stepDefinitions/wiki.ts index e54c6889..42cf651d 100644 --- a/features/stepDefinitions/wiki.ts +++ b/features/stepDefinitions/wiki.ts @@ -6,6 +6,7 @@ import path from 'path'; import type { IWikiWorkspace, IWorkspace } from '../../src/services/workspaces/interface'; import { parseDataTableRows } from '../supports/dataTable'; import { getLogPath, getSettingsPath, getWikiTestRootPath, getWikiTestWikiPath } from '../supports/paths'; +import { LOG_MARKER_WAIT_TIMEOUT } from '../supports/timeouts'; // Scenario-specific paths are computed via helper functions import type { ApplicationWorld } from './application'; @@ -301,42 +302,45 @@ Then('file {string} should exist in {string}', async function(this: ApplicationW } }); -Then('file {string} should not exist in {string}', { timeout: 15000 }, async function(this: ApplicationWorld, fileName: string, simpleDirectoryPath: string) { - // Replace {tmpDir} with wiki test root (not wiki subfolder) - let directoryPath = simpleDirectoryPath.replace('{tmpDir}', getWikiTestRootPath(this)); +Then( + 'file {string} should not exist in {string}', + async function(this: ApplicationWorld, fileName: string, simpleDirectoryPath: string) { + // Replace {tmpDir} with wiki test root (not wiki subfolder) + let directoryPath = simpleDirectoryPath.replace('{tmpDir}', getWikiTestRootPath(this)); - // Resolve symlinks on all platforms to handle sub-wikis correctly - if (await fs.pathExists(directoryPath)) { - try { - directoryPath = fs.realpathSync(directoryPath); - } catch { - // If realpathSync fails, continue with the original path + // Resolve symlinks on all platforms to handle sub-wikis correctly + if (await fs.pathExists(directoryPath)) { + try { + directoryPath = fs.realpathSync(directoryPath); + } catch { + // If realpathSync fails, continue with the original path + } } - } - const filePath = path.join(directoryPath, fileName); + const filePath = path.join(directoryPath, fileName); - try { - await backOff( - async () => { - if (!(await fs.pathExists(filePath))) { - return; - } - throw new Error('File still exists'); - }, - BACKOFF_OPTIONS, - ); - } catch { - throw new Error( - `File "${fileName}" should not exist but was found in directory: ${directoryPath}`, - ); - } -}); + try { + await backOff( + async () => { + if (!(await fs.pathExists(filePath))) { + return; + } + throw new Error('File still exists'); + }, + BACKOFF_OPTIONS, + ); + } catch { + throw new Error( + `File "${fileName}" should not exist but was found in directory: ${directoryPath}`, + ); + } + }, +); /** * Verify that a workspace in settings.json has a specific property set to a specific value */ -Then('settings.json should have workspace {string} with {string} set to {string}', { timeout: 10000 }, async function( +Then('settings.json should have workspace {string} with {string} set to {string}', async function( this: ApplicationWorld, workspaceName: string, propertyName: string, @@ -416,7 +420,7 @@ Then('settings.json should have workspace {string} with {string} set to {string} /** * Verify that a workspace in settings.json has a property array that contains a specific value */ -Then('settings.json should have workspace {string} with {string} containing {string}', { timeout: 10000 }, async function( +Then('settings.json should have workspace {string} with {string} containing {string}', async function( this: ApplicationWorld, workspaceName: string, propertyName: string, @@ -559,11 +563,9 @@ async function clearGitTestData(scenarioRoot?: string) { * Read docs/Testing.md section "Key E2E Testing Patterns" point 6 before attempting any changes. * Maximum allowed timeouts: Local 5s, CI 10s (exactly 2x local, no more) */ -Then('I wait for {string} log marker {string}', { timeout: process.env.CI ? 10 * 1000 : 5 * 1000 }, async function(this: ApplicationWorld, description: string, marker: string) { +Then('I wait for {string} log marker {string}', async function(this: ApplicationWorld, description: string, marker: string) { // Search in all log files using '*' pattern (includes TidGi-, wiki-, and workspace-named logs like WikiRenamed-) - // Internal wait timeout: Local 3s, CI 6s (to fit within step timeout) - const waitTimeout = process.env.CI ? 6000 : 3000; - await waitForLogMarker(this, marker, `Log marker "${marker}" not found. Expected: ${description}`, waitTimeout, '*'); + await waitForLogMarker(this, marker, `Log marker "${marker}" not found. Expected: ${description}`, LOG_MARKER_WAIT_TIMEOUT, '*'); }); /** @@ -576,21 +578,19 @@ Then('I wait for {string} log marker {string}', { timeout: process.env.CI ? 10 * * | watch-fs stabilized after restart| [test-id-WATCH_FS_STABILIZED] | * | SSE ready after restart | [test-id-SSE_READY] | */ -Then('I wait for log markers:', { timeout: process.env.CI ? 10 * 1000 : 5 * 1000 }, async function(this: ApplicationWorld, dataTable: DataTable) { +Then('I wait for log markers:', async function(this: ApplicationWorld, dataTable: DataTable) { const rows = dataTable.raw(); const dataRows = parseDataTableRows(rows, 2); if (dataRows[0]?.length !== 2) { throw new Error('Table must have exactly 2 columns: | description | marker |'); } - - const waitTimeout = process.env.CI ? 6000 : 3000; const errors: string[] = []; // Wait for markers sequentially to maintain order for (const [description, marker] of dataRows) { try { - await waitForLogMarker(this, marker, `Log marker "${marker}" not found. Expected: ${description}`, waitTimeout, '*'); + await waitForLogMarker(this, marker, `Log marker "${marker}" not found. Expected: ${description}`, LOG_MARKER_WAIT_TIMEOUT, '*'); } catch (error) { errors.push(`Failed to find log marker "${marker}" (${description}): ${error instanceof Error ? error.message : String(error)}`); } @@ -937,6 +937,57 @@ When('I create a new wiki workspace with name {string}', async function(this: Ap }); }); +/** + * Restart a workspace wiki worker + */ +When('I restart workspace {string}', async function(this: ApplicationWorld, workspaceName: string) { + if (!this.app) throw new Error('Application is not available'); + + const settingsPath = getSettingsPath(this); + const settings = JSON.parse(await fs.readFile(settingsPath, 'utf-8')) as { workspaces?: Record }; + if (!settings.workspaces) throw new Error('No workspaces found'); + + let targetWorkspaceId: string | undefined; + for (const [id, workspace] of Object.entries(settings.workspaces)) { + if ('name' in workspace && workspace.name === workspaceName) { + targetWorkspaceId = id; + break; + } + if ('wikiFolderLocation' in workspace && workspace.wikiFolderLocation) { + const folderName = path.basename(workspace.wikiFolderLocation); + if (folderName === workspaceName) { + targetWorkspaceId = id; + break; + } + } + } + + if (!targetWorkspaceId) throw new Error(`No workspace found: ${workspaceName}`); + + const result = await this.app.evaluate(async ({ BrowserWindow }, workspaceId: string) => { + const windows = BrowserWindow.getAllWindows(); + const mainWindow = windows.find(win => !win.isDestroyed() && win.webContents?.getURL().includes('index.html')); + if (!mainWindow) throw new Error('Main window not found'); + + return await mainWindow.webContents.executeJavaScript(` + (async () => { + const workspace = await window.service.workspace.get(${JSON.stringify(workspaceId)}); + if (!workspace) return { success: false, error: 'Workspace not found' }; + try { + await window.service.wiki.restartWiki(workspace); + // Reload view to show fresh content from disk after wiki restart + await window.service.view.reloadViewsWebContents(${JSON.stringify(workspaceId)}); + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } + })(); + `) as Promise<{ success: boolean; error?: string }>; + }, targetWorkspaceId); + + if (!result.success) throw new Error(`Failed to restart: ${result.error ?? 'Unknown error'}`); +}); + /** * Update workspace settings dynamically after app launch * This is useful for enabling features like enableFileSystemWatch in tests @@ -947,7 +998,7 @@ When('I create a new wiki workspace with name {string}', async function(this: Ap * | enableFileSystemWatch | true | * | syncOnInterval | false | */ -When('I update workspace {string} settings:', { timeout: 60000 }, async function(this: ApplicationWorld, workspaceName: string, dataTable: DataTable) { +When('I update workspace {string} settings:', async function(this: ApplicationWorld, workspaceName: string, dataTable: DataTable) { if (!this.app) { throw new Error('Application is not available'); } @@ -1022,6 +1073,20 @@ When('I update workspace {string} settings:', { timeout: 60000 }, async function throw new Error(`No workspace found with name: ${workspaceName}`); } + // If enableFileSystemWatch is being changed, check current state BEFORE updating + let watchFsCurrentlyEnabled = false; + if ('enableFileSystemWatch' in settingsUpdate) { + const currentWorkspace = await this.app.evaluate(async ({ BrowserWindow }, workspaceId: string) => { + const windows = BrowserWindow.getAllWindows(); + const mainWindow = windows.find(win => !win.isDestroyed() && win.webContents && win.webContents.getURL().includes('index.html')); + if (!mainWindow) return null; + + return await mainWindow.webContents.executeJavaScript(`window.service.workspace.get(${JSON.stringify(workspaceId)})`) as Promise; + }, targetWorkspaceId); + + watchFsCurrentlyEnabled = currentWorkspace !== null && isWikiWorkspace(currentWorkspace) && currentWorkspace.enableFileSystemWatch; + } + // Update workspace settings via main window await this.app.evaluate(async ({ BrowserWindow }, { workspaceId, updates }: { workspaceId: string; updates: Record }) => { const windows = BrowserWindow.getAllWindows(); @@ -1047,23 +1112,17 @@ When('I update workspace {string} settings:', { timeout: 60000 }, async function // If enableFileSystemWatch was changed, we need to restart the wiki for it to take effect // The wiki worker reads this config at startup, so changes don't apply until restart if ('enableFileSystemWatch' in settingsUpdate) { - // First, wait for the wiki to be fully started before attempting restart - // This prevents conflicts if the wiki is still initializing - // Wait for WATCH_FS since it indicates wiki worker is ready, or SSE_READY if watch is disabled - try { - await waitForLogMarker(this, '[test-id-WATCH_FS_STABILIZED]', 'watch-fs not ready before restart', 30000); - } catch { - // If watch-fs is disabled initially, wait for SSE instead - await waitForLogMarker(this, '[test-id-SSE_READY]', 'SSE not ready before restart', 30000); + // Only wait for watch-fs if it was enabled before the update + // If it was disabled, wiki is ready immediately without watch-fs markers + if (watchFsCurrentlyEnabled) { + await waitForLogMarker(this, '[test-id-WATCH_FS_STABILIZED]', 'watch-fs not ready before restart', LOG_MARKER_WAIT_TIMEOUT); } - // Only clear watch-fs related log markers to ensure we wait for fresh ones after restart - // Don't clear other markers like git-init-complete that won't appear again + // Clear log markers to ensure we wait for fresh ones after restart await clearLogLinesContaining(this, '[test-id-WATCH_FS_STABILIZED]'); - await clearLogLinesContaining(this, '[test-id-SSE_READY]'); // Restart the wiki - await this.app.evaluate(async ({ BrowserWindow }, workspaceId: string) => { + const restartResult = await this.app.evaluate(async ({ BrowserWindow }, workspaceId: string) => { const windows = BrowserWindow.getAllWindows(); const mainWindow = windows.find(win => !win.isDestroyed() && win.webContents && win.webContents.getURL().includes('index.html')); @@ -1071,20 +1130,31 @@ When('I update workspace {string} settings:', { timeout: 60000 }, async function throw new Error('Main window not found'); } - await mainWindow.webContents.executeJavaScript(` + const result = await mainWindow.webContents.executeJavaScript(` (async () => { const workspace = await window.service.workspace.get(${JSON.stringify(workspaceId)}); - if (workspace) { + if (!workspace) { + return { success: false, error: 'Workspace not found' }; + } + try { await window.service.wiki.restartWiki(workspace); + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; } })(); - `); + `) as Promise<{ success: boolean; error?: string }>; + return result; }, targetWorkspaceId); + if (!restartResult.success) { + throw new Error(`Failed to restart wiki: ${restartResult.error ?? 'Unknown error'}`); + } + // Wait for wiki to restart and watch-fs to stabilize // Only wait if enableFileSystemWatch was set to true if (settingsUpdate.enableFileSystemWatch === true) { - await waitForLogMarker(this, '[test-id-WATCH_FS_STABILIZED]', 'watch-fs did not stabilize after restart', 30000); + await waitForLogMarker(this, '[test-id-WATCH_FS_STABILIZED]', 'watch-fs did not stabilize after restart', LOG_MARKER_WAIT_TIMEOUT); } } }); @@ -1504,3 +1574,63 @@ When('I remove workspace {string} keeping files', async function(this: Applicati await new Promise(resolve => setTimeout(resolve, 500)); }); }); + +/** + * Open workspace in a new window using TidGi's built-in API + */ +When('I open workspace {string} in a new window', async function(this: ApplicationWorld, workspaceName: string) { + if (!this.app) { + throw new Error('Application not launched'); + } + + // Get workspace by name and open in new window + const success = await this.app.evaluate( + async ({ BrowserWindow }, name: string) => { + const windows = BrowserWindow.getAllWindows(); + const mainWindow = windows.find(win => !win.isDestroyed() && win.webContents && win.webContents.getURL().includes('index.html')); + + if (!mainWindow) { + return { success: false, error: 'Main window not found' }; + } + + try { + // Execute code in renderer to get workspace and open new window + const result = await mainWindow.webContents.executeJavaScript(` + (async () => { + try { + console.log('[test] Getting workspaces list...'); + const workspaces = await window.service.workspace.getWorkspacesAsList(); + console.log('[test] Found workspaces:', workspaces.length); + const workspace = workspaces.find(w => w.name === ${JSON.stringify(name)}); + if (!workspace) { + return { success: false, error: 'Workspace not found: ' + ${JSON.stringify(name)} }; + } + console.log('[test] Found workspace:', workspace.name, workspace.id); + const lastUrl = workspace.lastUrl || workspace.homeUrl; + console.log('[test] Opening window with URL:', lastUrl); + await window.service.workspaceView.openWorkspaceWindowWithView(workspace, { uri: lastUrl }); + console.log('[test] Window opened successfully'); + return { success: true }; + } catch (err) { + console.error('[test] Error:', err); + return { success: false, error: err instanceof Error ? err.message : String(err) }; + } + })(); + `) as { error?: string; success: boolean }; + return result; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + }, + workspaceName, + ); + + if (!success || !success.success) { + throw new Error(`Failed to open workspace in new window: ${success?.error || 'unknown error'}`); + } + + // Wait for the new window to be created and ready + await this.app.evaluate(async () => { + await new Promise(resolve => setTimeout(resolve, 2000)); + }); +}); diff --git a/features/stepDefinitions/wiki.ts.backup b/features/stepDefinitions/wiki.ts.backup deleted file mode 100644 index a6cd01f7..00000000 --- a/features/stepDefinitions/wiki.ts.backup +++ /dev/null @@ -1,1381 +0,0 @@ -import { DataTable, Given, Then, When } from '@cucumber/cucumber'; -import { exec as gitExec } from 'dugite'; -import { backOff } from 'exponential-backoff'; -import fs from 'fs-extra'; -import path from 'path'; -import type { IWikiWorkspace, IWorkspace } from '../../src/services/workspaces/interface'; -import { settingsDirectory, settingsPath, wikiTestRootPath, wikiTestWikiPath } from '../supports/paths'; -import type { ApplicationWorld } from './application'; - -// Type guard for wiki workspace -function isWikiWorkspace(workspace: IWorkspace): workspace is IWikiWorkspace { - return 'wikiFolderLocation' in workspace && workspace.wikiFolderLocation !== undefined; -} - -// Backoff configuration for retries -const BACKOFF_OPTIONS = { - numOfAttempts: 10, - startingDelay: 200, - timeMultiple: 1.5, -}; - -/** - * Generic function to wait for a log marker to appear in wiki log files. - */ -export async function waitForLogMarker(searchString: string, errorMessage: string, maxWaitMs = 10000, logFilePattern = 'wiki-'): Promise { - const logPath = path.join(process.cwd(), 'userData-test', 'logs'); - // Support multiple patterns separated by '|' - const patterns = logFilePattern.split('|'); - - try { - await backOff( - async () => { - try { - const files = await fs.readdir(logPath); - // Case-insensitive matching for log file patterns - const logFiles = files.filter(f => patterns.some(p => f.toLowerCase().startsWith(p.toLowerCase())) && f.endsWith('.log')); - - for (const file of logFiles) { - const content = await fs.readFile(path.join(logPath, file), 'utf-8'); - if (content.includes(searchString)) { - return; - } - } - } catch { - // Log directory might not exist yet, continue retrying - } - - throw new Error('Log marker not found yet'); - }, - { - numOfAttempts: Math.ceil(maxWaitMs / 100), - startingDelay: 100, - timeMultiple: 1, - maxDelay: 100, - delayFirstAttempt: false, - }, - ); - } catch { - // If backOff fails, throw the user-friendly error message - throw new Error(errorMessage); - } -} - -When('I cleanup test wiki so it could create a new one on start', async function() { - // Clean up main wiki folder - if (fs.existsSync(wikiTestWikiPath)) fs.removeSync(wikiTestWikiPath); - - // Clean up all sub-wiki folders in wiki-test directory (SubWiki*, SubWikiPreload, SubWikiTagTree, SubWikiFilter, etc.) - if (fs.existsSync(wikiTestRootPath)) { - const entries = fs.readdirSync(wikiTestRootPath, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isDirectory() && entry.name !== 'wiki') { - const subWikiPath = path.join(wikiTestRootPath, entry.name); - try { - fs.removeSync(subWikiPath); - } catch (error) { - console.warn(`Failed to remove sub-wiki folder ${entry.name}:`, error); - } - } - } - } - - /** - * Clean up log files to prevent reading stale logs from previous scenarios. - * This is critical for tests that wait for log markers like [test-id-WATCH_FS_STABILIZED] or [test-id-git-commit-complete], - * as Node.js file system caching can cause tests to read old log content. - * Must clean both wiki- and TidGi- log files for git-related tests. - */ - const logDirectory = path.join(process.cwd(), 'userData-test', 'logs'); - if (fs.existsSync(logDirectory)) { - const logFiles = fs.readdirSync(logDirectory).filter(f => (f.startsWith('wiki-') || f.startsWith('TidGi-')) && f.endsWith('.log')); - for (const logFile of logFiles) { - fs.removeSync(path.join(logDirectory, logFile)); - } - } - - type SettingsFile = { workspaces?: Record } & Record; - if (!fs.existsSync(settingsPath)) return; - - // Retry logic with exponential backoff for reading settings.json - it might be temporarily locked or corrupted - let settings: SettingsFile; - - try { - settings = await backOff( - async () => { - return fs.readJsonSync(settingsPath) as SettingsFile; - }, - { - numOfAttempts: 3, - startingDelay: 100, - timeMultiple: 2, - maxDelay: 500, - retry: (error: Error, attemptNumber: number) => { - console.warn(`Attempt ${attemptNumber}/3 failed to read settings.json:`, error); - - // If file is corrupted, don't retry - handle it in catch block - if (error instanceof SyntaxError || error.message.includes('Unexpected end of JSON input')) { - return false; - } - - return true; - }, - }, - ); - } catch (error) { - // If file is corrupted or all retries failed, create empty settings - console.warn('Settings file is corrupted or failed to read after retries, recreating with empty workspaces', error); - settings = { workspaces: {} }; - } - - const workspaces: Record = settings.workspaces ?? {}; - const filtered: Record = {}; - for (const id of Object.keys(workspaces)) { - const ws = workspaces[id]; - // Keep only page-type workspaces (agent, help, guide, add), remove all wiki workspaces - // This includes main wiki and all sub-wikis - if ('pageType' in ws && ws.pageType) { - filtered[id] = ws; - } - } - - // Write with exponential backoff retry logic to handle file locks - try { - await backOff( - async () => { - fs.writeJsonSync(settingsPath, { ...settings, workspaces: filtered }, { spaces: 2 }); - }, - { - numOfAttempts: 3, - startingDelay: 100, - timeMultiple: 2, - maxDelay: 500, - retry: (_error: Error, attemptNumber: number) => { - console.warn(`Attempt ${attemptNumber}/3 failed to write settings.json:`, _error); - return true; - }, - }, - ); - } catch (error) { - console.error('Failed to write settings.json after 3 attempts, continuing anyway', error); - } -}); - -/** - * Helper function to get directory tree structure - */ -async function getDirectoryTree(directory: string, prefix = '', maxDepth = 3, currentDepth = 0): Promise { - if (currentDepth >= maxDepth || !(await fs.pathExists(directory))) { - return ''; - } - - let tree = ''; - try { - const items = await fs.readdir(directory); - for (let index = 0; index < items.length; index++) { - const item = items[index]; - const isLast = index === items.length - 1; - const itemPath = path.join(directory, item); - const connector = isLast ? '└── ' : '├── '; - - try { - const stat = await fs.stat(itemPath); - tree += `${prefix}${connector}${item}${stat.isDirectory() ? '/' : ''}\n`; - - if (stat.isDirectory()) { - const newPrefix = prefix + (isLast ? ' ' : '│ '); - tree += await getDirectoryTree(itemPath, newPrefix, maxDepth, currentDepth + 1); - } - } catch { - tree += `${prefix}${connector}${item} [error reading]\n`; - } - } - } catch { - // Directory not readable - } - - return tree; -} - -/** - * Verify file exists in directory - */ -Then('file {string} should exist in {string}', async function(this: ApplicationWorld, fileName: string, simpleDirectoryPath: string) { - // Replace {tmpDir} with wiki test root (not wiki subfolder) - let directoryPath = simpleDirectoryPath.replace('{tmpDir}', wikiTestRootPath); - - // Resolve symlinks on all platforms to handle sub-wikis correctly - // On Linux, symlinks might point to the real path, so we need to follow them - if (await fs.pathExists(directoryPath)) { - try { - directoryPath = fs.realpathSync(directoryPath); - } catch { - // If realpathSync fails, continue with the original path - } - } - - const filePath = path.join(directoryPath, fileName); - - try { - await backOff( - async () => { - if (await fs.pathExists(filePath)) { - return; - } - throw new Error('File not found yet'); - }, - BACKOFF_OPTIONS, - ); - } catch { - // Get 1 level up from actualPath - const oneLevelsUp = path.resolve(directoryPath, '..'); - const tree = await getDirectoryTree(oneLevelsUp); - - // Also read all .tid files in the actualPath directory - let tidFilesContent = ''; - try { - if (await fs.pathExists(directoryPath)) { - const files = await fs.readdir(directoryPath); - const tidFiles = files.filter(f => f.endsWith('.tid')); - - if (tidFiles.length > 0) { - tidFilesContent = '\n\n.tid files in directory:\n'; - for (const tidFile of tidFiles) { - const tidPath = path.join(directoryPath, tidFile); - const content = await fs.readFile(tidPath, 'utf-8'); - tidFilesContent += `\n=== ${tidFile} ===\n${content}\n`; - } - } - } - } catch (readError) { - tidFilesContent = `\n\nError reading .tid files: ${String(readError)}`; - } - - throw new Error( - `File "${fileName}" not found in directory: ${directoryPath}\n\n` + - `Directory tree (1 level up from ${oneLevelsUp}):\n${tree}${tidFilesContent}`, - ); - } -}); - -Then('file {string} should not exist in {string}', { timeout: 15000 }, async function(this: ApplicationWorld, fileName: string, simpleDirectoryPath: string) { - // Replace {tmpDir} with wiki test root (not wiki subfolder) - let directoryPath = simpleDirectoryPath.replace('{tmpDir}', wikiTestRootPath); - - // Resolve symlinks on all platforms to handle sub-wikis correctly - if (await fs.pathExists(directoryPath)) { - try { - directoryPath = fs.realpathSync(directoryPath); - } catch { - // If realpathSync fails, continue with the original path - } - } - - const filePath = path.join(directoryPath, fileName); - - try { - await backOff( - async () => { - if (!(await fs.pathExists(filePath))) { - return; - } - throw new Error('File still exists'); - }, - BACKOFF_OPTIONS, - ); - } catch { - throw new Error( - `File "${fileName}" should not exist but was found in directory: ${directoryPath}`, - ); - } -}); - -/** - * Verify that a workspace in settings.json has a specific property set to a specific value - */ -Then('settings.json should have workspace {string} with {string} set to {string}', { timeout: 10000 }, async function( - this: ApplicationWorld, - workspaceName: string, - propertyName: string, - expectedValue: string, -) { - await backOff( - async () => { - if (!await fs.pathExists(settingsPath)) { - throw new Error(`settings.json not found at ${settingsPath}`); - } - - type SettingsFile = { workspaces?: Record } & Record; - const settings = await fs.readJson(settingsPath) as SettingsFile; - - if (!settings.workspaces) { - throw new Error('No workspaces found in settings.json'); - } - - // Find the workspace by name (check both settings.json and tidgi.config.json) - let workspace: IWorkspace | undefined; - for (const ws of Object.values(settings.workspaces)) { - if (ws.name === workspaceName) { - workspace = ws; - break; - } - // Also check tidgi.config.json for wiki workspaces - if (isWikiWorkspace(ws)) { - try { - const tidgiConfigPath = path.join(ws.wikiFolderLocation, 'tidgi.config.json'); - if (await fs.pathExists(tidgiConfigPath)) { - const tidgiConfig = await fs.readJson(tidgiConfigPath) as { name?: string }; - if (tidgiConfig.name === workspaceName) { - workspace = ws; - break; - } - } - } catch { - // Ignore - } - } - } - if (!workspace) { - throw new Error(`Workspace "${workspaceName}" not found in settings.json or tidgi.config.json`); - } - - // Get the property value - check both settings.json and tidgi.config.json - let actualValue = (workspace as unknown as Record)[propertyName]; - - // If not found in settings.json, check tidgi.config.json for wiki workspaces - if (actualValue === undefined && isWikiWorkspace(workspace)) { - try { - const tidgiConfigPath = path.join(workspace.wikiFolderLocation, 'tidgi.config.json'); - if (await fs.pathExists(tidgiConfigPath)) { - const tidgiConfig = await fs.readJson(tidgiConfigPath) as Record; - actualValue = tidgiConfig[propertyName]; - } - } catch { - // Ignore errors reading tidgi.config.json - } - } - - // Convert expected value to appropriate type for comparison - let parsedExpectedValue: unknown = expectedValue; - if (expectedValue === 'true') parsedExpectedValue = true; - else if (expectedValue === 'false') parsedExpectedValue = false; - else if (expectedValue === 'null') parsedExpectedValue = null; - else if (!isNaN(Number(expectedValue))) parsedExpectedValue = Number(expectedValue); - - if (actualValue !== parsedExpectedValue) { - throw new Error(`Expected "${propertyName}" to be "${expectedValue}" but got "${String(actualValue)}"`); - } - }, - BACKOFF_OPTIONS, - ); -}); - -/** - * Verify that a workspace in settings.json has a property array that contains a specific value - */ -Then('settings.json should have workspace {string} with {string} containing {string}', { timeout: 10000 }, async function( - this: ApplicationWorld, - workspaceName: string, - propertyName: string, - expectedValue: string, -) { - await backOff( - async () => { - if (!await fs.pathExists(settingsPath)) { - throw new Error(`settings.json not found at ${settingsPath}`); - } - - type SettingsFile = { workspaces?: Record } & Record; - const settings = await fs.readJson(settingsPath) as SettingsFile; - - if (!settings.workspaces) { - throw new Error('No workspaces found in settings.json'); - } - - // Find the workspace by name (check both settings.json and tidgi.config.json) - let workspace: IWorkspace | undefined; - for (const ws of Object.values(settings.workspaces)) { - if (ws.name === workspaceName) { - workspace = ws; - break; - } - // Also check tidgi.config.json for wiki workspaces - if (isWikiWorkspace(ws)) { - try { - const tidgiConfigPath = path.join(ws.wikiFolderLocation, 'tidgi.config.json'); - if (await fs.pathExists(tidgiConfigPath)) { - const tidgiConfig = await fs.readJson(tidgiConfigPath) as { name?: string }; - if (tidgiConfig.name === workspaceName) { - workspace = ws; - break; - } - } - } catch { - // Ignore - } - } - } - if (!workspace) { - throw new Error(`Workspace "${workspaceName}" not found in settings.json or tidgi.config.json`); - } - - // Get the property value - check both settings.json and tidgi.config.json - let actualValue = (workspace as unknown as Record)[propertyName]; - - // If not found in settings.json, check tidgi.config.json for wiki workspaces - if (actualValue === undefined && isWikiWorkspace(workspace)) { - try { - const tidgiConfigPath = path.join(workspace.wikiFolderLocation, 'tidgi.config.json'); - if (await fs.pathExists(tidgiConfigPath)) { - const tidgiConfig = await fs.readJson(tidgiConfigPath) as Record; - actualValue = tidgiConfig[propertyName]; - } - } catch { - // Ignore errors reading tidgi.config.json - } - } - - if (!Array.isArray(actualValue)) { - throw new Error(`Expected "${propertyName}" to be an array but got "${typeof actualValue}"`); - } - - if (!actualValue.includes(expectedValue)) { - throw new Error(`Expected "${propertyName}" to contain "${expectedValue}" but got [${actualValue.join(', ')}]`); - } - }, - BACKOFF_OPTIONS, - ); -}); - -/** - * Cleanup function for sub-wiki routing test - * Removes test workspaces created during the test - */ -async function clearSubWikiRoutingTestData() { - if (!(await fs.pathExists(settingsPath))) return; - - type SettingsFile = { workspaces?: Record } & Record; - const settings = await fs.readJson(settingsPath) as SettingsFile; - const workspaces: Record = settings.workspaces ?? {}; - const filtered: Record = {}; - - // Remove test workspaces (SubWiki, etc from sub-wiki routing tests) - for (const id of Object.keys(workspaces)) { - const ws = workspaces[id]; - const name = ws.name; - // Keep workspaces that don't match test patterns - if (name !== 'SubWiki') { - filtered[id] = ws; - } - } - - await fs.writeJson(settingsPath, { ...settings, workspaces: filtered }, { spaces: 2 }); - - // Remove test wiki folders from filesystem - const testFolders = ['SubWiki']; - for (const folder of testFolders) { - const wikiPath = path.join(wikiTestWikiPath, folder); - if (await fs.pathExists(wikiPath)) { - await fs.remove(wikiPath); - } - } -} - -/** - * Clear git test data to prevent state pollution between git tests - * Removes the entire wiki folder - it will be recreated on next test start - */ -async function clearGitTestData() { - const wikiPath = path.join(wikiTestWikiPath, 'wiki'); - if (!(await fs.pathExists(wikiPath))) return; - - try { - await fs.remove(wikiPath); - } catch (error) { - console.warn('Failed to remove wiki folder in git cleanup:', error); - } -} - -/** - * Generic step to wait for any log marker - * @param description - Human-readable description of what we're waiting for (comes first for readability) - * @param marker - The test-id marker to look for in logs - * - * This searches in TidGi- log files by default - */ -Then('I wait for {string} log marker {string}', async function(this: ApplicationWorld, description: string, marker: string) { - // Search in both TidGi- and wiki log files (wiki logs include wiki- and wiki2- etc.) - await waitForLogMarker(marker, `Log marker "${marker}" not found. Expected: ${description}`, 10000, 'TidGi-|wiki'); -}); - -/** - * Convenience step for waiting for SSE and watch-fs to be ready - * This is commonly used in Background sections - */ -Then('I wait for SSE and watch-fs to be ready', async function(this: ApplicationWorld) { - await waitForLogMarker('[test-id-WATCH_FS_STABILIZED]', 'watch-fs did not become ready within timeout', 20000); - await waitForLogMarker('[test-id-SSE_READY]', 'SSE backend did not become ready within timeout', 20000); -}); - -/** - * Remove log lines containing specific text from all log files (TidGi- and wiki- prefixed). - * This is useful when you need to wait for a log marker that may have appeared earlier in the scenario, - * and you want to ensure you're waiting for a new occurrence of that marker. - * @param marker - The text pattern to remove from log files - */ -When('I clear log lines containing {string}', async function(this: ApplicationWorld, marker: string) { - const logDirectory = path.join(process.cwd(), 'userData-test', 'logs'); - if (!fs.existsSync(logDirectory)) return; - - // Clear from both TidGi- and wiki- prefixed log files - const logFiles = fs.readdirSync(logDirectory).filter(f => (f.startsWith('TidGi-') || f.startsWith('wiki')) && f.endsWith('.log')); - - for (const logFile of logFiles) { - const logPath = path.join(logDirectory, logFile); - try { - const content = fs.readFileSync(logPath, 'utf-8'); - // Remove lines containing the marker - const filteredLines = content.split('\n').filter(line => !line.includes(marker)); - fs.writeFileSync(logPath, filteredLines.join('\n'), 'utf-8'); - } catch (error) { - console.warn(`Failed to clear log lines from ${logFile}:`, error); - } - } -}); - -/** - * Convenience steps for waiting for tiddler operations detected by watch-fs - * These use dynamic markers that include the tiddler name - */ -Then('I wait for tiddler {string} to be added by watch-fs', async function(this: ApplicationWorld, tiddlerTitle: string) { - await waitForLogMarker( - `[test-id-WATCH_FS_TIDDLER_ADDED] ${tiddlerTitle}`, - `Tiddler "${tiddlerTitle}" was not added within timeout`, - ); -}); - -Then('I wait for tiddler {string} to be updated by watch-fs', async function(this: ApplicationWorld, tiddlerTitle: string) { - await waitForLogMarker( - `[test-id-WATCH_FS_TIDDLER_UPDATED] ${tiddlerTitle}`, - `Tiddler "${tiddlerTitle}" was not updated within timeout`, - ); -}); - -Then('I wait for tiddler {string} to be deleted by watch-fs', async function(this: ApplicationWorld, tiddlerTitle: string) { - await waitForLogMarker( - `[test-id-WATCH_FS_TIDDLER_DELETED] ${tiddlerTitle}`, - `Tiddler "${tiddlerTitle}" was not deleted within timeout`, - ); -}); - -// File manipulation step definitions - -When('I create file {string} with content:', async function(this: ApplicationWorld, filePath: string, content: string) { - // Replace {tmpDir} placeholder with actual temp directory - const actualPath = filePath.replace('{tmpDir}', wikiTestRootPath); - - // Ensure directory exists - await fs.ensureDir(path.dirname(actualPath)); - - // Write the file with the provided content - await fs.writeFile(actualPath, content, 'utf-8'); -}); - -When('I modify file {string} to contain {string}', async function(this: ApplicationWorld, filePath: string, content: string) { - // Replace {tmpDir} placeholder with actual temp directory - const actualPath = filePath.replace('{tmpDir}', wikiTestRootPath); - - // Read the existing file - let fileContent = await fs.readFile(actualPath, 'utf-8'); - - // TiddlyWiki .tid files have a format: headers followed by blank line and text - // We need to preserve headers and only modify the text part - // Split by both \n and \r\n to handle different line endings - const lines = fileContent.split(/\r?\n/); - - const blankLineIndex = lines.findIndex(line => line.trim() === ''); - - if (blankLineIndex >= 0) { - // File has headers and content separated by blank line - // Keep headers, replace text after blank line - const headers = lines.slice(0, blankLineIndex + 1); - - // Note: We intentionally do NOT update the modified field here - // This simulates a real user editing the file in an external editor, - // where the modified field would not be automatically updated - // The echo prevention mechanism should detect this as a real external change - // because the content changed but the modified timestamp stayed the same - - fileContent = [...headers, content].join('\n'); - } else { - // File has only headers, no content yet (no blank line separator) - // We need to add the blank line separator and the content - // Again, we don't modify the modified field - fileContent = [...lines, '', content].join('\n'); - } - - // Write the modified content back - await fs.writeFile(actualPath, fileContent, 'utf-8'); -}); - -When('I modify file {string} to contain:', async function(this: ApplicationWorld, filePath: string, content: string) { - // Replace {tmpDir} placeholder with actual temp directory - const actualPath = filePath.replace('{tmpDir}', wikiTestRootPath); - - // For multi-line content with headers, just write the content directly - // (assumes the content includes all headers and structure) - await fs.writeFile(actualPath, content, 'utf-8'); -}); - -When('I delete file {string}', async function(this: ApplicationWorld, filePath: string) { - // Replace {tmpDir} placeholder with actual temp directory - const actualPath = filePath.replace('{tmpDir}', wikiTestRootPath); - - // Delete the file - await fs.remove(actualPath); -}); - -When('I delete file {string} in {string}', async function(this: ApplicationWorld, fileName: string, simpleDirectoryPath: string) { - // Replace {tmpDir} with wiki test root - const directoryPath = simpleDirectoryPath.replace('{tmpDir}', wikiTestRootPath); - const filePath = path.join(directoryPath, fileName); - - // Delete the file - await fs.remove(filePath); -}); - -When('I rename file {string} to {string}', async function(this: ApplicationWorld, oldPath: string, newPath: string) { - // Replace {tmpDir} placeholder with actual temp directory - const actualOldPath = oldPath.replace('{tmpDir}', wikiTestRootPath); - const actualNewPath = newPath.replace('{tmpDir}', wikiTestRootPath); - - // Ensure the target directory exists - await fs.ensureDir(path.dirname(actualNewPath)); - - // Rename/move the file - await fs.rename(actualOldPath, actualNewPath); -}); - -When('I modify file {string} to add field {string}', async function(this: ApplicationWorld, filePath: string, fieldLine: string) { - // Replace {tmpDir} placeholder with actual temp directory - const actualPath = filePath.replace('{tmpDir}', wikiTestRootPath); - - // Read the existing file - const fileContent = await fs.readFile(actualPath, 'utf-8'); - - // TiddlyWiki .tid files have headers followed by a blank line and text - // We need to add the field to the headers section - const lines = fileContent.split('\n'); - const blankLineIndex = lines.findIndex(line => line.trim() === ''); - - if (blankLineIndex >= 0) { - // Insert the new field before the blank line - lines.splice(blankLineIndex, 0, fieldLine); - } else { - // No blank line found, add to the beginning - lines.unshift(fieldLine); - } - - // Write the modified content back - await fs.writeFile(actualPath, lines.join('\n'), 'utf-8'); -}); - -When('I open edit workspace window for workspace with name {string}', async function(this: ApplicationWorld, workspaceName: string) { - if (!this.app) { - throw new Error('Application is not available'); - } - - // Use backOff to retry finding the workspace, as tidgi.config.json might be written asynchronously - let targetWorkspaceId: string | undefined; - - await backOff( - async () => { - // Read settings file to get workspace info - const settings = await fs.readJson(settingsPath) as { workspaces?: Record }; - const workspaces: Record = settings.workspaces ?? {}; - - // Find workspace by name or by wikiFolderLocation (in case name is removed from settings.json) - for (const [id, workspace] of Object.entries(workspaces)) { - if (workspace.pageType) continue; // Skip page workspaces - - // Try to match by name (if available in settings.json) - if (workspace.name === workspaceName) { - targetWorkspaceId = id; - return; - } - - // Try to read name from tidgi.config.json - if (isWikiWorkspace(workspace)) { - try { - const tidgiConfigPath = path.join(workspace.wikiFolderLocation, 'tidgi.config.json'); - if (await fs.pathExists(tidgiConfigPath)) { - const tidgiConfig = await fs.readJson(tidgiConfigPath) as { name?: string }; - if (tidgiConfig.name === workspaceName) { - targetWorkspaceId = id; - return; - } - } - } catch { - // Ignore errors reading tidgi.config.json - } - } - } - - // If not found, throw error to trigger retry - throw new Error(`Workspace "${workspaceName}" not found yet, will retry...`); - }, - BACKOFF_OPTIONS, - ); - - if (!targetWorkspaceId) { - throw new Error(`No workspace found with name: ${workspaceName}`); - } - - // Call window service through main window's webContents to open edit workspace window - await this.app.evaluate(async ({ BrowserWindow }, workspaceId: string) => { - const windows = BrowserWindow.getAllWindows(); - const mainWindow = windows.find(win => !win.isDestroyed() && win.webContents && win.webContents.getURL().includes('index.html')); - - if (!mainWindow) { - throw new Error('Main window not found'); - } - - // Call the window service to open edit workspace window - // Safely pass workspaceId using JSON serialization to avoid string interpolation vulnerability - await mainWindow.webContents.executeJavaScript(` - (async () => { - await window.service.window.open('editWorkspace', { workspaceID: ${JSON.stringify(workspaceId)} }); - })(); - `); - }, targetWorkspaceId); - - // Wait for the edit workspace window to appear - const success = await this.waitForWindowCondition( - 'editWorkspace', - (window) => window !== undefined && !window.isClosed(), - ); - - if (!success) { - throw new Error('Edit workspace window did not appear after opening'); - } -}); - -When('I create a new wiki workspace with name {string}', async function(this: ApplicationWorld, workspaceName: string) { - if (!this.app) { - throw new Error('Application is not available'); - } - - // Construct the full wiki path - const wikiPath = path.join(wikiTestRootPath, workspaceName); - - // Create the wiki folder using the template - const templatePath = path.join(process.cwd(), 'template', 'wiki'); - await fs.copy(templatePath, wikiPath); - - // Remove the copied .git directory from the template to start fresh - const gitPath = path.join(wikiPath, '.git'); - await fs.remove(gitPath).catch(() => { - // Ignore if .git doesn't exist - }); - - // Initialize fresh git repository for the new wiki using dugite - try { - // Initialize git repository with master branch - await gitExec(['init', '-b', 'master'], wikiPath); - - // Configure git user - await gitExec(['config', 'user.email', 'test@tidgi.test'], wikiPath); - await gitExec(['config', 'user.name', 'TidGi Test'], wikiPath); - - // Add all files and create initial commit - await gitExec(['add', '.'], wikiPath); - await gitExec(['commit', '-m', 'Initial commit'], wikiPath); - } catch (error) { - // Git initialization is not critical for the test, continue anyway - console.log('Git initialization skipped:', (error as Error).message); - } - - // Now create workspace configuration - await this.app.evaluate(async ({ BrowserWindow }, { wikiName, wikiFullPath }: { wikiName: string; wikiFullPath: string }) => { - const windows = BrowserWindow.getAllWindows(); - const mainWindow = windows.find(win => !win.isDestroyed() && win.webContents && win.webContents.getURL().includes('index.html')); - - if (!mainWindow) { - throw new Error('Main window not found'); - } - - // Call workspace service to create new workspace - // Safely pass parameters using JSON serialization to avoid string interpolation vulnerability - await mainWindow.webContents.executeJavaScript(` - (async () => { - await window.service.workspace.create({ - name: ${JSON.stringify(wikiName)}, - wikiFolderLocation: ${JSON.stringify(wikiFullPath)}, - isSubWiki: false, - storageService: 'local', - }); - })(); - `); - }, { wikiName: workspaceName, wikiFullPath: wikiPath }); - - // Wait for workspace to appear in UI - await this.app.evaluate(async () => { - await new Promise(resolve => setTimeout(resolve, 1000)); - }); -}); - -/** - * Update workspace settings dynamically after app launch - * This is useful for enabling features like enableFileSystemWatch in tests - * - * Usage: - * When I update workspace "wiki" settings: - * | property | value | - * | enableFileSystemWatch | true | - * | syncOnInterval | false | - */ -When('I update workspace {string} settings:', { timeout: 60000 }, async function(this: ApplicationWorld, workspaceName: string, dataTable: DataTable) { - if (!this.app) { - throw new Error('Application is not available'); - } - - // Parse settings from DataTable - const rows = dataTable.hashes(); - const settingsUpdate: Record = {}; - - for (const row of rows) { - const { property, value } = row; - - // Convert value to appropriate type - let parsedValue: unknown = value; - if (value === 'true') parsedValue = true; - else if (value === 'false') parsedValue = false; - else if (value === 'null') parsedValue = null; - else if (!isNaN(Number(value))) parsedValue = Number(value); - // Try to parse as JSON array - else if (value.startsWith('[') && value.endsWith(']')) { - try { - parsedValue = JSON.parse(value); - } catch { - // Keep as string if JSON parse fails - } - } - - settingsUpdate[property] = parsedValue; - } - - // Read settings file to get workspace ID - const settings = await fs.readJson(settingsPath) as { workspaces?: Record }; - const workspaces: Record = settings.workspaces ?? {}; - - // Find workspace by name or by wikiFolderLocation (in case name is removed from settings.json) - let targetWorkspaceId: string | undefined; - for (const [id, workspace] of Object.entries(workspaces)) { - if (workspace.pageType) continue; // Skip page workspaces - - // Try to match by name (if available in settings.json) - if (workspace.name === workspaceName) { - targetWorkspaceId = id; - break; - } - - // Try to read name from tidgi.config.json - if (isWikiWorkspace(workspace)) { - try { - const tidgiConfigPath = path.join(workspace.wikiFolderLocation, 'tidgi.config.json'); - if (await fs.pathExists(tidgiConfigPath)) { - const tidgiConfig = await fs.readJson(tidgiConfigPath) as { name?: string }; - if (tidgiConfig.name === workspaceName) { - targetWorkspaceId = id; - break; - } - } - } catch { - // Ignore errors - } - } - - // Fallback: try to match by folder name in wikiFolderLocation - if ('wikiFolderLocation' in workspace && workspace.wikiFolderLocation) { - const folderName = path.basename(workspace.wikiFolderLocation); - if (folderName === workspaceName) { - targetWorkspaceId = id; - break; - } - } - } - - if (!targetWorkspaceId) { - throw new Error(`No workspace found with name: ${workspaceName}`); - } - - // Update workspace settings via main window - await this.app.evaluate(async ({ BrowserWindow }, { workspaceId, updates }: { workspaceId: string; updates: Record }) => { - const windows = BrowserWindow.getAllWindows(); - const mainWindow = windows.find(win => !win.isDestroyed() && win.webContents && win.webContents.getURL().includes('index.html')); - - if (!mainWindow) { - throw new Error('Main window not found'); - } - - // Call workspace service to update workspace settings - await mainWindow.webContents.executeJavaScript(` - (async () => { - await window.service.workspace.update(${JSON.stringify(workspaceId)}, ${JSON.stringify(updates)}); - })(); - `); - }, { workspaceId: targetWorkspaceId, updates: settingsUpdate }); - - // Wait for settings to propagate - await this.app.evaluate(async () => { - await new Promise(resolve => setTimeout(resolve, 500)); - }); - - // If enableFileSystemWatch was changed, we need to restart the wiki for it to take effect - // The wiki worker reads this config at startup, so changes don't apply until restart - if ('enableFileSystemWatch' in settingsUpdate) { - // First, wait for the wiki to be fully started before attempting restart - // This prevents conflicts if the wiki is still initializing - // Wait for WATCH_FS since it indicates wiki worker is ready, or SSE_READY if watch is disabled - try { - await waitForLogMarker('[test-id-WATCH_FS_STABILIZED]', 'watch-fs not ready before restart', 30000); - } catch { - // If watch-fs is disabled initially, wait for SSE instead - await waitForLogMarker('[test-id-SSE_READY]', 'SSE not ready before restart', 30000); - } - - // Only clear watch-fs related log markers to ensure we wait for fresh ones after restart - // Don't clear other markers like git-init-complete that won't appear again - await clearLogLinesContaining('[test-id-WATCH_FS_STABILIZED]'); - await clearLogLinesContaining('[test-id-SSE_READY]'); - - // Restart the wiki - await this.app.evaluate(async ({ BrowserWindow }, workspaceId: string) => { - const windows = BrowserWindow.getAllWindows(); - const mainWindow = windows.find(win => !win.isDestroyed() && win.webContents && win.webContents.getURL().includes('index.html')); - - if (!mainWindow) { - throw new Error('Main window not found'); - } - - await mainWindow.webContents.executeJavaScript(` - (async () => { - const workspace = await window.service.workspace.get(${JSON.stringify(workspaceId)}); - if (workspace) { - await window.service.wiki.restartWiki(workspace); - } - })(); - `); - }, targetWorkspaceId); - - // Wait for wiki to restart and watch-fs to stabilize - // Only wait if enableFileSystemWatch was set to true - if (settingsUpdate.enableFileSystemWatch === true) { - await waitForLogMarker('[test-id-WATCH_FS_STABILIZED]', 'watch-fs did not stabilize after restart', 30000); - } - } -}); - -/** - * Clean up hibernation test data - remove wiki2 folder and its workspace config - */ -async function clearHibernationTestData() { - const wiki2Path = path.join(wikiTestRootPath, 'wiki2'); - - // Remove wiki2 folder - if (await fs.pathExists(wiki2Path)) { - try { - await fs.remove(wiki2Path); - } catch (error) { - console.warn('Failed to remove wiki2 folder in hibernation cleanup:', error); - } - } - - // Remove wiki2 workspace config from settings.json - const settingsPath = path.join(process.cwd(), 'userData-test', 'settings', 'settings.json'); - if (await fs.pathExists(settingsPath)) { - try { - type SettingsFile = { workspaces?: Record } & Record; - const settings = await fs.readJson(settingsPath) as SettingsFile; - if (settings.workspaces) { - // Find and remove wiki2 workspace by folder location - const wiki2WorkspaceId = Object.keys(settings.workspaces).find(id => { - const workspace = settings.workspaces?.[id]; - return workspace && 'wikiFolderLocation' in workspace && workspace.wikiFolderLocation === wiki2Path; - }); - - if (wiki2WorkspaceId && settings.workspaces) { - delete settings.workspaces[wiki2WorkspaceId]; - await fs.writeJson(settingsPath, settings, { spaces: 2 }); - } - } - } catch (error) { - console.warn('Failed to remove wiki2 workspace config in hibernation cleanup:', error); - } - } -} - -/** - * Setup a sub-wiki with optional settings and multiple pre-existing tiddlers. - * This creates the sub-wiki folder, tiddler files, and settings configuration - * so the app loads everything on first startup. - * - * @param subWikiName - Name of the sub-wiki folder - * @param tagName - Tag name for the sub-wiki routing - * @param options - Optional settings: includeTagTree, fileSystemPathFilter - * @param tiddlers - Array of {title, tags, content} objects from DataTable.hashes() - */ -async function setupSubWiki( - subWikiName: string, - tagName: string, - options: { - includeTagTree?: boolean; - fileSystemPathFilter?: string; - }, - tiddlers: Record[], -) { - // 1. Create sub-wiki folder - const subWikiPath = path.join(wikiTestRootPath, subWikiName); - await fs.ensureDir(subWikiPath); - - // 2. Create tiddler files - const now = new Date(); - const timestamp = now.toISOString().replace(/[-:T.Z]/g, '').slice(0, 17); - - for (const tiddler of tiddlers) { - const tiddlerFilePath = path.join(subWikiPath, `${tiddler.title}.tid`); - const tiddlerFileContent = `created: ${timestamp} -modified: ${timestamp} -tags: ${tiddler.tags} -title: ${tiddler.title} - -${tiddler.content} -`; - await fs.writeFile(tiddlerFilePath, tiddlerFileContent, 'utf-8'); - } - - // 3. Create main wiki folder structure (if not exists) - const mainWikiPath = wikiTestWikiPath; - const templatePath = path.join(process.cwd(), 'template', 'wiki'); - if (!await fs.pathExists(mainWikiPath)) { - await fs.copy(templatePath, mainWikiPath); - // Remove .git from template - await fs.remove(path.join(mainWikiPath, '.git')).catch(() => {/* ignore */}); - } - - // 4. Update settings.json with both main wiki and sub-wiki workspaces - await fs.ensureDir(settingsDirectory); - let settings: { workspaces?: Record } & Record = {}; - if (await fs.pathExists(settingsPath)) { - settings = await fs.readJson(settingsPath) as { workspaces?: Record }; - } - - // Generate unique IDs - const mainWikiId = 'main-wiki-test-id'; - const subWikiId = `sub-wiki-${subWikiName}-test-id`; - - // Create main wiki workspace if not exists - if (!settings.workspaces) { - settings.workspaces = {}; - } - - // Check if main wiki already exists - const existingMainWiki = Object.values(settings.workspaces).find( - ws => 'wikiFolderLocation' in ws && ws.wikiFolderLocation === mainWikiPath, - ); - - const mainWikiIdToUse = existingMainWiki?.id ?? mainWikiId; - - if (!existingMainWiki) { - settings.workspaces[mainWikiId] = { - id: mainWikiId, - name: 'wiki', - wikiFolderLocation: mainWikiPath, - isSubWiki: false, - storageService: 'local', - backupOnInterval: true, - excludedPlugins: [], - enableHTTPAPI: false, - includeTagTree: false, - fileSystemPathFilterEnable: false, - fileSystemPathFilter: null, - tagNames: [], - userName: '', - order: 0, - port: 5212, - readOnlyMode: false, - tokenAuth: false, - tagName: null, - mainWikiToLink: null, - mainWikiID: null, - enableFileSystemWatch: true, - lastNodeJSArgv: [], - homeUrl: `tidgi://${mainWikiId}`, - gitUrl: null, - active: true, - hibernated: false, - hibernateWhenUnused: false, - lastUrl: null, - picturePath: null, - syncOnInterval: false, - syncOnStartup: true, - transparentBackground: false, - } as unknown as IWorkspace; - } - - // Create sub-wiki workspace with optional settings - settings.workspaces[subWikiId] = { - id: subWikiId, - name: subWikiName, - wikiFolderLocation: subWikiPath, - isSubWiki: true, - mainWikiToLink: mainWikiPath, - mainWikiID: mainWikiIdToUse, - storageService: 'local', - backupOnInterval: true, - excludedPlugins: [], - enableHTTPAPI: false, - includeTagTree: options.includeTagTree ?? false, - fileSystemPathFilterEnable: Boolean(options.fileSystemPathFilter), - fileSystemPathFilter: options.fileSystemPathFilter ?? null, - tagNames: [tagName], - userName: '', - order: 1, - port: 5213, - readOnlyMode: false, - tokenAuth: false, - enableFileSystemWatch: true, - lastNodeJSArgv: [], - homeUrl: `tidgi://${subWikiId}`, - gitUrl: null, - active: false, - hibernated: false, - hibernateWhenUnused: false, - lastUrl: null, - picturePath: null, - syncOnInterval: false, - syncOnStartup: true, - transparentBackground: false, - } as unknown as IWorkspace; - - await fs.writeJson(settingsPath, settings, { spaces: 2 }); -} - -/** - * Setup a sub-wiki with tiddlers (basic, no special options) - */ -Given('I setup a sub-wiki {string} with tag {string} and tiddlers:', async function( - this: ApplicationWorld, - subWikiName: string, - tagName: string, - dataTable: DataTable, -) { - const rows = dataTable.hashes(); - await setupSubWiki(subWikiName, tagName, {}, rows); -}); - -/** - * Setup a sub-wiki with includeTagTree enabled and tiddlers - */ -Given('I setup a sub-wiki {string} with tag {string} and includeTagTree enabled and tiddlers:', async function( - this: ApplicationWorld, - subWikiName: string, - tagName: string, - dataTable: DataTable, -) { - const rows = dataTable.hashes(); - await setupSubWiki(subWikiName, tagName, { includeTagTree: true }, rows); -}); - -/** - * Setup a sub-wiki with custom filter and tiddlers - */ -Given('I setup a sub-wiki {string} with tag {string} and filter {string} and tiddlers:', async function( - this: ApplicationWorld, - subWikiName: string, - tagName: string, - filter: string, - dataTable: DataTable, -) { - const rows = dataTable.hashes(); - await setupSubWiki(subWikiName, tagName, { fileSystemPathFilter: filter }, rows); -}); - -export { clearGitTestData, clearHibernationTestData, clearSubWikiRoutingTestData, clearTestIdLogs }; - -/** - * Clear all test-id markers from log files to ensure fresh logs for next test phase - */ -async function clearTestIdLogs() { - const logPath = path.join(process.cwd(), 'userData-test', 'logs'); - - if (!await fs.pathExists(logPath)) { - return; - } - - const logFiles = await fs.readdir(logPath); - - for (const file of logFiles) { - if (file.endsWith('.log')) { - const filePath = path.join(logPath, file); - try { - const content = await fs.readFile(filePath, 'utf-8'); - // Remove all lines containing [test-id- - const lines = content.split('\n'); - const filteredLines = lines.filter(line => !line.includes('[test-id-')); - await fs.writeFile(filePath, filteredLines.join('\n'), 'utf-8'); - } catch (error) { - console.warn(`Failed to clear test-id markers from ${file}:`, error); - } - } - } -} - -When('I clear test-id markers from logs', async function(this: ApplicationWorld) { - await clearTestIdLogs(); -}); - -/** - * Clear log lines containing a specific marker from all log files. - * This is more targeted than clearTestIdLogs - it only removes lines matching the marker. - * @param marker - The text pattern to remove from log files - */ -async function clearLogLinesContaining(marker: string) { - const logDirectory = path.join(process.cwd(), 'userData-test', 'logs'); - if (!await fs.pathExists(logDirectory)) return; - - const logFiles = (await fs.readdir(logDirectory)).filter(f => f.endsWith('.log')); - - for (const logFile of logFiles) { - const logFilePath = path.join(logDirectory, logFile); - try { - const content = await fs.readFile(logFilePath, 'utf-8'); - const filteredLines = content.split('\n').filter(line => !line.includes(marker)); - await fs.writeFile(logFilePath, filteredLines.join('\n'), 'utf-8'); - } catch (error) { - console.warn(`Failed to clear log lines from ${logFile}:`, error); - } - } -} - -/** - * Verify JSON file contains expected values using JSONPath - * Example: - * Then file "config-test-wiki/tidgi.config.json" should contain JSON with: - * | jsonPath | value | - * | $.name | ConfigTestWiki | - * | $.port | 5300 | - */ -Then('file {string} should contain JSON with:', async function(this: ApplicationWorld, fileName: string, dataTable: DataTable) { - const rows = dataTable.hashes(); - const filePath = path.join(wikiTestRootPath, fileName); - - await backOff( - async () => { - if (!await fs.pathExists(filePath)) { - throw new Error(`File not found: ${filePath}`); - } - - const content = await fs.readFile(filePath, 'utf-8'); - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const json = JSON.parse(content); - - for (const row of rows) { - const jsonPath = row.jsonPath; - const expectedValue = row.value; - - // Simple JSONPath implementation for basic paths like $.name, $.port - const pathParts = jsonPath.replace(/^\$\./, '').split('.'); - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - let actualValue = json; - - for (const part of pathParts) { - if (actualValue && typeof actualValue === 'object' && part in actualValue) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - actualValue = actualValue[part]; - } else { - throw new Error(`Path ${jsonPath} not found in JSON`); - } - } - - // Convert to string for comparison - const actualValueString = String(actualValue); - if (actualValueString !== expectedValue) { - throw new Error(`Expected ${jsonPath} to be "${expectedValue}", but got "${actualValueString}"`); - } - } - }, - BACKOFF_OPTIONS, - ); -}); - -/** - * Remove workspace without deleting files (via API) - */ -When('I remove workspace {string} keeping files', async function(this: ApplicationWorld, workspaceName: string) { - if (!this.app) { - throw new Error('Application not launched'); - } - - if (!await fs.pathExists(settingsPath)) { - throw new Error(`Settings file not found at ${settingsPath}`); - } - - // Read settings file to get workspace ID - const settings = await fs.readJson(settingsPath) as { workspaces?: Record }; - const workspaces: Record = settings.workspaces ?? {}; - - // Find workspace by name - check both settings.json and tidgi.config.json - let targetWorkspaceId: string | undefined; - for (const [id, workspace] of Object.entries(workspaces)) { - if (workspace.pageType) continue; // Skip page workspaces - - let workspaceName_: string | undefined = workspace.name; - - // If name is not in settings.json, try to read from tidgi.config.json - if (!workspaceName_ && isWikiWorkspace(workspace)) { - try { - const tidgiConfigPath = path.join(workspace.wikiFolderLocation, 'tidgi.config.json'); - if (await fs.pathExists(tidgiConfigPath)) { - const tidgiConfig = await fs.readJson(tidgiConfigPath) as { name?: string }; - workspaceName_ = tidgiConfig.name; - } - } catch { - // Ignore errors reading tidgi.config.json - } - } - - if (workspaceName_ === workspaceName) { - targetWorkspaceId = id; - break; - } - } - - if (!targetWorkspaceId) { - throw new Error(`No workspace found with name: ${workspaceName}`); - } - - // Remove workspace via API (without showing dialog, directly call remove) - await this.app.evaluate(async ({ BrowserWindow }, { workspaceId }: { workspaceId: string }) => { - const windows = BrowserWindow.getAllWindows(); - const mainWindow = windows.find(win => !win.isDestroyed() && win.webContents && win.webContents.getURL().includes('index.html')); - - if (!mainWindow) { - throw new Error('Main window not found'); - } - - // Stop wiki and remove workspace without deleting files - await mainWindow.webContents.executeJavaScript(` - (async () => { - await window.service.wiki.stopWiki(${JSON.stringify(workspaceId)}); - await window.service.workspaceView.removeWorkspaceView(${JSON.stringify(workspaceId)}); - await window.service.workspace.remove(${JSON.stringify(workspaceId)}); - })(); - `); - }, { workspaceId: targetWorkspaceId }); - - // Wait for removal to propagate - await this.app.evaluate(async () => { - await new Promise(resolve => setTimeout(resolve, 500)); - }); -}); diff --git a/features/streamingStatus.feature b/features/streamingStatus.feature new file mode 100644 index 00000000..e13a9b19 --- /dev/null +++ b/features/streamingStatus.feature @@ -0,0 +1,105 @@ +Feature: Message Streaming Status + As a user + I want the send button to return to normal state after AI completes + So that I can send multiple messages consecutively + + Background: + Given I add test ai settings + And I have started the mock OpenAI server without rules + Then I launch the TidGi application + And I wait for the page to load completely + And I should see a "page body" element with selector "body" + # Navigate to agent workspace + And I click on "agent workspace button and new tab button" elements with selectors: + | element description | selector | + | agent workspace | [data-testid='workspace-agent'] | + | new tab button | [data-tab-id='new-tab-button'] | + + @agent @mockOpenAI @streamingStatus + Scenario: Send button returns to normal state after AI response completes + # Add mock response + Given I add mock OpenAI responses: + | response | stream | + | First reply | false | + | Second reply | false | + | Third reply | false | + + # Open agent chat + When I click on a "new tab button" element with selector "[data-tab-id='new-tab-button']" + And I should see a "search interface" element with selector ".aa-Autocomplete" + When I click on a "search input box" element with selector ".aa-Input" + And I should see an "autocomplete panel" element with selector ".aa-Panel" + When I click on an "agent suggestion" element with selector '[data-autocomplete-source-id="agentsSource"] .aa-ItemWrapper' + And I should see a "message input box" element with selector "[data-testid='agent-message-input']" + + # Send first message + When I click on a "message input textarea" element with selector "[data-testid='agent-message-input']" + When I type "First message" in "chat input" element with selector "[data-testid='agent-message-input']" + And I press "Enter" key + Then I should see 2 messages in chat history + + # Verify send button is in normal state (not streaming) + # The send icon should be visible and cancel icon should not be visible + And I should see a "send button icon" element with selector "[data-testid='send-icon']" + And I should not see a "cancel button icon" element with selector "[data-testid='cancel-icon']" + + # Send second message to confirm button works + When I type "Second message" in "chat input" element with selector "[data-testid='agent-message-input']" + And I press "Enter" key + Then I should see 4 messages in chat history + + # Verify send button is still in normal state + And I should see a "send button icon" element with selector "[data-testid='send-icon']" + And I should not see a "cancel button icon" element with selector "[data-testid='cancel-icon']" + + # Send third message to triple confirm + When I type "Third message" in "chat input" element with selector "[data-testid='agent-message-input']" + And I press "Enter" key + Then I should see 6 messages in chat history + + # Final verification + And I should see a "send button icon" element with selector "[data-testid='send-icon']" + And I should not see a "cancel button icon" element with selector "[data-testid='cancel-icon']" + + @agent @mockOpenAI @streamingStatus @imageUpload + Scenario: Image upload streaming status and history verification + # Add mock responses + Given I add mock OpenAI responses: + | response | stream | + | Received image and text | false | + | Received second message | false | + + # Open agent chat + When I click on a "new tab button" element with selector "[data-tab-id='new-tab-button']" + And I should see a "search interface" element with selector ".aa-Autocomplete" + When I click on a "search input box" element with selector ".aa-Input" + And I should see an "autocomplete panel" element with selector ".aa-Panel" + When I click on an "agent suggestion" element with selector '[data-autocomplete-source-id="agentsSource"] .aa-ItemWrapper' + And I should see a "message input box" element with selector "[data-testid='agent-message-input']" + + # Set file directly to the hidden file input using Playwright + When I set file "template/wiki/files/TiddlyWikiIconBlack.png" to file input with selector "input[type='file']" + # Verify image preview appears + Then I should see an "attachment preview" element with selector "[data-testid='attachment-preview']" + + # Send message with image + When I click on a "message input textarea" element with selector "[data-testid='agent-message-input']" + When I type "Describe this image" in "chat input" element with selector "[data-testid='agent-message-input']" + And I press "Enter" key + Then I should see 2 messages in chat history + + # Verify image appears in chat history + And I should see a "message image attachment" element with selector "[data-testid='message-image-attachment']" + + # Verify send button returned to normal after first message + And I should see a "send button icon" element with selector "[data-testid='send-icon']" + And I should not see a "cancel button icon" element with selector "[data-testid='cancel-icon']" + + # Send second message to check history includes image + When I type "Continue" in "chat input" element with selector "[data-testid='agent-message-input']" + And I press "Enter" key + Then I should see 4 messages in chat history + + # Verify send button is still normal after second message + And I should see a "send button icon" element with selector "[data-testid='send-icon']" + And I should not see a "cancel button icon" element with selector "[data-testid='cancel-icon']" diff --git a/features/supports/timeout-config.ts b/features/supports/timeout-config.ts new file mode 100644 index 00000000..c82d0181 --- /dev/null +++ b/features/supports/timeout-config.ts @@ -0,0 +1,11 @@ +import { setDefaultTimeout } from '@cucumber/cucumber'; + +const isCI = Boolean(process.env.CI); + +// Set global timeout for all steps and hooks +// Local: 5s, CI: 25s +const globalTimeout = isCI ? 25000 : 5000; + +console.log('[Timeout Config] Setting global timeout to:', globalTimeout, 'ms (CI:', isCI, ')'); + +setDefaultTimeout(globalTimeout); diff --git a/features/supports/timeouts.ts b/features/supports/timeouts.ts new file mode 100644 index 00000000..d375d4d3 --- /dev/null +++ b/features/supports/timeouts.ts @@ -0,0 +1,36 @@ +/** + * Centralized timeout configuration for E2E tests + * + * IMPORTANT: Most steps should NOT specify custom timeouts! + * CucumberJS global timeout is configured in cucumber.config.js: + * - Local: 5 seconds + * - CI: 10 seconds (exactly 2x local) + * + * Only special operations (like complex browser view UI interactions) should + * specify custom timeouts at the step level, with clear comments explaining why. + * + * If an operation times out, it indicates a performance issue that should be fixed, + * not a timeout that should be increased. + */ + +const isCI = Boolean(process.env.CI); + +/** + * Timeout for Playwright waitForSelector and similar operations + * These are internal timeouts for finding elements, not Cucumber step timeouts + * Local: 5s, CI: 25s + */ +export const PLAYWRIGHT_TIMEOUT = isCI ? 25000 : 5000; + +/** + * Shorter timeout for operations that should be very fast + * Local: 3s, CI: 15s + */ +export const PLAYWRIGHT_SHORT_TIMEOUT = isCI ? 15000 : 3000; + +/** + * Timeout for waiting log markers + * Internal wait should be shorter than step timeout to allow proper error reporting + * Local: 3s, CI: 15s + */ +export const LOG_MARKER_WAIT_TIMEOUT = isCI ? 15000 : 3000; diff --git a/localization/locales/en/agent.json b/localization/locales/en/agent.json index df0c6a72..f51a3f64 100644 --- a/localization/locales/en/agent.json +++ b/localization/locales/en/agent.json @@ -36,15 +36,22 @@ "Chat": { "Cancel": "Cancel", "ConfigError": { + "AuthenticationError": "{{provider}} authentication failed. Please check your API key in Settings.", + "AuthenticationFailed": "{{provider}} authentication failed. Please check your API key in Settings.", "GoToSettings": "Go to Settings", - "MissingConfigError": "No AI provider or model configured. Please configure in Settings.", "MissingAPIKeyError": "API key for {{provider}} not found. Please add it in Settings.", "MissingBaseURLError": "{{provider}} provider requires a base URL. Please configure it in Settings.", - "AuthenticationFailed": "{{provider}} authentication failed. Please check your API key in Settings.", - "ProviderNotFound": "Provider {{provider}} not found. Please configure it in Settings.", + "MissingConfigError": "No AI provider or model configured. Please configure in Settings.", "MissingProviderError": "Provider {{provider}} is not available. Please configure it in Settings.", + "ModelNoVisionSupport": "Model {{model}} does not support vision/image input. Please select a vision-capable model (look for models with 'vision' feature tag).", + "NoDefaultModel": "No default model configured. Please configure a default model in settings.", + "ProviderNotFound": "Provider {{provider}} not found. Please configure it in Settings.", "Title": "Configuration Issue" }, + "FileValidation": { + "NotAnImage": "Selected file is not an image ({{fileType}}). Please select an image file.", + "TooLarge": "File size {{size}}MB exceeds the limit ({{maxSize}}MB). Please select a smaller file." + }, "InputPlaceholder": "Type a message, Ctrl+Enter to send", "Send": "Send" }, @@ -232,6 +239,7 @@ "AutoRefresh": "Preview auto-refreshes with input text changes", "CodeEditor": "Code Editor", "Edit": "Edit", + "Enabled": "enable", "EnterEditSideBySide": "Show editor side-by-side", "EnterFullScreen": "Enter full screen", "EnterPreviewSideBySide": "Show preview side-by-side", @@ -494,6 +502,8 @@ "CaptionTitle": "Title", "Content": "Plugin content or description", "ContentTitle": "content", + "Enabled": "Determine whether this text is incorporated into the final prompt.", + "EnabledTitle": "enable", "ForbidOverrides": "Is it prohibited to override the parameters of this plugin at runtime?", "ForbidOverridesTitle": "Do not overwrite", "Id": "Plugin instance ID (unique within the same handler)", @@ -619,10 +629,6 @@ "WikiEmbed": "Wiki" } }, - "WikiEmbed": { - "Error": "Failed to embed wiki", - "Loading": "Loading wiki..." - }, "Tool": { "Git": { "Error": { @@ -682,5 +688,9 @@ } } } + }, + "WikiEmbed": { + "Error": "Failed to embed wiki", + "Loading": "Loading wiki..." } } diff --git a/localization/locales/en/translation.json b/localization/locales/en/translation.json index b7e935ce..d017b1bf 100644 --- a/localization/locales/en/translation.json +++ b/localization/locales/en/translation.json @@ -295,6 +295,7 @@ "InfiniteScroll": "Infinite Scroll", "LoadingFull": "Loading...", "LoadingMore": "Loading more...", + "LoadingWorkspace": "Loading workspace...", "Message": "Submit information", "MessageSearch": "Commit Message", "NewImage": "New image (added in this commit)", @@ -509,6 +510,8 @@ "OpenMetaDataFolderDetail": "TiddlyWiki's data and TidGi's workspace metadata are stored separately. TidGi's metadata includes workspace settings, etc., which are stored in this folder in JSON format.", "OpenV8CacheFolder": "Open the V8 cache folder", "OpenV8CacheFolderDetail": "The V8 cache folder stores cached files that accelerate application startup", + "OpenInstallerLogFolder": "Open the installer log folder", + "OpenInstallerLogFolderDetail": "The Windows installer log folder (SquirrelTemp) contains logs from application installation and updates", "Performance": "Performance", "PrivacyAndSecurity": "Privacy & Security", "ReceivePreReleaseUpdates": "Receive pre-release updates", diff --git a/localization/locales/fr/agent.json b/localization/locales/fr/agent.json index 351e20a0..736fe7c1 100644 --- a/localization/locales/fr/agent.json +++ b/localization/locales/fr/agent.json @@ -36,9 +36,22 @@ "Chat": { "Cancel": "Annuler", "ConfigError": { + "AuthenticationError": "L'authentification {{provider}} a échoué. Veuillez vérifier votre clé API dans les paramètres.", + "AuthenticationFailed": "L'authentification {{provider}} a échoué. Veuillez vérifier votre clé API dans les paramètres.", "GoToSettings": "Aller aux paramètres", + "MissingAPIKeyError": "Clé API pour {{provider}} introuvable. Veuillez l'ajouter dans les paramètres.", + "MissingBaseURLError": "Le fournisseur {{provider}} nécessite une URL de base. Veuillez la configurer dans les paramètres.", + "MissingConfigError": "Aucun fournisseur AI ou modèle configuré. Veuillez configurer dans les paramètres.", + "MissingProviderError": "Le fournisseur {{provider}} n'est pas disponible. Veuillez le configurer dans les paramètres.", + "ProviderNotFound": "Fournisseur {{provider}} introuvable. Veuillez le configurer dans les paramètres.", + "NoDefaultModel": "Aucun modèle par défaut configuré. Veuillez sélectionner un modèle dans les paramètres.", + "ModelNoVisionSupport": "Le modèle sélectionné ne prend pas en charge la vision. Veuillez choisir un modèle compatible avec la vision dans les paramètres.", "Title": "Problème de configuration" }, + "FileValidation": { + "NotAnImage": "Le fichier sélectionné n'est pas une image ({{fileType}}). Veuillez sélectionner un fichier image.", + "TooLarge": "La taille du fichier {{size}}MB dépasse la limite ({{maxSize}}MB). Veuillez sélectionner un fichier plus petit." + }, "InputPlaceholder": "Tapez un message, Ctrl+Entrée pour envoyer", "Send": "Envoyer" }, @@ -239,6 +252,7 @@ "AutoRefresh": "L'aperçu se rafraîchit automatiquement en fonction des modifications du texte saisi.", "CodeEditor": "Éditeur de code", "Edit": "Édition des mots-clés", + "Enabled": "activer", "EnterEditSideBySide": "Édition en affichage partagé", "EnterFullScreen": "entrer en plein écran", "EnterPreviewSideBySide": "Aperçu en mode écran partagé", @@ -501,6 +515,8 @@ "CaptionTitle": "titre", "Content": "Contenu ou description du plugin", "ContentTitle": "contenu", + "Enabled": "déterminer si ce texte est intégré dans l'invite finale", + "EnabledTitle": "activer", "ForbidOverrides": "Est-il interdit de remplacer les paramètres de ce plugin pendant l'exécution ?", "ForbidOverridesTitle": "Interdiction de couvrir", "Id": "ID d'instance du plugin (unique dans le même gestionnaire)", @@ -622,7 +638,8 @@ "EditAgentDefinition": "Agent éditorial intelligent", "NewTab": "Nouvel onglet", "NewWeb": "nouvelle page web", - "SplitView": "Affichage divisé" + "SplitView": "Affichage divisé", + "WikiEmbed": "Wiki" } }, "Tool": { @@ -685,5 +702,9 @@ } } }, - "Unknown": "inconnu" + "Unknown": "inconnu", + "WikiEmbed": { + "Error": "Échec de l'intégration dans le Wiki", + "Loading": "Chargement du Wiki en cours..." + } } diff --git a/localization/locales/fr/translation.json b/localization/locales/fr/translation.json index 931ded2c..60aef219 100644 --- a/localization/locales/fr/translation.json +++ b/localization/locales/fr/translation.json @@ -295,6 +295,7 @@ "InfiniteScroll": "chargement en défilement", "LoadingFull": "Chargement en cours...", "LoadingMore": "Charger plus...", + "LoadingWorkspace": "Chargement de l'espace de travail...", "Message": "soumettre les informations", "MessageSearch": "soumettre les informations", "NewImage": "Nouvelle image (ajoutée lors de cette soumission)", @@ -509,6 +510,8 @@ "OpenMetaDataFolderDetail": "Les données de TiddlyWiki et les métadonnées de l'espace de travail de TidGi sont stockées séparément. Les métadonnées de TidGi incluent les paramètres de l'espace de travail, etc., qui sont stockées dans ce dossier au format JSON.", "OpenV8CacheFolder": "Ouvrir le dossier de cache V8", "OpenV8CacheFolderDetail": "Le dossier de cache V8 stocke les fichiers mis en cache qui accélèrent le démarrage de l'application", + "OpenInstallerLogFolder": "Ouvrir le dossier des journaux d'installation", + "OpenInstallerLogFolderDetail": "Le dossier des journaux d'installation Windows (SquirrelTemp) contient les journaux d'installation et de mise à jour de l'application", "Performance": "Performance", "PrivacyAndSecurity": "Confidentialité & Sécurité", "ReceivePreReleaseUpdates": "Recevoir les mises à jour préliminaires", diff --git a/localization/locales/ja/agent.json b/localization/locales/ja/agent.json index 35bbfc8a..cc8f4db9 100644 --- a/localization/locales/ja/agent.json +++ b/localization/locales/ja/agent.json @@ -36,9 +36,22 @@ "Chat": { "Cancel": "キャンセル", "ConfigError": { + "AuthenticationError": "{{provider}} の認証に失敗しました。設定でAPIキーを確認してください。", + "AuthenticationFailed": "{{provider}} の認証に失敗しました。設定でAPIキーを確認してください。", "GoToSettings": "設定へ移動", + "MissingAPIKeyError": "{{provider}} のAPIキーが見つかりません。設定で追加してください。", + "MissingBaseURLError": "{{provider}} プロバイダーにはベーsURLの設定が必要です。設定で構成してください。", + "MissingConfigError": "AIプロバイダーまたはモデルが設定されていません。設定で構成してください。", + "MissingProviderError": "プロバイダー {{provider}} は利用できません。設定で構成してください。", + "NoDefaultModel": "デフォルトモデルが設定されていません。設定でモデルを選択してください。", + "ModelNoVisionSupport": "選択したモデルは画像入力(ビジョン)をサポートしていません。ビジョン機能を持つモデルを選択してください。", + "ProviderNotFound": "プロバイダー {{provider}} が見つかりません。設定で構成してください。", "Title": "設定の問題" }, + "FileValidation": { + "NotAnImage": "選択したファイルは画像形式ではありません({{fileType}})。画像ファイルを選択してください。", + "TooLarge": "ファイルサイズ {{size}}MB が制限({{maxSize}}MB)を超えています。もっと小さいファイルを選択してください。" + }, "InputPlaceholder": "メッセージを入力、Ctrl+Enterで送信", "Send": "送信" }, @@ -240,6 +253,7 @@ "AutoRefresh": "プレビューは入力テキストの変更に応じて自動的に更新されます", "CodeEditor": "コードエディタ", "Edit": "プロンプト編集", + "Enabled": "有効化", "EnterEditSideBySide": "分割画面表示編集", "EnterFullScreen": "全画面表示に入る", "EnterPreviewSideBySide": "分割画面表示プレビュー", @@ -502,6 +516,8 @@ "CaptionTitle": "タイトル", "Content": "プラグインの内容または説明", "ContentTitle": "内容", + "Enabled": "このテキストが最終的なプロンプトに組み込まれるかどうかを決定する", + "EnabledTitle": "有効化", "ForbidOverrides": "このプラグインのパラメータを実行時に上書きすることを禁止しますか?", "ForbidOverridesTitle": "上書き禁止", "Id": "プラグインインスタンスID(同一ハンドラ内で一意)", @@ -623,7 +639,8 @@ "EditAgentDefinition": "編集エージェント", "NewTab": "新しいタブ", "NewWeb": "新しいウェブページを作成", - "SplitView": "分割画面表示" + "SplitView": "分割画面表示", + "WikiEmbed": "ウィキ" } }, "Tool": { @@ -686,5 +703,9 @@ } } }, - "Unknown": "未知" + "Unknown": "未知", + "WikiEmbed": { + "Error": "Wikiへの埋め込みに失敗しました", + "Loading": "Wikiを読み込んでいます..." + } } diff --git a/localization/locales/ja/translation.json b/localization/locales/ja/translation.json index b03d8b91..f42a26b2 100644 --- a/localization/locales/ja/translation.json +++ b/localization/locales/ja/translation.json @@ -295,6 +295,7 @@ "InfiniteScroll": "スクロールロード", "LoadingFull": "読み込み中...", "LoadingMore": "さらに読み込む...", + "LoadingWorkspace": "ワークスペースを読み込んでいます...", "Message": "コミットメッセージ", "MessageSearch": "情報を提出する", "NewImage": "新しい画像(今回の提出で追加)", @@ -508,6 +509,8 @@ "OpenMetaDataFolderDetail": "太微のデータと太記のワークスペースデータは別々に保存されています。太記のデータにはワークスペースの設定などが含まれており、それらはJSON形式でこのフォルダ内に保存されています。", "OpenV8CacheFolder": "V8キャッシュフォルダを開く", "OpenV8CacheFolderDetail": "V8キャッシュフォルダには、アプリケーションの起動を高速化するためのキャッシュファイルが保存されています。", + "OpenInstallerLogFolder": "インストーラーログフォルダを開く", + "OpenInstallerLogFolderDetail": "Windowsインストーラーログフォルダ (SquirrelTemp) には、アプリケーションのインストールと更新のログが含まれています。", "Performance": "性能", "PrivacyAndSecurity": "プライバシーとセキュリティ", "ReceivePreReleaseUpdates": "プレリリース更新を受信する", diff --git a/localization/locales/ru/agent.json b/localization/locales/ru/agent.json index 9222d348..42fede39 100644 --- a/localization/locales/ru/agent.json +++ b/localization/locales/ru/agent.json @@ -36,9 +36,22 @@ "Chat": { "Cancel": "Отмена", "ConfigError": { + "AuthenticationError": "Ошибка аутентификации {{provider}}. Пожалуйста, проверьте API-ключ в настройках.", + "AuthenticationFailed": "Ошибка аутентификации {{provider}}. Пожалуйста, проверьте API-ключ в настройках.", "GoToSettings": "Перейти к настройкам", + "MissingAPIKeyError": "API-ключ для {{provider}} не найден. Пожалуйста, добавьте его в настройках.", + "MissingBaseURLError": "Провайдер {{provider}} требует базовый URL. Пожалуйста, настройте его в настройках.", + "MissingConfigError": "Провайдер AI или модель не настроены. Пожалуйста, настройте в настройках.", + "MissingProviderError": "Провайдер {{provider}} недоступен. Пожалуйста, настройте его в настройках.", + "NoDefaultModel": "Модель по умолчанию не настроена. Пожалуйста, выберите модель в настройках.", + "ModelNoVisionSupport": "Выбранная модель не поддерживает обработку изображений. Пожалуйста, выберите модель с поддержкой vision.", + "ProviderNotFound": "Провайдер {{provider}} не найден. Пожалуйста, настройте его в настройках.", "Title": "Проблема с конфигурацией" }, + "FileValidation": { + "NotAnImage": "Выбранный файл не является изображением ({{fileType}}). Пожалуйста, выберите файл изображения.", + "TooLarge": "Размер файла {{size}}МБ превышает лимит ({{maxSize}}МБ). Пожалуйста, выберите файл меньшего размера." + }, "InputPlaceholder": "Введите сообщение, Ctrl+Enter для отправки", "Send": "Отправить" }, @@ -240,6 +253,7 @@ "AutoRefresh": "Предварительный просмотр автоматически обновляется при изменении введенного текста.", "CodeEditor": "Редактор кода", "Edit": "редактирование подсказок", + "Enabled": "включить", "EnterEditSideBySide": "Редактирование с разделенным экраном", "EnterFullScreen": "перейти в полноэкранный режим", "EnterPreviewSideBySide": "Предварительный просмотр разделенного экрана", @@ -502,6 +516,8 @@ "CaptionTitle": "заголовок", "Content": "содержание или описание плагина", "ContentTitle": "содержание", + "Enabled": "Решить, будет ли этот текст включен в итоговый подсказку.", + "EnabledTitle": "включить", "ForbidOverrides": "Запрещено ли переопределять параметры этого плагина во время выполнения?", "ForbidOverridesTitle": "запрещено перекрывать", "Id": "ID экземпляра плагина (уникальный в пределах одного обработчика)", @@ -623,7 +639,8 @@ "EditAgentDefinition": "Редакторский интеллектуальный агент", "NewTab": "Новая вкладка", "NewWeb": "создать новую веб-страницу", - "SplitView": "разделенный экран" + "SplitView": "разделенный экран", + "WikiEmbed": "Вики" } }, "Tool": { @@ -686,5 +703,9 @@ } } }, - "Unknown": "неизвестный" + "Unknown": "неизвестный", + "WikiEmbed": { + "Error": "Не удалось встроить Wiki", + "Loading": "Загрузка Wiki..." + } } diff --git a/localization/locales/ru/translation.json b/localization/locales/ru/translation.json index ea208e2a..2f6644a7 100644 --- a/localization/locales/ru/translation.json +++ b/localization/locales/ru/translation.json @@ -295,6 +295,7 @@ "InfiniteScroll": "постепенная загрузка", "LoadingFull": "Загрузка...", "LoadingMore": "Загрузить больше...", + "LoadingWorkspace": "Загрузка рабочего пространства...", "Message": "отправить информацию", "MessageSearch": "отправить информацию", "NewImage": "Новые изображения (добавлены в этой отправке)", @@ -508,6 +509,8 @@ "OpenMetaDataFolderDetail": "Детали открытия папки с метаданными", "OpenV8CacheFolder": "Открыть папку с кешем V8", "OpenV8CacheFolderDetail": "Детали открытия папки с кешем V8", + "OpenInstallerLogFolder": "Открыть папку с логами установщика", + "OpenInstallerLogFolderDetail": "Папка с логами установщика Windows (SquirrelTemp) содержит журналы установки и обновления приложения", "Performance": "Производительность", "PrivacyAndSecurity": "Конфиденциальность и безопасность", "ReceivePreReleaseUpdates": "Получать предварительные обновления", diff --git a/localization/locales/zh-Hans/agent.json b/localization/locales/zh-Hans/agent.json index 69ebe96b..9af173f3 100644 --- a/localization/locales/zh-Hans/agent.json +++ b/localization/locales/zh-Hans/agent.json @@ -36,15 +36,22 @@ "Chat": { "Cancel": "取消", "ConfigError": { + "AuthenticationError": "{{provider}} 身份验证失败。请在设置中检查您的 API 密钥。", + "AuthenticationFailed": "{{provider}} 身份验证失败。请在设置中检查您的 API 密钥。", "GoToSettings": "前往设置", - "MissingConfigError": "未配置 AI 提供商或模型。请在设置中进行配置。", "MissingAPIKeyError": "未找到 {{provider}} 的 API 密钥。请在设置中添加。", "MissingBaseURLError": "{{provider}} 提供商需要配置基础 URL。请在设置中进行配置。", - "AuthenticationFailed": "{{provider}} 身份验证失败。请在设置中检查您的 API 密钥。", - "ProviderNotFound": "未找到提供商 {{provider}}。请在设置中进行配置。", + "MissingConfigError": "未配置 AI 提供商或模型。请在设置中进行配置。", "MissingProviderError": "提供商 {{provider}} 不可用。请在设置中进行配置。", + "ModelNoVisionSupport": "模型 {{model}} 不支持视觉/图片输入。请选择具有“视觉”功能标签的模型。", + "NoDefaultModel": "未配置默认模型。请在设置中配置默认模型。", + "ProviderNotFound": "未找到提供商 {{provider}}。请在设置中进行配置。", "Title": "配置问题" }, + "FileValidation": { + "NotAnImage": "所选文件不是图片格式({{fileType}})。请选择图片文件。", + "TooLarge": "文件大小 {{size}}MB 超过了限制({{maxSize}}MB)。请选择较小的文件。" + }, "InputPlaceholder": "输入消息,Ctrl+Enter 发送", "Send": "发送" }, @@ -229,6 +236,7 @@ "AutoRefresh": "预览会随输入文本的变化自动刷新", "CodeEditor": "代码编辑器", "Edit": "提示词编辑", + "Enabled": "启用", "EnterEditSideBySide": "分屏显示编辑", "EnterFullScreen": "进入全屏", "EnterPreviewSideBySide": "分屏显示预览", @@ -475,6 +483,8 @@ "CaptionTitle": "标题", "Content": "插件内容或说明", "ContentTitle": "内容", + "Enabled": "决定这个文本是否被拼入最终的提示词里", + "EnabledTitle": "启用", "ForbidOverrides": "是否禁止在运行时覆盖此插件的参数", "ForbidOverridesTitle": "禁止覆盖", "Id": "插件实例 ID(同一 handler 内唯一)", @@ -600,10 +610,6 @@ "WikiEmbed": "Wiki" } }, - "WikiEmbed": { - "Error": "嵌入Wiki失败", - "Loading": "正在加载Wiki..." - }, "Tool": { "Git": { "Error": { @@ -663,5 +669,9 @@ } } } + }, + "WikiEmbed": { + "Error": "嵌入Wiki失败", + "Loading": "正在加载Wiki..." } } diff --git a/localization/locales/zh-Hans/translation.json b/localization/locales/zh-Hans/translation.json index f56d8da5..c9413f6a 100644 --- a/localization/locales/zh-Hans/translation.json +++ b/localization/locales/zh-Hans/translation.json @@ -295,6 +295,7 @@ "InfiniteScroll": "滚动加载", "LoadingFull": "加载中...", "LoadingMore": "加载更多...", + "LoadingWorkspace": "正在加载工作区...", "Message": "提交信息", "MessageSearch": "提交信息", "NewImage": "新图片(本次提交添加)", @@ -527,6 +528,8 @@ "OpenMetaDataFolderDetail": "太微的数据和太记的工作区数据是分开存放的,太记的数据包含工作区的设置等,它们以 JSON 形式存放在这个文件夹里。", "OpenV8CacheFolder": "打开V8缓存文件夹", "OpenV8CacheFolderDetail": "V8缓存文件夹存有加速应用启动的快取文件", + "OpenInstallerLogFolder": "打开安装包日志文件夹", + "OpenInstallerLogFolderDetail": "Windows 安装程序日志文件夹 (SquirrelTemp) 包含应用安装和更新的日志", "Performance": "性能", "PrivacyAndSecurity": "隐私和安全", "ProviderAddedSuccessfully": "提供商已成功添加", diff --git a/localization/locales/zh-Hant/agent.json b/localization/locales/zh-Hant/agent.json index b0751029..36952ea6 100644 --- a/localization/locales/zh-Hant/agent.json +++ b/localization/locales/zh-Hant/agent.json @@ -36,9 +36,21 @@ "Chat": { "Cancel": "取消", "ConfigError": { + "AuthenticationError": "{{provider}} 身份驗證失敗。請在設置中檢查您的 API 密鑰。", + "AuthenticationFailed": "{{provider}} 身份驗證失敗。請在設置中檢查您的 API 密鑰。", "GoToSettings": "前往設置", + "MissingAPIKeyError": "未找到 {{provider}} 的 API 密鑰。請在設置中添加。", + "MissingBaseURLError": "{{provider}} 提供商需要配置基礎 URL。請在設置中進行配置。", + "MissingConfigError": "未配置 AI 提供商或模型。請在設置中進行配置。", + "MissingProviderError": "提供商 {{provider}} 不可用。請在設置中進行配置。", + "NoDefaultModel": "未配置默認模型。請在設定中配置默認模型。", + "ProviderNotFound": "未找到提供商 {{provider}}。請在設置中進行配置。", "Title": "配置問題" }, + "FileValidation": { + "NotAnImage": "所選檔案不是圖片格式({{fileType}})。請選擇圖片檔案。", + "TooLarge": "檔案大小 {{size}}MB 超過了限制({{maxSize}}MB)。請選擇較小的檔案。" + }, "InputPlaceholder": "輸入消息,Ctrl+Enter 發送", "Send": "發送" }, @@ -223,6 +235,7 @@ "AutoRefresh": "預覽會隨輸入文本的變化自動刷新", "CodeEditor": "代碼編輯器", "Edit": "提示詞編輯", + "Enabled": "啟用", "EnterEditSideBySide": "分屏顯示編輯", "EnterFullScreen": "進入全螢幕", "EnterPreviewSideBySide": "分屏顯示預覽", @@ -485,6 +498,8 @@ "CaptionTitle": "標題", "Content": "外掛內容或說明", "ContentTitle": "內容", + "Enabled": "決定這個文本是否被拼入最終的提示詞裡", + "EnabledTitle": "啟用", "ForbidOverrides": "是否禁止在執行時覆蓋此插件的參數", "ForbidOverridesTitle": "禁止覆蓋", "Id": "外掛實例 ID(同一 handler 內唯一)", @@ -606,7 +621,8 @@ "EditAgentDefinition": "編輯智慧體", "NewTab": "新建標籤頁", "NewWeb": "新建網頁", - "SplitView": "分屏展示" + "SplitView": "分屏展示", + "WikiEmbed": "維基" } }, "Tool": { @@ -668,5 +684,9 @@ } } } + }, + "WikiEmbed": { + "Error": "嵌入Wiki失敗", + "Loading": "正在載入Wiki..." } } diff --git a/localization/locales/zh-Hant/translation.json b/localization/locales/zh-Hant/translation.json index 8346b378..6613a4fe 100644 --- a/localization/locales/zh-Hant/translation.json +++ b/localization/locales/zh-Hant/translation.json @@ -295,6 +295,7 @@ "InfiniteScroll": "滾動加載", "LoadingFull": "載入中...", "LoadingMore": "載入更多...", + "LoadingWorkspace": "正在載入工作區...", "Message": "提交資訊", "MessageSearch": "提交資訊", "NewImage": "新圖片(本次提交新增)", @@ -512,6 +513,8 @@ "OpenMetaDataFolderDetail": "太微的數據和太記的工作區數據是分開存放的,太記的封包含工作區的設置等,它們以 JSON 形式存放在這個文件夾裡。", "OpenV8CacheFolder": "打開V8快取文件夾", "OpenV8CacheFolderDetail": "V8快取文件夾存有加速應用啟動的快取文件", + "OpenInstallerLogFolder": "打開安裝包日誌文件夾", + "OpenInstallerLogFolderDetail": "Windows 安裝程序日誌文件夾 (SquirrelTemp) 包含應用安裝和更新的日誌", "Performance": "性能", "PrivacyAndSecurity": "隱私和安全", "ReceivePreReleaseUpdates": "接收預發布更新", diff --git a/package.json b/package.json index 53dd5ec5..173c4200 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "tidgi", "productName": "TidGi", "description": "Customizable personal knowledge-base with Github as unlimited storage and blogging platform.", - "version": "0.13.0-prerelease18", + "version": "0.13.0-prerelease19", "license": "MPL 2.0", "packageManager": "pnpm@10.24.0", "scripts": { @@ -20,7 +20,7 @@ "test:unit": "cross-env ELECTRON_RUN_AS_NODE=1 ./node_modules/.bin/electron --max-old-space-size=8192 ./node_modules/vitest/vitest.mjs run", "test:unit:coverage": "pnpm run test:unit --coverage", "test:prepare-e2e": "cross-env READ_DOC_BEFORE_USING='docs/Testing.md' && pnpm run clean && pnpm run build:plugin && cross-env NODE_ENV=test DEBUG=electron-forge:* electron-forge package", - "test:e2e": "rimraf -- ./test-artifacts && cross-env NODE_ENV=test tsx scripts/developmentMkdir.ts && cross-env NODE_ENV=test cucumber-js --config features/cucumber.config.js", + "test:e2e": "rimraf -- ./test-artifacts && cross-env NODE_ENV=test tsx scripts/developmentMkdir.ts && cross-env NODE_ENV=test cucumber-js --config features/cucumber.config.js --exit", "test:manual-e2e": "pnpm exec cross-env NODE_ENV=test tsx ./scripts/start-e2e-app.ts", "make": "pnpm run build:plugin && cross-env NODE_ENV=production electron-forge make", "make:analyze": "cross-env ANALYZE=true pnpm run make", diff --git a/scripts/start-e2e-app.ts b/scripts/start-e2e-app.ts index f427007b..2f5d32e5 100644 --- a/scripts/start-e2e-app.ts +++ b/scripts/start-e2e-app.ts @@ -1,12 +1,51 @@ // pnpm exec cross-env NODE_ENV=test tsx ./scripts/start-e2e-app.ts +// or: pnpm exec cross-env NODE_ENV=test tsx ./scripts/start-e2e-app.ts "Configure root tiddler and verify content loads after restart" /* eslint-disable unicorn/prevent-abbreviations */ import { spawn } from 'child_process'; +import fs from 'fs-extra'; +import path from 'path'; import { getPackedAppPath } from '../features/supports/paths'; // You can also use `pnpm dlx tsx scripts/startMockOpenAI.ts` +/** + * Get the most recent test scenario directory from test-artifacts + */ +function getMostRecentScenarioName(): string | undefined { + const testArtifactsDir = path.resolve(process.cwd(), 'test-artifacts'); + + if (!fs.existsSync(testArtifactsDir)) { + return undefined; + } + + try { + const entries = fs.readdirSync(testArtifactsDir, { withFileTypes: true }); + const scenarioDirs = entries + .filter(entry => entry.isDirectory()) + .map(entry => ({ + name: entry.name, + time: fs.statSync(path.join(testArtifactsDir, entry.name)).mtime.getTime(), + })) + .sort((a, b) => b.time - a.time); + + return scenarioDirs[0]?.name; + } catch (error) { + console.warn('Failed to get most recent scenario:', error); + return undefined; + } +} + const appPath = getPackedAppPath(); -console.log('Starting TidGi E2E app:', appPath); + +// Get scenario name from command line argument or detect most recent +const scenarioName = process.argv[2] || getMostRecentScenarioName(); + +if (scenarioName) { + console.log('Starting TidGi E2E app with scenario:', scenarioName); +} else { + console.log('Starting TidGi E2E app without scenario (using legacy userData-test)'); +} +console.log('App path:', appPath); const environment = Object.assign({}, process.env, { NODE_ENV: 'test', @@ -15,7 +54,10 @@ const environment = Object.assign({}, process.env, { LC_ALL: process.env.LC_ALL || 'zh-Hans.UTF-8', }); -const child = spawn(appPath, [], { env: environment, stdio: 'inherit' }); +// Pass scenario name as argument to the app if available +const args = scenarioName ? [`--test-scenario=${scenarioName}`] : []; + +const child = spawn(appPath, args, { env: environment, stdio: 'inherit' }); child.on('exit', code => process.exit(code ?? 0)); child.on('error', error => { console.error('Failed to start TidGi app:', error); diff --git a/src/main.ts b/src/main.ts index f8507a87..a572c310 100755 --- a/src/main.ts +++ b/src/main.ts @@ -148,16 +148,19 @@ const commonInit = async (): Promise => { await wikiGitWorkspaceService.initialize(); // Create default page workspaces before initializing all workspace views await workspaceService.initializeDefaultPageWorkspaces(); + + // Initialize tidgi mini window if enabled (must be done BEFORE initializeAllWorkspaceView) + // This only creates the window, views will be created by initializeAllWorkspaceView + await windowService.initializeTidgiMiniWindow(); + // perform wiki startup and git sync for each workspace + // This will also create views for tidgi mini window (in addViewForAllBrowserViews) await workspaceViewService.initializeAllWorkspaceView(); logger.info('[test-id-ALL_WORKSPACE_VIEW_INITIALIZED] All workspace views initialized'); // Process any pending deep link after workspaces are initialized await deepLinkService.processPendingDeepLink(); - // Initialize tidgi mini window if enabled - await windowService.initializeTidgiMiniWindow(); - ipcMain.emit('request-update-pause-notifications-info'); // Fix webview is not resized automatically // when window is maximized on Linux diff --git a/src/pages/Agent/store/agentChatStore/actions/agentActions.ts b/src/pages/Agent/store/agentChatStore/actions/agentActions.ts index 697eccff..b6a2f6ad 100644 --- a/src/pages/Agent/store/agentChatStore/actions/agentActions.ts +++ b/src/pages/Agent/store/agentChatStore/actions/agentActions.ts @@ -202,6 +202,25 @@ export const agentActions = ( const { messages: currentMessages, orderedMessageIds: currentOrderedIds } = get(); const newMessageIds: string[] = []; + // Check if agent is in a terminal state (no more streaming expected) + const isAgentTerminalState = fullAgent.status.state === 'completed' || + fullAgent.status.state === 'failed' || + fullAgent.status.state === 'canceled'; + + // If agent just became terminal, clear all streaming for this agent's messages + // This is a failsafe in case message-level status updates were missed + if (isAgentTerminalState) { + fullAgent.messages.forEach(message => { + if (get().streamingMessageIds.has(message.id)) { + console.log('[AgentChat] Agent terminal state, clearing streaming for message', { + messageId: message.id, + agentState: fullAgent.status.state, + }); + get().setMessageStreaming(message.id, false); + } + }); + } + // Process new messages - backend already sorts messages by modified time fullAgent.messages.forEach(message => { const existingMessage = currentMessages.get(message.id); @@ -214,8 +233,11 @@ export const agentActions = ( // Subscribe to AI message updates if ((message.role === 'agent' || message.role === 'assistant') && !messageSubscriptions.has(message.id)) { - // Mark as streaming - get().setMessageStreaming(message.id, true); + // Only mark as streaming if agent is still working + // This prevents marking completed messages as streaming when loading history + if (!isAgentTerminalState) { + get().setMessageStreaming(message.id, true); + } // Create message-specific subscription messageSubscriptions.set( message.id, @@ -224,23 +246,18 @@ export const agentActions = ( if (status?.message) { // Update the message in our map get().messages.set(status.message.id, status.message); - // If status indicates stream is finished (completed, canceled, failed), clear streaming flag - if (status.state !== 'working') { - try { - get().setMessageStreaming(status.message.id, false); - // Unsubscribe and clean up subscription for this message - const sub = messageSubscriptions.get(status.message.id); - if (sub) { - sub.unsubscribe(); - messageSubscriptions.delete(status.message.id); - } - } catch { - // Ignore cleanup errors - } + // Clear streaming flag when status is completed + if (status.state === 'completed' || status.state === 'failed' || status.state === 'canceled') { + console.log('[AgentChat] Message completed via status update, clearing streaming', { + messageId: status.message.id, + state: status.state, + }); + get().setMessageStreaming(status.message.id, false); } } }, - error: (error_) => { + error: (error_: unknown) => { + console.error('[AgentChat] Message subscription error', { messageId: message.id, error: error_ }); void window.service.native.log( 'error', `Error in message subscription for ${message.id}`, @@ -249,8 +266,15 @@ export const agentActions = ( error: error_, }, ); + // Clean up on error + get().setMessageStreaming(message.id, false); + messageSubscriptions.delete(message.id); }, complete: () => { + console.log('[AgentChat] Message subscription completed', { + messageId: message.id, + streamingIds: Array.from(get().streamingMessageIds), + }); get().setMessageStreaming(message.id, false); messageSubscriptions.delete(message.id); }, diff --git a/src/pages/Agent/store/agentChatStore/actions/messageActions.ts b/src/pages/Agent/store/agentChatStore/actions/messageActions.ts index c38b8a4f..f9a9a719 100644 --- a/src/pages/Agent/store/agentChatStore/actions/messageActions.ts +++ b/src/pages/Agent/store/agentChatStore/actions/messageActions.ts @@ -33,7 +33,7 @@ export const messageActions = ( }); }, - sendMessage: async (content: string) => { + sendMessage: async (content: string, file?: File) => { const storeAgent = get().agent; if (!storeAgent?.id) { set({ error: new Error('No active agent in store') }); @@ -42,7 +42,46 @@ export const messageActions = ( try { set({ loading: true }); - await window.service.agentInstance.sendMsgToAgent(storeAgent.id, { text: content }); + // In Electron Renderer, File object has a 'path' property which is the absolute path. + // We need to extract it because simple serialization might lose it or fail to transmit the File object correctly via IPC. + void window.service.native.log( + 'debug', + 'Sending message with file', + { + function: 'messageActions.sendMessage', + hasFile: !!file, + fileName: file?.name, + fileType: file?.type, + fileSize: file?.size, + filePath: (file as unknown as { path?: string })?.path, + }, + ); + + let fileBuffer: ArrayBuffer | undefined; + // If path is missing (e.g. web file, pasted image), read content + if (file && !(file as unknown as { path?: string }).path) { + try { + fileBuffer = await file.arrayBuffer(); + } catch (error) { + console.error('Failed to read file buffer', error); + } + } + + const fileData = file + ? { + path: (file as unknown as { path?: string }).path, + name: file.name, + type: file.type, + size: file.size, + lastModified: file.lastModified, + buffer: fileBuffer, + } + : undefined; + + await window.service.agentInstance.sendMsgToAgent(storeAgent.id, { + text: content, + file: fileData as unknown as File, + }); } catch (error) { set({ error: error as Error }); void window.service.native.log( diff --git a/src/pages/Agent/store/agentChatStore/types.ts b/src/pages/Agent/store/agentChatStore/types.ts index 30a845eb..045dbc0b 100644 --- a/src/pages/Agent/store/agentChatStore/types.ts +++ b/src/pages/Agent/store/agentChatStore/types.ts @@ -70,8 +70,9 @@ export interface BasicActions { /** * Sends a message from the user to the agent. * @param content The message content + * @param file Optional file attachment */ - sendMessage: (content: string) => Promise; + sendMessage: (content: string, file?: File) => Promise; /** * Creates a new agent instance from a definition. diff --git a/src/pages/ChatTabContent/components/InputContainer.tsx b/src/pages/ChatTabContent/components/InputContainer.tsx index 9a581a4f..a6426c5a 100644 --- a/src/pages/ChatTabContent/components/InputContainer.tsx +++ b/src/pages/ChatTabContent/components/InputContainer.tsx @@ -1,5 +1,7 @@ // Input container component for message entry +import AttachFileIcon from '@mui/icons-material/AttachFile'; +import CloseIcon from '@mui/icons-material/Close'; import SendIcon from '@mui/icons-material/Send'; import CancelIcon from '@mui/icons-material/StopCircle'; import { Box, IconButton, TextField } from '@mui/material'; @@ -7,12 +9,18 @@ import { styled } from '@mui/material/styles'; import React from 'react'; import { useTranslation } from 'react-i18next'; +const Wrapper = styled(Box)` + display: flex; + flex-direction: column; + background-color: ${props => props.theme.palette.background.paper}; + border-top: 1px solid ${props => props.theme.palette.divider}; +`; + const Container = styled(Box)` display: flex; padding: 12px 16px; gap: 12px; - border-top: 1px solid ${props => props.theme.palette.divider}; - background-color: ${props => props.theme.palette.background.paper}; + align-items: flex-end; `; const InputField = styled(TextField)` @@ -31,6 +39,9 @@ interface InputContainerProps { onKeyPress: (event: React.KeyboardEvent) => void; disabled?: boolean; isStreaming?: boolean; + selectedFile?: File; + onFileSelect?: (file: File) => void; + onClearFile?: () => void; } /** @@ -45,40 +56,158 @@ export const InputContainer: React.FC = ({ onKeyPress, disabled = false, isStreaming = false, + selectedFile, + onFileSelect, + onClearFile, }) => { const { t } = useTranslation('agent'); + const fileInputReference = React.useRef(null); + const [previewUrl, setPreviewUrl] = React.useState(); + + React.useEffect(() => { + if (selectedFile) { + const url = URL.createObjectURL(selectedFile); + setPreviewUrl(url); + return () => { + URL.revokeObjectURL(url); + }; + } else { + setPreviewUrl(undefined); + } + }, [selectedFile]); + + const handleFileChange = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + // Validate file type + if (!file.type.startsWith('image/')) { + void window.service.native.log('error', 'Selected file is not an image', { fileType: file.type }); + void window.service.native.showElectronMessageBox({ + type: 'error', + title: t('Agent.Error.Title'), + message: t('Agent.Error.FileValidation.NotAnImage', { fileType: file.type }), + buttons: ['OK'], + }); + return; + } + // Validate file size (10MB limit) + const maxSize = 10 * 1024 * 1024; + if (file.size > maxSize) { + void window.service.native.log('error', 'File size exceeds limit', { fileSize: file.size, maxSize }); + void window.service.native.showElectronMessageBox({ + type: 'error', + title: t('Agent.Error.Title'), + message: t('Agent.Error.FileValidation.TooLarge', { + size: (file.size / 1024 / 1024).toFixed(2), + maxSize: (maxSize / 1024 / 1024).toFixed(0), + }), + buttons: ['OK'], + }); + return; + } + if (onFileSelect) { + onFileSelect(file); + } + } + // Reset value so same file can be selected again if needed + if (event.target) { + event.target.value = ''; + } + }; return ( - - - {isStreaming ? : } - - ), - }, - }} - /> - + + {selectedFile && previewUrl && ( + + + { + // Future: open preview dialog + const win = window.open(); + if (win) { + const img = win.document.createElement('img'); + img.src = previewUrl; + img.style.maxWidth = '100%'; + win.document.body.append(img); + } + }} + /> + + + + + + )} + + + fileInputReference.current?.click()} + disabled={disabled || isStreaming} + color={selectedFile ? 'primary' : 'default'} + data-testid='agent-attach-button' + > + + + + {isStreaming ? : } + + ), + }, + }} + /> + + ); }; diff --git a/src/pages/ChatTabContent/components/MessageBubble.tsx b/src/pages/ChatTabContent/components/MessageBubble.tsx index 193a937b..c1c95bc2 100644 --- a/src/pages/ChatTabContent/components/MessageBubble.tsx +++ b/src/pages/ChatTabContent/components/MessageBubble.tsx @@ -9,6 +9,56 @@ import { isMessageExpiredForAI } from '../../../services/agentInstance/utilities import { useAgentChatStore } from '../../Agent/store/agentChatStore/index'; import { MessageRenderer } from './MessageRenderer'; +const ImageAttachment = ({ file }: { file: File | { path: string } }) => { + const [source, setSource] = React.useState(); + const [previewUrl, setPreviewUrl] = React.useState(); + + React.useEffect(() => { + // Check for File object (from current session upload) + if (file instanceof File) { + const url = URL.createObjectURL(file); + setSource(url); + setPreviewUrl(url); + return () => { + URL.revokeObjectURL(url); + }; + } // Check for persisted file object with path + else if (file && typeof file === 'object' && 'path' in file) { + const filePath = `file://${(file as { path: string }).path}`; + setSource(filePath); + setPreviewUrl(filePath); + } + }, [file]); + + if (!source) return null; + + return ( + { + if (!previewUrl) return; + const win = window.open(); + if (win) { + const img = win.document.createElement('img'); + img.src = previewUrl; + img.style.maxWidth = '100%'; + win.document.body.append(img); + } + }} + /> + ); +}; + const BubbleContainer = styled(Box, { shouldForwardProp: (property) => property !== '$user' && property !== '$centered' && property !== '$expired', })<{ $user: boolean; $centered: boolean; $expired?: boolean }>` @@ -116,6 +166,7 @@ export const MessageBubble: React.FC = ({ messageId }) => { $expired={isExpired} data-testid={!isUser ? (isStreaming ? 'assistant-streaming-text' : 'assistant-message') : undefined} > + {message.metadata?.file ? : null} diff --git a/src/pages/ChatTabContent/components/MessageRenderer/ErrorMessageRenderer.tsx b/src/pages/ChatTabContent/components/MessageRenderer/ErrorMessageRenderer.tsx index fbd9480a..866825d4 100644 --- a/src/pages/ChatTabContent/components/MessageRenderer/ErrorMessageRenderer.tsx +++ b/src/pages/ChatTabContent/components/MessageRenderer/ErrorMessageRenderer.tsx @@ -36,18 +36,24 @@ const ErrorActions = styled(Box)` /** * Extract error details from message content and metadata + * + * IMPORTANT: When adding new error types or i18n keys, remember to update: + * - i18nPlaceholders.ts: Add the new translation key to prevent i18n-ally from removing it + * - localization/locales/……/agent.json: Add the actual translations */ function extractErrorDetails(message: MessageRendererProps['message']): { errorName: string; errorCode: string; provider: string; errorMessage: string; + params?: Record; } { // Default values let errorName = 'Error'; let errorCode = 'UNKNOWN_ERROR'; let provider = ''; let errorMessage = message.content; + let parameters: Record | undefined; // Check if metadata exists and contains error details if (message.metadata?.errorDetail) { @@ -56,12 +62,14 @@ function extractErrorDetails(message: MessageRendererProps['message']): { code: string; provider: string; message?: string; + params?: Record; }; errorName = errorDetail.name || errorName; errorCode = errorDetail.code || errorCode; provider = errorDetail.provider || provider; errorMessage = errorDetail.message || message.content; + parameters = errorDetail.params; } return { @@ -69,6 +77,7 @@ function extractErrorDetails(message: MessageRendererProps['message']): { errorCode, provider, errorMessage, + params: parameters, }; } @@ -78,7 +87,7 @@ function extractErrorDetails(message: MessageRendererProps['message']): { */ export const ErrorMessageRenderer: React.FC = ({ message }) => { const { t } = useTranslation('agent'); - const { errorName, errorCode, provider, errorMessage } = extractErrorDetails(message); + const { errorName, errorCode, provider, errorMessage, params } = extractErrorDetails(message); // Handle navigation to settings const handleGoToSettings = async () => { @@ -87,8 +96,26 @@ export const ErrorMessageRenderer: React.FC = ({ message } // Check if this is a provider-related error that could be fixed in settings const isSettingsFixableError = - ['MissingConfigError', 'MissingProviderError', 'AuthenticationFailed', 'MissingAPIKeyError', 'MissingBaseURLError', 'ProviderNotFound'].includes(errorName) || - ['NO_DEFAULT_MODEL', 'PROVIDER_NOT_FOUND', 'AUTHENTICATION_FAILED', 'MISSING_API_KEY', 'MISSING_BASE_URL'].includes(errorCode); + ['MissingConfigError', 'MissingProviderError', 'AuthenticationError', 'MissingAPIKeyError', 'MissingBaseURLError', 'UnsupportedFeatureError'].includes(errorName) || + ['NO_DEFAULT_MODEL', 'PROVIDER_NOT_FOUND', 'AUTHENTICATION_FAILED', 'MISSING_API_KEY', 'MISSING_BASE_URL', 'MODEL_NO_VISION_SUPPORT'].includes(errorCode); + + // Determine the display message with proper i18n handling + let displayMessage = errorMessage; + + // Check if errorMessage is an i18n key (starts with known prefixes) + if (errorMessage.startsWith('Chat.ConfigError.')) { + // Try to translate with params if available + const translatedMessage = t(errorMessage, params || { provider }); + // If translation returns the key itself (no translation found), fall back to errorMessage + displayMessage = translatedMessage !== errorMessage ? translatedMessage : errorMessage; + } else { + // Try to find translation based on errorName or errorCode + const possibleKey = `Chat.ConfigError.${errorName}`; + const translatedByName = t(possibleKey, params || { provider }); + if (translatedByName !== possibleKey) { + displayMessage = translatedByName; + } + } return ( @@ -101,9 +128,7 @@ export const ErrorMessageRenderer: React.FC = ({ message } - {provider - ? t(`Chat.ConfigError.${errorName}`, { provider }) || errorMessage - : errorMessage} + {displayMessage} {isSettingsFixableError && ( diff --git a/src/pages/ChatTabContent/components/MessageRenderer/i18nPlaceholders.ts b/src/pages/ChatTabContent/components/MessageRenderer/i18nPlaceholders.ts new file mode 100644 index 00000000..20a0668f --- /dev/null +++ b/src/pages/ChatTabContent/components/MessageRenderer/i18nPlaceholders.ts @@ -0,0 +1,24 @@ +import { t } from '@services/libs/i18n/placeholder'; + +/** + * Placeholders for error message translations used in ErrorMessageRenderer + * These are registered here to prevent i18n-ally tools from removing them as unused keys. + * The actual translation happens dynamically in the component using provider names. + */ +export const errorMessageI18nKeys = { + title: t('Chat.ConfigError.Title'), + goToSettings: t('Chat.ConfigError.GoToSettings'), + + // Error type translations - these use interpolation with {{provider}} or {{model}} + missingConfigError: t('Chat.ConfigError.MissingConfigError'), + missingProviderError: t('Chat.ConfigError.MissingProviderError'), + authenticationError: t('Chat.ConfigError.AuthenticationError'), + missingAPIKeyError: t('Chat.ConfigError.MissingAPIKeyError'), + missingBaseURLError: t('Chat.ConfigError.MissingBaseURLError'), + modelNoVisionSupport: t('Chat.ConfigError.ModelNoVisionSupport'), + noDefaultModel: t('Chat.ConfigError.NoDefaultModel'), + + // Legacy keys that may still exist in i18n files + authenticationFailed: t('Chat.ConfigError.AuthenticationFailed'), + providerNotFound: t('Chat.ConfigError.ProviderNotFound'), +} as const; diff --git a/src/pages/ChatTabContent/components/PromptPreviewDialog/PromptConfigForm/templates/ArrayFieldItemTemplate.tsx b/src/pages/ChatTabContent/components/PromptPreviewDialog/PromptConfigForm/templates/ArrayFieldItemTemplate.tsx index 6375c51c..f452dbfc 100644 --- a/src/pages/ChatTabContent/components/PromptPreviewDialog/PromptConfigForm/templates/ArrayFieldItemTemplate.tsx +++ b/src/pages/ChatTabContent/components/PromptPreviewDialog/PromptConfigForm/templates/ArrayFieldItemTemplate.tsx @@ -195,7 +195,7 @@ export function ArrayFieldItemTemplate { event.stopPropagation(); }} - slotProps={{ input: { 'aria-label': t('Prompt.Enabled', { defaultValue: 'Enabled' }) } }} + slotProps={{ input: { 'aria-label': t('Prompt.Enabled') } }} sx={{ p: 0.5, mr: 0.5 }} /> diff --git a/src/pages/ChatTabContent/hooks/useMessageHandling.tsx b/src/pages/ChatTabContent/hooks/useMessageHandling.tsx index 9c3eca83..1f325684 100644 --- a/src/pages/ChatTabContent/hooks/useMessageHandling.tsx +++ b/src/pages/ChatTabContent/hooks/useMessageHandling.tsx @@ -29,6 +29,7 @@ export function useMessageHandling({ })), ); const [message, setMessage] = useState(''); + const [selectedFile, setSelectedFile] = useState(); const [parametersOpen, setParametersOpen] = useState(false); const [sendingMessage, setSendingMessage] = useState(false); @@ -46,18 +47,32 @@ export function useMessageHandling({ setMessage(event.target.value); }, []); + /** + * Handle file selection + */ + const handleFileSelect = useCallback((file: File) => { + setSelectedFile(file); + }, []); + + /** + * Handle clearing selected file + */ + const handleClearFile = useCallback(() => { + setSelectedFile(undefined); + }, []); + /** * Handle sending a message */ const handleSendMessage = useCallback(async () => { - if (!message.trim() || !agent || sendingMessage || !agentId) return; + if ((!message.trim() && !selectedFile) || !agent || sendingMessage || !agentId) return; // Store the current scroll position status before sending message const wasAtBottom = isUserAtBottom(); setSendingMessage(true); try { - await sendMessage(message); + await sendMessage(message, selectedFile); setMessage(''); // After sending, update the scroll position reference to ensure proper scrolling isUserAtBottomReference.current = wasAtBottom; @@ -67,9 +82,11 @@ export function useMessageHandling({ debouncedScrollToBottom(); } } finally { + // Always clear file selection, even if send fails + setSelectedFile(undefined); setSendingMessage(false); } - }, [message, agent, sendingMessage, agentId, isUserAtBottom, sendMessage, debouncedScrollToBottom, isUserAtBottomReference]); + }, [message, selectedFile, agent, sendingMessage, agentId, isUserAtBottom, sendMessage, debouncedScrollToBottom, isUserAtBottomReference]); /** * Handle keyboard events for sending messages @@ -93,5 +110,8 @@ export function useMessageHandling({ handleMessageChange, handleSendMessage, handleKeyPress, + selectedFile, + handleFileSelect, + handleClearFile, }; } diff --git a/src/pages/ChatTabContent/index.tsx b/src/pages/ChatTabContent/index.tsx index c4bc9312..6b1fa2b6 100644 --- a/src/pages/ChatTabContent/index.tsx +++ b/src/pages/ChatTabContent/index.tsx @@ -98,6 +98,9 @@ export const ChatTabContent: React.FC = ({ tab }) => { handleMessageChange, handleSendMessage, handleKeyPress, + selectedFile, + handleFileSelect, + handleClearFile, } = useMessageHandling({ agentId: tab.agentId, isUserAtBottom, @@ -223,6 +226,9 @@ export const ChatTabContent: React.FC = ({ tab }) => { onKeyPress={handleKeyPress} disabled={!agent || isWorking} isStreaming={isStreaming} + selectedFile={selectedFile} + onFileSelect={handleFileSelect} + onClearFile={handleClearFile} /> {/* Model parameter dialog */} diff --git a/src/services/agentInstance/agentFrameworks/taskAgent.ts b/src/services/agentInstance/agentFrameworks/taskAgent.ts index 8c02d228..032db931 100644 --- a/src/services/agentInstance/agentFrameworks/taskAgent.ts +++ b/src/services/agentInstance/agentFrameworks/taskAgent.ts @@ -123,8 +123,18 @@ export async function* basicPromptConcatHandler(context: AgentFrameworkContext) logger.debug('Starting AI generation', { method: 'processLLMCall', modelName: aiApiConfig.default?.model || 'unknown', - flatPrompts, - messages: context.agent.messages, + // Summarize prompts to avoid logging large binary data + flatPromptsCount: flatPrompts.length, + flatPromptsSummary: flatPrompts.map(message => ({ + role: message.role, + contentType: Array.isArray(message.content) ? 'multimodal' : 'text', + contentLength: Array.isArray(message.content) + ? message.content.length + : typeof message.content === 'string' + ? message.content.length + : 0, + })), + messagesCount: context.agent.messages.length, }); // Delegate AI API calls to externalAPIService diff --git a/src/services/agentInstance/index.ts b/src/services/agentInstance/index.ts index 8877616c..324165f7 100644 --- a/src/services/agentInstance/index.ts +++ b/src/services/agentInstance/index.ts @@ -447,19 +447,27 @@ export class AgentInstanceService implements IAgentInstanceService { if (lastResult?.message) { // Complete the message stream directly using the last message from the generator const statusKey = `${agentId}:${lastResult.message.id}`; - if (this.statusSubjects.has(statusKey)) { - const subject = this.statusSubjects.get(statusKey); - if (subject) { - // Send final update with completed state - subject.next({ - state: 'completed', - message: lastResult.message, - modified: new Date(), - }); - // Complete the Observable and remove the subject - subject.complete(); - this.statusSubjects.delete(statusKey); - } + const subject = this.statusSubjects.get(statusKey); + if (subject) { + logger.debug(`[${agentId}] Completing message stream`, { messageId: lastResult.message.id }); + // Send final update with completed state + subject.next({ + state: 'completed', + message: lastResult.message, + modified: new Date(), + }); + // Complete and clean up the Observable + // Use queueMicrotask to ensure IPC message delivery before completing subject + // This schedules the completion after the current synchronous code and pending microtasks + queueMicrotask(() => { + try { + subject.complete(); + this.statusSubjects.delete(statusKey); + logger.debug(`[${agentId}] Subject completed and deleted`, { messageId: lastResult.message?.id }); + } catch (error) { + logger.error(`[${agentId}] Error completing subject`, { messageId: lastResult.message?.id, error }); + } + }); } // Trigger agentStatusChanged hook for completion @@ -478,6 +486,26 @@ export class AgentInstanceService implements IAgentInstanceService { const errorMessage = error instanceof Error ? error.message : String(error); logger.error(`Agent handler execution failed: ${errorMessage}`); + // Clear any pending message subscriptions for this agent + for (const key of Array.from(this.statusSubjects.keys())) { + if (key.startsWith(`${agentId}:`)) { + const subject = this.statusSubjects.get(key); + if (subject) { + try { + subject.next({ + state: 'failed', + message: {} as AgentInstanceMessage, + modified: new Date(), + }); + subject.complete(); + } catch { + // ignore + } + this.statusSubjects.delete(key); + } + } + } + // Trigger agentStatusChanged hook for failure await frameworkHooks.agentStatusChanged.promise({ agentFrameworkContext: frameworkContext, @@ -710,6 +738,9 @@ export class AgentInstanceService implements IAgentInstanceService { logger.debug('User message saved to database', { when: new Date().toISOString(), ...summary, + hasMetadata: !!userMessage.metadata, + hasFile: !!userMessage.metadata?.file, + metadataKeys: userMessage.metadata ? Object.keys(userMessage.metadata) : [], source: 'saveUserMessage', }); } catch (error) { diff --git a/src/services/agentInstance/promptConcat/__tests__/promptConcatWithImage.test.ts b/src/services/agentInstance/promptConcat/__tests__/promptConcatWithImage.test.ts new file mode 100644 index 00000000..93422982 --- /dev/null +++ b/src/services/agentInstance/promptConcat/__tests__/promptConcatWithImage.test.ts @@ -0,0 +1,153 @@ +import type { AgentDefinition } from '../../../agentDefinition/interface'; +import { AgentFrameworkContext } from '../../agentFrameworks/utilities/type'; +import type { AgentInstance, AgentInstanceMessage } from '../../interface'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +describe('promptConcatStream with image', () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + }); + + it('should include image part in the last user message prompts', async () => { + // Mock fs-extra BEFORE importing SUT + const mockReadFile = vi.fn().mockResolvedValue(Buffer.from('mock-image-data')); + const mockFs = { + readFile: mockReadFile, + ensureDir: vi.fn(), + copy: vi.fn(), + }; + + vi.doMock('fs-extra', () => ({ + ...mockFs, + default: mockFs, + })); + + vi.doMock('@services/libs/log', () => ({ + logger: { + debug: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + }, + })); + + // Import SUT dynamically + const { promptConcatStream } = await import('../promptConcat'); + + const messages: AgentInstanceMessage[] = [ + { + id: 'msg1', + role: 'user', + agentId: 'agent1', + content: 'Describe this image', + metadata: { + file: { path: '/path/to/image.png', name: 'image.png' }, + }, + } as AgentInstanceMessage, + ]; + + const context: AgentFrameworkContext = { + agent: { id: 'agent1' } as Partial as AgentInstance, + agentDef: {} as Partial as AgentDefinition, + isCancelled: () => false, + }; + + const stream = promptConcatStream( + { + agentFrameworkConfig: { + prompts: [], + response: [], + plugins: [], + }, + }, + messages, + context, + ); + + let finalResult; + for await (const state of stream) { + finalResult = state; + } + + const lastMessage = finalResult?.flatPrompts[finalResult.flatPrompts.length - 1]; + + expect(lastMessage).toBeDefined(); + expect(Array.isArray(lastMessage?.content)).toBe(true); + + const content = lastMessage?.content as Array<{ type: string; image?: unknown; text?: string }>; + expect(content).toHaveLength(2); + expect(content[0]).toEqual({ type: 'image', image: expect.anything() }); + expect(content[1]).toEqual({ type: 'text', text: 'Describe this image' }); + + expect(mockReadFile).toHaveBeenCalledWith('/path/to/image.png'); + }); + + it('should fall back to text only if file read fails', async () => { + const mockReadFile = vi.fn().mockRejectedValue(new Error('Read failed')); + const mockFs = { + readFile: mockReadFile, + ensureDir: vi.fn(), + copy: vi.fn(), + }; + + vi.doMock('fs-extra', () => ({ + ...mockFs, + default: mockFs, + })); + + vi.doMock('@services/libs/log', () => ({ + logger: { + debug: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + }, + })); + + // Import SUT dynamically + const { promptConcatStream } = await import('../promptConcat'); + + const messages: AgentInstanceMessage[] = [ + { + id: 'msg1', + role: 'user', + agentId: 'agent1', + content: 'Describe this image', + metadata: { + file: { path: '/path/to/image.png' }, + }, + } as AgentInstanceMessage, + ]; + + const context: AgentFrameworkContext = { + agent: { id: 'agent1' } as Partial as AgentInstance, + agentDef: {} as Partial as AgentDefinition, + isCancelled: () => false, + }; + + const stream = promptConcatStream( + { + agentFrameworkConfig: { + prompts: [], + response: [], + plugins: [], + }, + }, + messages, + context, + ); + + let finalResult; + for await (const state of stream) { + finalResult = state; + } + + const logger = (await import('@services/libs/log')).logger; + + const lastMessage = finalResult?.flatPrompts[finalResult.flatPrompts.length - 1]; + expect(lastMessage?.content).toBe('Describe this image'); + expect(logger.error).toHaveBeenCalled(); + }); +}); diff --git a/src/services/agentInstance/promptConcat/infrastructure/messagePersistence.ts b/src/services/agentInstance/promptConcat/infrastructure/messagePersistence.ts index fe7a8b7d..a69083fc 100644 --- a/src/services/agentInstance/promptConcat/infrastructure/messagePersistence.ts +++ b/src/services/agentInstance/promptConcat/infrastructure/messagePersistence.ts @@ -7,6 +7,9 @@ import { container } from '@services/container'; import { logger } from '@services/libs/log'; import serviceIdentifier from '@services/serviceIdentifier'; +import { app } from 'electron'; +import * as fs from 'fs-extra'; +import * as path from 'path'; import type { IAgentInstanceService } from '../../interface'; import type { AIResponseContext, PromptConcatHooks, ToolExecutionContext, UserMessageContext } from '../../tools/types'; import { createAgentMessage } from '../../utilities'; @@ -20,15 +23,87 @@ export function registerMessagePersistence(hooks: PromptConcatHooks): void { try { const { agentFrameworkContext, content, messageId } = context; + logger.debug('userMessageReceived hook called', { + messageId, + hasFile: !!content.file, + fileInfo: content.file + ? { + hasPath: !!(content.file as unknown as { path?: string }).path, + hasName: !!(content.file as unknown as { name?: string }).name, + hasBuffer: !!(content.file as unknown as { buffer?: ArrayBuffer }).buffer, + } + : null, + }); + + let persistedFileMetadata: Record | undefined; + + // Handle file attachment persistence + if (content.file) { + try { + // content.file coming from IPC might be a plain object with path and optional buffer + const fileObject = content.file as unknown as { path?: string; name?: string; buffer?: ArrayBuffer }; + + if ((fileObject.path || fileObject.buffer) && app) { + const userDataPath = app.getPath('userData'); + const storageDirectory = path.join(userDataPath, 'agent_attachments', agentFrameworkContext.agent.id); + await fs.ensureDir(storageDirectory); + + const extension = path.extname(fileObject.name || (fileObject.path || '')) || '.bin'; + const newFileName = `${messageId}${extension}`; + const newPath = path.join(storageDirectory, newFileName); + + if (fileObject.path) { + await fs.copy(fileObject.path, newPath); + } else if (fileObject.buffer) { + await fs.writeFile(newPath, Buffer.from(fileObject.buffer)); + } + + persistedFileMetadata = { + path: newPath, + originalPath: fileObject.path, + name: fileObject.name, + savedAt: new Date(), + }; + } else if (fileObject.path || fileObject.name) { + // If app is not available (e.g., in some test scenarios), at least preserve file info without buffer + persistedFileMetadata = { + path: fileObject.path, + name: fileObject.name, + }; + } + } catch (error) { + logger.error('Failed to persist attachment', { error, messageId }); + // Even on error, try to preserve basic file info (without buffer which can't be serialized) + const fileObject = content.file as unknown as { path?: string; name?: string }; + if (fileObject.path || fileObject.name) { + persistedFileMetadata = { + path: fileObject.path, + name: fileObject.name, + }; + } + } + } + // Create user message using the helper function const userMessage = createAgentMessage(messageId, agentFrameworkContext.agent.id, { role: 'user', content: content.text, contentType: 'text/plain', - metadata: content.file ? { file: content.file } : undefined, + metadata: persistedFileMetadata ? { file: persistedFileMetadata } : undefined, duration: undefined, // User messages persist indefinitely by default }); + // Debug log + if (persistedFileMetadata) { + logger.debug('User message created with file metadata', { + messageId, + hasMetadata: !!userMessage.metadata, + hasFile: !!userMessage.metadata?.file, + filePath: (userMessage.metadata?.file as Record | undefined)?.path, + fileName: (userMessage.metadata?.file as Record | undefined)?.name, + }); + } + // Add message to the agent's message array for immediate use agentFrameworkContext.agent.messages.push(userMessage); diff --git a/src/services/agentInstance/promptConcat/modifiers/fullReplacement.ts b/src/services/agentInstance/promptConcat/modifiers/fullReplacement.ts index 4552d33e..fb8a2e31 100644 --- a/src/services/agentInstance/promptConcat/modifiers/fullReplacement.ts +++ b/src/services/agentInstance/promptConcat/modifiers/fullReplacement.ts @@ -93,12 +93,33 @@ const fullReplacementDefinition = registerModifier({ type PromptRole = NonNullable; const role: PromptRole = normalizeRole(message.role); delete found.prompt.text; - found.prompt.children!.push({ - id: `history-${index}`, - caption: `History message ${index + 1}`, - role, - text: message.content, - }); + + // Check if message has an image attachment + const hasImage = Boolean((message.metadata as { file?: { path?: string } })?.file?.path); + + if (hasImage) { + // For messages with images, create a child prompt that will be processed by infrastructure + found.prompt.children!.push({ + id: `history-${index}`, + caption: `History message ${index + 1}`, + role, + text: message.content, + // Preserve file metadata so it can be loaded by messagePersistence + ...(message.metadata?.file + ? { + file: message.metadata.file as unknown as Record, + } + : {}), + }); + } else { + // For text-only messages, just add the text + found.prompt.children!.push({ + id: `history-${index}`, + caption: `History message ${index + 1}`, + role, + text: message.content, + }); + } }); } else { found.prompt.text = '无聊天历史。'; diff --git a/src/services/agentInstance/promptConcat/promptConcat.ts b/src/services/agentInstance/promptConcat/promptConcat.ts index a364891b..bca65872 100644 --- a/src/services/agentInstance/promptConcat/promptConcat.ts +++ b/src/services/agentInstance/promptConcat/promptConcat.ts @@ -15,6 +15,7 @@ import { logger } from '@services/libs/log'; import { ModelMessage } from 'ai'; +import * as fs from 'fs-extra'; import { cloneDeep } from 'lodash'; import { AgentFrameworkContext } from '../agentFrameworks/utilities/type'; import { AgentInstanceMessage } from '../interface'; @@ -313,7 +314,26 @@ export async function* promptConcatStream( messageId: userMessage.id, contentLength: userMessage.content.length, }); - flatPrompts.push({ role: 'user', content: userMessage.content }); + + // Check for file attachment + const fileMetadata = userMessage.metadata?.file as { path: string } | undefined; + if (fileMetadata?.path) { + try { + const imageBuffer = await fs.readFile(fileMetadata.path); + flatPrompts.push({ + role: 'user', + content: [ + { type: 'image', image: imageBuffer }, + { type: 'text', text: userMessage.content }, + ], + }); + } catch (error) { + logger.error('Failed to read attached file', { error, path: fileMetadata.path }); + flatPrompts.push({ role: 'user', content: userMessage.content }); + } + } else { + flatPrompts.push({ role: 'user', content: userMessage.content }); + } } logger.debug('Streaming prompt concatenation completed', { diff --git a/src/services/database/schema/externalAPILog.ts b/src/services/database/schema/externalAPILog.ts index e31f6a97..caf12edb 100644 --- a/src/services/database/schema/externalAPILog.ts +++ b/src/services/database/schema/externalAPILog.ts @@ -22,6 +22,8 @@ export interface RequestMetadata { messageCount?: number; /** Input count for embedding requests */ inputCount?: number; + /** Whether the request contains image content */ + hasImageContent?: boolean; /** Request configuration summary */ configSummary?: Record; } diff --git a/src/services/externalAPI/defaultProviders.ts b/src/services/externalAPI/defaultProviders.ts index f88bc478..79b59121 100644 --- a/src/services/externalAPI/defaultProviders.ts +++ b/src/services/externalAPI/defaultProviders.ts @@ -62,6 +62,16 @@ export default { caption: 'TeleSpeechASR', features: ['transcriptions'], }, + { + name: 'Qwen/Qwen-Image', + caption: 'Qwen Image', + features: ['imageGeneration'], + }, + { + name: 'zai-org/GLM-4.6V', + caption: 'GLM-4.6V', + features: ['language', 'vision', 'toolCalling'], + }, ], }, { diff --git a/src/services/externalAPI/index.ts b/src/services/externalAPI/index.ts index 4121b60e..a15f3361 100644 --- a/src/services/externalAPI/index.ts +++ b/src/services/externalAPI/index.ts @@ -48,6 +48,7 @@ export class ExternalAPIService implements IExternalAPIService { private dataSource: DataSource | null = null; private apiLogRepository: Repository | null = null; + private initializationPromise: Promise | null = null; // Prevent race condition in lazy initialization private activeRequests: Map = new Map(); private settingsLoaded = false; @@ -232,13 +233,26 @@ export class ExternalAPIService implements IExternalAPIService { const externalAPIDebug = await this.preferenceService.get('externalAPIDebug'); if (!externalAPIDebug) return; - // Ensure API logging is initialized. - // For 'update' events we skip writes to avoid expensive DB churn. + // Ensure API logging is initialized (lazy initialization) if (!this.apiLogRepository) { - // If repository isn't initialized, skip all log writes (including start/error/done/cancel). - // Tests that require logs should explicitly call `initialize()` before invoking generateFromAI. - logger.warn('API log repository not initialized; skipping ExternalAPI log write'); - return; + // Reuse existing initialization promise to prevent race condition + if (!this.initializationPromise) { + this.initializationPromise = (async () => { + try { + await this.databaseService.initializeDatabase('externalApi'); + this.dataSource = await this.databaseService.getDatabase('externalApi'); + this.apiLogRepository = this.dataSource.getRepository(ExternalAPILogEntity); + logger.debug('External API logging initialized (lazy)'); + } catch (error) { + logger.warn('Failed to initialize API log repository', error); + this.initializationPromise = null; // Reset on failure to allow retry + throw error; + } + })(); + } + await this.initializationPromise; + // If repository is still null after initialization, return early + if (!this.apiLogRepository) return; } // Try save; on UNIQUE race, fetch existing and merge, then save again @@ -466,18 +480,38 @@ export class ExternalAPIService implements IExternalAPIService { if (!modelConfig?.provider || !modelConfig?.model) { yield { requestId, - content: 'No default model configured', + content: 'Chat.ConfigError.NoDefaultModel', status: 'error', errorDetail: { name: 'MissingConfigError', code: 'NO_DEFAULT_MODEL', provider: 'unknown', + message: 'Chat.ConfigError.NoDefaultModel', }, }; return; } - logger.debug(`[${requestId}] Starting generateFromAI with messages`, messages); + // Prepare messages for logging - convert Buffer to metadata only for better visibility + const messagesForLog = messages.map(message => { + if (Array.isArray(message.content)) { + return { + ...message, + content: message.content.map(part => { + if (typeof part === 'object' && 'type' in part && part.type === 'image' && 'image' in part) { + const imagePart = part as { type: 'image'; image: Buffer | Uint8Array }; + return { + type: 'image', + imageSize: imagePart.image.length, + imageFormat: 'buffer', + }; + } + return part; + }), + }; + } + return message; + }); // Log request start. If caller requested blocking logs (tests), await the DB write so it's visible synchronously. if (options?.awaitLogs) { @@ -487,9 +521,10 @@ export class ExternalAPIService implements IExternalAPIService { provider: modelConfig.provider, model: modelConfig.model, messageCount: messages.length, + hasImageContent: messages.some(m => Array.isArray(m.content) && m.content.some((p: { type?: string }) => p.type === 'image')), }, requestPayload: { - messages: messages, + messages: messagesForLog, config: config, }, }); @@ -500,9 +535,10 @@ export class ExternalAPIService implements IExternalAPIService { provider: modelConfig.provider, model: modelConfig.model, messageCount: messages.length, + hasImageContent: messages.some(m => Array.isArray(m.content) && m.content.some((p: { type?: string }) => p.type === 'image')), }, requestPayload: { - messages: messages, + messages: messagesForLog, config: config, }, }); @@ -512,18 +548,54 @@ export class ExternalAPIService implements IExternalAPIService { // Send start event yield { requestId, content: '', status: 'start' }; + // Check if request contains images and verify model supports vision + const hasImageContent = messages.some(m => Array.isArray(m.content) && m.content.some((p: { type?: string }) => p.type === 'image')); + if (hasImageContent) { + // Find the model configuration to check if it supports vision + const providers = await this.getAIProviders(); + const provider = providers.find(p => p.provider === modelConfig.provider); + const model = provider?.models?.find(m => m.name === modelConfig.model); + + if (!model?.features?.includes('vision')) { + const errorResponse = { + requestId, + content: 'Chat.ConfigError.ModelNoVisionSupport', + status: 'error' as const, + errorDetail: { + name: 'UnsupportedFeatureError', + code: 'MODEL_NO_VISION_SUPPORT', + provider: modelConfig.provider, + message: 'Chat.ConfigError.ModelNoVisionSupport', + params: { model: modelConfig.model }, + }, + }; + if (options?.awaitLogs) { + await this.logAPICall(requestId, 'streaming', 'error', { + errorDetail: errorResponse.errorDetail, + }); + } else { + void this.logAPICall(requestId, 'streaming', 'error', { + errorDetail: errorResponse.errorDetail, + }); + } + yield errorResponse; + return; + } + } + // Get provider configuration const providerConfig = await this.getProviderConfig(modelConfig.provider); if (!providerConfig) { - const errorMessage = `Provider ${modelConfig.provider} not found or not configured`; const errorResponse = { requestId, - content: errorMessage, + content: 'Chat.ConfigError.ProviderNotFound', status: 'error' as const, errorDetail: { name: 'MissingProviderError', code: 'PROVIDER_NOT_FOUND', provider: modelConfig.provider, + message: 'Chat.ConfigError.ProviderNotFound', + params: { provider: modelConfig.provider }, }, }; if (options?.awaitLogs) { diff --git a/src/services/externalAPI/interface.ts b/src/services/externalAPI/interface.ts index 7bef2047..8ed9ad0c 100644 --- a/src/services/externalAPI/interface.ts +++ b/src/services/externalAPI/interface.ts @@ -6,6 +6,22 @@ import { AiAPIConfig } from '@services/agentInstance/promptConcat/promptConcatSc import type { ExternalAPILogEntity } from '@services/database/schema/externalAPILog'; import { ModelMessage } from 'ai'; +/** + * Shared error detail structure used across all AI responses + */ +export interface AIErrorDetail { + /** Error type name */ + name: string; + /** Error code */ + code: string; + /** Provider name associated with the error */ + provider: string; + /** Human readable error message (may be an i18n key) */ + message?: string; + /** Parameters for i18n interpolation */ + params?: Record; +} + /** * AI streaming response status interface */ @@ -16,16 +32,7 @@ export interface AIStreamResponse { /** * Structured error details, provided when status is 'error' */ - errorDetail?: { - /** Error type name */ - name: string; - /** Error code */ - code: string; - /** Provider name associated with the error */ - provider: string; - /** Human readable error message */ - message?: string; - }; + errorDetail?: AIErrorDetail; } /** @@ -70,16 +77,7 @@ export interface AISpeechResponse { /** * Structured error details, provided when status is 'error' */ - errorDetail?: { - /** Error type name */ - name: string; - /** Error code */ - code: string; - /** Provider name associated with the error */ - provider: string; - /** Human readable error message */ - message?: string; - }; + errorDetail?: AIErrorDetail; } /** @@ -98,16 +96,7 @@ export interface AITranscriptionResponse { /** * Structured error details, provided when status is 'error' */ - errorDetail?: { - /** Error type name */ - name: string; - /** Error code */ - code: string; - /** Provider name associated with the error */ - provider: string; - /** Human readable error message */ - message?: string; - }; + errorDetail?: AIErrorDetail; } /** @@ -133,16 +122,7 @@ export interface AIImageGenerationResponse { /** * Structured error details, provided when status is 'error' */ - errorDetail?: { - /** Error type name */ - name: string; - /** Error code */ - code: string; - /** Provider name associated with the error */ - provider: string; - /** Human readable error message */ - message?: string; - }; + errorDetail?: AIErrorDetail; } /** diff --git a/src/services/view/index.ts b/src/services/view/index.ts index ff25eed0..4cffea92 100644 --- a/src/services/view/index.ts +++ b/src/services/view/index.ts @@ -256,8 +256,12 @@ export class View implements IViewService { return; } if (browserWindow === undefined) { - logger.warn(`BrowserViewService.addView: ${workspace.id} 's browser window is not ready`); - return; + logger.error(`BrowserViewService.addView: ${workspace.id} 's browser window is not ready`, { + windowName, + workspaceId: workspace.id, + workspaceName: workspace.name, + }); + throw new Error(`Browser window ${windowName} is not ready for workspace ${workspace.id}`); } const sharedWebPreferences = await this.getSharedWebPreferences(workspace); const view = await this.createViewAddToWindow(workspace, browserWindow, sharedWebPreferences, windowName); diff --git a/src/services/view/setupViewEventHandlers.ts b/src/services/view/setupViewEventHandlers.ts index a871eb10..fe0e6ce9 100644 --- a/src/services/view/setupViewEventHandlers.ts +++ b/src/services/view/setupViewEventHandlers.ts @@ -125,7 +125,9 @@ export default function setupViewEventHandlers( }); // focus on initial load // https://github.com/atomery/webcatalog/issues/398 - if (workspace.active && !browserWindow.isDestroyed() && browserWindow.isFocused() && !view.webContents.isFocused()) { + // Get current browser window dynamically to handle workspace hibernation/wake-up scenarios + const currentBrowserWindow = BrowserWindow.fromWebContents(view.webContents); + if (currentBrowserWindow && workspace.active && !currentBrowserWindow.isDestroyed() && currentBrowserWindow.isFocused() && !view.webContents.isFocused()) { view.webContents.focus(); } // update isLoading to false when load succeed diff --git a/src/services/windows/handleCreateBasicWindow.ts b/src/services/windows/handleCreateBasicWindow.ts index b9dedf41..8482cfe2 100644 --- a/src/services/windows/handleCreateBasicWindow.ts +++ b/src/services/windows/handleCreateBasicWindow.ts @@ -22,6 +22,10 @@ export async function handleCreateBasicWindow( const newWindowURL = (windowMeta !== undefined && 'uri' in windowMeta ? windowMeta.uri : undefined) ?? getMainWindowEntry(); if (config?.multiple !== true) { windowService.set(windowName, newWindow); + const verifySet = windowService.get(windowName); + if (verifySet === undefined) { + throw new Error(`Failed to set window ${windowName} in windowService`); + } } const unregisterContextMenu = await menuService.initContextMenuForWindowWebContents(newWindow.webContents); diff --git a/src/services/windows/index.ts b/src/services/windows/index.ts index 128628ad..84b07dab 100644 --- a/src/services/windows/index.ts +++ b/src/services/windows/index.ts @@ -501,7 +501,7 @@ export class Window implements IWindowService { /** * Initialize tidgi mini window on app startup. - * Creates window, determines target workspace based on preferences, and sets up view. + * Only creates the window, views will be created later by initializeAllWorkspaceView. */ public async initializeTidgiMiniWindow(): Promise { const tidgiMiniWindowEnabled = await this.preferenceService.get('tidgiMiniWindow'); @@ -510,50 +510,10 @@ export class Window implements IWindowService { return; } - // Create the window but don't show it yet + // Only create the window but don't create views yet + // Views will be created by initializeAllWorkspaceView -> addViewForAllBrowserViews await this.openTidgiMiniWindow(true, false); - - // Determine which workspace to show based on preferences (sync vs fixed) - const { shouldSync, targetWorkspaceId } = await getTidgiMiniWindowTargetWorkspace(); - - if (!targetWorkspaceId) { - logger.info('No target workspace for tidgi mini window (sync disabled and no fixed workspace selected)', { function: 'initializeTidgiMiniWindow' }); - return; - } - - const workspaceService = container.get(serviceIdentifier.Workspace); - const targetWorkspace = await workspaceService.get(targetWorkspaceId); - - if (!targetWorkspace || targetWorkspace.pageType) { - // Skip page workspaces (like Agent) - they don't have browser views - logger.debug('Target workspace is a page type or not found, skipping view creation', { - function: 'initializeTidgiMiniWindow', - targetWorkspaceId, - isPageType: targetWorkspace?.pageType, - }); - return; - } - - // Create view for the target workspace - const viewService = container.get(serviceIdentifier.View); - const existingView = viewService.getView(targetWorkspace.id, WindowNames.tidgiMiniWindow); - - if (!existingView) { - logger.info('Creating tidgi mini window view for target workspace', { - function: 'initializeTidgiMiniWindow', - workspaceId: targetWorkspace.id, - shouldSync, - }); - await viewService.addView(targetWorkspace, WindowNames.tidgiMiniWindow); - } - - // Realign to ensure view is properly positioned - logger.info('Realigning workspace view for tidgi mini window after initialization', { - function: 'initializeTidgiMiniWindow', - workspaceId: targetWorkspace.id, - shouldSync, - }); - await container.get(serviceIdentifier.WorkspaceView).realignActiveWorkspace(targetWorkspace.id); + logger.debug('TidGi mini window created, views will be initialized by initializeAllWorkspaceView', { function: 'initializeTidgiMiniWindow' }); } public async updateWindowProperties(windowName: WindowNames, properties: { alwaysOnTop?: boolean }): Promise { diff --git a/src/windows/GitLog/CommitDetailsPanel.tsx b/src/windows/GitLog/CommitDetailsPanel.tsx index a13d2c5b..fbc20b63 100644 --- a/src/windows/GitLog/CommitDetailsPanel.tsx +++ b/src/windows/GitLog/CommitDetailsPanel.tsx @@ -435,7 +435,7 @@ export function CommitDetailsPanel( disabled={isUndoing} startIcon={isUndoing ? : undefined} > - {isUndoing ? t('GitLog.Undoing', { defaultValue: 'Undoing...' }) : t('GitLog.UndoCommit', { defaultValue: 'Undo this commit' })} + {isUndoing ? t('GitLog.Undoing') : t('GitLog.UndoCommit')} )} @@ -499,12 +499,12 @@ export function CommitDetailsPanel( {/* Edit Commit Message Modal */} - {t('GitLog.EditCommitMessageTitle', { defaultValue: '修改最近一次提交的信息' })} + {t('GitLog.EditCommitMessageTitle')} - {t('GitLog.EditCommitMessageHint', { defaultValue: '仅可修改最近一次提交的提交信息;若暂存区有变更,它们将包含进新的提交。' })} + {t('GitLog.EditCommitMessageHint')} - + diff --git a/src/windows/GitLog/index.tsx b/src/windows/GitLog/index.tsx index 8fa1f9b5..7696f727 100644 --- a/src/windows/GitLog/index.tsx +++ b/src/windows/GitLog/index.tsx @@ -40,7 +40,7 @@ export default function GitHistory(): React.JSX.Element { - {t('GitLog.LoadingWorkspace', { defaultValue: '正在加载工作区...' })} + {t('GitLog.LoadingWorkspace')} diff --git a/src/windows/Notifications/index.tsx b/src/windows/Notifications/index.tsx index a1616458..0636d86a 100644 --- a/src/windows/Notifications/index.tsx +++ b/src/windows/Notifications/index.tsx @@ -116,7 +116,7 @@ export default function Notifications(): React.JSX.Element { popupState.close(); }} > - {t(shortcut.key, { defaultValue: shortcut.name })} + {t(shortcut.key)} ))} - {t('Notification.Custom', { defaultValue: 'Custom...' })} + {t('Notification.Custom')} @@ -138,8 +138,8 @@ export default function Notifications(): React.JSX.Element { { await window.service.window.open(WindowNames.preferences, { preferenceGotoTab: PreferenceSections.notifications }); void window.remote.closeCurrentWindow(); @@ -151,7 +151,7 @@ export default function Notifications(): React.JSX.Element { } return ( - {t('Notification.PauseNotifications', { defaultValue: 'Pause notifications' })}}> + {t('Notification.PauseNotifications')}}> {quickShortcuts.map((shortcut) => ( - + ))} - + { await window.service.window.open(WindowNames.preferences, { preferenceGotoTab: PreferenceSections.notifications }); void window.remote.closeCurrentWindow(); @@ -195,7 +195,7 @@ export default function Notifications(): React.JSX.Element { if (tilDate === null) return; pauseNotification(tilDate, t); }} - label={t('Notification.Custom', { defaultValue: 'Custom' })} + label={t('Notification.Custom')} open={showDateTimePicker} onOpen={() => { showDateTimePickerSetter(true); diff --git a/src/windows/Preferences/sections/DeveloperTools.tsx b/src/windows/Preferences/sections/DeveloperTools.tsx index 96635ba9..efbe25d5 100644 --- a/src/windows/Preferences/sections/DeveloperTools.tsx +++ b/src/windows/Preferences/sections/DeveloperTools.tsx @@ -20,6 +20,7 @@ export function DeveloperTools(props: ISectionProps): React.JSX.Element { const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [externalApiInfo, setExternalApiInfo] = useState<{ exists: boolean; size?: number; path?: string }>({ exists: false }); + const [isWindows, setIsWindows] = useState(false); useEffect(() => { const fetchInfo = async () => { @@ -41,6 +42,14 @@ export function DeveloperTools(props: ISectionProps): React.JSX.Element { void fetchInfo(); }, []); + useEffect(() => { + const checkPlatform = async () => { + const platform = await window.service.context.get('platform'); + setIsWindows(platform === 'win32'); + }; + void checkPlatform(); + }, []); + return ( <> {t('Preference.DeveloperTools')} @@ -89,6 +98,33 @@ export function DeveloperTools(props: ISectionProps): React.JSX.Element { + {isWindows && ( + { + const localAppData = process.env.LOCALAPPDATA; + if (localAppData) { + // %APPDATA%\Local\SquirrelTemp\SquirrelSetup.log + const squirrelTemporaryPath = `${localAppData}\\SquirrelTemp`; + try { + await window.service.native.openPath(squirrelTemporaryPath, true); + } catch (error: unknown) { + void window.service.native.log( + 'error', + 'DeveloperTools: open SquirrelTemp folder failed', + { + function: 'DeveloperTools.openSquirrelTempFolder', + error: error as Error, + path: squirrelTemporaryPath, + }, + ); + } + } + }} + > + + + + )} { diff --git a/src/windows/Preferences/sections/ExternalAPI/__tests__/index.test.tsx b/src/windows/Preferences/sections/ExternalAPI/__tests__/index.test.tsx index 977fdf95..80bc2933 100644 --- a/src/windows/Preferences/sections/ExternalAPI/__tests__/index.test.tsx +++ b/src/windows/Preferences/sections/ExternalAPI/__tests__/index.test.tsx @@ -396,22 +396,28 @@ describe('ExternalAPI Component', () => { expect(comboboxes).toHaveLength(6); // Check that default model is displayed (first combobox) - expect(comboboxes[0]).toHaveValue('gpt-4'); + // Combobox displays the label text, not the value + expect(comboboxes[0]).toHaveValue('GPT-4 Language Model'); // Check that embedding model is displayed (second combobox) - expect(comboboxes[1]).toHaveValue('text-embedding-3-small'); + // Combobox displays the label text, not the value + expect(comboboxes[1]).toHaveValue('OpenAI Embedding Model'); // Check that speech model is displayed (third combobox) - expect(comboboxes[2]).toHaveValue('gpt-speech'); + // Combobox displays the label text, not the value + expect(comboboxes[2]).toHaveValue('GPT Speech'); // Check that image generation model is displayed (fourth combobox) - expect(comboboxes[3]).toHaveValue('dall-e'); + // Combobox displays the label text, not the value + expect(comboboxes[3]).toHaveValue('DALL-E'); // Check that transcriptions model is displayed (fifth combobox) - expect(comboboxes[4]).toHaveValue('whisper'); + // Combobox displays the label text, not the value + expect(comboboxes[4]).toHaveValue('Whisper'); // Check that free model is displayed (sixth combobox) - expect(comboboxes[5]).toHaveValue('gpt-free'); + // Combobox displays the label text, not the value + expect(comboboxes[5]).toHaveValue('GPT Free'); }); it('should render provider configuration section', async () => { diff --git a/src/windows/Preferences/sections/ExternalAPI/components/AIModelParametersDialog.tsx b/src/windows/Preferences/sections/ExternalAPI/components/AIModelParametersDialog.tsx index e2007a32..dbf2d263 100644 --- a/src/windows/Preferences/sections/ExternalAPI/components/AIModelParametersDialog.tsx +++ b/src/windows/Preferences/sections/ExternalAPI/components/AIModelParametersDialog.tsx @@ -92,11 +92,11 @@ export function AIModelParametersDialog({ open, onClose, config, onSave }: AIMod return ( - {t('Preference.ModelParameters', { defaultValue: 'Model Parameters', ns: 'agent' })} + {t('Preference.ModelParameters', { ns: 'agent' })} - {t('Preference.Temperature', { defaultValue: 'Temperature', ns: 'agent' })}: {parameters.temperature?.toFixed(2)} + {t('Preference.Temperature', { ns: 'agent' })}: {parameters.temperature?.toFixed(2)} - {t('Preference.TemperatureDescription', { - defaultValue: 'Higher values produce more creative and varied results, lower values are more deterministic.', - ns: 'agent', - })} + {t('Preference.TemperatureDescription', { ns: 'agent' })} - {t('Preference.TopP', { defaultValue: 'Top P', ns: 'agent' })}: {parameters.topP?.toFixed(2)} + {t('Preference.TopP', { ns: 'agent' })}: {parameters.topP?.toFixed(2)} - {t('Preference.TopPDescription', { - defaultValue: 'Controls diversity. Lower values make text more focused, higher values more diverse.', - ns: 'agent', - })} + {t('Preference.TopPDescription', { ns: 'agent' })} tokens, }, }} - helperText={t('Preference.MaxTokensDescription', { - defaultValue: 'Maximum number of tokens to generate. 1000 tokens is about 750 words.', - ns: 'agent', - })} + helperText={t('Preference.MaxTokensDescription', { ns: 'agent' })} /> diff --git a/src/windows/Preferences/sections/ExternalAPI/components/ModelFeatureChip.tsx b/src/windows/Preferences/sections/ExternalAPI/components/ModelFeatureChip.tsx new file mode 100644 index 00000000..22568919 --- /dev/null +++ b/src/windows/Preferences/sections/ExternalAPI/components/ModelFeatureChip.tsx @@ -0,0 +1,26 @@ +import { Chip } from '@mui/material'; +import defaultProvidersConfig from '@services/externalAPI/defaultProviders'; +import { useTranslation } from 'react-i18next'; + +export function ModelFeatureChip({ feature }: { feature: string }) { + const { t } = useTranslation('agent'); + + const getFeatureLabel = (featureValue: string) => { + const featureDefinition = defaultProvidersConfig.modelFeatures.find(f => f.value === featureValue); + if (featureDefinition) { + // featureDefinition.i18nKey is "ModelFeature.Language" etc. + // pass it to t() + return t(featureDefinition.i18nKey); + } + return featureValue; + }; + + return ( + + ); +} diff --git a/src/windows/Preferences/sections/ExternalAPI/components/ModelSelector.tsx b/src/windows/Preferences/sections/ExternalAPI/components/ModelSelector.tsx index 757d2313..1fe8f386 100644 --- a/src/windows/Preferences/sections/ExternalAPI/components/ModelSelector.tsx +++ b/src/windows/Preferences/sections/ExternalAPI/components/ModelSelector.tsx @@ -1,9 +1,10 @@ -import { Autocomplete } from '@mui/material'; +import { Autocomplete, Box, Typography } from '@mui/material'; import { ModelSelection } from '@services/agentInstance/promptConcat/promptConcatSchema'; import { AIProviderConfig, ModelInfo } from '@services/externalAPI/interface'; import React from 'react'; import { useTranslation } from 'react-i18next'; import { TextField } from '../../../PreferenceComponents'; +import { ModelFeatureChip } from './ModelFeatureChip'; interface ModelSelectorProps { selectedModel: ModelSelection | undefined; @@ -24,6 +25,7 @@ export function ModelSelector({ selectedModel, modelOptions, onChange, onClear, const filteredModelOptions = onlyShowEnabled ? modelOptions.filter(m => m[0].enabled) : modelOptions; + return ( option[0].provider} - getOptionLabel={(option) => option[1].name} + getOptionLabel={(option) => option[1].caption || option[1].name} + renderOption={(props, option) => { + const modelInfo = option[1]; + return ( +
  • + + + {modelInfo.caption || modelInfo.name} + + + {modelInfo.features && modelInfo.features.length > 0 && ( + + {modelInfo.features.map(feature => )} + + )} + +
  • + ); + }} renderInput={(parameters) => ( { + if (!selected) return t('Preference.NoPresetSelected', { ns: 'agent' }); + const model = availableDefaultModels.find(m => m.name === selected); + if (model) return model.caption || model.name; + return selected; + }} > {t('Preference.NoPresetSelected', { ns: 'agent' })} {availableDefaultModels.map((model) => ( - - {model.caption || model.name} + + + + {model.caption || model.name} + + {model.features && model.features.length > 0 && ( + + {model.features.map(feature => )} + + )} + ))}