Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions apps/desktop/e2e-tests/test/helpers/openSettingsWindow.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
export async function openSettingsWindow() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Add JSDoc documentation for the exported helper.

As a shared E2E helper used across multiple test files, this function should have JSDoc comments explaining its purpose, return value, and error conditions to improve maintainability.

📝 Suggested JSDoc
+/**
+ * Opens the TouchAI settings window via the E2E bridge and switches focus to it.
+ * 
+ * This helper waits for the __TOUCHAI_E2E__ bridge to be available, invokes
+ * openSettingsWindow(), polls window handles to find the settings window by URL,
+ * and switches the WebDriver session focus to that window.
+ * 
+ * `@returns` {Promise<{mainWindowHandle: string, settingsHandle: string}>} 
+ *   An object containing both window handles for test cleanup/navigation.
+ * `@throws` {Error} If the settings window fails to open or cannot be located.
+ */
 export async function openSettingsWindow() {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function openSettingsWindow() {
/**
* Opens the TouchAI settings window via the E2E bridge and switches focus to it.
*
* This helper waits for the __TOUCHAI_E2E__ bridge to be available, invokes
* openSettingsWindow(), polls window handles to find the settings window by URL,
* and switches the WebDriver session focus to that window.
*
* `@returns` {Promise<{mainWindowHandle: string, settingsHandle: string}>}
* An object containing both window handles for test cleanup/navigation.
* `@throws` {Error} If the settings window fails to open or cannot be located.
*/
export async function openSettingsWindow() {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/e2e-tests/test/helpers/openSettingsWindow.js` at line 1, Add
JSDoc for the exported async helper openSettingsWindow: document the function's
purpose (opens the app settings window in E2E tests), its signature (async,
returns Promise<void>), any side effects and error conditions (throws or rejects
if window fails to open or selector not found), and note usage/context (shared
E2E helper used by multiple tests). Place the comment directly above the export
of openSettingsWindow and include `@returns` {Promise<void>} and an `@throws` or
`@rejects` description so consumers know how to handle failures.

const mainWindowHandle = await browser.getWindowHandle();
let settingsHandle = null;

await browser.waitUntil(async () => {
return browser.execute(() => Boolean(window.__TOUCHAI_E2E__));
});

await browser
.executeAsync((done) => {
window.__TOUCHAI_E2E__
.openSettingsWindow()
.then(() => done({ ok: true }))
.catch((error) => done({ ok: false, error: String(error) }));
})
.then((result) => {
if (!result?.ok) {
throw new Error(
`Failed to open settings window: ${result?.error ?? 'unknown error'}`
);
}
});

await browser.waitUntil(async () => {
const handles = await browser.getWindowHandles();
for (const handle of handles) {
if (handle === mainWindowHandle) {
continue;
}

await browser.switchToWindow(handle);
const currentUrl = await browser.getUrl();
if (currentUrl.includes('/settings')) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Consider more precise URL matching for the settings window.

The substring check currentUrl.includes('/settings') could match unintended URLs like /settings-old or /user-settings. While unlikely in the current test environment, a more precise check would be more robust.

♻️ More precise URL matching
-            if (currentUrl.includes('/settings')) {
+            if (currentUrl.endsWith('/settings') || currentUrl.includes('/settings?') || currentUrl.includes('/settings#')) {
                 settingsHandle = handle;
                 return true;
             }

Or use a URL object for more reliable parsing:

             await browser.switchToWindow(handle);
             const currentUrl = await browser.getUrl();
-            if (currentUrl.includes('/settings')) {
+            const url = new URL(currentUrl);
+            if (url.pathname === '/settings' || url.pathname.endsWith('/settings')) {
                 settingsHandle = handle;
                 return true;
             }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/e2e-tests/test/helpers/openSettingsWindow.js` at line 33,
Replace the loose substring check on currentUrl in openSettingsWindow.js with a
precise pathname match: parse the currentUrl (using the URL API) and compare the
URL.pathname to '/settings' or use a regex that enforces '/settings' followed
only by a slash or end-of-string (e.g., '/settings(/|$)') so that names like
'/settings-old' or '/user-settings' don’t match; update the conditional that
references currentUrl.includes('/settings') accordingly (look for the variable
currentUrl and the conditional block starting with if
(currentUrl.includes('/settings'))).

settingsHandle = handle;
return true;
}
}

await browser.switchToWindow(mainWindowHandle);
return false;
});

if (!settingsHandle) {
throw new Error('Unable to locate settings window handle.');
}

