put 'Talk with AI' menu on top and attachment i18n

Introduce a reusable createTalkWithAIMenuItems helper to build "Talk with AI" menu entries (default agent + other agents submenu) and integrate it into workspace menu generation. Add new i18n keys for Agent.Attachment and WikiEmbed across locales and update UI to use translation keys (remove hardcoded fallback strings). Improve chat input/attachment behavior: expose a test-id for the attachment listbox, use i18n for labels/placeholders, and tweak input component wiring. Fix Cucumber step handling by normalizing expected newline sequences and safely handling empty message content. Also adjust memo deps in SortableWorkspaceSelectorButton to include id.
This commit is contained in:
linonetwo 2026-02-11 17:21:03 +08:00
parent f91cce6e8e
commit ad0597577a
15 changed files with 192 additions and 81 deletions

View file

@ -148,8 +148,10 @@ Feature: Agent Workflow - Tool Usage and Multi-Round Conversation
# Click attachment button to open autocomplete
When I click on a "attach button" element with selector "[data-testid='agent-attach-button']"
# Autocomplete should open showing image option + tiddler options
And I should see a "attachment autocomplete input" element with selector "[data-testid='attachment-autocomplete-input']"
And I should see a "attachment listbox" element with selector "[data-testid='attachment-listbox']"
And I should see "attachment autocomplete input and attachment listbox" elements with selectors:
| element description | selector |
| attachment autocomplete input | [data-testid='attachment-autocomplete-input'] |
| attachment listbox | [data-testid='attachment-listbox'] |
# Click on our test tiddler option
When I click on a "test tiddler option" element with selector "[data-testid='attachment-option-tiddler-TestAttachmentTiddler']"
# Verify the chip is displayed
@ -159,9 +161,6 @@ Feature: Agent Workflow - Tool Usage and Multi-Round Conversation
When I type "" in "chat input" element with selector "[data-testid='agent-message-input']"
And I press "Enter" key
# Verify the mock server received the rendered content (wikitext converted to plain text)
Then the last AI request user message should contain "WikiTestContentMarker123"
And the last AI request user message should contain "Wiki Entry from"
And the last AI request user message should contain "TestAttachmentTiddler"
Then the last AI request user message should contain "WikiTestHeader\n\nThis is a test with WikiTestContentMarker123"
# Verify wikitext was converted to plain text (!! becomes "Header", not raw !!)
And the last AI request user message should contain "WikiTestHeader"
And the last AI request user message should not contain "!!"

View file

@ -284,8 +284,12 @@ Then('the last AI request user message should contain {string}', async function(
}
const lastUserMessage = userMessages[userMessages.length - 1];
if (!lastUserMessage.content || !lastUserMessage.content.includes(expectedText)) {
throw new Error(`Expected user message to contain "${expectedText}", but got: "${lastUserMessage.content}"`);
const content = lastUserMessage.content ?? '';
const normalizedExpectedText = expectedText.replaceAll('\\n', '\n');
const contentHasExpectedText = content.includes(expectedText) || content.includes(normalizedExpectedText);
if (!contentHasExpectedText) {
throw new Error(`Expected user message to contain "${expectedText}", but got: "${content}"`);
}
});

View file

