Fix/misc bug (#679)

* Create ErrorDuringRelease.md

* Enforce test timeouts and add root tiddler scenario

Set global and step timeouts to 5s (local) and 10s (CI) across cucumber config and step definitions to standardize test execution times. Add a new scenario to verify root tiddler configuration and content loading after restart. Enhance start-e2e-app script to accept or auto-detect test scenario names and pass them to the app.

* Improve error handling for window and view initialization

Enhanced error reporting and handling when browser windows are not ready or fail to register in windowService. Updated focus logic to dynamically retrieve the current browser window, improving reliability during workspace hibernation and wake-up scenarios.

* Remove AfterAll hook and add --exit to e2e tests

Eliminates the AfterAll hook that forced process exit in cleanup.ts to prevent hanging after tests. Adds the --exit flag to the cucumber-js command in the e2e test script to ensure proper test process termination.

* Add step to restart workspace in wiki tests

Introduces a new step definition 'I restart workspace {string}' to programmatically restart a wiki workspace during tests. Updates the root tiddler scenario to use this step for verifying lazy-load behavior after workspace restart, improving test reliability and clarity.

* Centralize and standardize E2E test timeouts

Extracted timeout values into features/supports/timeouts.ts and replaced hardcoded timeouts in step definitions with named constants. This ensures consistent timeout handling across local and CI environments, reduces duplication, and clarifies intent. Also improved workspace update logic to check watch-fs state before restart and cleaned up related log marker handling.

* Improve i18n coverage and add Windows installer log access

Expanded and unified i18n keys for error messages and UI labels across multiple languages. Refactored code to remove hardcoded default values from translation calls. Added a Developer Tools option to open the Windows installer log folder (SquirrelTemp) when running on Windows. Introduced a placeholder file to preserve dynamic i18n keys for error messages.

* Initialize Tidgi mini window before workspace views

Moved the initialization of the Tidgi mini window to occur before initializing all workspace views in main.ts to ensure correct view creation. Added a clarifying comment in DeveloperTools.tsx regarding the SquirrelSetup.log path.

* Refactor Tidgi mini window initialization logic

Tidgi mini window creation now only creates the window; view creation is deferred to initializeAllWorkspaceView. Updated related comments and logging for clarity. Also fixed formatting in French translations and improved documentation for error handling during release.

* Add model feature chips to model selection UI

Introduces a ModelFeatureChip component to visually display model features in the model selector and new model dialog. Updates defaultProviders to include new models with features, and enhances the UI to show feature chips for each model, improving clarity for users selecting models.

* Add image attachment support to chat messages

This update enables users to attach image files to chat messages, including UI changes for file selection and preview, backend persistence of attachments, and prompt concatenation logic to include images in AI requests. It also adds error handling and i18n for model vision support, updates message rendering to display images, and improves logging and API validation for vision-capable models.

* Improve streaming status handling for agent messages

Adds a failsafe to clear streaming status when an agent reaches a terminal state and refines logic to prevent marking completed messages as streaming. Also updates message stream completion in AgentInstanceService to ensure proper cleanup and delivery of IPC messages. Includes new feature tests for message streaming status and image upload scenarios.

* Add cross-window sync feature and test steps

Introduces a new feature file for cross-window synchronization scenarios. Adds step definitions to open workspaces in new windows and execute TiddlyWiki code programmatically. Removes obsolete wiki.ts.backup file and updates agentActions for related actions.

* feat(sync): fix cross-window synchronization via SSE

- Remove overly aggressive echo prevention in backend that blocked all SSE updates
- Backend now forwards all wiki change events to subscribers
- Add comprehensive cross-window sync tests verifying bidirectional updates
- Test main->new window sync and new->main window sync scenarios
- Version bump to 0.13.0-prerelease19

* Improve file attachment handling in chat and tests

Refactors file input handling in chat tests to use Playwright's setInputFiles, updates message sending types to support optional file attachments, and enhances file metadata persistence and logging. Adjusts test expectations and UI logic to better handle and display image attachments, and clarifies combobox value assertions in ExternalAPI tests.

* Add file input validation and improve i18n messages

Added image type and size validation (10MB limit) to file input in InputContainer. Improved image preview logic. Updated French, Japanese, and Russian translations with new error messages for missing/default model and vision support. Enhanced type safety in promptConcatWithImage tests and messagePersistence logging. Fixed race condition in ExternalAPIService lazy initialization. Updated CommitDetailsPanel to use common cancel translation key.

* review

* Update browserView.ts

* Update timeouts.ts

* Update cucumber.config.js

* Update cucumber.config.js

* Move global timeout config to separate module

Extracted global timeout setup from cucumber.config.js to features/supports/timeout-config.ts using setDefaultTimeout. This ensures the timeout is set via code rather than config, improving clarity and maintainability.

* Update newAgent.feature
This commit is contained in:
lin onetwo 2026-01-26 02:43:27 +08:00 committed by GitHub
parent fad4449d81
commit a712b2ff51
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
63 changed files with 1678 additions and 1736 deletions

View file

@ -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.

View file

@ -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

View file

@ -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,

View file

@ -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)