await browser.switchToWindow(settingsHandle);
return { mainWindowHandle, settingsHandle };
}
68 changes: 68 additions & 0 deletions apps/desktop/e2e-tests/test/specs/settings-memory.e2e.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { openSettingsWindow } from '../helpers/openSettingsWindow.js';

describe('TouchAI settings memory acceptance', () => {
it('persists a completed memory when the user switches sections immediately', async () => {
const { mainWindowHandle } = await openSettingsWindow();
const settingsView = await $("[data-testid='settings-view']");
const memoryNav = await $("[data-testid='settings-nav-memory']");
const generalNav = await $("[data-testid='settings-nav-general']");

await settingsView.waitForDisplayed();
await memoryNav.waitForDisplayed();
await memoryNav.click();

const addButton = await $("[data-testid='settings-memory-add-button']");
await addButton.waitForDisplayed();
await addButton.click();

const titleInput = await $("[data-testid='settings-memory-title-input']");
const applicabilityInput = await $("[data-testid='settings-memory-applicability-input']");
const contentInput = await $("[data-testid='settings-memory-content-input']");

await titleInput.waitForDisplayed();
await titleInput.setValue('Desktop workflow');
await applicabilityInput.setValue('When TouchAI settings or tray flows matter');
await contentInput.setValue(
'TouchAI is a desktop agent. Verify settings and tray behavior before answering.'
);

await generalNav.click();

const generalSection = await $("[data-testid='settings-general-section']");
await generalSection.waitForDisplayed();

await memoryNav.click();
const persistedTitleInput = await $("[data-testid='settings-memory-title-input']");
const persistedApplicabilityInput = await $(
"[data-testid='settings-memory-applicability-input']"
);
const persistedContentInput = await $("[data-testid='settings-memory-content-input']");
await persistedTitleInput.waitForDisplayed();

await browser.waitUntil(async () => {
return (await persistedTitleInput.getValue()) === 'Desktop workflow';
});
await browser.waitUntil(async () => {
return (
(await persistedApplicabilityInput.getValue()) ===
'When TouchAI settings or tray flows matter'
);
});
await browser.waitUntil(async () => {
return (
(await persistedContentInput.getValue()) ===
'TouchAI is a desktop agent. Verify settings and tray behavior before answering.'
);
});

const memoryItems = await $$("[data-testid^='settings-memory-item-']");
if (memoryItems.length !== 1) {
throw new Error(
`Expected exactly one persisted memory item, received ${memoryItems.length}.`
);
}

await browser.closeWindow();
await browser.switchToWindow(mainWindowHandle);
});
});
49 changes: 3 additions & 46 deletions apps/desktop/e2e-tests/test/specs/settings-smoke.e2e.js
Original file line number Diff line number Diff line change
@@ -1,51 +1,8 @@
import { openSettingsWindow } from '../helpers/openSettingsWindow.js';