@ -16,6 +16,13 @@
"Title": "API Debug Logs"
},
"Agent": {
"Attachment": {
"AddAttachment": "Add attachment",
"AddImage": "📷 Add Image",
"NoOptions": "No options available",
"SearchPlaceholder": "Search...",
"SelectAttachment": "Select attachment"
},
"EditTitle": "Edit agent name",
"InvalidTabType": "Invalid tab type. A chat tab is required.",
"LoadingChat": "Loading conversation...",
@ -96,6 +103,10 @@
"SetupAgentDescription": "Configure your new agent by choosing a name and template",
"Title": "Create New Agent"
},
"WikiEmbed": {
"Error": "Failed to embed wiki",
"Loading": "Loading wiki..."
},
"EditAgent": {
"AgentDescription": "Agent Description",
"AgentDescriptionHelper": "Describe the functionality and purpose of your intelligent agent.",

View file

@ -16,6 +16,13 @@
"Title": "Journal de débogage API"
},
"Agent": {
"Attachment": {
"AddAttachment": "Ajouter une pièce jointe",
"AddImage": "📷 Ajouter une image",
"NoOptions": "Aucune option disponible",
"SearchPlaceholder": "Rechercher...",
"SelectAttachment": "Sélectionner une pièce jointe"
},
"EditTitle": "Modifier le nom de l'agent intelligent",
"InvalidTabType": "Type d'onglet non valide. Un onglet de chat est requis.",
"LoadingChat": "Chargement de la conversation en cours...",
@ -96,6 +103,10 @@
"SetupAgentDescription": "Nommez votre agent et choisissez un modèle comme point de départ",
"Title": "Créer un nouvel agent intelligent"
},
"WikiEmbed": {
"Error": "Échec de lintégration du wiki",
"Loading": "Chargement du wiki..."
},
"EditAgent": {
"AgentDescription": "Description de l'agent intelligent",
"AgentDescriptionHelper": "Décrivez les fonctionnalités et l'utilité de votre agent intelligent",

View file

@ -16,6 +16,13 @@
"Title": "API デバッグログ"
},
"Agent": {
"Attachment": {
"AddAttachment": "添付を追加",
"AddImage": "📷 画像を追加",
"NoOptions": "利用可能な項目がありません",
"SearchPlaceholder": "検索...",
"SelectAttachment": "添付を選択"
},
"EditTitle": "編集エージェント名",
"InvalidTabType": "無効なタブタイプです。チャットタブが必要です。",
"LoadingChat": "会話を読み込んでいます...",
@ -96,6 +103,10 @@
"SetupAgentDescription": "あなたのエージェントに名前を付け、テンプレートを出発点として選択してください",
"Title": "新しいインテリジェントエージェントを作成する"
},
"WikiEmbed": {
"Error": "Wiki の埋め込みに失敗しました",
"Loading": "Wiki を読み込んでいます..."
},
"EditAgent": {
"AgentDescription": "エージェントの説明",
"AgentDescriptionHelper": "あなたのエージェントの機能と用途を説明してください",

View file

@ -16,6 +16,13 @@
"Title": "Журнал отладки API"
},
"Agent": {
"Attachment": {
"AddAttachment": "Добавить вложение",
"AddImage": "📷 Добавить изображение",
"NoOptions": "Нет доступных вариантов",
"SearchPlaceholder": "Поиск...",
"SelectAttachment": "Выбрать вложение"
},
"EditTitle": "редактировать имя интеллектуального агента",
"InvalidTabType": "Неверный тип вкладки. Требуется вкладка чата.",
"LoadingChat": "Загрузка диалога...",
@ -96,6 +103,10 @@
"SetupAgentDescription": "Назовите своего агента и выберите шаблон в качестве отправной точки",
"Title": "Создать нового интеллектуального агента"
},
"WikiEmbed": {
"Error": "Не удалось встроить Wiki",
"Loading": "Загрузка Wiki..."
},
"EditAgent": {
"AgentDescription": "описание агента",
"AgentDescriptionHelper": "Опишите функции и назначение вашего агента.",

View file

@ -16,6 +16,13 @@
"Title": "外部接口调试日志"
},
"Agent": {
"Attachment": {
"AddAttachment": "添加附件",
"AddImage": "📷 添加图片",
"NoOptions": "暂无可选项",
"SearchPlaceholder": "搜索...",
"SelectAttachment": "选择附件"
},
"EditTitle": "编辑智能体名字",
"InvalidTabType": "无效的标签页类型。需要聊天标签页。",
"LoadingChat": "正在加载对话...",
@ -96,6 +103,10 @@
"SetupAgentDescription": "为您的智能体命名并选择一个模板作为起点",
"Title": "创建新智能体"
},
"WikiEmbed": {
"Error": "嵌入 Wiki 失败",
"Loading": "正在加载 Wiki..."
},
"EditAgent": {
"AgentDescription": "智能体描述",
"AgentDescriptionHelper": "描述您的智能体的功能和用途",

View file

@ -16,6 +16,13 @@
"Title": "外部介面除錯日誌"
},
"Agent": {
"Attachment": {
"AddAttachment": "新增附件",
"AddImage": "📷 新增圖片",
"NoOptions": "沒有可用選項",
"SearchPlaceholder": "搜尋...",
"SelectAttachment": "選擇附件"
},
"EditTitle": "編輯智慧體名字",
"InvalidTabType": "無效的標籤頁類型。需要聊天標籤頁。",
"LoadingChat": "正在載入對話...",
@ -95,6 +102,10 @@
"SetupAgentDescription": "為您的智慧體命名並選擇一個模板作為起點",
"Title": "創建新智慧體"
},
"WikiEmbed": {
"Error": "嵌入 Wiki 失敗",
"Loading": "正在載入 Wiki..."
},
"EditAgent": {
"AgentDescription": "智能體描述",
"AgentDescriptionHelper": "描述您的智慧體的功能和用途",

View file

@ -165,7 +165,7 @@ export const WikiEmbedTabContent: React.FC<WikiEmbedTabContentProps> = ({ tab, i
return (
<Container>
<LoadingContainer>
<Typography color='error'>{t('WikiEmbed.Error', 'Failed to embed wiki')}</Typography>
<Typography color='error'>{t('WikiEmbed.Error')}</Typography>
<Typography variant='body2' color='textSecondary'>{error}</Typography>
</LoadingContainer>
</Container>
@ -178,7 +178,7 @@ export const WikiEmbedTabContent: React.FC<WikiEmbedTabContentProps> = ({ tab, i
<LoadingContainer>
<CircularProgress size={32} />
<Typography variant='body2' color='textSecondary'>
{t('WikiEmbed.Loading', 'Loading wiki...')}
{t('WikiEmbed.Loading')}
</Typography>
</LoadingContainer>
)}

View file

@ -48,6 +48,9 @@ interface InputContainerProps {
onWikiTiddlerSelect?: (tiddler: WikiTiddlerAttachment) => void;
onRemoveWikiTiddler?: (index: number) => void;
}
const attachmentListboxSlotProps: React.HTMLAttributes<HTMLUListElement> & { 'data-testid': string } = {
'data-testid': 'attachment-listbox',
};
/**
* Input container component for message entry
@ -146,7 +149,7 @@ export const InputContainer: React.FC<InputContainerProps> = ({
try {
// Build options: first is "Add Image", then wiki tiddlers from all non-hibernated workspaces
const options: Array<{ type: 'image' | 'tiddler'; title: string; workspaceName?: string; testId?: string }> = [
{ type: 'image', title: t('Agent.Attachment.AddImage', '📷 Add Image'), workspaceName: '', testId: 'AddImage' },
{ type: 'image', title: t('Agent.Attachment.AddImage'), workspaceName: '', testId: 'AddImage' },
];
// Get all workspaces
@ -294,7 +297,7 @@ export const InputContainer: React.FC<InputContainerProps> = ({
disabled={disabled || isStreaming}
color={(selectedFile || selectedWikiTiddlers.length > 0) ? 'primary' : 'default'}
data-testid='agent-attach-button'
title={t('Agent.Attachment.AddAttachment', 'Add attachment')}
title={t('Agent.Attachment.AddAttachment')}
>
<AttachFileIcon />
</IconButton>
@ -346,15 +349,13 @@ export const InputContainer: React.FC<InputContainerProps> = ({
getOptionLabel={(option) => option.title}
onChange={handleSelectAttachment}
slotProps={{
listbox: {
'data-testid': 'attachment-listbox',
},
listbox: attachmentListboxSlotProps,
}}
renderInput={(parameters) => (
<TextField
{...parameters}
label={t('Agent.Attachment.SelectAttachment', 'Select attachment')}
placeholder={t('Agent.Attachment.SearchPlaceholder', 'Search...')}
label={t('Agent.Attachment.SelectAttachment')}
placeholder={t('Agent.Attachment.SearchPlaceholder')}
autoFocus
data-testid='attachment-autocomplete-input'
/>
@ -367,7 +368,7 @@ export const InputContainer: React.FC<InputContainerProps> = ({
</li>
);
}}
noOptionsText={t('Agent.Attachment.NoOptions', 'No options available')}
noOptionsText={t('Agent.Attachment.NoOptions')}
/>
</Paper>
</ClickAwayListener>

View file

@ -50,7 +50,7 @@ export const ChatTabContent: React.FC<ChatTabContentProps> = ({ tab, isSplitView
return (
<Box sx={{ p: 2, textAlign: 'center' }}>
<Typography color='error'>
{t('Agent.InvalidTabType', 'Invalid tab type. Expected chat tab.')}
{t('Agent.InvalidTabType')}
</Typography>
</Box>
);

View file

@ -109,7 +109,7 @@ export function SortableWorkspaceSelectorButton({ index, workspace, showSidebarT
editFlags: { canCopy: false },
});
},
[t, workspace],
[t, workspace, id],
);
return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners} onContextMenu={onWorkspaceContextMenu}>

View file

@ -0,0 +1,82 @@
import { IAskAIWithSelectionData, WindowChannel } from '@/constants/channels';
import type { AgentDefinition, IAgentDefinitionService } from '@services/agentDefinition/interface';
import type { IWindowService } from '@services/windows/interface';
import { WindowNames } from '@services/windows/WindowProperties';
import type { MenuItemConstructorOptions } from 'electron';
import type { TFunction } from 'i18next';
interface ICreateTalkWithAIMenuItemsOptions {
/** Agent definition service to get available agents */
agentDefinitionService: Pick<IAgentDefinitionService, 'getAgentDef' | 'getAgentDefs'>;
/** Selected text to send to AI (empty string if none) */
selectionText: string;
/** Translation function */
t: TFunction;
/** Wiki URL for context */
wikiUrl?: string;
/** Window service to get main window */
windowService: Pick<IWindowService, 'get'>;
/** Workspace ID for context */
workspaceId?: string;
}
/**
* Create "Talk with AI" menu items with default agent and other agents submenu
* This is shared between context menu (page right-click) and workspace menu (workspace icon right-click)
*/
export async function createTalkWithAIMenuItems(
options: ICreateTalkWithAIMenuItemsOptions,
): Promise<MenuItemConstructorOptions[]> {
const { agentDefinitionService, selectionText, t, wikiUrl, windowService, workspaceId } = options;
// Get all agent definitions
const defaultAgentDefinition = await agentDefinitionService.getAgentDef(); // No parameter = default agent
const allAgentDefinitions = await agentDefinitionService.getAgentDefs();
const otherAgentDefinitions = allAgentDefinitions.filter((definition: AgentDefinition) => definition.id !== defaultAgentDefinition?.id);
const menuItems: MenuItemConstructorOptions[] = [];
// Add menu item for default agent
menuItems.push({
id: 'talk-with-ai',
label: t('ContextMenu.TalkWithAI'),
click: async () => {
const data: IAskAIWithSelectionData = {
selectionText,
wikiUrl,
workspaceId,
agentDefId: undefined, // Use default agent
};
// Send to main window
const mainWindow = windowService.get(WindowNames.main);
if (mainWindow !== undefined) {
mainWindow.webContents.send(WindowChannel.askAIWithSelection, data);
}
},
});
// Add submenu for other agents if there are any
if (otherAgentDefinitions.length > 0) {
menuItems.push({
id: 'talk-with-ai-more',
label: t('ContextMenu.TalkWithAIMore'),
submenu: otherAgentDefinitions.map((agentDefinition: AgentDefinition) => ({
label: agentDefinition.name,
click: async () => {
const data: IAskAIWithSelectionData = {
selectionText,
wikiUrl,
workspaceId,
agentDefId: agentDefinition.id,
};
const mainWindow = windowService.get(WindowNames.main);
if (mainWindow !== undefined) {
mainWindow.webContents.send(WindowChannel.askAIWithSelection, data);
}
},
})),
});
}
return menuItems;
}

View file

@ -1,6 +1,5 @@
import { IAskAIWithSelectionData, WindowChannel } from '@/constants/channels';
import { getWorkspaceIdFromUrl } from '@/constants/urls';
import type { AgentDefinition, IAgentDefinitionService } from '@services/agentDefinition/interface';
import type { IAgentDefinitionService } from '@services/agentDefinition/interface';
import type { IAuthenticationService } from '@services/auth/interface';
import { container } from '@services/container';
import type { IContextService } from '@services/context/interface';
@ -149,6 +148,7 @@ export class MenuService implements IMenuService {
// Add workspace-specific menu items if workspace exists and is a wiki
if (workspace !== undefined && isWikiWorkspace(workspace)) {
const services = {
agentDefinition: container.get<IAgentDefinitionService>(serviceIdentifier.AgentDefinition),
auth: container.get<IAuthenticationService>(serviceIdentifier.Authentication),
context: container.get<IContextService>(serviceIdentifier.Context),
externalAPI: this.externalAPIService,
@ -318,6 +318,7 @@ export class MenuService implements IMenuService {
const menu = contextMenuBuilder.buildMenuForElement(info);
const workspaces = await workspaceService.getWorkspacesAsList();
const services = {
agentDefinition: container.get<IAgentDefinitionService>(serviceIdentifier.AgentDefinition),
auth: authService,
context: contextService,
externalAPI: this.externalAPIService,
@ -336,64 +337,6 @@ export class MenuService implements IMenuService {
// workspace menus (template items are added at the end via insert(0) in reverse order)
menu.append(new MenuItem({ type: 'separator' }));
// Add "Talk with AI" menu items when there's selected text
if (info.selectionText && info.selectionText.trim().length > 0) {
const wikiUrl = webContents.getURL();
const workspaceId = getWorkspaceIdFromUrl(wikiUrl);
// Get all agent definitions
const agentDefinitionService = container.get<IAgentDefinitionService>(serviceIdentifier.AgentDefinition);
const defaultAgentDefinition = await agentDefinitionService.getAgentDef(); // No parameter = default agent
const allAgentDefinitions = await agentDefinitionService.getAgentDefs();
const otherAgentDefinitions = allAgentDefinitions.filter((definition: AgentDefinition) => definition.id !== defaultAgentDefinition?.id);
// Add menu item for default agent
menu.append(
new MenuItem({
label: i18n.t('ContextMenu.TalkWithAI'),
click: async () => {
const data: IAskAIWithSelectionData = {
selectionText: info.selectionText!,
wikiUrl,
workspaceId: workspaceId ?? undefined,
agentDefId: undefined, // Use default agent
};
// Only send to main window to avoid duplicate processing
const mainWindow = windowService.get(WindowNames.main);
if (mainWindow !== undefined) {
mainWindow.webContents.send(WindowChannel.askAIWithSelection, data);
}
},
}),
);
// Add submenu for other agents if there are any
if (otherAgentDefinitions.length > 0) {
menu.append(
new MenuItem({
label: i18n.t('ContextMenu.TalkWithAIMore'),
submenu: otherAgentDefinitions.map((agentDefinition: AgentDefinition) => ({
label: agentDefinition.name,
click: async () => {
const data: IAskAIWithSelectionData = {
selectionText: info.selectionText!,
wikiUrl,
workspaceId: workspaceId ?? undefined,
agentDefId: agentDefinition.id,
};
const mainWindow = windowService.get(WindowNames.main);
if (mainWindow !== undefined) {
mainWindow.webContents.send(WindowChannel.askAIWithSelection, data);
}
},
})),
}),
);
}
menu.append(new MenuItem({ type: 'separator' }));
}
// Note: Simplified menu and "Current Workspace" are now provided by the frontend template
// (from SortableWorkspaceSelectorButton or content view), so we don't add them here
menu.append(

View file

@ -1,10 +1,12 @@
import { WikiChannel } from '@/constants/channels';
import { getDefaultHTTPServerIP } from '@/constants/urls';
import type { IAgentDefinitionService } from '@services/agentDefinition/interface';
import type { IAuthenticationService } from '@services/auth/interface';
import type { IContextService } from '@services/context/interface';
import type { IExternalAPIService } from '@services/externalAPI/interface';
import type { IGitService } from '@services/git/interface';
import { createBackupMenuItems, createSyncMenuItems } from '@services/git/menuItems';
import { createTalkWithAIMenuItems } from '@services/menu/createTalkWithAIMenuItems';
import type { INativeService } from '@services/native/interface';
import type { IPreferenceService } from '@services/preferences/interface';
import type { ISyncService } from '@services/sync/interface';
@ -22,6 +24,7 @@ import type { IWorkspace, IWorkspaceService } from './interface';
import { isWikiWorkspace } from './interface';
interface IWorkspaceMenuRequiredServices {
agentDefinition: Pick<IAgentDefinitionService, 'getAgentDef' | 'getAgentDefs'>;
auth: Pick<IAuthenticationService, 'getStorageServiceUserInfo'>;
context: Pick<IContextService, 'isOnline'>;
externalAPI: Pick<IExternalAPIService, 'getAIConfig'>;
@ -32,7 +35,7 @@ interface IWorkspaceMenuRequiredServices {
view: Pick<IViewService, 'reloadViewsWebContents' | 'getViewCurrentUrl'>;
wiki: Pick<IWikiService, 'wikiOperationInBrowser' | 'wikiOperationInServer'>;
wikiGitWorkspace: Pick<IWikiGitWorkspaceService, 'removeWorkspace'>;
window: Pick<IWindowService, 'open'>;
window: Pick<IWindowService, 'open' | 'get'>;
workspace: Pick<IWorkspaceService, 'getActiveWorkspace' | 'getSubWorkspacesAsList' | 'openWorkspaceTiddler'>;
workspaceView: Pick<
IWorkspaceViewService,
@ -62,6 +65,19 @@ export async function getSimplifiedWorkspaceMenuTemplate(
const { id, storageService, isSubWiki } = workspace;
const template: MenuItemConstructorOptions[] = [];
const lastUrl = await service.view.getViewCurrentUrl(id, WindowNames.main);
const talkWithAIMenuItems = await createTalkWithAIMenuItems({
agentDefinitionService: service.agentDefinition,
selectionText: '',
t,
wikiUrl: lastUrl,
windowService: service.window,
workspaceId: id,
});
template.push(...talkWithAIMenuItems);
template.push({ type: 'separator' });
// Add "Current Workspace" submenu with full menu
const fullMenuTemplate = await getWorkspaceMenuTemplate(workspace, t, service);
if (fullMenuTemplate.length > 0) {