View file

@ -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

View file

@ -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);

View file

@ -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);
});

View file

@ -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}`);
}
});

View file

@ -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);
});

View file

@ -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);

View file

@ -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<string, IWorkspace> };
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<IWorkspace | null>;
}, 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<string, unknown> }) => {
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));
});
});

File diff suppressed because it is too large Load diff

View file

@ -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']"

View file

@ -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);

View file

@ -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;

View file

@ -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..."
}
}

View file

@ -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",

View file

@ -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..."
}
}

View file

@ -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",

View file

@ -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を読み込んでいます..."
}
}

View file

@ -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": "プレリリース更新を受信する",

View file

@ -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..."
}
}

View file

@ -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": "Получать предварительные обновления",

View file

@ -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..."
}
}

View file

@ -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": "提供商已成功添加",

View file

@ -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..."
}
}

View file

@ -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": "接收預發布更新",

View file

@ -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",

View file

@ -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);

View file

@ -148,16 +148,19 @@ const commonInit = async (): Promise<void> => {
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

View file

@ -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);
},

View file

@ -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(

View file

@ -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<void>;
sendMessage: (content: string, file?: File) => Promise<void>;
/**
* Creates a new agent instance from a definition.

View file

@ -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<InputContainerProps> = ({
onKeyPress,
disabled = false,
isStreaming = false,
selectedFile,
onFileSelect,
onClearFile,
}) => {
const { t } = useTranslation('agent');
const fileInputReference = React.useRef<HTMLInputElement>(null);
const [previewUrl, setPreviewUrl] = React.useState<string | undefined>();
React.useEffect(() => {
if (selectedFile) {
const url = URL.createObjectURL(selectedFile);
setPreviewUrl(url);
return () => {
URL.revokeObjectURL(url);
};
} else {
setPreviewUrl(undefined);
}
}, [selectedFile]);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
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 (
<Container>
<InputField
value={value}
onChange={onChange}
onKeyDown={onKeyPress}
placeholder={t('Chat.InputPlaceholder')}
variant='outlined'
fullWidth
multiline
maxRows={4}
disabled={disabled}
slotProps={{
input: {
inputProps: { 'data-testid': 'agent-message-input' },
endAdornment: (
<IconButton
data-testid='agent-send-button'
onClick={isStreaming ? onCancel : onSend}
// During streaming, cancel button should always be enabled
// Only disable the button when not streaming and the input is empty
disabled={isStreaming ? false : (disabled || !value.trim())}
color={isStreaming ? 'error' : 'primary'}
title={isStreaming ? t('Chat.Cancel') : t('Chat.Send')}
>
{isStreaming ? <CancelIcon data-testid='cancel-icon' /> : <SendIcon data-testid='send-icon' />}
</IconButton>
),
},
}}
/>
</Container>
<Wrapper>
{selectedFile && previewUrl && (
<Box sx={{ p: 1, px: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
<Box
sx={{ position: 'relative', display: 'inline-block' }}
>
<Box
component='img'
src={previewUrl}
data-testid='attachment-preview'
sx={{
height: 80,
width: 'auto',
borderRadius: 1,
cursor: 'pointer',
border: '1px solid',
borderColor: 'divider',
}}
onClick={() => {
// 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);
}
}}
/>
<IconButton
size='small'
onClick={onClearFile}
sx={{
position: 'absolute',
top: -8,
right: -8,
bgcolor: 'background.paper',
boxShadow: 1,
'&:hover': { bgcolor: 'background.default' },
}}
>
<CloseIcon fontSize='small' />
</IconButton>
</Box>
</Box>
)}
<Container>
<input
type='file'
hidden
ref={fileInputReference}
accept='image/*'
onChange={handleFileChange}
/>
<IconButton
onClick={() => fileInputReference.current?.click()}
disabled={disabled || isStreaming}
color={selectedFile ? 'primary' : 'default'}
data-testid='agent-attach-button'
>
<AttachFileIcon />
</IconButton>
<InputField
value={value}
onChange={onChange}
onKeyDown={onKeyPress}
placeholder={t('Chat.InputPlaceholder')}
variant='outlined'
fullWidth
multiline
maxRows={4}
disabled={disabled}
slotProps={{
input: {
inputProps: { 'data-testid': 'agent-message-input' },
endAdornment: (
<IconButton
data-testid='agent-send-button'
onClick={isStreaming ? onCancel : onSend}
// During streaming, cancel button should always be enabled
// Only disable the button when not streaming and the input is empty AND no file selected
disabled={isStreaming ? false : (disabled || (!value.trim() && !selectedFile))}
color={isStreaming ? 'error' : 'primary'}
title={isStreaming ? t('Chat.Cancel') : t('Chat.Send')}
>
{isStreaming ? <CancelIcon data-testid='cancel-icon' /> : <SendIcon data-testid='send-icon' />}
</IconButton>
),
},
}}
/>
</Container>
</Wrapper>
);
};

View file

@ -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<string | undefined>();
const [previewUrl, setPreviewUrl] = React.useState<string | undefined>();
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 (
<Box
component='img'
src={source}
data-testid='message-image-attachment'
sx={{
maxWidth: '100%',
maxHeight: 300,
borderRadius: 1,
mb: 1,
display: 'block',
cursor: 'pointer',
}}
onClick={() => {
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<MessageBubbleProps> = ({ messageId }) => {
$expired={isExpired}
data-testid={!isUser ? (isStreaming ? 'assistant-streaming-text' : 'assistant-message') : undefined}
>
{message.metadata?.file ? <ImageAttachment file={message.metadata.file as File | { path: string }} /> : null}
<MessageRenderer message={message} isUser={isUser} />
</MessageContent>

View file

@ -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<string, string>;
} {
// Default values
let errorName = 'Error';
let errorCode = 'UNKNOWN_ERROR';
let provider = '';
let errorMessage = message.content;
let parameters: Record<string, string> | 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<string, string>;
};
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<MessageRendererProps> = ({ 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<MessageRendererProps> = ({ 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 (
<ErrorWrapper data-testid='error-message'>
@ -101,9 +128,7 @@ export const ErrorMessageRenderer: React.FC<MessageRendererProps> = ({ message }
</ErrorHeader>
<Typography variant='body1'>
{provider
? t(`Chat.ConfigError.${errorName}`, { provider }) || errorMessage
: errorMessage}
{displayMessage}
</Typography>
{isSettingsFixableError && (

View file

@ -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;

View file

@ -195,7 +195,7 @@ export function ArrayFieldItemTemplate<T = unknown, S extends RJSFSchema = RJSFS
onClick={(event) => {
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 }}
/>

View file

@ -29,6 +29,7 @@ export function useMessageHandling({
})),
);
const [message, setMessage] = useState('');
const [selectedFile, setSelectedFile] = useState<File | undefined>();
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,
};
}

View file

@ -98,6 +98,9 @@ export const ChatTabContent: React.FC<ChatTabContentProps> = ({ tab }) => {
handleMessageChange,
handleSendMessage,
handleKeyPress,
selectedFile,
handleFileSelect,
handleClearFile,
} = useMessageHandling({
agentId: tab.agentId,
isUserAtBottom,
@ -223,6 +226,9 @@ export const ChatTabContent: React.FC<ChatTabContentProps> = ({ tab }) => {
onKeyPress={handleKeyPress}
disabled={!agent || isWorking}
isStreaming={isStreaming}
selectedFile={selectedFile}
onFileSelect={handleFileSelect}
onClearFile={handleClearFile}
/>
{/* Model parameter dialog */}

View file

@ -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

View file

@ -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) {

View file

@ -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<AgentInstance> as AgentInstance,
agentDef: {} as Partial<AgentDefinition> 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<AgentInstance> as AgentInstance,
agentDef: {} as Partial<AgentDefinition> 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();
});
});

View file

@ -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<string, unknown> | 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<string, unknown> | undefined)?.path,
fileName: (userMessage.metadata?.file as Record<string, unknown> | undefined)?.name,
});
}
// Add message to the agent's message array for immediate use
agentFrameworkContext.agent.messages.push(userMessage);

View file

@ -93,12 +93,33 @@ const fullReplacementDefinition = registerModifier({
type PromptRole = NonNullable<IPrompt['role']>;
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<string, unknown>,
}
: {}),
});
} 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 = '无聊天历史。';

View file

@ -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', {

View file

@ -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<string, unknown>;
}

View file

@ -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'],
},
],
},
{

View file

@ -48,6 +48,7 @@ export class ExternalAPIService implements IExternalAPIService {
private dataSource: DataSource | null = null;
private apiLogRepository: Repository<ExternalAPILogEntity> | null = null;
private initializationPromise: Promise<void> | null = null; // Prevent race condition in lazy initialization
private activeRequests: Map<string, AbortController> = 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) {

View file

@ -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<string, string>;
}
/**
* 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;
}
/**

View file

@ -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);

View file

@ -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

View file

@ -22,6 +22,10 @@ export async function handleCreateBasicWindow<N extends WindowNames>(
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);

View file

@ -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<void> {
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<IWorkspaceService>(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<IViewService>(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<IWorkspaceViewService>(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<void> {

View file

@ -435,7 +435,7 @@ export function CommitDetailsPanel(
disabled={isUndoing}
startIcon={isUndoing ? <CircularProgress size={16} color='inherit' /> : undefined}
>
{isUndoing ? t('GitLog.Undoing', { defaultValue: 'Undoing...' }) : t('GitLog.UndoCommit', { defaultValue: 'Undo this commit' })}
{isUndoing ? t('GitLog.Undoing') : t('GitLog.UndoCommit')}
</Button>
<Button
@ -457,7 +457,7 @@ export function CommitDetailsPanel(
fullWidth
disabled={isAmending}
>
{t('GitLog.EditCommitMessage', { defaultValue: '修改提交信息' })}
{t('GitLog.EditCommitMessage')}
</Button>
)}
@ -499,12 +499,12 @@ export function CommitDetailsPanel(
{/* Edit Commit Message Modal */}
<Dialog open={isEditMessageOpen} onClose={handleCloseEditMessage} fullWidth maxWidth='sm'>
<DialogTitle>{t('GitLog.EditCommitMessageTitle', { defaultValue: '修改最近一次提交的信息' })}</DialogTitle>
<DialogTitle>{t('GitLog.EditCommitMessageTitle')}</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin='dense'
label={t('GitLog.EditCommitMessagePlaceholder', { defaultValue: '新的提交信息' })}
label={t('GitLog.EditCommitMessagePlaceholder')}
type='text'
fullWidth
value={newCommitMessage}
@ -513,13 +513,13 @@ export function CommitDetailsPanel(
}}
/>
<Typography variant='caption' color='text.secondary'>
{t('GitLog.EditCommitMessageHint', { defaultValue: '仅可修改最近一次提交的提交信息;若暂存区有变更,它们将包含进新的提交。' })}
{t('GitLog.EditCommitMessageHint')}
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseEditMessage}>{t('Common.Cancel', { defaultValue: '取消' })}</Button>
<Button onClick={handleCloseEditMessage}>{t('Common.Cancel')}</Button>
<Button onClick={handleConfirmEditMessage} disabled={isAmending || !newCommitMessage.trim()} variant='contained'>
{isAmending ? t('GitLog.Committing', { defaultValue: '提交中...' }) : t('GitLog.EditCommitMessageConfirm', { defaultValue: '保存' })}
{isAmending ? t('GitLog.Committing') : t('GitLog.EditCommitMessageConfirm')}
</Button>
</DialogActions>
</Dialog>

View file

@ -40,7 +40,7 @@ export default function GitHistory(): React.JSX.Element {
<LoadingContainer>
<CircularProgress />
<Typography variant='body2' color='textSecondary' sx={{ mt: 2 }}>
{t('GitLog.LoadingWorkspace', { defaultValue: '正在加载工作区...' })}
{t('GitLog.LoadingWorkspace')}
</Typography>
</LoadingContainer>
</Root>

View file

@ -116,7 +116,7 @@ export default function Notifications(): React.JSX.Element {
popupState.close();
}}
>
{t(shortcut.key, { defaultValue: shortcut.name })}
{t(shortcut.key)}
</MenuItem>
))}
<MenuItem
@ -126,7 +126,7 @@ export default function Notifications(): React.JSX.Element {
popupState.close();
}}
>
{t('Notification.Custom', { defaultValue: 'Custom...' })}
{t('Notification.Custom')}
</MenuItem>
</Menu>
</>
@ -138,8 +138,8 @@ export default function Notifications(): React.JSX.Element {
<ListItemButton>
<ListItemText
primary={pauseNotificationsInfo.reason === 'scheduled'
? t('Notification.AdjustSchedule', { defaultValue: 'Adjust schedule...' })
: t('Notification.PauseBySchedule', { defaultValue: 'Pause notifications by schedule...' })}
? t('Notification.AdjustSchedule')
: t('Notification.PauseBySchedule')}
onClick={async () => {
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 (
<List subheader={<ListSubheader component='div'>{t('Notification.PauseNotifications', { defaultValue: 'Pause notifications' })}</ListSubheader>}>
<List subheader={<ListSubheader component='div'>{t('Notification.PauseNotifications')}</ListSubheader>}>
{quickShortcuts.map((shortcut) => (
<ListItemButton
key={shortcut.key}
@ -159,7 +159,7 @@ export default function Notifications(): React.JSX.Element {
pauseNotification(shortcut.calcDate(), t);
}}
>
<ListItemText primary={t(shortcut.key, { defaultValue: shortcut.name })} />
<ListItemText primary={t(shortcut.key)} />
</ListItemButton>
))}
<ListItemButton
@ -167,12 +167,12 @@ export default function Notifications(): React.JSX.Element {
showDateTimePickerSetter(true);
}}
>
<ListItemText primary={t('Notification.Custom', { defaultValue: 'Custom...' })} />
<ListItemText primary={t('Notification.Custom')} />
</ListItemButton>
<Divider />
<ListItemButton>
<ListItemText
primary={t('Notification.PauseBySchedule', { defaultValue: 'Pause notifications by schedule...' })}
primary={t('Notification.PauseBySchedule')}
onClick={async () => {
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);

View file

@ -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 (
<>
<SectionTitle ref={props.sections.developers.ref}>{t('Preference.DeveloperTools')}</SectionTitle>
@ -89,6 +98,33 @@ export function DeveloperTools(props: ISectionProps): React.JSX.Element {
<ListItemText primary={t('Preference.OpenV8CacheFolder')} secondary={t('Preference.OpenV8CacheFolderDetail')} />
<ChevronRightIcon color='action' />
</ListItemButton>
{isWindows && (
<ListItemButton
onClick={async () => {
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,
},
);
}
}
}}
>
<ListItemText primary={t('Preference.OpenInstallerLogFolder')} secondary={t('Preference.OpenInstallerLogFolderDetail')} />
<ChevronRightIcon color='action' />
</ListItemButton>
)}
<Divider />
<ListItemButton
onClick={async () => {

View file

@ -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 () => {

View file

@ -92,11 +92,11 @@ export function AIModelParametersDialog({ open, onClose, config, onSave }: AIMod
return (
<Dialog open={open} onClose={onClose} maxWidth='md' fullWidth>
<DialogTitle>{t('Preference.ModelParameters', { defaultValue: 'Model Parameters', ns: 'agent' })}</DialogTitle>
<DialogTitle>{t('Preference.ModelParameters', { ns: 'agent' })}</DialogTitle>
<DialogContent>
<FormControl fullWidth sx={{ mt: 2 }}>
<FormHelperText>
{t('Preference.Temperature', { defaultValue: 'Temperature', ns: 'agent' })}: {parameters.temperature?.toFixed(2)}
{t('Preference.Temperature', { ns: 'agent' })}: {parameters.temperature?.toFixed(2)}
</FormHelperText>
<Slider
value={parameters.temperature}
@ -108,16 +108,13 @@ export function AIModelParametersDialog({ open, onClose, config, onSave }: AIMod
valueLabelDisplay='auto'
/>
<FormHelperText>
{t('Preference.TemperatureDescription', {
defaultValue: 'Higher values produce more creative and varied results, lower values are more deterministic.',
ns: 'agent',
})}
{t('Preference.TemperatureDescription', { ns: 'agent' })}
</FormHelperText>
</FormControl>
<FormControl fullWidth sx={{ mt: 3 }}>
<FormHelperText>
{t('Preference.TopP', { defaultValue: 'Top P', ns: 'agent' })}: {parameters.topP?.toFixed(2)}
{t('Preference.TopP', { ns: 'agent' })}: {parameters.topP?.toFixed(2)}
</FormHelperText>
<Slider
value={parameters.topP}
@ -129,16 +126,13 @@ export function AIModelParametersDialog({ open, onClose, config, onSave }: AIMod
valueLabelDisplay='auto'
/>
<FormHelperText>
{t('Preference.TopPDescription', {
defaultValue: 'Controls diversity. Lower values make text more focused, higher values more diverse.',
ns: 'agent',
})}
{t('Preference.TopPDescription', { ns: 'agent' })}
</FormHelperText>
</FormControl>
<FormControl fullWidth sx={{ mt: 3 }}>
<TextField
label={t('Preference.MaxTokens', { defaultValue: 'Max Tokens', ns: 'agent' })}
label={t('Preference.MaxTokens', { ns: 'agent' })}
value={parameters.maxTokens}
onChange={handleMaxTokensChange}
type='number'
@ -147,28 +141,19 @@ export function AIModelParametersDialog({ open, onClose, config, onSave }: AIMod
endAdornment: <InputAdornment position='end'>tokens</InputAdornment>,
},
}}
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' })}
/>
</FormControl>
<FormControl fullWidth sx={{ mt: 3 }}>
<TextField
label={t('Preference.SystemPrompt', { defaultValue: 'System Prompt', ns: 'agent' })}
label={t('Preference.SystemPrompt', { ns: 'agent' })}
value={parameters.systemPrompt}
onChange={handleSystemPromptChange}
multiline
rows={4}
placeholder={t('Preference.SystemPromptPlaceholder', {
defaultValue: 'Optional: Provide system instructions to guide the AI',
ns: 'agent',
})}
helperText={t('Preference.SystemPromptDescription', {
defaultValue: 'System instructions that define how the AI should behave (optional)',
ns: 'agent',
})}
placeholder={t('Preference.SystemPromptPlaceholder', { ns: 'agent' })}
helperText={t('Preference.SystemPromptDescription', { ns: 'agent' })}
/>
</FormControl>
</DialogContent>

View file

@ -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 (
<Chip
label={getFeatureLabel(feature)}
size='small'
variant='outlined'
sx={{ fontSize: '0.7rem', height: 20 }}
/>
);
}

View file

@ -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 (
<Autocomplete
value={selectedValue}
@ -36,7 +38,25 @@ export function ModelSelector({ selectedModel, modelOptions, onChange, onClear,
}}
options={filteredModelOptions}
groupBy={(option) => option[0].provider}
getOptionLabel={(option) => option[1].name}
getOptionLabel={(option) => option[1].caption || option[1].name}
renderOption={(props, option) => {
const modelInfo = option[1];
return (
<li {...props}>
<Box sx={{ display: 'flex', flexDirection: 'column', width: '100%' }}>
<Typography variant='body1'>
{modelInfo.caption || modelInfo.name}
</Typography>
{modelInfo.features && modelInfo.features.length > 0 && (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mt: 0.5 }}>
{modelInfo.features.map(feature => <ModelFeatureChip key={feature} feature={feature} />)}
</Box>
)}
</Box>
</li>
);
}}
renderInput={(parameters) => (
<TextField
{...parameters}

View file

@ -19,6 +19,7 @@ import defaultProvidersConfig from '@services/externalAPI/defaultProviders';
import { ModelFeature, ModelInfo } from '@services/externalAPI/interface';
import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { ModelFeatureChip } from './ModelFeatureChip';
interface ModelDialogProps {
open: boolean;
@ -105,11 +106,26 @@ export function NewModelDialog({
onSelectDefaultModel(event.target.value);
}}
label={t('Preference.PresetModels', { ns: 'agent' })}
renderValue={(selected) => {
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;
}}
>
<MenuItem value=''>{t('Preference.NoPresetSelected', { ns: 'agent' })}</MenuItem>
{availableDefaultModels.map((model) => (
<MenuItem key={model.name} value={model.name}>
{model.caption || model.name}
<MenuItem key={model.name} value={model.name} sx={{ py: 1 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', width: '100%', gap: 0.5 }}>
<Typography variant='body1'>
{model.caption || model.name}
</Typography>
{model.features && model.features.length > 0 && (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{model.features.map(feature => <ModelFeatureChip key={feature} feature={feature} />)}
</Box>
)}
</Box>
</MenuItem>
))}
</Select>