describe('TouchAI settings smoke', () => {
it('opens the settings window and persists the start-minimized toggle', async () => {
const mainWindowHandle = await browser.getWindowHandle();
let settingsHandle = null;

await browser.waitUntil(async () => {
return browser.execute(() => Boolean(window.__TOUCHAI_E2E__));
});

await browser
.executeAsync((done) => {
window.__TOUCHAI_E2E__
.openSettingsWindow()
.then(() => done({ ok: true }))
.catch((error) => done({ ok: false, error: String(error) }));
})
.then((result) => {
if (!result?.ok) {
throw new Error(
`Failed to open settings window: ${result?.error ?? 'unknown error'}`
);
}
});

await browser.waitUntil(async () => {
const handles = await browser.getWindowHandles();
for (const handle of handles) {
if (handle === mainWindowHandle) {
continue;
}

await browser.switchToWindow(handle);
const currentUrl = await browser.getUrl();
if (currentUrl.includes('/settings')) {
settingsHandle = handle;
return true;
}
}

await browser.switchToWindow(mainWindowHandle);
return false;
});

if (!settingsHandle) {
throw new Error('Unable to locate settings window handle.');
}

await browser.switchToWindow(settingsHandle);
const { mainWindowHandle } = await openSettingsWindow();

const settingsView = await $("[data-testid='settings-view']");
const generalSection = await $("[data-testid='settings-general-section']");
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/e2e-tests/wdio.conf.js
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ export const config = {
path.join(__dirname, 'test/specs/search-smoke.e2e.js'),
path.join(__dirname, 'test/specs/settings-smoke.e2e.js'),
],
path.join(__dirname, 'test/specs/settings-memory.e2e.js'),
],
bail: 1,
maxInstances: 1,
Expand Down
69 changes: 60 additions & 9 deletions apps/desktop/src-tauri/src/core/database/runtime/import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,22 +155,72 @@ async fn ensure_import_required_tables(
];

for &table in REQUIRED_TABLES {
let exists = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM imported.sqlite_master WHERE type = 'table' AND name = ?",
)
.bind(table)
.fetch_one(&mut **connection)
.await
.map_err(|error| format!("Failed to inspect import table '{table}': {error}"))?;

if exists == 0 {
if !imported_table_exists(connection, table).await? {
return Err(format!("导入数据库缺少必需数据表: {table}"));
}
}

Ok(())
}

async fn imported_table_exists(
connection: &mut sqlx::pool::PoolConnection<Sqlite>,
table: &str,
) -> Result<bool, String> {
let exists = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM imported.sqlite_master WHERE type = 'table' AND name = ?",
)
.bind(table)
.fetch_one(&mut **connection)
.await
.map_err(|error| format!("Failed to inspect import table '{table}': {error}"))?;

Ok(exists > 0)
}

async fn prepare_full_import_memory_items(
connection: &mut sqlx::pool::PoolConnection<Sqlite>,
) -> Result<(), String> {
sqlx::raw_sql(
"DROP TABLE IF EXISTS temp_imported_memory_items;
CREATE TEMP TABLE temp_imported_memory_items (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
applicability TEXT NOT NULL,
content TEXT NOT NULL,
enabled INTEGER NOT NULL,
source_session_id INTEGER,
source_message_id INTEGER,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_used_at TEXT
);",
)
.execute(&mut **connection)
.await
.map_err(|error| format!("Failed to prepare imported memory staging table: {error}"))?;

if !imported_table_exists(connection, "memory_items").await? {
return Ok(());
}

sqlx::raw_sql(
"INSERT INTO temp_imported_memory_items (
id, title, applicability, content, enabled, source_session_id, source_message_id,
created_at, updated_at, last_used_at
)
SELECT
id, title, applicability, content, enabled, source_session_id, source_message_id,
created_at, updated_at, last_used_at
FROM imported.memory_items;",
)
.execute(&mut **connection)
.await
.map_err(|error| format!("Failed to stage imported memory items: {error}"))?;

Ok(())
}

async fn merge_chat_data(
connection: &mut sqlx::pool::PoolConnection<Sqlite>,
database_contract: &DatabaseContractSource,
Expand All @@ -194,6 +244,7 @@ async fn merge_full_data(
)
.await?;
merge_chat_data(connection, database_contract).await?;
prepare_full_import_memory_items(connection).await?;
execute_sql_artifact_on_connection(
connection,
database_contract,
Expand Down
25 changes: 25 additions & 0 deletions apps/desktop/src/database/artifacts/import/full_postlude.sql
Original file line number Diff line number Diff line change
@@ -1,3 +1,26 @@
DELETE FROM main.memory_items;

INSERT INTO main.memory_items (
id, title, applicability, content, enabled, source_session_id, source_message_id,
created_at, updated_at, last_used_at
)
SELECT
source_memory.id,
source_memory.title,
source_memory.applicability,
source_memory.content,
source_memory.enabled,
source_session_map.target_session_id,
source_message_map.target_message_id,
source_memory.created_at,
source_memory.updated_at,
source_memory.last_used_at
FROM temp_imported_memory_items AS source_memory
LEFT JOIN temp_session_map AS source_session_map
ON source_session_map.source_session_id = source_memory.source_session_id
LEFT JOIN temp_message_map AS source_message_map
ON source_message_map.source_message_id = source_memory.source_message_id;

DELETE FROM main.sqlite_sequence
WHERE name IN (
'providers',
Expand All @@ -8,6 +31,7 @@ WHERE name IN (
'message_attachments',
'session_turns',
'session_turn_attempts',
'memory_items',
'settings',
'statistics',
'llm_metadata'
Expand All @@ -21,6 +45,7 @@ INSERT INTO main.sqlite_sequence (name, seq) SELECT 'attachments', COALESCE(MAX(
INSERT INTO main.sqlite_sequence (name, seq) SELECT 'message_attachments', COALESCE(MAX(id), 0) FROM main.message_attachments;
INSERT INTO main.sqlite_sequence (name, seq) SELECT 'session_turns', COALESCE(MAX(id), 0) FROM main.session_turns;
INSERT INTO main.sqlite_sequence (name, seq) SELECT 'session_turn_attempts', COALESCE(MAX(id), 0) FROM main.session_turn_attempts;
INSERT INTO main.sqlite_sequence (name, seq) SELECT 'memory_items', COALESCE(MAX(id), 0) FROM main.memory_items;
INSERT INTO main.sqlite_sequence (name, seq) SELECT 'settings', COALESCE(MAX(id), 0) FROM main.settings;
INSERT INTO main.sqlite_sequence (name, seq) SELECT 'statistics', COALESCE(MAX(id), 0) FROM main.statistics;
INSERT INTO main.sqlite_sequence (name, seq) SELECT 'llm_metadata', COALESCE(MAX(id), 0) FROM main.llm_metadata;
12 changes: 12 additions & 0 deletions apps/desktop/src/database/artifacts/runtime/seed.sql
Original file line number Diff line number Diff line change
Expand Up @@ -117,12 +117,24 @@ INSERT INTO built_in_tools (
SELECT 'file_search', 'FileSearch', '搜索本机文件', 1, 'low', NULL
WHERE NOT EXISTS (SELECT 1 FROM built_in_tools WHERE tool_id = 'file_search');

INSERT INTO built_in_tools (
tool_id, display_name, description, enabled, risk_level, config_json
)
SELECT 'memory', 'Memory', '读取和维护记忆', 1, 'medium', NULL
WHERE NOT EXISTS (SELECT 1 FROM built_in_tools WHERE tool_id = 'memory');

INSERT INTO built_in_tools (
tool_id, display_name, description, enabled, risk_level, config_json
)
SELECT 'read', 'Read', '读取本地文件或目录,支持图片与 PDF', 1, 'medium', NULL
WHERE NOT EXISTS (SELECT 1 FROM built_in_tools WHERE tool_id = 'read');

INSERT INTO built_in_tools (
tool_id, display_name, description, enabled, risk_level, config_json
)
SELECT 'search_conversation', 'SearchConversation', '搜索历史对话', 1, 'low', NULL
WHERE NOT EXISTS (SELECT 1 FROM built_in_tools WHERE tool_id = 'search_conversation');

INSERT INTO built_in_tools (
tool_id, display_name, description, enabled, risk_level, config_json
)
Expand Down
17 changes: 17 additions & 0 deletions apps/desktop/src/database/drizzle/0003_memory_items.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
CREATE TABLE `memory_items` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`title` text NOT NULL,
`applicability` text NOT NULL,
`content` text NOT NULL,
`enabled` integer DEFAULT 1 NOT NULL,
`source_session_id` integer,
`source_message_id` integer,
`created_at` text DEFAULT (datetime('now')) NOT NULL,
`updated_at` text DEFAULT (datetime('now')) NOT NULL,
`last_used_at` text,
FOREIGN KEY (`source_session_id`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`source_message_id`) REFERENCES `messages`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE INDEX `memory_items_enabled_idx` ON `memory_items` (`enabled`);--> statement-breakpoint

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | 💤 Low value

Minor: Statement-breakpoint comment formatting inconsistency.

The statement-breakpoint comment is inline on line 16 but on a separate line at line 15. This is cosmetic and doesn't affect functionality, but creates slight inconsistency in the migration file formatting.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/database/drizzle/0003_memory_items.sql` at line 16, The
migration contains an inconsistent placement of the statement-breakpoint comment
relative to the SQL: find the CREATE INDEX `memory_items_enabled_idx` ON
`memory_items` (`enabled`) statement and make the statement-breakpoint comment
placement consistent with the rest of the file (either move the `--
statement-breakpoint` to its own preceding line or place it inline after the
semicolon for all statements); update the single instance so formatting matches
the other migration statements.

CREATE INDEX `memory_items_updated_at_idx` ON `memory_items` (`updated_at`);
7 changes: 7 additions & 0 deletions apps/desktop/src/database/drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@
"when": 1780185600000,
"tag": "0002_quick_search_click_stats_unique",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1780550400000,
"tag": "0003_memory_items",
"breakpoints": true
}
]
}
2 changes: 2 additions & 0 deletions apps/desktop/src/database/queries/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ export * from './llmMetadata';
export * from './mcpServers';
export * from './mcpToolLogs';
export * from './mcpTools';
export * from './memoryItems';
export * from './messages';
export * from './models';
export * from './providers';
export * from './quickSearchClicks';
export * from './searchConversation';
export * from './sessions';
export * from './sessionTurnAttempts';
export * from './sessionTurns';
Expand Down
Loading
Loading