From 3718d0bd398471b352db9490f1d7c376aefa80d2 Mon Sep 17 00:00:00 2001 From: lin onetwo Date: Mon, 15 Dec 2025 17:33:59 +0800 Subject: [PATCH] Fix/edit agent and several bugs (#670) * refactor: simplify tool writing * feat: load prompt from plugin in a wiki, let agent know what to do based on https://github.com/TiddlyWiki/TiddlyWiki5/issues/9378 * fix: i18n fix: i18n fix: wrong i18n structure fix: empty i18n * Add ContentLoading component and suspense fallback * fix: monaco loading * docs: usage of chrome mcp to contron during dev * fix: provider config truncate user input when typing * fix: legacy usage * Update package.json * fix: not loadin initial data * feat: better prompt sort * fix: sorting of array * fix: drag * Create DragAndDrop.md * feat: directly enter edit mode * fix: workspace config change cause immediate main wiki restart * Add 'Press Enter to confirm' to tag help texts * fix: dont show system tag when adding sub wiki * feat: inform user to press enter on tag auto complete * refactor: let sub wiki auto complete tag * Revert Add 'Press Enter to confirm' to tag help texts * fix: not able to open prompt editor by click prompt tree * fix: click to open plugin config * chore: remove log * feat: Auto-select the first file if none is selected * fix: don't preview not enabled prompt parts * fix: Keep i18n ally think these keys exist, otherwise it will delete them during "check usage" * lint: fix * Update externalAPI.logging.test.ts --- .vscode/mcp.json | 13 + docs/Development.md | 2 + docs/MCP.md | 28 + docs/internal/DragAndDrop.md | 28 + docs/internal/PromptTreeNavigation.md | 156 ++++ localization/locales/en/agent.json | 277 ++++--- localization/locales/en/translation.json | 16 +- localization/locales/fr/agent.json | 271 ++++--- localization/locales/fr/translation.json | 8 +- localization/locales/ja/agent.json | 271 ++++--- localization/locales/ja/translation.json | 8 +- localization/locales/ru/agent.json | 271 ++++--- localization/locales/ru/translation.json | 8 +- localization/locales/zh-Hans/agent.json | 265 ++++--- localization/locales/zh-Hans/translation.json | 60 +- localization/locales/zh-Hant/agent.json | 275 ++++--- localization/locales/zh-Hant/translation.json | 8 +- package.json | 2 + pnpm-lock.yaml | 12 + src/__tests__/__mocks__/services-container.ts | 2 +- src/__tests__/__mocks__/services-i18n.ts | 17 + src/__tests__/setup-vitest.ts | 1 + src/constants/defaultTiddlerNames.ts | 6 + src/global.d.ts | 34 + src/helpers/monacoConfig.ts | 19 + .../agentChatStore/actions/previewActions.ts | 37 +- src/pages/Agent/store/agentChatStore/index.ts | 2 +- src/pages/Agent/store/agentChatStore/types.ts | 23 +- .../ChatTabContent/components/ChatHeader.tsx | 18 +- .../components/FlatPromptList.tsx | 8 +- .../components/LastUpdatedIndicator.tsx | 8 +- .../PromptPreviewDialog/EditView.tsx | 170 +++-- .../PreviewProgressBar.tsx | 29 +- .../PromptPreviewDialog/PreviewTabsView.tsx | 94 ++- .../context/ArrayItemContext.tsx | 29 +- .../PromptConfigForm/index.tsx | 4 +- .../PromptConfigForm/store/arrayFieldStore.ts | 248 ++++++ .../templates/ArrayFieldItemTemplate.tsx | 271 +++++-- .../templates/ArrayFieldTemplate.tsx | 249 ++++++- .../PromptPreviewDialog.promptConcat.test.tsx | 55 +- .../__tests__/PromptPreviewDialog.ui.test.tsx | 16 +- .../components/PromptPreviewDialog/index.tsx | 132 ++-- .../ChatTabContent/components/PromptTree.tsx | 30 +- .../components/__tests__/PromptTree.test.tsx | 60 ++ src/pages/Main/ContentLoading.tsx | 21 + .../SortableWorkspaceSelectorList.tsx | 106 ++- src/pages/Main/index.tsx | 21 +- .../__tests__/taskAgent.test.ts | 10 +- .../agentFrameworks/taskAgent.ts | 8 +- .../agentFrameworks/taskAgents.json | 29 + src/services/agentInstance/index.ts | 8 +- .../agentInstance/promptConcat/Readme.md | 104 ++- .../infrastructure/agentStatus.ts | 48 ++ .../promptConcat/infrastructure/index.ts | 24 + .../infrastructure/messagePersistence.ts | 128 ++++ .../infrastructure/streamingResponse.ts | 160 ++++ .../promptConcat/modifiers/defineModifier.ts | 301 ++++++++ .../promptConcat/modifiers/dynamicPosition.ts | 96 +++ .../promptConcat/modifiers/fullReplacement.ts | 145 ++++ .../promptConcat/modifiers/index.ts | 34 + .../promptConcat/promptConcat.ts | 24 +- .../promptConcat/promptConcatSchema/index.ts | 7 +- .../{plugin.ts => tools.ts} | 16 +- .../promptConcat/responseConcat.ts | 9 +- .../fullReplacementPlugin.duration.test.ts | 12 +- .../__tests__/messageManagementPlugin.test.ts | 4 +- .../__tests__/wikiOperationPlugin.test.ts | 87 ++- .../tools/__tests__/wikiSearchPlugin.test.ts | 27 +- .../agentInstance/tools/defineTool.ts | 705 ++++++++++++++++++ src/services/agentInstance/tools/git.ts | 255 +++++++ src/services/agentInstance/tools/index.ts | 209 ++---- .../agentInstance/tools/messageManagement.ts | 269 ------- src/services/agentInstance/tools/prompt.ts | 298 -------- .../agentInstance/tools/schemaRegistry.ts | 4 + .../agentInstance/tools/tiddlywikiPlugin.ts | 268 +++++++ src/services/agentInstance/tools/types.ts | 4 +- .../agentInstance/tools/wikiOperation.ts | 523 ++++--------- .../agentInstance/tools/wikiSearch.ts | 699 ++++------------- .../agentInstance/tools/workspacesList.ts | 164 ++-- .../__tests__/schemaToToolContent.test.ts | 37 +- .../__tests__/externalAPI.logging.test.ts | 26 +- src/services/externalAPI/defaultProviders.ts | 2 +- src/services/externalAPI/index.ts | 11 +- src/services/externalAPI/interface.ts | 2 +- src/services/git/gitOperations.ts | 3 - .../wikiOperations/executor/scripts/common.ts | 16 + .../executor/wikiOperationInBrowser.ts | 3 + .../executor/wikiOperationInServer.ts | 3 + .../sender/sendWikiOperationsToBrowser.ts | 7 + src/services/workspaces/index.ts | 3 - src/windows/AddWorkspace/CloneWikiForm.tsx | 17 +- src/windows/AddWorkspace/ExistedWikiForm.tsx | 13 +- src/windows/AddWorkspace/NewWikiForm.tsx | 14 +- src/windows/AddWorkspace/useAvailableTags.ts | 2 +- src/windows/AddWorkspace/useForm.ts | 7 +- .../EditWorkspace/SubWorkspaceRouting.tsx | 18 +- src/windows/EditWorkspace/index.tsx | 6 - src/windows/GitLog/CommitDetailsPanel.tsx | 17 +- .../__tests__/addProviderIntegration.test.tsx | 6 +- .../ExternalAPI/__tests__/index.test.tsx | 136 +++- .../__tests__/useAIConfigManagement.test.ts | 2 +- .../ExternalAPI/components/ModelSelector.tsx | 20 +- .../ExternalAPI/components/ProviderConfig.tsx | 81 +- .../__tests__/ProviderConfig.test.tsx | 4 +- .../sections/ExternalAPI/index.tsx | 85 +-- vite.renderer.config.ts | 13 + 106 files changed, 5915 insertions(+), 3007 deletions(-) create mode 100644 .vscode/mcp.json create mode 100644 docs/MCP.md create mode 100644 docs/internal/DragAndDrop.md create mode 100644 docs/internal/PromptTreeNavigation.md create mode 100644 src/__tests__/__mocks__/services-i18n.ts create mode 100644 src/global.d.ts create mode 100644 src/helpers/monacoConfig.ts create mode 100644 src/pages/ChatTabContent/components/PromptPreviewDialog/PromptConfigForm/store/arrayFieldStore.ts create mode 100644 src/pages/ChatTabContent/components/__tests__/PromptTree.test.tsx create mode 100644 src/pages/Main/ContentLoading.tsx create mode 100644 src/services/agentInstance/promptConcat/infrastructure/agentStatus.ts create mode 100644 src/services/agentInstance/promptConcat/infrastructure/index.ts create mode 100644 src/services/agentInstance/promptConcat/infrastructure/messagePersistence.ts create mode 100644 src/services/agentInstance/promptConcat/infrastructure/streamingResponse.ts create mode 100644 src/services/agentInstance/promptConcat/modifiers/defineModifier.ts create mode 100644 src/services/agentInstance/promptConcat/modifiers/dynamicPosition.ts create mode 100644 src/services/agentInstance/promptConcat/modifiers/fullReplacement.ts create mode 100644 src/services/agentInstance/promptConcat/modifiers/index.ts rename src/services/agentInstance/promptConcat/promptConcatSchema/{plugin.ts => tools.ts} (67%) create mode 100644 src/services/agentInstance/tools/defineTool.ts create mode 100644 src/services/agentInstance/tools/git.ts delete mode 100644 src/services/agentInstance/tools/messageManagement.ts delete mode 100644 src/services/agentInstance/tools/prompt.ts create mode 100644 src/services/agentInstance/tools/tiddlywikiPlugin.ts diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 00000000..1a07462c --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,13 @@ +{ + "servers": { + "chromedevtools/chrome-devtools-mcp": { + "type": "stdio", + "command": "pnpm", + "args": [ + "dlx", + "chrome-devtools-mcp@latest", + "--browserUrl=http://localhost:9222" + ] + } + } +} diff --git a/docs/Development.md b/docs/Development.md index 5651c0fd..3d2f81e8 100644 --- a/docs/Development.md +++ b/docs/Development.md @@ -71,6 +71,8 @@ pnpm run start:dev:debug-main # Debug main process pnpm run start:dev:debug-react # Debug React renderer, react-devtool will be available in devtools ``` +MCP DevTools: see [docs/MCP.md](./MCP.md) for connecting the chrome-devtools MCP to the running Electron app. + #### Show electron-packager debug logs If you want to see detailed logs from electron-packager during packaging, set the environment variable `DEBUG=electron-packager`: diff --git a/docs/MCP.md b/docs/MCP.md new file mode 100644 index 00000000..3373bec4 --- /dev/null +++ b/docs/MCP.md @@ -0,0 +1,28 @@ +# MCP (Chrome DevTools) quick start + +This repo ships a ready-to-use [chrome-devtools-mcp](https://github.com/ChromeDevTools/mcp) config at `.vscode/mcp.json` that points to `http://localhost:9222`. + +## Prerequisites + +- Install deps: `pnpm install` +- Ensure no other Chrome is occupying `9222` (close Chrome if needed) + +## Start Electron with DevTools port + +- Run `pnpm run start:dev:mcp` (check active terminal see if it is already running) +- Ports: `9222` for Chrome DevTools (renderer), `9229` for Node Inspector (main process) + +## Connect from VS Code MCP + +- Open Command Palette → `MCP: Start Servers` (uses `.vscode/mcp.json`) +- The Chrome MCP server will attach to `http://localhost:9222` +- Use `list_pages` to see all open windows/pages (including main app and preference windows) +- Use `select_page` with page index to switch between different windows +- Use `take_snapshot` to inspect the current page's UI elements +- Use `close_page` to close a specific window + +## Troubleshooting + +- If pages do not show: close other Chrome instances or change the port in `.vscode/mcp.json` and rerun `start:dev:mcp` +- If you see `Debugger listening on ws://127.0.0.1:9229/...`, that is the main-process Node inspector; keep using `9222` for renderer DevTools +- Multiple windows (e.g., preferences dialog) appear as separate pages in `list_pages` — use `select_page` to switch context diff --git a/docs/internal/DragAndDrop.md b/docs/internal/DragAndDrop.md new file mode 100644 index 00000000..3f5930b2 --- /dev/null +++ b/docs/internal/DragAndDrop.md @@ -0,0 +1,28 @@ +# Drag-and-Drop Optimistic Update (TidGi) + +## Implementation Summary + +TidGi uses **optimistic UI updates** for drag-and-drop reordering in both form arrays and sidebar workspace lists. This ensures a smooth user experience without flicker or delay, even when backend or form updates are slow. + +### Key Points + +- When a drag ends, the UI immediately updates the order using local state (store or useState), before waiting for backend or form data to update. +- The backend update (or form update) is triggered asynchronously. When the new data arrives, the local optimistic state is cleared. +- This prevents the UI from briefly reverting to the old order before the update is confirmed. + +### Implementation Details + +- **Form Arrays**: Uses zustand store (`itemsOrder`) for optimistic rendering. See `ArrayFieldTemplate.tsx` and `arrayFieldStore.ts`. +- **Sidebar Workspaces**: Uses local React state (`optimisticOrder`) for workspace ID order. See `SortableWorkspaceSelectorList.tsx`. + +### Cautions + +- Always clear the optimistic state when the real data arrives, to avoid stale UI. +- Do not mutate the original data objects; always clone or use new arrays/objects. +- If backend update fails, consider showing an error or reverting the optimistic state. + +### References + +- [ArrayFieldTemplate.tsx](../../src/pages/ChatTabContent/components/PromptPreviewDialog/PromptConfigForm/templates/ArrayFieldTemplate.tsx) +- [arrayFieldStore.ts](../../src/pages/ChatTabContent/components/PromptPreviewDialog/PromptConfigForm/store/arrayFieldStore.ts) +- [SortableWorkspaceSelectorList.tsx](../../src/pages/Main/WorkspaceIconAndSelector/SortableWorkspaceSelectorList.tsx) diff --git a/docs/internal/PromptTreeNavigation.md b/docs/internal/PromptTreeNavigation.md new file mode 100644 index 00000000..a40aa796 --- /dev/null +++ b/docs/internal/PromptTreeNavigation.md @@ -0,0 +1,156 @@ +# Prompt Tree Navigation to Form Editor + +## Overview + +Click on any prompt item in the tree view and automatically open the corresponding form editor with the correct tab and expanded state. + +## Implementation Details + +### 1. Source Path Tracking + +When plugins inject content into the prompt tree using `injectToolList()` or `injectContent()`, the system automatically adds a `source` field to track the original configuration location. + +**File**: `src/services/agentInstance/tools/defineTool.ts` + +```typescript +// Build source path pointing to the plugin configuration +const pluginIndex = context.pluginIndex; +const source = pluginIndex !== undefined ? ['plugins', toolConfig.id] : undefined; + +const toolPrompt: IPrompt = { + id: `${toolId}-tool-list-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + text: toolContent, + caption: options.caption ?? `${definition.displayName} Tools`, + enabled: true, + source, // ['plugins', 'plugin-id'] +}; +``` + +The `source` array format: `['plugins', pluginId]` or `['prompts', promptId, ...]` + +### 2. Navigation Trigger + +**File**: `src/pages/ChatTabContent/components/PromptTree.tsx` + +When a user clicks a tree node: + +```typescript +const handleNodeClick = useCallback((event: React.MouseEvent) => { + event.stopPropagation(); + + // Use source path if available, otherwise construct from fieldPath + const targetFieldPath = (node.source && node.source.length > 0) + ? node.source + : [...fieldPath, node.id]; + + setFormFieldsToScrollTo(targetFieldPath); +}, [node.source, node.id, fieldPath, setFormFieldsToScrollTo]); +``` + +### 3. Tab Switching + +**File**: `src/pages/ChatTabContent/components/PromptPreviewDialog/PromptConfigForm/templates/RootObjectFieldTemplate.tsx` + +The root form template listens to the navigation path and switches to the appropriate tab: + +```typescript +useEffect(() => { + if (formFieldsToScrollTo.length > 0) { + const targetTab = formFieldsToScrollTo[0]; // 'prompts', 'plugins', or 'response' + const tabIndex = properties.findIndex(property => property.name === targetTab); + if (tabIndex !== -1 && tabIndex !== activeTab) { + setActiveTab(tabIndex); + } + } +}, [formFieldsToScrollTo, properties, activeTab]); +``` + +### 4. Item Expansion + +**File**: `src/pages/ChatTabContent/components/PromptPreviewDialog/EditView.tsx` + +The EditView component handles the expansion of nested items: + +```typescript +useEffect(() => { + if (formFieldsToScrollTo.length > 0 && editorMode === 'form') { + const savedPath = [...formFieldsToScrollTo]; + + // Wait for RootObjectFieldTemplate to switch tabs + setTimeout(() => { + setFormFieldsToScrollTo([]); // Clear after tab switches + + // Step 1: Expand top-level item + const topLevelKey = savedPath[0]; + const firstItemId = savedPath[1]; + expandItemsByPath(topLevelKey, [firstItemId]); + + // Step 2: Expand nested children if present + if (savedPath.length > 2) { + setTimeout(() => { + const parentIndex = findParentIndex(topLevelKey, firstItemId); + const nestedFieldPath = `${topLevelKey}_${parentIndex}_children`; + const nestedItemIds = savedPath.slice(2); + expandItemsByPath(nestedFieldPath, nestedItemIds); + }, 300); + } + }, 100); + } +}, [formFieldsToScrollTo, editorMode, agentFrameworkConfig]); +``` + +### 5. Store Management + +**File**: `src/pages/Agent/store/agentChatStore/PromptPreviewStore.ts` + +The store maintains: +- Expansion state for all array fields +- Navigation path queue +- Helper functions to expand/collapse items + +```typescript +interface PromptPreviewStore { + arrayExpansionStore: Record>; + formFieldsToScrollTo: string[]; + + setItemExpanded: (fieldPath: string, index: number, expanded: boolean) => void; + setFormFieldsToScrollTo: (path: string[]) => void; +} +``` + +## Data Flow + +1. User clicks "Git Tools" in tree view +2. `PromptTree` reads `node.source` → `['plugins', 'g3f4e5d6-7e8f-9g0h-1i2j-l3m4n5o6p7q8']` +3. Calls `setFormFieldsToScrollTo(['plugins', 'g3f4e5d6-7e8f-9g0h-1i2j-l3m4n5o6p7q8'])` +4. `RootObjectFieldTemplate` detects path[0] = 'plugins' → switches to plugins tab +5. `EditView` expands the plugin item with id 'g3f4e5d6-7e8f-9g0h-1i2j-l3m4n5o6p7q8' +6. Form scrolls to and highlights the target element + +## Timing Considerations + +The implementation uses careful timing to ensure proper rendering: + +- **100ms delay**: Wait for tab switch before expanding items +- **300ms delay**: Wait for parent expansion before expanding nested children +- **500ms delay**: Wait for all expansions before scrolling to target + +These delays account for React's rendering cycles and DOM updates. + +## Extension Points + +To add navigation support for custom components: + +1. Add `source` field when creating prompts +2. Ensure `source` points to the configuration location: `['topLevelKey', 'itemId', ...]` +3. The navigation system will automatically handle the rest + +## Related Files + +- `src/services/agentInstance/tools/defineTool.ts` - Source path injection +- `src/services/agentInstance/tools/types.ts` - Context types with pluginIndex +- `src/services/agentInstance/promptConcat/promptConcat.ts` - Plugin index passing +- `src/pages/ChatTabContent/components/PromptTree.tsx` - Click handler +- `src/pages/ChatTabContent/components/PromptPreviewDialog/EditView.tsx` - Expansion logic +- `src/pages/ChatTabContent/components/PromptPreviewDialog/PromptConfigForm/templates/RootObjectFieldTemplate.tsx` - Tab switching +- `src/pages/Agent/store/agentChatStore/PromptPreviewStore.ts` - State management diff --git a/localization/locales/en/agent.json b/localization/locales/en/agent.json index 261841de..b1712663 100644 --- a/localization/locales/en/agent.json +++ b/localization/locales/en/agent.json @@ -40,9 +40,7 @@ "Title": "Configuration Issue" }, "InputPlaceholder": "Type a message, Ctrl+Enter to send", - "Send": "Send", - "SessionGroup": { - } + "Send": "Send" }, "Common": { "None": "Not selected" @@ -83,8 +81,6 @@ "SelectedTemplate": "Selected Template", "SetupAgent": "Setup Agent", "SetupAgentDescription": "Configure your new agent by choosing a name and template", - "Steps": { - }, "Title": "Create New Agent" }, "EditAgent": { @@ -106,8 +102,6 @@ "PreviewChat": "Preview Chat", "Save": "Save", "Saving": "Saving...", - "Steps": { - }, "Title": "Edit Agent" }, "ModelFeature": { @@ -176,6 +170,7 @@ "ExternalApiDatabaseDescription": "Database containing external API debug information, occupying {{size}}", "FailedToAddModel": "Failed to add model", "FailedToAddProvider": "Failed to add provider", + "FailedToDeleteProvider": "Failed to delete provider {{providerName}}", "FailedToRemoveModel": "Failed to remove model", "FailedToSaveSettings": "Failed to save settings", "FailedToUpdateModel": "Failed to update model", @@ -206,6 +201,7 @@ "ProviderClass": "Provider Interface Type", "ProviderConfiguration": "Provider Configuration", "ProviderConfigurationDescription": "Configure the API key and other settings for AI providers", + "ProviderDeleted": "Provider {{providerName}} has been deleted", "ProviderDisabled": "Provider disabled", "ProviderEnabled": "Provider enabled", "ProviderName": "Provider Name", @@ -229,6 +225,12 @@ "Prompt": { "AutoRefresh": "Preview auto-refreshes with input text changes", "CodeEditor": "Code Editor", + "Edit": "Edit", + "EnterEditSideBySide": "Show editor side-by-side", + "EnterFullScreen": "Enter full screen", + "EnterPreviewSideBySide": "Show preview side-by-side", + "ExitFullScreen": "Exit full screen", + "ExitSideBySide": "Exit side-by-side", "Flat": "Flat View", "FormEditor": "Form Editor", "LastUpdated": "Last updated", @@ -242,10 +244,13 @@ }, "PromptConfig": { "AddItem": "Add project", + "Collapse": "fold", "EmptyArray": "No items have been added yet. Click the button below to add your first item.", + "Expand": "Expand", "ItemCount": "{{count}} items", - "RemoveItem": "Remove item", + "ItemIndex": "Item {{index}}", "Tabs": { + "Plugins": "plugin", "Prompts": "prompt", "Response": "response" }, @@ -257,8 +262,20 @@ }, "Schema": { "AIConfig": { + "Default": "Default conversation model used", + "DefaultTitle": "default model", "Description": "AI Conversation Settings Configuration", - "Title": "AI Configuration" + "Embedding": "Embedding models for vector search", + "EmbeddingTitle": "embedding model", + "Free": "Low-cost models used for cost-sensitive tasks", + "FreeTitle": "free/low-cost models", + "ImageGeneration": "Models for image generation", + "ImageGenerationTitle": "Image generation model", + "Speech": "Model for text-to-speech (TTS)", + "SpeechTitle": "Speech synthesis model", + "Title": "AI Configuration", + "Transcriptions": "Model for Speech-to-Text (STT)", + "TranscriptionsTitle": "Speech-to-text model" }, "AgentConfig": { "Description": "Agent Configuration", @@ -266,50 +283,103 @@ "IdTitle": "Agent ID", "PromptConfig": { "Description": "Prompt configuration", + "Plugins": "Plugin Configuration List", "Prompts": "Prompt Configuration List", "Response": "Response Configuration List", "Title": "Prompt Configuration" }, "Title": "Agent Configuration" }, - "AutoReroll": { - }, "BaseAPIConfig": { - "API": "API providers and model configurations", - "APITitle": "API Configuration", - "Description": "Basic API Configuration", "ModelParameters": "Model parameter configuration", - "ModelParametersTitle": "model parameters", - "Title": "Basic API Configuration" + "ModelParametersTitle": "model parameters" + }, + "Common": { + "ToolListPosition": { + "Description": "Configuration for tool list insertion position in prompts", + "Position": "Insertion position relative to target", + "PositionTitle": "Position", + "TargetId": "ID of target element", + "TargetIdTitle": "Target ID" + }, + "ToolListPositionTitle": "Tool List Position" }, "DefaultAgents": { "Description": "Default Agent Configuration List", "Title": "default intelligent agent" }, - "DynamicPosition": { - }, "FullReplacement": { "Description": "Complete replacement of parameter configuration", "SourceType": "source type", "SourceTypeTitle": "source type", - "SourceTypes": { - }, "TargetId": "Target Element ID", "TargetIdTitle": "Target ID", "Title": "Fully replace parameters" }, - "Function": { - }, - "HandlerConfig": { - }, - "JavascriptTool": { + "Git": { + "Description": "Search Git commit logs and read specific file contents", + "Title": "Git Tools", + "Tool": { + "Parameters": { + "commitHash": { + "Description": "Submit hash value", + "Title": "Submit hash" + }, + "filePath": { + "Description": "File path (for file search mode)", + "Title": "File Path" + }, + "maxLines": { + "Description": "Maximum number of lines (default: 500)", + "Title": "Maximum number of lines" + }, + "page": { + "Description": "Result page number (starting from 1)", + "Title": "Page" + }, + "pageSize": { + "Description": "Number of results per page", + "Title": "Page Size" + }, + "searchMode": { + "Description": "Search mode: message, file, dateRange, or none", + "Title": "Search Mode" + }, + "searchQuery": { + "Description": "Search query string (for message search mode)", + "Title": "Search Query" + }, + "since": { + "Description": "Start date (ISO 8601 format)", + "Title": "Since" + }, + "until": { + "Description": "End date (ISO 8601 format)", + "Title": "Until" + }, + "workspaceName": { + "Description": "Workspace name or ID to search", + "Title": "Workspace Name" + } + }, + "ReadFile": { + "Parameters": { + "commitHash": { + }, + "filePath": { + }, + "maxLines": { + }, + "workspaceName": { + } + } + } + } }, "MCP": { "Description": "Model Context Protocol Parameter Configuration", "Id": "MCP Server ID", "IdTitle": "Server ID", - "ResponseProcessing": { - }, "TimeoutMessage": "timeout message", "TimeoutMessageTitle": "timeout message", "TimeoutSecond": "Timeout (seconds)", @@ -328,29 +398,23 @@ "TopP": "Top P sampling parameter", "TopPTitle": "Top P" }, + "ModelSelection": { + "Description": "Select model provider and specific model name", + "Model": "Model name (e.g., gpt-4o, Qwen2.5-7B-Instruct)", + "ModelTitle": "model", + "Provider": "Model providers (such as OpenAI, SiliconFlow, Google, etc.)", + "ProviderTitle": "provider", + "Title": "model selection" + }, "Plugin": { - "Caption": "brief description", - "CaptionTitle": "Title", - "Content": "Plugin content or description", - "ContentTitle": "content", - "ForbidOverrides": "Is it prohibited to override this plugin's parameters at runtime?", - "ForbidOverridesTitle": "Do not overwrite", - "Id": "Plugin ID", - "IdTitle": "ID", - "PluginId": "Identifier for selecting specific plugins", - "PluginIdTitle": "Plugin identifier" }, "Position": { - "Bottom": "Offset a few messages from the bottom", - "BottomTitle": "bottom offset", "Description": "Position Parameter Configuration", "TargetId": "target element ID", "TargetIdTitle": "Target ID", "Title": "positional arguments", "Type": "Location Type", - "TypeTitle": "Location Type", - "Types": { - } + "TypeTitle": "Location Type" }, "Prompt": { "Caption": "brief description", @@ -369,66 +433,72 @@ "System": "System - Defines the behavioral rules and background settings for AI", "User": "User - Simulate user input and requests" }, + "Source": "The source or citation of this prompt (e.g., file path, URL, etc.)", + "SourceTitle": "source", "Tags": "Tag List", "TagsTitle": "label", "Text": "The prompt content can include syntax supported by wiki text, such as <>.", "TextTitle": "text", "Title": "prompt" }, - "PromptDynamicModification": { - "DynamicModificationTypes": { - } - }, - "PromptPart": { - }, "ProviderModel": { - "Description": "Provider and Model Configuration", - "EmbeddingModel": "Embedding model name for semantic search and vector operations", - "EmbeddingModelTitle": "Embedding Model", - "FreeModel": "Free small model for generating summary titles and backup titles", - "FreeModelTitle": "Free Model", - "ImageGenerationModel": "Image generation model name for creating images from text", - "ImageGenerationModelTitle": "Image Generation Model", - "Model": "AI model name", - "ModelTitle": "Model", - "Provider": "AI provider name", - "ProviderTitle": "Provider", - "SpeechModel": "Speech generation model name for text-to-speech operations", - "SpeechModelTitle": "Speech Model", - "Title": "Provider and Model", - "TranscriptionsModel": "Transcriptions model name for speech-to-text operations", - "TranscriptionsModelTitle": "Transcriptions Model" - }, - "RAG": { - "Removal": { - }, - "SourceTypes": { - } }, "Response": { + "Caption": "Response header, used to identify the response entry", + "CaptionTitle": "Title", "Description": "The response from an external API, typically used as the target for dynamic modifications in responses, shares the same structure as the prompt. It can be filled with preset content or serve as a placeholder or container, where ResponseDynamicModification injects the specific content from the external API's response.", + "Id": "Unique identifier for response configuration, facilitating reference.", + "IdTitle": "ID", "Title": "response" }, - "ResponseDynamicModification": { - "DynamicModificationTypes": { + "TiddlyWikiPlugin": { + "ActionsTag": { + "Description": "Tag marking Action Tiddlers, which AI can find to discover executable actions", + "Title": "Actions Tag" }, - "ResponseProcessingTypes": { + "DataSourceTag": { + "Description": "Tag marking DataSource tiddlers, which AI can use to understand data fetching and filtering methods", + "Title": "DataSource Tag" + }, + "DescribeTag": { + "Description": "Tag marking brief descriptions, which auto-load into prompts to help AI understand available functionality", + "Title": "Describe Tag" + }, + "Description": "Load DataSource and Actions details for specific plugins. Describe-tagged entries auto-load as background information", + "EnableCache": { + "Description": "Enable caching to avoid re-querying wiki each time prompts are generated. Disable cache to refresh and reload descriptions", + "Title": "Enable Cache" + }, + "Title": "TiddlyWiki Plugin", + "Tool": { + "Parameters": { + "pluginTitle": { + "Description": "Plugin title to load details for. System searches for DataSource and Actions entries containing this title", + "Title": "Plugin Title" + } + } + }, + "WorkspaceNameOrID": { + "Description": "Workspace name or ID to load plugin info from (default 'wiki')", + "Title": "Workspace Name or ID" } }, - "ToolCalling": { - }, - "Trigger": { - "Model": { - } - }, - "Wiki": { + "Tool": { + "Caption": "Brief description (for UI display.", + "CaptionTitle": "Title", + "Content": "Plugin content or description", + "ContentTitle": "content", + "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)", + "IdTitle": "Plugin instance ID", + "ToolId": "Select the type of tool to use", + "ToolIdTitle": "Tool Type" }, "WikiOperation": { "Description": "Execute Tiddler operations (add, delete, or set text) in wiki workspaces", "Title": "Wiki Operation", "Tool": { - "Examples": { - }, "Parameters": { "extraMeta": { "Description": "JSON string of extra metadata such as tags and fields, defaults to \"{}\"", @@ -450,18 +520,16 @@ "Description": "Title of the Tiddler", "Title": "Tiddler Title" }, + "variables": { + "Description": "Variables passed to the action tiddler, in JSON format string, default is \"{}\"", + "Title": "variable" + }, "workspaceName": { "Description": "Name or ID of the workspace to operate on", "Title": "Workspace Name" } } }, - "ToolListPosition": { - "Position": "Position relative to target element (before/after)", - "PositionTitle": "Insert Position", - "TargetId": "ID of the target element where the tool list will be inserted", - "TargetIdTitle": "Target ID" - }, "ToolResultDuration": "Number of rounds tool execution results remain visible in conversation, after which they become grayed out", "ToolResultDurationTitle": "Tool Result Duration" }, @@ -500,12 +568,6 @@ }, "UpdateEmbeddings": { "Description": "Generate or update vector embedding indexes for Wiki workspaces to enable semantic search.", - "Parameters": { - "forceUpdate": { - }, - "workspaceName": { - } - }, "forceUpdate": { "Description": "Whether to forcibly regenerate the embedding index, overwriting existing embedded data (if set to true, incremental updates will be ignored).", "Title": "Forced Update" @@ -516,15 +578,16 @@ } } }, - "ToolListPosition": { - "Position": "Insertion position relative to the target position", - "PositionTitle": "insertion position", - "TargetId": "The ID of the target element, the tool list will be inserted relative to this element.", - "TargetIdTitle": "Target ID" - }, - "ToolListPositionTitle": "Tool List Location", "ToolResultDuration": "The number of turns during which the tool execution result remains visible in the conversation; after exceeding this number, the result will be displayed grayed out.", "ToolResultDurationTitle": "Tool Result Duration Rounds" + }, + "WorkspacesList": { + "Description": "Inject the list of available Wiki workspaces into the prompt", + "Position": "Insertion position: 'before' means prepend, 'after' means append.", + "PositionTitle": "insertion position", + "TargetId": "The ID of the target prompt word, the list will be inserted relative to this prompt word.", + "TargetIdTitle": "Target ID", + "Title": "Workspace List" } }, "Search": { @@ -550,7 +613,10 @@ } }, "Tool": { - "Plugin": { + "Git": { + "Error": { + "WorkspaceNotFound": "Workspace with name or ID \"{{workspaceName}}\" does not exist" + } }, "Schema": { "Description": "Description", @@ -559,12 +625,19 @@ "Parameters": "Parameters", "Required": "Required" }, + "TiddlyWikiPlugin": { + "Error": { + "PluginTitleRequired": "Plugin title is required", + "WorkspaceNotFound": "The workspace name or ID \"{{workspaceNameOrID}}\" does not exist" + } + }, "WikiOperation": { "Error": { "WorkspaceNotExist": "Workspace {{workspaceID}} does not exist", "WorkspaceNotFound": "Workspace with name or ID \"{{workspaceName}}\" does not exist. Available workspaces: {{availableWorkspaces}}" }, "Success": { + "ActionInvoked": "Action item \"{{actionTitle}}\" was successfully executed in the wiki workspace \"{{workspaceName}}\".", "Added": "Successfully added tiddler \"{{title}}\" in wiki workspace \"{{workspaceName}}\"", "Deleted": "Successfully deleted tiddler \"{{title}}\" from wiki workspace \"{{workspaceName}}\"", "Updated": "Successfully set text for tiddler \"{{title}}\" in wiki workspace \"{{workspaceName}}\"" diff --git a/localization/locales/en/translation.json b/localization/locales/en/translation.json index 60810dcd..9d2d7ebe 100644 --- a/localization/locales/en/translation.json +++ b/localization/locales/en/translation.json @@ -71,6 +71,7 @@ "TagName": "Tag Name", "TagNameHelp": "Tiddlers with this Tag will be add to this sub-wiki (you can add or change this Tag later, by right-click workspace Icon and choose Edit Workspace)", "TagNameHelpForMain": "New entries with this tag will be prioritized for storage in this workspace.", + "TagNameInputWarning": "⚠️ Please press Enter to confirm tag input", "ThisPathIsNotAWikiFolder": "The directory is not a Wiki folder \"{{wikiPath}}\"", "UseFilter": "Use filters", "UseFilterHelp": "Use filter expressions instead of tags to match entries and determine whether to save them in the current workspace.", @@ -166,8 +167,6 @@ "EnableHTTPSDescription": "To provide secure TLS encrypted access, you need to have your own HTTPS certificate, which can be downloaded from the domain name provider, or you can search for free HTTPS certificate application methods.", "ExcludedPlugins": "plugins to ignore", "ExcludedPluginsDescription": "When starting the wiki as a blog in read-only mode, you may want to not load some editing-related plugins to reduce the size of the first-loaded web page, such as $:/plugins/tiddlywiki/codemirror, etc. After all, the loaded blog does not need these editing functions.", - "IgnoreSymlinks": "Ignore Symlinks", - "IgnoreSymlinksDescription": "Symlinks are similar to shortcuts. The old version used them to implement sub-wiki functionality, but the new version no longer needs them. You can manually delete legacy symlinks.", "Generate": "Generate", "HTTPSCertPath": "Cert file path", "HTTPSCertPathDescription": "The location of the certificate file with the suffix .crt, generally ending with xxx_public.crt.", @@ -179,6 +178,8 @@ "HTTPSUploadKey": "Add Key file", "HibernateDescription": "Save CPU usage, memory and battery. This will disable auto sync, you need to manually commit and sync to backup data.", "HibernateTitle": "Hibernate when not used", + "IgnoreSymlinks": "Ignore Symlinks", + "IgnoreSymlinksDescription": "Symlinks are similar to shortcuts. The old version used them to implement sub-wiki functionality, but the new version no longer needs them. You can manually delete legacy symlinks.", "IsSubWorkspace": "Is SubWorkspace", "LastNodeJSArgv": "Command line arguments from the latest startup", "LastVisitState": "Last page visited", @@ -217,6 +218,9 @@ "WikiRootTiddler": "Wiki Root Tiddler", "WikiRootTiddlerDescription": "Wiki's root tiddler determines the core behavior of the system, please read the official documentation to understand before modifying", "WikiRootTiddlerItems": { + "all": "Load all at once", + "lazy-images": "Load images on demand", + "lazy-all": "Load images and text on demand" } }, "Error": { @@ -259,7 +263,6 @@ "AndMoreFiles": "+{{count}} more", "Author": "author", "BinaryFileCannotDisplay": "Binary file cannot be displayed as text", - "ClearSearch": "Clear search", "Committing": "Committing...", "ContentView": "file content", "CopyFilePath": "Copy file path", @@ -278,7 +281,6 @@ "FailedToLoadDiff": "Failed to load differences", "FileNameSearch": "File Name", "FileSearchPlaceholder": "e.g., pages, *.tsx, config.json", - "Files": "a file", "FilesChanged": "{{count}} file changed", "FilesChanged_other": "{{count}} files changed", "Hash": "hash value", @@ -296,19 +298,19 @@ "NewImage": "New image (added in this commit)", "NoCommits": "No submission records", "NoFilesChanged": "No file changes", - "SearchCommits": "Search commits...", - "SearchMode": "Search Mode", - "StartDate": "Start Date", "OpenInExternalEditor": "Open in External editor", "OpenInGitHub": "Open in GitHub", "OpenWithDefaultProgram": "Open with default program", "PreviousVersion": "Previous version", "RevertCommit": "Revert this commit", "Reverting": "Reverting...", + "SearchCommits": "Search commits...", + "SearchMode": "Search Mode", "SelectCommit": "Select a submission to view details", "SelectFileToViewDiff": "Select a file to view differences", "ShowFull": "Show Full", "ShowInExplorer": "Show in Explorer", + "StartDate": "Start Date", "Title": "Git history", "UnknownDate": "Unknown date", "WarningMessage": "Note: Checkout and rollback operations will modify workspace files; proceed with caution." diff --git a/localization/locales/fr/agent.json b/localization/locales/fr/agent.json index bc583538..351e20a0 100644 --- a/localization/locales/fr/agent.json +++ b/localization/locales/fr/agent.json @@ -40,9 +40,7 @@ "Title": "Problème de configuration" }, "InputPlaceholder": "Tapez un message, Ctrl+Entrée pour envoyer", - "Send": "Envoyer", - "SessionGroup": { - } + "Send": "Envoyer" }, "Common": { "None": "non sélectionné" @@ -172,6 +170,7 @@ "ExternalApiDatabaseDescription": "Base de données contenant des informations de débogage d'API externes, occupant un espace de {{size}}", "FailedToAddModel": "Échec de l'ajout du modèle", "FailedToAddProvider": "Échec de l'ajout du fournisseur", + "FailedToDeleteProvider": "Échec de la suppression du fournisseur {{providerName}}", "FailedToRemoveModel": "Échec de la suppression du modèle", "FailedToSaveSettings": "Échec de l'enregistrement des paramètres", "FailedToUpdateModel": "Impossible de mettre à jour le modèle", @@ -201,6 +200,7 @@ "ProviderClass": "Type d'interface du fournisseur", "ProviderConfiguration": "Configuration du fournisseur", "ProviderConfigurationDescription": "Configurer la clé API et d'autres paramètres pour les fournisseurs AI", + "ProviderDeleted": "Le fournisseur {{providerName}} a été supprimé", "ProviderDisabled": "Fournisseur désactivé", "ProviderEnabled": "Fournisseur activé", "ProviderName": "Nom du fournisseur", @@ -238,6 +238,12 @@ "Prompt": { "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", + "EnterEditSideBySide": "Édition en affichage partagé", + "EnterFullScreen": "entrer en plein écran", + "EnterPreviewSideBySide": "Aperçu en mode écran partagé", + "ExitFullScreen": "quitter le mode plein écran", + "ExitSideBySide": "quitter le mode d'écran partagé", "Flat": "vue en mosaïque", "FormEditor": "Éditeur de formulaire", "LastUpdated": "Dernière mise à jour", @@ -251,10 +257,13 @@ }, "PromptConfig": { "AddItem": "ajouter un projet", + "Collapse": "plier", "EmptyArray": "Aucun élément n'a encore été ajouté. Cliquez sur le bouton ci-dessous pour ajouter votre premier élément.", + "Expand": "déployer", "ItemCount": "{{count}} éléments", - "RemoveItem": "supprimer l'élément de la liste", + "ItemIndex": "Article {{index}}", "Tabs": { + "Plugins": "plugin", "Prompts": "mot-clé", "Response": "réponse" }, @@ -266,8 +275,20 @@ }, "Schema": { "AIConfig": { + "Default": "modèle de dialogue utilisé par défaut", + "DefaultTitle": "modèle par défaut", "Description": "Configuration des paramètres de conversation IA", - "Title": "Configuration de l'IA" + "Embedding": "modèle d'incorporation pour la recherche vectorielle", + "EmbeddingTitle": "modèle d'intégration", + "Free": "modèle à faible coût utilisé pour les tâches sensibles aux coûts", + "FreeTitle": "modèles gratuits/à faible coût", + "ImageGeneration": "Modèle pour la génération d'images", + "ImageGenerationTitle": "modèle de génération d'images", + "Speech": "Modèle pour la synthèse vocale (TTS)", + "SpeechTitle": "modèle de synthèse vocale", + "Title": "Configuration de l'IA", + "Transcriptions": "Modèle pour la conversion de parole en texte (STT)", + "TranscriptionsTitle": "modèle de reconnaissance vocale" }, "AgentConfig": { "Description": "Configuration de l'agent intelligent", @@ -275,50 +296,103 @@ "IdTitle": "ID de l'agent intelligent", "PromptConfig": { "Description": "configuration des mots-clés", + "Plugins": "Liste de configuration des plugins", "Prompts": "Liste de configuration des mots-clés", "Response": "Liste de configuration des réponses", "Title": "configuration des mots-clés" }, "Title": "Configuration de l'agent intelligent" }, - "AutoReroll": { - }, "BaseAPIConfig": { - "API": "Fournisseur d'API et configuration du modèle", - "APITitle": "Configuration de l'API", - "Description": "Configuration de l'API de base", "ModelParameters": "Configuration des paramètres du modèle", - "ModelParametersTitle": "paramètres du modèle", - "Title": "Configuration de base des API" + "ModelParametersTitle": "paramètres du modèle" + }, + "Common": { + "ToolListPosition": { + "Description": "Configuration de la position d'insertion de la liste des outils dans les invites", + "Position": "Position d'insertion par rapport à la cible", + "PositionTitle": "position", + "TargetId": "ID de l'élément cible", + "TargetIdTitle": "ID cible" + }, + "ToolListPositionTitle": "position de la liste des outils" }, "DefaultAgents": { "Description": "Liste de configuration des agents intelligents par défaut", "Title": "agent intelligent par défaut" }, - "DynamicPosition": { - }, "FullReplacement": { "Description": "remplacement complet de la configuration des paramètres", "SourceType": "type source", "SourceTypeTitle": "type source", - "SourceTypes": { - }, "TargetId": "ID de l'élément cible", "TargetIdTitle": "ID cible", "Title": "paramètre de remplacement complet" }, - "Function": { - }, - "HandlerConfig": { - }, - "JavascriptTool": { + "Git": { + "Description": "Rechercher les journaux de validation Git et lire le contenu de fichiers spécifiques", + "Title": "Outils Git", + "Tool": { + "Parameters": { + "commitHash": { + "Description": "soumettre le hachage", + "Title": "hash de soumission" + }, + "filePath": { + "Description": "Chemin du fichier (pour le mode de recherche de fichiers)", + "Title": "chemin du fichier" + }, + "maxLines": { + "Description": "Nombre maximum de lignes (500 par défaut)", + "Title": "nombre maximum de lignes" + }, + "page": { + "Description": "Numéro de page du résultat (commençant à 1)", + "Title": "numéro de page" + }, + "pageSize": { + "Description": "Nombre de résultats par page", + "Title": "Nombre par page" + }, + "searchMode": { + "Description": "Mode de recherche : par informations soumises, chemin du fichier, plage de dates ou ne pas rechercher", + "Title": "mode de recherche" + }, + "searchQuery": { + "Description": "chaîne de requête de recherche (mode de recherche de messages)", + "Title": "requête de recherche" + }, + "since": { + "Description": "Date de début (format ISO 8601)", + "Title": "date de début" + }, + "until": { + "Description": "Date de fin (format ISO 8601)", + "Title": "date de fin" + }, + "workspaceName": { + "Description": "Nom ou ID de l'espace de travail à rechercher", + "Title": "Nom de l'espace de travail" + } + }, + "ReadFile": { + "Parameters": { + "commitHash": { + }, + "filePath": { + }, + "maxLines": { + }, + "workspaceName": { + } + } + } + } }, "MCP": { "Description": "Configuration des paramètres du protocole de contexte du modèle", "Id": "ID du serveur MCP", "IdTitle": "ID du serveur", - "ResponseProcessing": { - }, "TimeoutMessage": "message en retard", "TimeoutMessageTitle": "message en retard", "TimeoutSecond": "Délai d'expiration (secondes)", @@ -337,29 +411,23 @@ "TopP": "Paramètre d'échantillonnage Top P", "TopPTitle": "Top P" }, + "ModelSelection": { + "Description": "Choisir le fournisseur de modèles et le nom spécifique du modèle", + "Model": "Nom du modèle (par exemple gpt-4o, Qwen2.5-7B-Instruct)", + "ModelTitle": "modèle", + "Provider": "Fournisseurs de modèles (comme OpenAI, SiliconFlow, Google, etc.)", + "ProviderTitle": "fournisseur", + "Title": "sélection de modèle" + }, "Plugin": { - "Caption": "brève description", - "CaptionTitle": "titre", - "Content": "Contenu ou description du plugin", - "ContentTitle": "contenu", - "ForbidOverrides": "Est-il interdit de remplacer les paramètres de ce plugin pendant l'exécution ?", - "ForbidOverridesTitle": "Interdiction de couvrir", - "Id": "ID du plugin", - "IdTitle": "ID", - "PluginId": "Identifiant utilisé pour sélectionner un plugin spécifique", - "PluginIdTitle": "identifiant du plugin" }, "Position": { - "Bottom": "Décaler quelques messages depuis le bas", - "BottomTitle": "décalage du bas", "Description": "configuration des paramètres de position", "TargetId": "ID de l'élément cible", "TargetIdTitle": "ID cible", "Title": "paramètre positionnel", "Type": "type d'emplacement", - "TypeTitle": "type d'emplacement", - "Types": { - } + "TypeTitle": "type d'emplacement" }, "Prompt": { "Caption": "brève description", @@ -378,64 +446,72 @@ "System": "Système - Définir les règles de comportement et le contexte de l'IA", "User": "Utilisateur - Simuler les entrées et requêtes de l'utilisateur" }, + "Source": "La source ou la référence de cette invite (par exemple, chemin du fichier, URL, etc.)", + "SourceTitle": "source", "Tags": "Liste des étiquettes", "TagsTitle": "étiquette", "Text": "Le contenu des mots d'invite peut inclure la syntaxe prise en charge par le texte wiki, comme <>.", "TextTitle": "texte", "Title": "mot-clé" }, - "PromptDynamicModification": { - "DynamicModificationTypes": { - } - }, - "PromptPart": { - }, "ProviderModel": { - "Description": "Fournisseur et configuration du modèle", - "EmbeddingModel": "Nom du modèle d'incorporation pour la recherche sémantique et les opérations vectorielles", - "EmbeddingModelTitle": "modèle d'intégration", - "ImageGenerationModel": "Nom du modèle de génération d'images utilisé pour les opérations de génération d'images à partir de texte", - "ImageGenerationModelTitle": "modèle de génération d'images", - "Model": "Nom du modèle d'IA", - "ModelTitle": "modèle", - "Provider": "Nom du fournisseur d'IA", - "ProviderTitle": "fournisseur", - "SpeechModel": "Nom du modèle de génération vocale utilisé pour les opérations de synthèse vocale", - "SpeechModelTitle": "modèle vocal", - "Title": "modèle de fournisseur", - "TranscriptionsModel": "Nom du modèle de reconnaissance vocale utilisé pour la conversion de la parole en texte", - "TranscriptionsModelTitle": "modèle de reconnaissance vocale" - }, - "RAG": { - "Removal": { - }, - "SourceTypes": { - } }, "Response": { + "Caption": "En-tête de réponse, utilisé pour identifier cette entrée de réponse", + "CaptionTitle": "titre", "Description": "La réponse de l'API externe, souvent utilisée comme cible pour des modifications dynamiques en réponse, a la même structure que les mots d'invite. Elle peut être préremplie avec du contenu prédéfini ou servir d'espace réservé (placeholder) ou de conteneur, où ResponseDynamicModification insère le contenu spécifique de la réponse de l'API externe.", + "Id": "Identifiant unique de configuration de réponse pour faciliter la référence", + "IdTitle": "ID", "Title": "réponse" }, - "ResponseDynamicModification": { - "DynamicModificationTypes": { + "TiddlyWikiPlugin": { + "ActionsTag": { + "Description": "Balise Action Tiddler, l'IA peut trouver des actions exécutables grâce à cette étiquette.", + "Title": "Balises Actions" }, - "ResponseProcessingTypes": { + "DataSourceTag": { + "Description": "Étiquette des entrées de source de données, grâce à laquelle l'IA peut comprendre les méthodes d'acquisition et de filtrage des données.", + "Title": "Balise DataSource" + }, + "DescribeTag": { + "Description": "Balises pour les descriptions courtes qui sont automatiquement chargées dans les invites afin d'aider l'IA à comprendre les fonctionnalités disponibles.", + "Title": "Décrire l'étiquette" + }, + "Description": "Chargement des sources de données et des instructions d'action pour les plugins spécifiques. Le système chargera automatiquement les entrées avec l'étiquette Describe comme informations contextuelles.", + "EnableCache": { + "Description": "Activer le cache pour éviter de réinterroger le wiki à chaque génération d'invites. Lorsque le cache est désactivé, les descriptions sont effacées et rechargées.", + "Title": "Activer le cache" + }, + "Title": "Plugin TiddlyWiki", + "Tool": { + "Parameters": { + "pluginTitle": { + "Description": "Titre du plugin à charger pour les détails. Le système recherchera les entrées DataSource et Actions contenant ce titre.", + "Title": "Titre du plugin" + } + } + }, + "WorkspaceNameOrID": { + "Description": "Nom ou ID de l'espace de travail pour charger les informations du plugin (par défaut 'wiki')", + "Title": "Nom ou ID de l'espace de travail" } }, - "ToolCalling": { - }, - "Trigger": { - "Model": { - } - }, - "Wiki": { + "Tool": { + "Caption": "Brève description (pour l'affichage dans l'interface utilisateur)", + "CaptionTitle": "titre", + "Content": "Contenu ou description du plugin", + "ContentTitle": "contenu", + "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)", + "IdTitle": "ID d'instance du plugin", + "ToolId": "Choisissez le type d'outil à utiliser", + "ToolIdTitle": "type d'outil" }, "WikiOperation": { "Description": "Effectuer des opérations sur les Tiddlers (ajout, suppression ou définition de texte) dans l'espace de travail Wiki", "Title": "Opérations Wiki", "Tool": { - "Examples": { - }, "Parameters": { "extraMeta": { "Description": "Chaîne JSON de métadonnées supplémentaires, telles que des étiquettes et des champs, par défaut \"{}\"", @@ -457,18 +533,16 @@ "Description": "Le titre de Tiddler", "Title": "Titre du Tiddler" }, + "variables": { + "Description": "Variables transmises à l'action tiddler, chaîne de caractères au format JSON, par défaut \"{}\"", + "Title": "variable" + }, "workspaceName": { "Description": "Nom ou ID de l'espace de travail à manipuler", "Title": "Nom de l'espace de travail" } } }, - "ToolListPosition": { - "Position": "par rapport à la position d'insertion de l'élément cible (avant/après)", - "PositionTitle": "position d'insertion", - "TargetId": "ID de l'élément cible pour insérer la liste des outils", - "TargetIdTitle": "ID cible" - }, "ToolResultDuration": "Le nombre de tours pendant lesquels les résultats de l'exécution des outils restent visibles dans la conversation, après quoi ils seront affichés en gris.", "ToolResultDurationTitle": "nombre de tours consécutifs des résultats de l'outil" }, @@ -507,12 +581,6 @@ }, "UpdateEmbeddings": { "Description": "Générer ou mettre à jour l'index d'embedding vectoriel pour l'espace de travail Wiki, destiné à la recherche sémantique.", - "Parameters": { - "forceUpdate": { - }, - "workspaceName": { - } - }, "forceUpdate": { "Description": "Forcer la régénération de l'index d'incorporation, écrasant les données incorporées existantes (si défini sur true, ignore les mises à jour incrémentielles).", "Title": "mise à jour forcée" @@ -523,15 +591,16 @@ } } }, - "ToolListPosition": { - "Position": "position d'insertion par rapport à la position cible", - "PositionTitle": "position d'insertion", - "TargetId": "ID de l'élément cible, la liste des outils sera insérée par rapport à cet élément", - "TargetIdTitle": "ID cible" - }, - "ToolListPositionTitle": "Emplacement de la liste des outils", "ToolResultDuration": "Le nombre de tours pendant lesquels les résultats de l'exécution des outils restent visibles dans la conversation, après quoi ils seront affichés en gris.", "ToolResultDurationTitle": "nombre de tours consécutifs avec résultat d'outil" + }, + "WorkspacesList": { + "Description": "Injecter la liste des espaces de travail Wiki disponibles dans l'invite", + "Position": "Position d'insertion : before pour avant, after pour après", + "PositionTitle": "position d'insertion", + "TargetId": "ID du mot-clé cible, la liste sera insérée par rapport à ce mot-clé", + "TargetIdTitle": "ID cible", + "Title": "Liste des espaces de travail" } }, "Search": { @@ -557,7 +626,10 @@ } }, "Tool": { - "Plugin": { + "Git": { + "Error": { + "WorkspaceNotFound": "Le nom ou l'ID de l'espace de travail \"{{workspaceName}}\" n'existe pas." + } }, "Schema": { "Description": "décrire", @@ -566,12 +638,19 @@ "Parameters": "paramètre", "Required": "nécessaire" }, + "TiddlyWikiPlugin": { + "Error": { + "PluginTitleRequired": "Le titre du plugin ne peut pas être vide.", + "WorkspaceNotFound": "Le nom ou l'ID de l'espace de travail \"{{workspaceNameOrID}}\" n'existe pas" + } + }, "WikiOperation": { "Error": { "WorkspaceNotExist": "L'espace de travail {{workspaceID}} n'existe pas.", "WorkspaceNotFound": "Le nom ou l'ID de l'espace de travail \"{{workspaceName}}\" n'existe pas. Espaces de travail disponibles : {{availableWorkspaces}}" }, "Success": { + "ActionInvoked": "L'action \"{{actionTitle}}\" a été exécutée avec succès dans l'espace de travail Wiki \"{{workspaceName}}\".", "Added": "Le Tiddler \"{{title}}\" a été ajouté avec succès à l'espace de travail Wiki \"{{workspaceName}}\".", "Deleted": "Le Tiddler \"{{title}}\" a été supprimé avec succès de l'espace de travail Wiki \"{{workspaceName}}\".", "Updated": "Le texte du Tiddler \"{{title}}\" a été défini avec succès dans l'espace de travail Wiki \"{{workspaceName}}\"." diff --git a/localization/locales/fr/translation.json b/localization/locales/fr/translation.json index 4ad358de..1ed7858d 100644 --- a/localization/locales/fr/translation.json +++ b/localization/locales/fr/translation.json @@ -71,6 +71,7 @@ "TagName": "Nom de l'étiquette", "TagNameHelp": "Les tiddlers avec cette étiquette seront ajoutés à ce sous-wiki (vous pouvez ajouter ou modifier cette étiquette plus tard, en cliquant avec le bouton droit sur l'icône de l'espace de travail et en choisissant Modifier l'espace de travail)", "TagNameHelpForMain": "Les nouvelles entrées avec cette étiquette seront prioritairement enregistrées dans cet espace de travail.", + "TagNameInputWarning": "⚠️ Veuillez appuyer sur Entrée pour confirmer la saisie de l'étiquette", "ThisPathIsNotAWikiFolder": "Le répertoire n'est pas un dossier Wiki \"{{wikiPath}}\"", "UseFilter": "utiliser un filtre", "UseFilterHelp": "Utilisez des expressions de filtre plutôt que des étiquettes pour correspondre aux entrées et décider si elles doivent être stockées dans l'espace de travail actuel.", @@ -217,6 +218,9 @@ "WikiRootTiddler": "Tiddler racine du Wiki", "WikiRootTiddlerDescription": "Le tiddler racine du Wiki détermine le comportement de base du système, veuillez lire la documentation officielle pour comprendre avant de modifier", "WikiRootTiddlerItems": { + "all": "Tout charger en une fois", + "lazy-images": "Charger les images à la demande", + "lazy-all": "Charger les images et le texte à la demande" } }, "Error": { @@ -259,7 +263,6 @@ "AndMoreFiles": "Il reste encore {{count}} fichier(s)", "Author": "auteur", "BinaryFileCannotDisplay": "Le fichier binaire ne peut pas être affiché sous forme de texte.", - "ClearSearch": "Effacer la recherche", "Committing": "En cours de soumission...", "ContentView": "contenu du fichier", "CopyFilePath": "copier le chemin du fichier", @@ -278,7 +281,6 @@ "FailedToLoadDiff": "Échec du chargement des différences", "FileNameSearch": "nom de fichier", "FileSearchPlaceholder": "Par exemple : pages, *.tsx, config.json", - "Files": "fichier", "FilesChanged": "fichiers modifiés", "FilesChanged_other": "{{count}} fichiers modifiés", "Hash": "valeur de hachage", @@ -565,8 +567,6 @@ "Save": "sauvegarder", "Schema": { "ProviderModel": { - "FreeModel": "petit modèle gratuit utilisé pour générer des titres de résumé et des titres de sauvegarde", - "FreeModelTitle": "modèle gratuit" } }, "Scripting": { diff --git a/localization/locales/ja/agent.json b/localization/locales/ja/agent.json index 5281e22a..35bbfc8a 100644 --- a/localization/locales/ja/agent.json +++ b/localization/locales/ja/agent.json @@ -40,9 +40,7 @@ "Title": "設定の問題" }, "InputPlaceholder": "メッセージを入力、Ctrl+Enterで送信", - "Send": "送信", - "SessionGroup": { - } + "Send": "送信" }, "Common": { "None": "選択されていません" @@ -173,6 +171,7 @@ "ExternalApiDatabaseDescription": "外部APIデバッグ情報を含むデータベースで、占有スペースは{{size}}です", "FailedToAddModel": "モデルの追加に失敗しました", "FailedToAddProvider": "プロバイダーの追加に失敗しました", + "FailedToDeleteProvider": "プロバイダー {{providerName}} の削除に失敗しました", "FailedToRemoveModel": "モデルの削除に失敗しました", "FailedToSaveSettings": "設定の保存に失敗しました", "FailedToUpdateModel": "モデルを更新できません", @@ -202,6 +201,7 @@ "ProviderClass": "プロバイダーインターフェースタイプ", "ProviderConfiguration": "プロバイダー設定", "ProviderConfigurationDescription": "AIプロバイダーのAPIキーやその他の設定を構成します", + "ProviderDeleted": "プロバイダー {{providerName}} は削除されました", "ProviderDisabled": "プロバイダーが無効になりました", "ProviderEnabled": "プロバイダーが有効になりました", "ProviderName": "プロバイダー名", @@ -239,6 +239,12 @@ "Prompt": { "AutoRefresh": "プレビューは入力テキストの変更に応じて自動的に更新されます", "CodeEditor": "コードエディタ", + "Edit": "プロンプト編集", + "EnterEditSideBySide": "分割画面表示編集", + "EnterFullScreen": "全画面表示に入る", + "EnterPreviewSideBySide": "分割画面表示プレビュー", + "ExitFullScreen": "全画面表示を終了", + "ExitSideBySide": "分割画面を終了する", "Flat": "グリッドビュー", "FormEditor": "フォームエディター", "LastUpdated": "前回の更新時間", @@ -252,10 +258,13 @@ }, "PromptConfig": { "AddItem": "プロジェクトを追加", + "Collapse": "折りたたみ", "EmptyArray": "まだアイテムが追加されていません。下のボタンをクリックして最初のアイテムを追加してください。", + "Expand": "展開", "ItemCount": "{{count}} 件", - "RemoveItem": "リスト項目を削除", + "ItemIndex": "第 {{index}} 項", "Tabs": { + "Plugins": "プラグイン", "Prompts": "プロンプト", "Response": "応答" }, @@ -267,8 +276,20 @@ }, "Schema": { "AIConfig": { + "Default": "デフォルトで使用される対話モデル", + "DefaultTitle": "デフォルトモデル", "Description": "AI 会話設定の構成", - "Title": "AI設定" + "Embedding": "ベクトル検索用の埋め込みモデル", + "EmbeddingTitle": "埋め込みモデル", + "Free": "低コストモデルを使用したコストセンシティブタスク", + "FreeTitle": "無料/低コストモデル", + "ImageGeneration": "画像生成用のモデル", + "ImageGenerationTitle": "画像生成モデル", + "Speech": "テキスト読み上げ(TTS)用のモデル", + "SpeechTitle": "音声合成モデル", + "Title": "AI設定", + "Transcriptions": "音声からテキストへの変換(STT)に使用されるモデル", + "TranscriptionsTitle": "音声テキスト変換モデル" }, "AgentConfig": { "Description": "エージェント設定", @@ -276,50 +297,103 @@ "IdTitle": "エージェントID", "PromptConfig": { "Description": "プロンプト設定", + "Plugins": "プラグイン設定リスト", "Prompts": "プロンプト設定リスト", "Response": "応答設定リスト", "Title": "プロンプト設定" }, "Title": "エージェント設定" }, - "AutoReroll": { - }, "BaseAPIConfig": { - "API": "APIプロバイダーとモデル設定", - "APITitle": "API設定", - "Description": "基本API設定", "ModelParameters": "モデルパラメータ設定", - "ModelParametersTitle": "モデルパラメータ", - "Title": "基本API設定" + "ModelParametersTitle": "モデルパラメータ" + }, + "Common": { + "ToolListPosition": { + "Description": "プロンプト内でのツールリスト挿入位置の設定", + "Position": "ターゲットに対する挿入位置", + "PositionTitle": "挿入", + "TargetId": "ターゲット要素のID", + "TargetIdTitle": "ターゲットID" + }, + "ToolListPositionTitle": "ツールリスト位置" }, "DefaultAgents": { "Description": "デフォルトのインテリジェントエージェント設定リスト", "Title": "デフォルトエージェント" }, - "DynamicPosition": { - }, "FullReplacement": { "Description": "完全置換パラメータ設定", "SourceType": "ソースタイプ", "SourceTypeTitle": "ソースタイプ", - "SourceTypes": { - }, "TargetId": "ターゲット要素ID", "TargetIdTitle": "ターゲットID", "Title": "完全置換パラメータ" }, - "Function": { - }, - "HandlerConfig": { - }, - "JavascriptTool": { + "Git": { + "Description": "Gitのコミットログを検索し、特定のファイル内容を読み取る", + "Title": "Git ツール", + "Tool": { + "Parameters": { + "commitHash": { + "Description": "ハッシュ値を提出", + "Title": "ハッシュを提出" + }, + "filePath": { + "Description": "ファイルパス(ファイル検索モード用)", + "Title": "ファイルパス" + }, + "maxLines": { + "Description": "最大行数(デフォルト500)", + "Title": "最大行数" + }, + "page": { + "Description": "結果ページ番号(1から開始)", + "Title": "ページ番号" + }, + "pageSize": { + "Description": "ページあたりの結果数", + "Title": "ページあたりの数量" + }, + "searchMode": { + "Description": "検索モード:提出情報、ファイルパス、日付範囲で検索または検索しない", + "Title": "検索モード" + }, + "searchQuery": { + "Description": "検索クエリ文字列(メッセージ検索モード)", + "Title": "検索クエリ" + }, + "since": { + "Description": "開始日(ISO 8601形式)", + "Title": "開始日" + }, + "until": { + "Description": "終了日(ISO 8601形式)", + "Title": "終了日" + }, + "workspaceName": { + "Description": "検索するワークスペース名またはID", + "Title": "ワークスペース名" + } + }, + "ReadFile": { + "Parameters": { + "commitHash": { + }, + "filePath": { + }, + "maxLines": { + }, + "workspaceName": { + } + } + } + } }, "MCP": { "Description": "モデルコンテキストプロトコルパラメータ設定", "Id": "MCP サーバー ID", "IdTitle": "サーバーID", - "ResponseProcessing": { - }, "TimeoutMessage": "タイムアウトメッセージ", "TimeoutMessageTitle": "タイムアウトメッセージ", "TimeoutSecond": "タイムアウト時間(秒)", @@ -338,29 +412,23 @@ "TopP": "Top P サンプリングパラメータ", "TopPTitle": "トップP" }, + "ModelSelection": { + "Description": "モデルプロバイダーと具体的なモデル名を選択する", + "Model": "モデル名(例:gpt-4o、Qwen2.5-7B-Instruct)", + "ModelTitle": "モデル", + "Provider": "モデルプロバイダー(例:OpenAI、SiliconFlow、Googleなど)", + "ProviderTitle": "プロバイダー", + "Title": "モデル選択" + }, "Plugin": { - "Caption": "簡単な説明", - "CaptionTitle": "タイトル", - "Content": "プラグインの内容または説明", - "ContentTitle": "内容", - "ForbidOverrides": "このプラグインのパラメータを実行時に上書きすることを禁止しますか?", - "ForbidOverridesTitle": "上書き禁止", - "Id": "プラグインID", - "IdTitle": "ID", - "PluginId": "特定のプラグインを選択するための識別子", - "PluginIdTitle": "プラグイン識別子" }, "Position": { - "Bottom": "下部から数件のメッセージをオフセット", - "BottomTitle": "底部オフセット", "Description": "位置パラメータ設定", "TargetId": "ターゲット要素ID", "TargetIdTitle": "ターゲットID", "Title": "位置引数", "Type": "位置タイプ", - "TypeTitle": "位置タイプ", - "Types": { - } + "TypeTitle": "位置タイプ" }, "Prompt": { "Caption": "簡単な説明", @@ -379,64 +447,72 @@ "System": "システム - AIの行動ルールと背景設定を定義する", "User": "ユーザー - ユーザーの入力とリクエストをシミュレートする" }, + "Source": "このプロンプトの出典または引用(例:ファイルパス、URLなど)", + "SourceTitle": "出典", "Tags": "タグリスト", "TagsTitle": "ラベル", "Text": "プロンプトの内容には、<<変数名>>などのウィキテキストでサポートされている構文を含めることができます。", "TextTitle": "テキスト", "Title": "プロンプト" }, - "PromptDynamicModification": { - "DynamicModificationTypes": { - } - }, - "PromptPart": { - }, "ProviderModel": { - "Description": "プロバイダーとモデル設定", - "EmbeddingModel": "意味検索とベクトル操作のための埋め込みモデルの名称", - "EmbeddingModelTitle": "埋め込みモデル", - "ImageGenerationModel": "テキストから画像を生成する操作に使用される画像生成モデルの名称", - "ImageGenerationModelTitle": "画像生成モデル", - "Model": "AIモデル名", - "ModelTitle": "モデル", - "Provider": "AIプロバイダー名", - "ProviderTitle": "プロバイダー", - "SpeechModel": "音声生成モデルの名称(テキスト読み上げ操作用)", - "SpeechModelTitle": "音声モデル", - "Title": "プロバイダーモデル", - "TranscriptionsModel": "音声をテキストに変換する操作に使用される音声認識モデルの名称", - "TranscriptionsModelTitle": "音声認識モデル" - }, - "RAG": { - "Removal": { - }, - "SourceTypes": { - } }, "Response": { + "Caption": "レスポンスヘッダー、このレスポンスエントリを識別するために使用されます", + "CaptionTitle": "タイトル", "Description": "外部APIのレスポンスは、通常、動的に変更される対象として応答され、その構造はプロンプトと同じです。プリセット内容を記入することもできますし、プレースホルダーやコンテナとして機能させ、ResponseDynamicModificationによって外部APIのレスポンスの具体的な内容が入力されることもあります。", + "Id": "応答設定の一意の識別子。参照用に便利です。", + "IdTitle": "ID", "Title": "応答" }, - "ResponseDynamicModification": { - "DynamicModificationTypes": { + "TiddlyWikiPlugin": { + "ActionsTag": { + "Description": "Action Tiddlerにタグ付けするラベル、AIはこのラベルを通じて実行可能なアクションを見つけることができます", + "Title": "Actions タグ" }, - "ResponseProcessingTypes": { + "DataSourceTag": { + "Description": "データソースエントリにタグを付け、AIはこのタグを通じてデータの取得とフィルタリング方法を理解できます", + "Title": "DataSource タグ" + }, + "DescribeTag": { + "Description": "短い説明をマークするタグで、これらの説明は自動的にプロンプトに読み込まれ、AIが利用可能な機能を理解するのに役立ちます。", + "Title": "Describe タグ" + }, + "Description": "特定のプラグインのデータソースとアクションの説明をロードします。システムは自動的にDescribeタグのエントリを背景情報としてロードします。", + "EnableCache": { + "Description": "キャッシュを有効にして、プロンプト生成時に毎回Wikiを再クエリするのを防ぎます。キャッシュを無効にすると、説明内容がクリアされ再読み込みされます。", + "Title": "キャッシュを有効にする" + }, + "Title": "TiddlyWiki プラグイン", + "Tool": { + "Parameters": { + "pluginTitle": { + "Description": "詳細をロードするためのプラグインのタイトル。システムはこのタイトルを含むDataSourceとActionsのエントリを検索します。", + "Title": "プラグインタイトル" + } + } + }, + "WorkspaceNameOrID": { + "Description": "プラグイン情報を読み込むワークスペース名またはID(デフォルトは 'wiki')", + "Title": "ワークスペース名またはID" } }, - "ToolCalling": { - }, - "Trigger": { - "Model": { - } - }, - "Wiki": { + "Tool": { + "Caption": "短い説明(UI表示用)", + "CaptionTitle": "タイトル", + "Content": "プラグインの内容または説明", + "ContentTitle": "内容", + "ForbidOverrides": "このプラグインのパラメータを実行時に上書きすることを禁止しますか?", + "ForbidOverridesTitle": "上書き禁止", + "Id": "プラグインインスタンスID(同一ハンドラ内で一意)", + "IdTitle": "プラグインインスタンスID", + "ToolId": "使用するツールの種類を選択", + "ToolIdTitle": "ツールタイプ" }, "WikiOperation": { "Description": "Wiki ワークスペースで Tiddler 操作を実行する(追加、削除、またはテキストの設定)", "Title": "Wiki 操作", "Tool": { - "Examples": { - }, "Parameters": { "extraMeta": { "Description": "追加メタデータのJSON文字列(例:タグやフィールド)、デフォルトは「{}」", @@ -458,18 +534,16 @@ "Description": "Tiddler のタイトル", "Title": "Tiddler タイトル" }, + "variables": { + "Description": "アクションタドラーに渡される変数、JSON形式の文字列、デフォルトは\"{}\"", + "Title": "変数" + }, "workspaceName": { "Description": "操作するワークスペースの名前またはID", "Title": "ワークスペース名" } } }, - "ToolListPosition": { - "Position": "ターゲット要素に対する挿入位置(before/after)", - "PositionTitle": "挿入位置", - "TargetId": "ツールリストを挿入する対象要素のID", - "TargetIdTitle": "ターゲットID" - }, "ToolResultDuration": "ツールの実行結果が会話内で表示されるターン数。この数を超えると、結果はグレー表示になります。", "ToolResultDurationTitle": "ツール結果の持続ターン数" }, @@ -508,12 +582,6 @@ }, "UpdateEmbeddings": { "Description": "Wikiワークスペースのベクトル埋め込みインデックスを生成または更新し、セマンティック検索に使用します", - "Parameters": { - "forceUpdate": { - }, - "workspaceName": { - } - }, "forceUpdate": { "Description": "強制的に埋め込みインデックスを再生成し、既存の埋め込みデータを上書きするかどうか(trueに設定すると増分更新が無視されます)。", "Title": "強制更新" @@ -524,15 +592,16 @@ } } }, - "ToolListPosition": { - "Position": "目標位置に対する挿入位置", - "PositionTitle": "挿入位置", - "TargetId": "ターゲット要素のID、ツールリストはこの要素に対して挿入されます", - "TargetIdTitle": "ターゲットID" - }, - "ToolListPositionTitle": "ツールリストの位置", "ToolResultDuration": "ツールの実行結果が会話中に表示されるターン数。このターン数を超えると、結果はグレー表示になります。", "ToolResultDurationTitle": "ツール結果の継続ラウンド数" + }, + "WorkspacesList": { + "Description": "利用可能なWikiワークスペースのリストをプロンプトに注入する", + "Position": "挿入位置:before は前挿入、after は後挿入", + "PositionTitle": "挿入位置", + "TargetId": "ターゲットプロンプトのID、リストはこのプロンプトに対して挿入されます", + "TargetIdTitle": "ターゲットID", + "Title": "ワークスペースリスト" } }, "Search": { @@ -558,7 +627,10 @@ } }, "Tool": { - "Plugin": { + "Git": { + "Error": { + "WorkspaceNotFound": "ワークスペース名またはID「{{workspaceName}}」は存在しません" + } }, "Schema": { "Description": "説明", @@ -567,12 +639,19 @@ "Parameters": "パラメータ", "Required": "必須" }, + "TiddlyWikiPlugin": { + "Error": { + "PluginTitleRequired": "プラグインのタイトルは空にできません", + "WorkspaceNotFound": "ワークスペース名またはID「{{workspaceNameOrID}}」は存在しません" + } + }, "WikiOperation": { "Error": { "WorkspaceNotExist": "ワークスペース{{workspaceID}}は存在しません", "WorkspaceNotFound": "ワークスペース名またはID「{{workspaceName}}」は存在しません。利用可能なワークスペース:{{availableWorkspaces}}" }, "Success": { + "ActionInvoked": "ワークスペース「{{workspaceName}}」でアクション項目「{{actionTitle}}」が正常に実行されました", "Added": "Wikiワークスペース「{{workspaceName}}」にTiddler「{{title}}」を追加しました", "Deleted": "Wikiワークスペース「{{workspaceName}}」からTiddler「{{title}}」の削除に成功しました", "Updated": "Wikiワークスペース「{{workspaceName}}」でTiddler「{{title}}」のテキストを正常に設定しました" diff --git a/localization/locales/ja/translation.json b/localization/locales/ja/translation.json index 45c4c908..3580619a 100644 --- a/localization/locales/ja/translation.json +++ b/localization/locales/ja/translation.json @@ -71,6 +71,7 @@ "TagName": "タグ名", "TagNameHelp": "このタグを持つTiddlerはこのサブWikiに追加されます(後で右クリックしてワークスペースアイコンを選択し、ワークスペースを編集することでこのタグを追加または変更できます)", "TagNameHelpForMain": "このタグが付いた新しいエントリは、このワークスペースに優先的に保存されます", + "TagNameInputWarning": "⚠️ ラベル入力を確認するにはEnterキーを押してください", "ThisPathIsNotAWikiFolder": "このディレクトリはWikiフォルダではありません \"{{wikiPath}}\"", "UseFilter": "フィルターを使用する", "UseFilterHelp": "フィルター式を使用してエントリをマッチングし、現在のワークスペースに保存するかどうかを決定します。タグではなくフィルター式を用います。", @@ -217,6 +218,9 @@ "WikiRootTiddler": "WikiルートTiddler", "WikiRootTiddlerDescription": "WikiのルートTiddlerはシステムのコア動作を決定します。変更する前に公式ドキュメントを読んで理解してください", "WikiRootTiddlerItems": { + "all": "一度にすべて読み込む", + "lazy-images": "必要に応じて画像を読み込む", + "lazy-all": "必要に応じて画像とテキストを読み込む" } }, "Error": { @@ -259,7 +263,6 @@ "AndMoreFiles": "残り {{count}} ファイル", "Author": "著者", "BinaryFileCannotDisplay": "バイナリファイルはテキストとして表示できません", - "ClearSearch": "検索をクリア", "Committing": "送信中...", "ContentView": "ファイル内容", "CopyFilePath": "ファイルパスをコピー", @@ -278,7 +281,6 @@ "FailedToLoadDiff": "差分の読み込みに失敗しました", "FileNameSearch": "ファイル名", "FileSearchPlaceholder": "例えば:pages、*.tsx、config.json", - "Files": "ファイル", "FilesChanged": "変更されたファイル", "FilesChanged_other": "{{count}} 個のファイルに変更があります", "Hash": "ハッシュ値", @@ -564,8 +566,6 @@ "Save": "保存", "Schema": { "ProviderModel": { - "FreeModel": "要約タイトルの生成やバックアップタイトルの作成に使用する無料の小型モデル", - "FreeModelTitle": "無料モデル" } }, "Scripting": { diff --git a/localization/locales/ru/agent.json b/localization/locales/ru/agent.json index 113263ae..9222d348 100644 --- a/localization/locales/ru/agent.json +++ b/localization/locales/ru/agent.json @@ -40,9 +40,7 @@ "Title": "Проблема с конфигурацией" }, "InputPlaceholder": "Введите сообщение, Ctrl+Enter для отправки", - "Send": "Отправить", - "SessionGroup": { - } + "Send": "Отправить" }, "Common": { "None": "не выбрано" @@ -173,6 +171,7 @@ "ExternalApiDatabaseDescription": "База данных, содержащая отладочную информацию внешнего API, занимает пространство размером {{size}}.", "FailedToAddModel": "Не удалось добавить модель", "FailedToAddProvider": "Не удалось добавить поставщика", + "FailedToDeleteProvider": "Не удалось удалить поставщика {{providerName}}", "FailedToRemoveModel": "Не удалось удалить модель", "FailedToSaveSettings": "Не удалось сохранить настройки", "FailedToUpdateModel": "Не удалось обновить модель", @@ -202,6 +201,7 @@ "ProviderClass": "Тип интерфейса поставщика", "ProviderConfiguration": "Конфигурация поставщика", "ProviderConfigurationDescription": "Настройте API ключ поставщика AI и другие настройки", + "ProviderDeleted": "Провайдер {{providerName}} удален", "ProviderDisabled": "Поставщик отключен", "ProviderEnabled": "Поставщик включен", "ProviderName": "Имя поставщика", @@ -239,6 +239,12 @@ "Prompt": { "AutoRefresh": "Предварительный просмотр автоматически обновляется при изменении введенного текста.", "CodeEditor": "Редактор кода", + "Edit": "редактирование подсказок", + "EnterEditSideBySide": "Редактирование с разделенным экраном", + "EnterFullScreen": "перейти в полноэкранный режим", + "EnterPreviewSideBySide": "Предварительный просмотр разделенного экрана", + "ExitFullScreen": "выйти из полноэкранного режима", + "ExitSideBySide": "выход из разделенного экрана", "Flat": "плиточное представление", "FormEditor": "редактор форм", "LastUpdated": "Дата последнего обновления", @@ -252,10 +258,13 @@ }, "PromptConfig": { "AddItem": "добавить проект", + "Collapse": "складывать", "EmptyArray": "Еще не добавлено ни одного элемента. Нажмите на кнопку ниже, чтобы добавить первый элемент.", + "Expand": "развернуть", "ItemCount": "{{count}} элементов", - "RemoveItem": "удалить элемент списка", + "ItemIndex": "Пункт {{index}}", "Tabs": { + "Plugins": "плагин", "Prompts": "подсказка", "Response": "отклик" }, @@ -267,8 +276,20 @@ }, "Schema": { "AIConfig": { + "Default": "модель диалога по умолчанию", + "DefaultTitle": "модель по умолчанию", "Description": "Настройка конфигурации AI-диалога", - "Title": "AI конфигурация" + "Embedding": "Модель встраивания для векторного поиска", + "EmbeddingTitle": "встраиваемая модель", + "Free": "низкозатратная модель, используемая для чувствительных к затратам задач", + "FreeTitle": "бесплатные/низкозатратные модели", + "ImageGeneration": "Модель для генерации изображений", + "ImageGenerationTitle": "модель генерации изображений", + "Speech": "Модель для преобразования текста в речь (TTS)", + "SpeechTitle": "модель синтеза речи", + "Title": "AI конфигурация", + "Transcriptions": "Модель для преобразования речи в текст (STT)", + "TranscriptionsTitle": "модель преобразования речи в текст" }, "AgentConfig": { "Description": "Конфигурация агента", @@ -276,50 +297,103 @@ "IdTitle": "ID агента", "PromptConfig": { "Description": "настройка подсказок", + "Plugins": "Список конфигураций плагинов", "Prompts": "Список конфигурации подсказок", "Response": "список конфигураций ответа", "Title": "настройка подсказок" }, "Title": "Конфигурация агента" }, - "AutoReroll": { - }, "BaseAPIConfig": { - "API": "API-провайдеры и конфигурация моделей", - "APITitle": "Настройка API", - "Description": "Базовая настройка API", "ModelParameters": "Настройка параметров модели", - "ModelParametersTitle": "параметры модели", - "Title": "Базовая настройка API" + "ModelParametersTitle": "параметры модели" + }, + "Common": { + "ToolListPosition": { + "Description": "Конфигурация позиции вставки списка инструментов в подсказки", + "Position": "позиция вставки относительно цели", + "PositionTitle": "вставить", + "TargetId": "ID целевого элемента", + "TargetIdTitle": "ID цели" + }, + "ToolListPositionTitle": "позиция списка орудий" }, "DefaultAgents": { "Description": "список конфигураций агента по умолчанию", "Title": "агент по умолчанию" }, - "DynamicPosition": { - }, "FullReplacement": { "Description": "полная замена параметров конфигурации", "SourceType": "тип источника", "SourceTypeTitle": "тип источника", - "SourceTypes": { - }, "TargetId": "ID целевого элемента", "TargetIdTitle": "ID цели", "Title": "полная замена параметров" }, - "Function": { - }, - "HandlerConfig": { - }, - "JavascriptTool": { + "Git": { + "Description": "Поиск журнала фиксаций Git и чтение содержимого определенных файлов", + "Title": "Инструменты Git", + "Tool": { + "Parameters": { + "commitHash": { + "Description": "отправить хэш-значение", + "Title": "хэш коммита" + }, + "filePath": { + "Description": "Путь к файлу (используется для режима поиска файлов)", + "Title": "путь к файлу" + }, + "maxLines": { + "Description": "Максимальное количество строк (по умолчанию 500)", + "Title": "максимальное количество строк" + }, + "page": { + "Description": "номер страницы результата (начиная с 1)", + "Title": "страница" + }, + "pageSize": { + "Description": "Количество результатов на страницу", + "Title": "количество на страницу" + }, + "searchMode": { + "Description": "Режим поиска: по информации о подаче, пути файла, диапазону дат или не искать", + "Title": "режим поиска" + }, + "searchQuery": { + "Description": "Строка поискового запроса (режим поиска сообщений)", + "Title": "поисковый запрос" + }, + "since": { + "Description": "Дата начала (в формате ISO 8601)", + "Title": "Дата начала" + }, + "until": { + "Description": "Дата окончания (в формате ISO 8601)", + "Title": "дата окончания" + }, + "workspaceName": { + "Description": "Имя или ID рабочей области для поиска", + "Title": "Название рабочей области" + } + }, + "ReadFile": { + "Parameters": { + "commitHash": { + }, + "filePath": { + }, + "maxLines": { + }, + "workspaceName": { + } + } + } + } }, "MCP": { "Description": "Настройка параметров протокола контекста модели", "Id": "Идентификатор сервера MCP", "IdTitle": "ID сервера", - "ResponseProcessing": { - }, "TimeoutMessage": "сообщение с истекшим сроком", "TimeoutMessageTitle": "сообщение о тайм-ауте", "TimeoutSecond": "Таймаут (секунды)", @@ -338,29 +412,23 @@ "TopP": "Параметр выборки Top P", "TopPTitle": "Топ P" }, + "ModelSelection": { + "Description": "выбор поставщика модели и конкретного названия модели", + "Model": "Название модели (например, gpt-4o, Qwen2.5-7B-Instruct)", + "ModelTitle": "модель", + "Provider": "поставщики моделей (например, OpenAI, SiliconFlow, Google и др.)", + "ProviderTitle": "провайдер", + "Title": "выбор модели" + }, "Plugin": { - "Caption": "краткое описание", - "CaptionTitle": "заголовок", - "Content": "содержание или описание плагина", - "ContentTitle": "содержание", - "ForbidOverrides": "Запрещено ли переопределять параметры этого плагина во время выполнения?", - "ForbidOverridesTitle": "запрещено перекрывать", - "Id": "ID плагина", - "IdTitle": "ID", - "PluginId": "идентификатор для выбора конкретного плагина", - "PluginIdTitle": "идентификатор плагина" }, "Position": { - "Bottom": "смещение нескольких сообщений снизу", - "BottomTitle": "смещение дна", "Description": "настройка позиционных параметров", "TargetId": "ID целевого элемента", "TargetIdTitle": "ID цели", "Title": "позиционные аргументы", "Type": "тип местоположения", - "TypeTitle": "тип местоположения", - "Types": { - } + "TypeTitle": "тип местоположения" }, "Prompt": { "Caption": "краткое описание", @@ -379,64 +447,72 @@ "System": "Система - определение правил поведения и фоновых установок ИИ", "User": "Пользователь - имитация ввода и запросов пользователя" }, + "Source": "Источник или ссылка на подсказку (например, путь к файлу, URL и т. д.)", + "SourceTitle": "источник", "Tags": "список тегов", "TagsTitle": "метка", "Text": "Содержание подсказки может включать синтаксис, поддерживаемый вики-текстом, например <<имя_переменной>>.", "TextTitle": "текст", "Title": "подсказка" }, - "PromptDynamicModification": { - "DynamicModificationTypes": { - } - }, - "PromptPart": { - }, "ProviderModel": { - "Description": "провайдер и конфигурация модели", - "EmbeddingModel": "Название модели встраивания для семантического поиска и векторных операций", - "EmbeddingModelTitle": "встраиваемая модель", - "ImageGenerationModel": "Название модели генерации изображений для операций создания изображений из текста", - "ImageGenerationModelTitle": "модель генерации изображений", - "Model": "Название модели ИИ", - "ModelTitle": "модель", - "Provider": "Название поставщика ИИ", - "ProviderTitle": "провайдер", - "SpeechModel": "Название модели генерации речи для операций преобразования текста в речь", - "SpeechModelTitle": "голосовая модель", - "Title": "модель поставщика", - "TranscriptionsModel": "Название модели распознавания речи для преобразования голоса в текст", - "TranscriptionsModelTitle": "модель распознавания речи" - }, - "RAG": { - "Removal": { - }, - "SourceTypes": { - } }, "Response": { + "Caption": "Заголовок ответа, используемый для идентификации записи ответа.", + "CaptionTitle": "заголовок", "Description": "Ответ от внешнего API, который обычно служит целью для динамического изменения в ответе, имеет такую же структуру, как и подсказка. Можно заполнить его предустановленным содержимым или использовать в качестве заполнителя (контейнера), куда ResponseDynamicModification внесёт конкретное содержимое ответа от внешнего API.", + "Id": "уникальный идентификатор конфигурации ответа для удобства ссылки", + "IdTitle": "ID", "Title": "отклик" }, - "ResponseDynamicModification": { - "DynamicModificationTypes": { + "TiddlyWikiPlugin": { + "ActionsTag": { + "Description": "Метка Action Tiddler, с помощью которой ИИ может найти выполняемые действия.", + "Title": "Метка Actions" }, - "ResponseProcessingTypes": { + "DataSourceTag": { + "Description": "Метка для записи источника данных, с помощью которой ИИ может понять метод получения и фильтрации данных.", + "Title": "Тег DataSource" + }, + "DescribeTag": { + "Description": "Метки для краткого описания, которые автоматически загружаются в подсказки и помогают ИИ понять доступные функции.", + "Title": "Опишите тег" + }, + "Description": "Загрузка источника данных и инструкций для конкретных плагинов. Система автоматически загружает записи с тегом Describe в качестве фоновой информации.", + "EnableCache": { + "Description": "Включить кеширование, чтобы избежать повторных запросов к вики при каждом создании подсказки. При отключении кеширования описания будут очищены и загружены заново.", + "Title": "включить кэширование" + }, + "Title": "Плагины TiddlyWiki", + "Tool": { + "Parameters": { + "pluginTitle": { + "Description": "Заголовок плагина для загрузки деталей. Система будет искать записи DataSource и Actions, содержащие этот заголовок.", + "Title": "Заголовок плагина" + } + } + }, + "WorkspaceNameOrID": { + "Description": "Имя или идентификатор рабочей области для загрузки информации о плагине (по умолчанию 'wiki')", + "Title": "название или идентификатор рабочей области" } }, - "ToolCalling": { - }, - "Trigger": { - "Model": { - } - }, - "Wiki": { + "Tool": { + "Caption": "краткое описание (для отображения в интерфейсе)", + "CaptionTitle": "заголовок", + "Content": "содержание или описание плагина", + "ContentTitle": "содержание", + "ForbidOverrides": "Запрещено ли переопределять параметры этого плагина во время выполнения?", + "ForbidOverridesTitle": "запрещено перекрывать", + "Id": "ID экземпляра плагина (уникальный в пределах одного обработчика)", + "IdTitle": "ID экземпляра плагина", + "ToolId": "Выберите тип инструмента для использования", + "ToolIdTitle": "тип инструмента" }, "WikiOperation": { "Description": "Выполнение операций с Tiddler (добавление, удаление или установка текста) в рабочей области Wiki", "Title": "Wiki операции", "Tool": { - "Examples": { - }, "Parameters": { "extraMeta": { "Description": "JSON-строка дополнительных метаданных, таких как теги и поля, по умолчанию \"{}\"", @@ -458,18 +534,16 @@ "Description": "Заголовок Tiddler", "Title": "Заголовок Tiddler" }, + "variables": { + "Description": "переменные, передаваемые в action tiddler, строка в формате JSON, по умолчанию \"{}\"", + "Title": "переменная" + }, "workspaceName": { "Description": "Имя или идентификатор рабочей области для работы", "Title": "название рабочего пространства" } } }, - "ToolListPosition": { - "Position": "относительно места вставки целевого элемента (before/after)", - "PositionTitle": "позиция вставки", - "TargetId": "ID целевого элемента для вставки списка инструментов", - "TargetIdTitle": "ID цели" - }, "ToolResultDuration": "Количество ходов в диалоге, в течение которых результаты выполнения инструмента остаются видимыми; после этого результаты будут отображаться серым цветом.", "ToolResultDurationTitle": "количество раундов устойчивого результата инструмента" }, @@ -508,12 +582,6 @@ }, "UpdateEmbeddings": { "Description": "Сгенерировать или обновить векторный индекс вложений для рабочей области Wiki, предназначенный для семантического поиска.", - "Parameters": { - "forceUpdate": { - }, - "workspaceName": { - } - }, "forceUpdate": { "Description": "Принудительно ли пересоздавать индекс вложений, перезаписывая существующие данные (если установлено значение true, инкрементные обновления игнорируются).", "Title": "принудительное обновление" @@ -524,15 +592,16 @@ } } }, - "ToolListPosition": { - "Position": "Позиция вставки относительно целевой позиции", - "PositionTitle": "позиция вставки", - "TargetId": "ID целевого элемента, список инструментов будет вставлен относительно этого элемента.", - "TargetIdTitle": "ID цели" - }, - "ToolListPositionTitle": "Расположение списка инструментов", "ToolResultDuration": "Количество ходов, в течение которых результаты выполнения инструмента остаются видимыми в диалоге; после превышения этого количества результаты будут отображаться серым цветом.", "ToolResultDurationTitle": "количество раундов устойчивого результата инструмента" + }, + "WorkspacesList": { + "Description": "Внедрить список доступных рабочих областей Wiki в подсказку", + "Position": "Позиция вставки: before — перед, after — после.", + "PositionTitle": "позиция вставки", + "TargetId": "ID целевого подсказочного слова, список будет вставлен относительно этого подсказочного слова.", + "TargetIdTitle": "ID цели", + "Title": "Список рабочих областей" } }, "Search": { @@ -558,7 +627,10 @@ } }, "Tool": { - "Plugin": { + "Git": { + "Error": { + "WorkspaceNotFound": "Название или идентификатор рабочей области \"{{workspaceName}}\" не существует" + } }, "Schema": { "Description": "описание", @@ -567,12 +639,19 @@ "Parameters": "параметр", "Required": "необходимый" }, + "TiddlyWikiPlugin": { + "Error": { + "PluginTitleRequired": "Заголовок плагина не может быть пустым.", + "WorkspaceNotFound": "Название или идентификатор рабочей области \"{{workspaceNameOrID}}\" не существует." + } + }, "WikiOperation": { "Error": { "WorkspaceNotExist": "Рабочее пространство {{workspaceID}} не существует", "WorkspaceNotFound": "Название или идентификатор рабочего пространства \"{{workspaceName}}\" не существует. Доступные рабочие пространства: {{availableWorkspaces}}" }, "Success": { + "ActionInvoked": "Действие \"{{actionTitle}}\" успешно выполнено в рабочей области Вики \"{{workspaceName}}\".", "Added": "Успешно добавлен Tiddler \"{{title}}\" в рабочее пространство Wiki \"{{workspaceName}}\".", "Deleted": "Успешно удален Tiddler \"{{title}}\" из рабочего пространства Wiki \"{{workspaceName}}\".", "Updated": "Текст Tiddler \"{{title}}\" успешно установлен в рабочем пространстве Wiki \"{{workspaceName}}\"." diff --git a/localization/locales/ru/translation.json b/localization/locales/ru/translation.json index 8cfef0a6..b183e914 100644 --- a/localization/locales/ru/translation.json +++ b/localization/locales/ru/translation.json @@ -71,6 +71,7 @@ "TagName": "Имя тега", "TagNameHelp": "Тидлеры с этим тегом будут добавлены в эту под-Wiki (вы можете добавить или изменить этот тег позже, щелкнув правой кнопкой мыши значок рабочего пространства и выбрав Настроить рабочее пространство)", "TagNameHelpForMain": "Новые записи с этой меткой будут сохраняться в первую очередь в этой рабочей области.", + "TagNameInputWarning": "⚠️ Нажмите Enter для подтверждения ввода метки", "ThisPathIsNotAWikiFolder": "Каталог не является папкой Wiki \"{{wikiPath}}\"", "UseFilter": "использовать фильтр", "UseFilterHelp": "Используйте выражения фильтров вместо меток для сопоставления записей и определения, следует ли сохранять их в текущей рабочей области.", @@ -217,6 +218,9 @@ "WikiRootTiddler": "Корневой тидлер Wiki", "WikiRootTiddlerDescription": "Корневой тидлер Wiki определяет основное поведение системы, пожалуйста, прочитайте официальную документацию, чтобы понять перед изменением", "WikiRootTiddlerItems": { + "all": "Загрузить все сразу", + "lazy-images": "Загружать изображения по требованию", + "lazy-all": "Загружать изображения и текст по требованию" } }, "Error": { @@ -259,7 +263,6 @@ "AndMoreFiles": "Осталось {{count}} файлов", "Author": "автор", "BinaryFileCannotDisplay": "Двоичный файл не может быть отображен как текст.", - "ClearSearch": "Очистить поиск", "Committing": "Отправка...", "ContentView": "содержание файла", "CopyFilePath": "скопировать путь к файлу", @@ -278,7 +281,6 @@ "FailedToLoadDiff": "Не удалось загрузить различия", "FileNameSearch": "имя файла", "FileSearchPlaceholder": "Например: pages, *.tsx, config.json", - "Files": "файл", "FilesChanged": "измененные файлы", "FilesChanged_other": "{{count}} файлов изменено", "Hash": "хэш-значение", @@ -564,8 +566,6 @@ "Save": "сохранить", "Schema": { "ProviderModel": { - "FreeModel": "бесплатная небольшая модель для генерации заголовков сводок и резервных заголовков", - "FreeModelTitle": "бесплатная модель" } }, "Scripting": { diff --git a/localization/locales/zh-Hans/agent.json b/localization/locales/zh-Hans/agent.json index 60f9987d..75eb902c 100644 --- a/localization/locales/zh-Hans/agent.json +++ b/localization/locales/zh-Hans/agent.json @@ -40,9 +40,7 @@ "Title": "配置问题" }, "InputPlaceholder": "输入消息,Ctrl+Enter 发送", - "Send": "发送", - "SessionGroup": { - } + "Send": "发送" }, "Common": { "None": "未选择" @@ -83,8 +81,6 @@ "SelectedTemplate": "已选择模板", "SetupAgent": "设置智能体", "SetupAgentDescription": "为您的智能体命名并选择一个模板作为起点", - "Steps": { - }, "Title": "创建新智能体" }, "EditAgent": { @@ -106,8 +102,6 @@ "PreviewChat": "预览聊天", "Save": "保存", "Saving": "保存中...", - "Steps": { - }, "Title": "编辑智能体定义" }, "ModelFeature": { @@ -228,6 +222,12 @@ "Prompt": { "AutoRefresh": "预览会随输入文本的变化自动刷新", "CodeEditor": "代码编辑器", + "Edit": "提示词编辑", + "EnterEditSideBySide": "分屏显示编辑", + "EnterFullScreen": "进入全屏", + "EnterPreviewSideBySide": "分屏显示预览", + "ExitFullScreen": "退出全屏", + "ExitSideBySide": "退出分屏", "Flat": "平铺视图", "FormEditor": "表单编辑器", "LastUpdated": "上次更新时间", @@ -241,10 +241,13 @@ }, "PromptConfig": { "AddItem": "添加项目", + "Collapse": "折叠", "EmptyArray": "还没有添加任何项目。点击下面的按钮添加第一个项目。", + "Expand": "展开", "ItemCount": "{{count}} 项", - "RemoveItem": "删除列表项", + "ItemIndex": "第 {{index}} 项", "Tabs": { + "Plugins": "插件", "Prompts": "提示词", "Response": "响应" }, @@ -256,8 +259,20 @@ }, "Schema": { "AIConfig": { + "Default": "默认使用的对话模型", + "DefaultTitle": "默认模型", "Description": "AI 会话设置配置", - "Title": "AI 配置" + "Embedding": "用于向量搜索的嵌入模型", + "EmbeddingTitle": "嵌入模型", + "Free": "成本敏感任务使用的低成本模型", + "FreeTitle": "免费/低成本模型", + "ImageGeneration": "用于图像生成的模型", + "ImageGenerationTitle": "图像生成模型", + "Speech": "用于文本转语音(TTS)的模型", + "SpeechTitle": "语音合成模型", + "Title": "AI 配置", + "Transcriptions": "用于语音转文本(STT)的模型", + "TranscriptionsTitle": "语音转文本模型" }, "AgentConfig": { "Description": "智能体配置", @@ -265,50 +280,91 @@ "IdTitle": "智能体 ID", "PromptConfig": { "Description": "提示词配置", + "Plugins": "插件配置列表", "Prompts": "提示词配置列表", "Response": "响应配置列表", "Title": "提示词配置" }, "Title": "智能体配置" }, - "AutoReroll": { - }, "BaseAPIConfig": { - "API": "API 提供商和模型配置", - "APITitle": "API 配置", - "Description": "基础接口配置", "ModelParameters": "模型参数配置", - "ModelParametersTitle": "模型参数", - "Title": "基础接口配置" + "ModelParametersTitle": "模型参数" + }, + "Common": { + "ToolListPosition": { + "Description": "工具列表在提示词中的插入位置配置", + "Position": "相对于目标位置的插入位置", + "PositionTitle": "插入位置", + "TargetId": "目标元素的ID,工具列表将相对于此元素插入", + "TargetIdTitle": "目标ID" + }, + "ToolListPositionTitle": "工具列表位置" }, "DefaultAgents": { "Description": "默认智能体配置列表", "Title": "默认智能体" }, - "DynamicPosition": { - }, "FullReplacement": { "Description": "完全替换参数配置", "SourceType": "数据来源类型,决定用什么内容来替换目标元素", "SourceTypeTitle": "源类型", - "SourceTypes": { - }, "TargetId": "目标元素ID", "TargetIdTitle": "目标ID", "Title": "完全替换参数" }, - "Function": { - }, - "HandlerConfig": { - }, - "JavascriptTool": { + "Git": { + "Description": "搜索 Git 提交日志和读取特定文件内容", + "Title": "Git 工具", + "Tool": { + "Parameters": { + "commitHash": { + "Description": "提交哈希值", + "Title": "提交哈希" + }, + "filePath": { + "Description": "文件路径(用于文件搜索模式)", + "Title": "文件路径" + }, + "maxLines": { + "Description": "最大行数(默认500)", + "Title": "最大行数" + }, + "page": { + "Description": "结果页码(从1开始)", + "Title": "页码" + }, + "pageSize": { + "Description": "每页结果数量", + "Title": "每页数量" + }, + "searchMode": { + "Description": "搜索模式:按提交信息、文件路径、日期范围或不搜索", + "Title": "搜索模式" + }, + "searchQuery": { + "Description": "搜索查询字符串(消息搜索模式)", + "Title": "搜索查询" + }, + "since": { + "Description": "开始日期(ISO 8601 格式)", + "Title": "开始日期" + }, + "until": { + "Description": "结束日期(ISO 8601 格式)", + "Title": "结束日期" + }, + "workspaceName": { + "Description": "要搜索的工作区名称或ID", + "Title": "工作区名称" + } + } + } }, "MCP": { "Description": "模型上下文协议参数配置", "Id": "MCP 服务器 ID", "IdTitle": "服务器 ID", - "ResponseProcessing": { - }, "TimeoutMessage": "超时消息", "TimeoutMessageTitle": "超时消息", "TimeoutSecond": "超时时间(秒)", @@ -327,29 +383,21 @@ "TopP": "Top P 采样参数", "TopPTitle": "Top P" }, - "Plugin": { - "Caption": "简短描述", - "CaptionTitle": "标题", - "Content": "插件内容或说明", - "ContentTitle": "内容", - "ForbidOverrides": "是否禁止在运行时覆盖此插件的参数", - "ForbidOverridesTitle": "禁止覆盖", - "Id": "插件 ID", - "IdTitle": "ID", - "PluginId": "用于选择具体插件的标识符", - "PluginIdTitle": "插件标识" + "ModelSelection": { + "Description": "选择模型提供商与具体模型名称", + "Model": "模型名称(如 gpt-4o、Qwen2.5-7B-Instruct)", + "ModelTitle": "模型", + "Provider": "模型提供商(如 OpenAI、SiliconFlow、Google 等)", + "ProviderTitle": "提供商", + "Title": "模型选择" }, "Position": { - "Bottom": "自底部偏移几条消息", - "BottomTitle": "底部偏移", "Description": "位置参数配置,用于确定内容插入的精确位置。支持相对位置、绝对位置、前置和后置四种定位方式", "TargetId": "目标元素ID", "TargetIdTitle": "目标ID", "Title": "位置参数", "Type": "位置类型,用于确定内容插入的精确位置。支持相对位置、绝对位置、前置和后置四种定位方式", - "TypeTitle": "位置类型", - "Types": { - } + "TypeTitle": "位置类型" }, "Prompt": { "Caption": "简短描述", @@ -368,66 +416,70 @@ "System": "系统 - 定义AI的行为规则和背景设定", "User": "用户 - 模拟用户的输入和请求" }, + "Source": "此提示词的来源或引用(例如文件路径、URL 等)", + "SourceTitle": "来源", "Tags": "标签列表", "TagsTitle": "标签", "Text": "提示词内容,可以包含维基文本支持的语法,例如<<变量名>>。", "TextTitle": "文本", "Title": "提示词" }, - "PromptDynamicModification": { - "DynamicModificationTypes": { - } - }, - "PromptPart": { - }, - "ProviderModel": { - "Description": "提供商和模型配置", - "EmbeddingModel": "用于语义搜索和向量操作的嵌入模型名称", - "EmbeddingModelTitle": "嵌入模型", - "FreeModel": "用于生成总结标题、生成备份标题时使用的免费小模型", - "FreeModelTitle": "免费模型", - "ImageGenerationModel": "用于文字生成图像操作的图像生成模型名称", - "ImageGenerationModelTitle": "图像生成模型", - "Model": "AI 模型名称", - "ModelTitle": "模型", - "Provider": "AI 提供商名称", - "ProviderTitle": "提供商", - "SpeechModel": "用于文字转语音操作的语音生成模型名称", - "SpeechModelTitle": "语音模型", - "Title": "提供商模型", - "TranscriptionsModel": "用于语音转文字操作的语音识别模型名称", - "TranscriptionsModelTitle": "语音识别模型" - }, - "RAG": { - "Removal": { - }, - "SourceTypes": { - } - }, "Response": { + "Caption": "响应标题,用于标识该响应条目", + "CaptionTitle": "标题", "Description": "外部API的响应,通常作为响应动态修改的目标,结构与提示词的一样,可以填写预置内容,也可以作为占位符或容器,由 ResponseDynamicModification 填入外部API的响应的具体内容。", + "Id": "响应配置的唯一标识符,便于引用", + "IdTitle": "ID", "Title": "响应" }, - "ResponseDynamicModification": { - "DynamicModificationTypes": { + "TiddlyWikiPlugin": { + "ActionsTag": { + "Description": "标记 Action Tiddler 的标签,AI 可以通过这个标签找到可执行的动作", + "Title": "Actions 标签" }, - "ResponseProcessingTypes": { + "DataSourceTag": { + "Description": "标记数据源条目的标签,AI 可以通过这个标签了解数据的获取和筛选方法", + "Title": "DataSource 标签" + }, + "DescribeTag": { + "Description": "标记简短描述的标签,这些描述会自动加载到提示词中,帮助 AI 理解可用功能", + "Title": "Describe 标签" + }, + "Description": "加载特定插件的数据源和动作说明。系统会自动加载 Describe 标签的条目作为背景信息。", + "EnableCache": { + "Description": "启用缓存以避免每次生成提示词时重新查询 wiki。禁用缓存时将清除并重新加载描述内容。", + "Title": "启用缓存" + }, + "Title": "TiddlyWiki 插件", + "Tool": { + "Parameters": { + "pluginTitle": { + "Description": "要加载详情的插件标题。系统会搜索包含该标题的 DataSource 和 Actions 条目。", + "Title": "插件标题" + } + } + }, + "WorkspaceNameOrID": { + "Description": "要加载插件信息的工作区名称或ID(默认为 'wiki')", + "Title": "工作区名称或ID" } }, - "ToolCalling": { - }, - "Trigger": { - "Model": { - } - }, - "Wiki": { + "Tool": { + "Caption": "简短描述(用于 UI 展示)", + "CaptionTitle": "标题", + "Content": "插件内容或说明", + "ContentTitle": "内容", + "ForbidOverrides": "是否禁止在运行时覆盖此插件的参数", + "ForbidOverridesTitle": "禁止覆盖", + "Id": "插件实例 ID(同一 handler 内唯一)", + "IdTitle": "插件实例 ID", + "ToolId": "选择要使用的工具类型", + "ToolIdTitle": "工具类型" }, "WikiOperation": { "Description": "在维基工作区中执行 条目操作(添加、删除或设置文本)", "Title": "Wiki 操作", "Tool": { - "Examples": { - }, "Parameters": { "extraMeta": { "Description": "额外元数据的 JSON 字符串,如标签和字段,默认为 \"{}\"", @@ -449,18 +501,16 @@ "Description": "条目的标题", "Title": "条目标题" }, + "variables": { + "Description": "传递给 action tiddler 的变量,JSON 格式字符串,默认为 \"{}\"", + "Title": "变量" + }, "workspaceName": { "Description": "要操作的工作区名称或ID", "Title": "工作区名称" } } }, - "ToolListPosition": { - "Position": "相对于目标元素的插入位置(before/after)", - "PositionTitle": "插入位置", - "TargetId": "要插入工具列表的目标元素的ID", - "TargetIdTitle": "目标ID" - }, "ToolResultDuration": "工具执行结果在对话中保持可见的轮数,超过此轮数后结果将变灰显示", "ToolResultDurationTitle": "工具结果持续轮数" }, @@ -499,12 +549,6 @@ }, "UpdateEmbeddings": { "Description": "为Wiki工作区生成或更新向量嵌入索引,用于语义搜索", - "Parameters": { - "forceUpdate": { - }, - "workspaceName": { - } - }, "forceUpdate": { "Description": "是否强制重新生成嵌入索引,覆盖已有的嵌入数据(如果设置为 true 则忽略增量更新)。", "Title": "强制更新" @@ -515,15 +559,16 @@ } } }, - "ToolListPosition": { - "Position": "相对于目标位置的插入位置", - "PositionTitle": "插入位置", - "TargetId": "目标元素的ID,工具列表将相对于此元素插入", - "TargetIdTitle": "目标ID" - }, - "ToolListPositionTitle": "工具列表位置", "ToolResultDuration": "工具执行结果在对话中保持可见的轮数,超过此轮数后结果将变灰显示", "ToolResultDurationTitle": "工具结果持续轮数" + }, + "WorkspacesList": { + "Description": "将可用的 Wiki 工作区列表注入到提示词中", + "Position": "插入位置:before 为前置,after 为后置", + "PositionTitle": "插入位置", + "TargetId": "目标提示词的 ID,列表将相对于此提示词插入", + "TargetIdTitle": "目标 ID", + "Title": "工作区列表" } }, "Search": { @@ -549,7 +594,10 @@ } }, "Tool": { - "Plugin": { + "Git": { + "Error": { + "WorkspaceNotFound": "工作区名称或ID\"{{workspaceName}}\"不存在" + } }, "Schema": { "Description": "描述", @@ -558,12 +606,19 @@ "Parameters": "参数", "Required": "必需" }, + "TiddlyWikiPlugin": { + "Error": { + "PluginTitleRequired": "插件标题不能为空", + "WorkspaceNotFound": "工作区名称或ID\"{{workspaceNameOrID}}\"不存在" + } + }, "WikiOperation": { "Error": { "WorkspaceNotExist": "工作区{{workspaceID}}不存在", "WorkspaceNotFound": "工作区名称或ID\"{{workspaceName}}\"不存在。可用工作区:{{availableWorkspaces}}" }, "Success": { + "ActionInvoked": "成功在维基工作区\"{{workspaceName}}\"中执行了动作条目\"{{actionTitle}}\"", "Added": "成功在维基工作区\"{{workspaceName}}\"中添加了条目\"{{title}}\"", "Deleted": "成功从维基工作区\"{{workspaceName}}\"中删除了条目\"{{title}}\"", "Updated": "成功在维基工作区\"{{workspaceName}}\"中设置了条目\"{{title}}\"的文本" diff --git a/localization/locales/zh-Hans/translation.json b/localization/locales/zh-Hans/translation.json index 802fbf30..9acb31c7 100644 --- a/localization/locales/zh-Hans/translation.json +++ b/localization/locales/zh-Hans/translation.json @@ -19,6 +19,8 @@ "CustomServerUrlDescription": "OAuth 服务器的基础 URL(例如:http://127.0.0.1:8888)", "ExistedWikiLocation": "现有的知识库的位置", "ExtractedWikiFolderName": "转换后的知识库文件夹名称", + "FilterExpression": "筛选器表达式", + "FilterExpressionHelp": "每行一个TiddlyWiki筛选器表达式,任一匹配即存入此工作区。例如 [in-tagtree-of[Calendar]!tag[Public]]", "GitBranch": "Git 分支", "GitBranchDescription": "要使用的 Git 分支(默认:main)", "GitDefaultBranchDescription": "你的Git的默认分支,Github在黑命贵事件后将其从master改为了main", @@ -30,6 +32,9 @@ "GitUserName": "Git 用户名", "GitUserNameDescription": "用于登录Git的账户名,注意是你的仓库网址中你的名字部分", "ImportWiki": "导入知识库: ", + "IncludeTagTree": "包括整个标签树", + "IncludeTagTreeHelp": "勾选后,只要标签的标签的标签(任意级……)是这个,就会被划分到此子工作区里", + "IncludeTagTreeHelpForMain": "勾选后,只要标签的标签的标签(任意级……)是这个,就会被划分到此工作区里", "LocalWikiHtml": "HTML文件的路径", "LocalWorkspace": "本地知识库", "LocalWorkspaceDescription": "仅在本地使用,完全掌控自己的数据。太记会为你创建一个本地的 git 备份系统,让你可以回退到之前的版本,但当文件夹被删除时所有内容还是会丢失。", @@ -56,6 +61,9 @@ "SubWikiCreationCompleted": "子知识库创建完毕", "SubWorkspace": "子知识库", "SubWorkspaceDescription": "必须依附于一个主知识库,可用于存放私有内容。注意两点:子知识库不能放在主知识库文件夹内;子知识库一般用于同步数据到一个私有的Github仓库内,仅本人可读写,故仓库地址不能与主知识库一样。\n子知识库通过创建一个到主知识库的软链接(快捷方式)来生效,创建链接后主知识库内便可看到子知识库内的内容了。", + "SubWorkspaceOptions": "子工作区设置", + "SubWorkspaceOptionsDescriptionForMain": "配置此主工作区优先保存哪些条目。设置标签后,带有此标签的新条目会优先保存在主工作区,而非子工作区。匹配顺序按侧边栏从上到下,先匹配到的工作区优先", + "SubWorkspaceOptionsDescriptionForSub": "配置此子工作区保存哪些条目。带有指定标签的新条目将被保存到此子工作区。匹配顺序按侧边栏从上到下,先匹配到的工作区优先", "SubWorkspaceWillLinkTo": "子知识库将链接到", "SwitchCreateNewOrOpenExisted": "切换创建新的还是打开现有的知识库", "SyncedWorkspace": "云端同步知识库", @@ -63,17 +71,10 @@ "TagName": "标签名", "TagNameHelp": "加上这些标签之一的笔记将会自动被放入这个子知识库内(可先不填,之后右键点击这个工作区的图标选择编辑工作区修改)", "TagNameHelpForMain": "带有这些标签的新条目将优先保存在此工作区", - "IncludeTagTree": "包括整个标签树", - "IncludeTagTreeHelp": "勾选后,只要标签的标签的标签(任意级……)是这个,就会被划分到此子工作区里", - "IncludeTagTreeHelpForMain": "勾选后,只要标签的标签的标签(任意级……)是这个,就会被划分到此工作区里", + "TagNameInputWarning": "⚠️ 请按回车确认标签输入", + "ThisPathIsNotAWikiFolder": "该目录不是一个知识库文件夹 \"{{wikiPath}}\"", "UseFilter": "使用筛选器", "UseFilterHelp": "用筛选器表达式而不是标签来匹配条目,决定是否存入当前工作区", - "FilterExpression": "筛选器表达式", - "FilterExpressionHelp": "每行一个TiddlyWiki筛选器表达式,任一匹配即存入此工作区。例如 [in-tagtree-of[Calendar]!tag[Public]]", - "SubWorkspaceOptions": "子工作区设置", - "SubWorkspaceOptionsDescriptionForMain": "配置此主工作区优先保存哪些条目。设置标签后,带有此标签的新条目会优先保存在主工作区,而非子工作区。匹配顺序按侧边栏从上到下,先匹配到的工作区优先", - "SubWorkspaceOptionsDescriptionForSub": "配置此子工作区保存哪些条目。带有指定标签的新条目将被保存到此子工作区。匹配顺序按侧边栏从上到下,先匹配到的工作区优先", - "ThisPathIsNotAWikiFolder": "该目录不是一个知识库文件夹 \"{{wikiPath}}\"", "WaitForLogin": "等待登录", "WikiExisted": "知识库已经存在于该位置 \"{{newWikiPath}}\"", "WikiNotStarted": "知识库 页面未成功启动或未成功加载", @@ -166,8 +167,6 @@ "EnableHTTPSDescription": "提供安全的TLS加密访问,需要你有自己的HTTPS证书,可以从域名提供商那下载,也可以搜索免费的HTTPS证书申请方式。", "ExcludedPlugins": "需忽略的插件", "ExcludedPluginsDescription": "在只读模式启动知识库作为博客时,你可能希望不加载一些编辑相关的插件以减小初次加载的网页大小,例如 $:/plugins/tiddlywiki/codemirror 等,毕竟加载的博客不需要这些编辑功能。", - "IgnoreSymlinks": "忽略符号链接", - "IgnoreSymlinksDescription": "符号链接类似快捷方式,旧版曾用它实现子工作区功能,但新版已经不再需要。你可以手动删除遗留的符号链接。", "Generate": "生成", "HTTPSCertPath": "Cert文件路径", "HTTPSCertPathDescription": "后缀为 .crt 的证书文件的所在位置,一般以 xxx_public.crt 结尾。", @@ -179,6 +178,8 @@ "HTTPSUploadKey": "添加Key文件", "HibernateDescription": "在工作区未使用时休眠以节省 CPU 和内存消耗并省电,这会关闭所有自动同步功能,需要手动同步备份数据。", "HibernateTitle": "开启休眠", + "IgnoreSymlinks": "忽略符号链接", + "IgnoreSymlinksDescription": "符号链接类似快捷方式,旧版曾用它实现子工作区功能,但新版已经不再需要。你可以手动删除遗留的符号链接。", "IsSubWorkspace": "是子工作区", "LastNodeJSArgv": "最近一次启动的命令行参数", "LastVisitState": "上次访问的页面", @@ -217,6 +218,9 @@ "WikiRootTiddler": "知识库根条目", "WikiRootTiddlerDescription": "知识库的根条目(root-tiddler)决定了系统的核心行为,修改前请阅读官方文档来了解", "WikiRootTiddlerItems": { + "all": "全量加载", + "lazy-images": "按需加载图片", + "lazy-all": "按需加载图片和文本" } }, "Error": { @@ -259,7 +263,6 @@ "AndMoreFiles": "还有 {{count}} 个文件", "Author": "作者", "BinaryFileCannotDisplay": "二进制文件无法显示为文本", - "ClearSearch": "清除搜索", "Committing": "提交中...", "ContentView": "文件内容", "CopyFilePath": "复制文件路径", @@ -278,7 +281,6 @@ "FailedToLoadDiff": "加载差异失败", "FileNameSearch": "文件名", "FileSearchPlaceholder": "例如:pages、*.tsx、config.json", - "Files": "文件", "FilesChanged": "变更了 {{count}} 个文件", "FilesChanged_other": "{{count}} 个文件有改动", "Hash": "哈希值", @@ -296,19 +298,19 @@ "NewImage": "新图片(本次提交添加)", "NoCommits": "没有提交记录", "NoFilesChanged": "没有文件变更", - "SearchCommits": "搜索提交记录...", - "SearchMode": "搜索模式", - "StartDate": "开始日期", "OpenInExternalEditor": "在外部编辑器中打开", "OpenInGitHub": "在 GitHub 中打开", "OpenWithDefaultProgram": "用默认程序打开", "PreviousVersion": "修改前版本", "RevertCommit": "回退此提交", "Reverting": "回退中...", + "SearchCommits": "搜索提交记录...", + "SearchMode": "搜索模式", "SelectCommit": "选择一个提交来查看详情", "SelectFileToViewDiff": "选择一个文件以查看差异", "ShowFull": "展开全部", "ShowInExplorer": "在资源管理器中显示", + "StartDate": "开始日期", "Title": "Git 历史记录", "UnknownDate": "未知日期", "WarningMessage": "注意:签出和回退操作会修改工作区文件,请谨慎操作。" @@ -443,18 +445,22 @@ "AIGenerateBackupTitleDescription": "使用人工智能根据变更内容自动生成 Git 备份标题,默认开启", "AIGenerateBackupTitleTimeout": "AI 生成备份标题超时时间", "AIGenerateBackupTitleTimeoutDescription": "等待 AI 生成标题的最长时间,超时后将使用默认标题", + "AddNewProvider": "添加新提供商", "AlwaysOnTop": "保持窗口在其他窗口上方", "AlwaysOnTopDetail": "让太记的主窗口永远保持在其它窗口上方,不会被其他窗口覆盖", "AntiAntiLeech": "有的网站做了防盗链,会阻止某些图片在你的知识库上显示,我们通过模拟访问该网站的请求头来绕过这种限制。", "AskDownloadLocation": "下载前询问每个文件的保存位置", "AttachToTaskbar": "附加到任务栏", "AttachToTaskbarShowSidebar": "附加到任务栏的窗口包含侧边栏", + "BaseURLRequired": "基础 URL 是必填项", + "CancelAddProvider": "取消添加", "ChooseLanguage": "选择语言 Choose Language", "ClearBrowsingData": "清空浏览器数据(不影响Git内容)", "ClearBrowsingDataDescription": "清除Cookie、缓存等", "ClearBrowsingDataMessage": "你确定吗?所有浏览数据将被清除。此操作无法撤消。", "ConfirmDelete": "确认删除", "ConfirmDeleteExternalApiDatabase": "确定要删除包含外部 API Debug 信息的数据库吗?此操作无法撤销。", + "ConfirmDeleteProvider": "确认删除提供商 {{providerName}}?", "DarkTheme": "黑暗主题", "DefaultUserName": "默认编辑者名", "DefaultUserNameDetail": "在知识库中默认使用的编辑者名,将在创建或编辑条目时填入 creator 字段。可以被工作区内设置的编辑者名覆盖。", @@ -467,6 +473,13 @@ "DownloadLocation": "下载位置", "Downloads": "下载", "ExternalApiDatabaseDescription": "包含外部 API Debug 信息的数据库,占用空间为 {{size}}", + "FailedToAddModel": "模型添加失败", + "FailedToAddProvider": "提供商添加失败", + "FailedToDeleteProvider": "删除提供商 {{providerName}} 失败", + "FailedToRemoveModel": "移除模型失败", + "FailedToSaveSettings": "保存设置失败", + "FailedToUpdateModel": "模型更新失败", + "FailedToUpdateProviderStatus": "更新提供商状态失败", "FriendLinks": "友链", "General": "界面和交互", "HibernateAllUnusedWorkspaces": "在程序启动时休眠所有未使用的工作区", @@ -484,9 +497,15 @@ "LightTheme": "亮色主题", "Logout": "登出", "Miscellaneous": "其他设置", + "ModelAddedSuccessfully": "模型已成功添加", + "ModelAlreadyExists": "模型已存在", + "ModelNameRequired": "模型名称是必填项", + "ModelRemovedSuccessfully": "模型已成功移除", + "ModelUpdatedSuccessfully": "模型已成功更新", "MoreWorkspaceSyncSettings": "更多工作区同步设置", "MoreWorkspaceSyncSettingsDescription": "请右键工作区图标,点右键菜单里的「编辑工作区」来打开工作区设置,在里面配各个工作区的同步设置。", "Network": "网络", + "NoProvidersAvailable": "没有可用的提供商", "Notifications": "通知", "NotificationsDetail": "设置通知暂停时间", "NotificationsDisableSchedule": "按时间自动禁用通知:", @@ -501,6 +520,14 @@ "OpenV8CacheFolderDetail": "V8缓存文件夹存有加速应用启动的快取文件", "Performance": "性能", "PrivacyAndSecurity": "隐私和安全", + "ProviderAddedSuccessfully": "提供商已成功添加", + "ProviderAlreadyExists": "提供商已存在", + "ProviderConfiguration": "提供商配置", + "ProviderConfigurationDescription": "管理AI提供商和模型", + "ProviderDeleted": "提供商 {{providerName}} 已删除", + "ProviderDisabled": "提供商已禁用", + "ProviderEnabled": "提供商已启用", + "ProviderNameRequired": "提供商名称是必填项", "ReceivePreReleaseUpdates": "接收预发布更新", "RememberLastVisitState": "记住上次访问的页面,恢复打开时的上次访问状态", "RequireRestart": "需要重启", @@ -526,6 +553,7 @@ "SearchNoWorkspaces": "未找到工作区", "Seconds": "秒", "SelectWorkspace": "选择工作区", + "SettingsSaved": "设置已保存", "ShareBrowsingData": "在工作区之间共享浏览器数据(cookies、缓存等),关闭后可以每个工作区登不同的第三方服务账号。", "ShowSideBar": "显示侧边栏", "ShowSideBarDetail": "侧边栏让你可以在工作区之间快速切换", diff --git a/localization/locales/zh-Hant/agent.json b/localization/locales/zh-Hant/agent.json index 61bbdca9..b0751029 100644 --- a/localization/locales/zh-Hant/agent.json +++ b/localization/locales/zh-Hant/agent.json @@ -40,9 +40,7 @@ "Title": "配置問題" }, "InputPlaceholder": "輸入消息,Ctrl+Enter 發送", - "Send": "發送", - "SessionGroup": { - } + "Send": "發送" }, "Common": { "None": "未選擇" @@ -83,8 +81,6 @@ "SelectedTemplate": "已選擇模板", "SetupAgent": "設置智慧體", "SetupAgentDescription": "為您的智慧體命名並選擇一個模板作為起點", - "Steps": { - }, "Title": "創建新智慧體" }, "EditAgent": { @@ -106,8 +102,6 @@ "PreviewChat": "預覽聊天", "Save": "保存", "Saving": "保存中...", - "Steps": { - }, "Title": "編輯智慧體定義" }, "ModelFeature": { @@ -174,6 +168,7 @@ "ExternalApiDatabaseDescription": "包含外部介面Debug 資訊的資料庫,占用空間為 {{size}}", "FailedToAddModel": "無法添加模型", "FailedToAddProvider": "添加提供商失敗", + "FailedToDeleteProvider": "刪除提供商 {{providerName}} 失敗", "FailedToRemoveModel": "無法刪除模型", "FailedToSaveSettings": "無法保存設置", "FailedToUpdateModel": "無法更新模型", @@ -203,6 +198,7 @@ "ProviderClass": "提供商介面類型", "ProviderConfiguration": "提供商配置", "ProviderConfigurationDescription": "配置人工智慧提供商的介面金鑰和其他設置", + "ProviderDeleted": "提供商 {{providerName}} 已刪除", "ProviderDisabled": "提供方已禁用", "ProviderEnabled": "提供方已啟用", "ProviderName": "提供商名稱", @@ -226,6 +222,12 @@ "Prompt": { "AutoRefresh": "預覽會隨輸入文本的變化自動刷新", "CodeEditor": "代碼編輯器", + "Edit": "提示詞編輯", + "EnterEditSideBySide": "分屏顯示編輯", + "EnterFullScreen": "進入全螢幕", + "EnterPreviewSideBySide": "分屏顯示預覽", + "ExitFullScreen": "退出全螢幕", + "ExitSideBySide": "退出分屏", "Flat": "平鋪視圖", "FormEditor": "表單編輯器", "LastUpdated": "上次更新時間", @@ -239,10 +241,13 @@ }, "PromptConfig": { "AddItem": "添加項目", + "Collapse": "折疊", "EmptyArray": "還沒有添加任何項目。點擊下面的按鈕添加第一個項目。", + "Expand": "展開", "ItemCount": "{{count}} 項", - "RemoveItem": "刪除列表項", + "ItemIndex": "第 {{index}} 項", "Tabs": { + "Plugins": "外掛程式", "Prompts": "提示詞", "Response": "響應" }, @@ -254,8 +259,20 @@ }, "Schema": { "AIConfig": { + "Default": "預設使用的對話模型", + "DefaultTitle": "預設模型", "Description": "AI 會話設置配置", - "Title": "AI 配置" + "Embedding": "用於向量搜索的嵌入模型", + "EmbeddingTitle": "嵌入模型", + "Free": "成本敏感任務使用的低成本模型", + "FreeTitle": "免費/低成本模型", + "ImageGeneration": "用於圖像生成的模型", + "ImageGenerationTitle": "圖像生成模型", + "Speech": "用於文本轉語音(TTS)的模型", + "SpeechTitle": "語音合成模型", + "Title": "AI 配置", + "Transcriptions": "用於語音轉文本(STT)的模型", + "TranscriptionsTitle": "語音轉文本模型" }, "AgentConfig": { "Description": "智慧體配置", @@ -263,50 +280,103 @@ "IdTitle": "智慧體 ID", "PromptConfig": { "Description": "提示詞配置", + "Plugins": "外掛配置清單", "Prompts": "提示詞配置列表", "Response": "響應配置列表", "Title": "提示詞配置" }, "Title": "智慧體配置" }, - "AutoReroll": { - }, "BaseAPIConfig": { - "API": "API 提供商和模型配置", - "APITitle": "API 配置", - "Description": "基礎介面配置", "ModelParameters": "模型參數配置", - "ModelParametersTitle": "模型參數", - "Title": "基礎介面配置" + "ModelParametersTitle": "模型參數" + }, + "Common": { + "ToolListPosition": { + "Description": "工具列表在提示詞中的插入位置配置", + "Position": "相對於目標位置的插入位置", + "PositionTitle": "插入位置", + "TargetId": "目標元素的ID,工具列表將相對於此元素插入", + "TargetIdTitle": "目標ID" + }, + "ToolListPositionTitle": "工具列表位置" }, "DefaultAgents": { "Description": "默認智慧體配置列表", "Title": "默認智慧體" }, - "DynamicPosition": { - }, "FullReplacement": { "Description": "完全替換參數配置", "SourceType": "數據來源類型,決定用什麼內容來替換目標元素", "SourceTypeTitle": "源類型", - "SourceTypes": { - }, "TargetId": "目標元素ID", "TargetIdTitle": "目標ID", "Title": "完全替換參數" }, - "Function": { - }, - "HandlerConfig": { - }, - "JavascriptTool": { + "Git": { + "Description": "搜尋 Git 提交日誌和讀取特定檔案內容", + "Title": "Git 工具", + "Tool": { + "Parameters": { + "commitHash": { + "Description": "提交雜湊值", + "Title": "提交雜湊" + }, + "filePath": { + "Description": "檔案路徑(用於檔案搜尋模式)", + "Title": "檔案路徑" + }, + "maxLines": { + "Description": "最大行數(預設500)", + "Title": "最大行數" + }, + "page": { + "Description": "結果頁碼(從1開始)", + "Title": "頁碼" + }, + "pageSize": { + "Description": "每頁結果數量", + "Title": "每頁數量" + }, + "searchMode": { + "Description": "搜尋模式:依提交資訊、檔案路徑、日期範圍或不搜尋", + "Title": "搜尋模式" + }, + "searchQuery": { + "Description": "搜尋查詢字串(訊息搜尋模式)", + "Title": "搜尋查詢" + }, + "since": { + "Description": "開始日期(ISO 8601 格式)", + "Title": "開始日期" + }, + "until": { + "Description": "結束日期(ISO 8601 格式)", + "Title": "結束日期" + }, + "workspaceName": { + "Description": "要搜尋的工作區名稱或ID", + "Title": "工作區名稱" + } + }, + "ReadFile": { + "Parameters": { + "commitHash": { + }, + "filePath": { + }, + "maxLines": { + }, + "workspaceName": { + } + } + } + } }, "MCP": { "Description": "模型上下文協議參數配置", "Id": "MCP 伺服器 ID", "IdTitle": "伺服器 ID", - "ResponseProcessing": { - }, "TimeoutMessage": "超時消息", "TimeoutMessageTitle": "超時消息", "TimeoutSecond": "超時時間(秒)", @@ -325,29 +395,23 @@ "TopP": "Top P 採樣參數", "TopPTitle": "Top P" }, + "ModelSelection": { + "Description": "選擇模型提供商與具體模型名稱", + "Model": "模型名稱(如 gpt-4o、Qwen2.5-7B-Instruct)", + "ModelTitle": "模型", + "Provider": "模型供應商(如 OpenAI、SiliconFlow、Google 等)", + "ProviderTitle": "供應商", + "Title": "模型選擇" + }, "Plugin": { - "Caption": "簡短描述", - "CaptionTitle": "標題", - "Content": "外掛內容或說明", - "ContentTitle": "內容", - "ForbidOverrides": "是否禁止在執行時覆蓋此插件的參數", - "ForbidOverridesTitle": "禁止覆蓋", - "Id": "外掛 ID", - "IdTitle": "ID", - "PluginId": "用於選擇具體插件的識別符", - "PluginIdTitle": "插件標識" }, "Position": { - "Bottom": "自底部偏移幾條消息", - "BottomTitle": "底部偏移", "Description": "位置參數配置,用於確定內容插入的精確位置。支持相對位置、絕對位置、前置和後置四種定位方式", "TargetId": "目標元素ID", "TargetIdTitle": "目標ID", "Title": "位置參數", "Type": "位置類型,用於確定內容插入的精確位置。支持相對位置、絕對位置、前置和後置四種定位方式", - "TypeTitle": "位置類型", - "Types": { - } + "TypeTitle": "位置類型" }, "Prompt": { "Caption": "簡短描述", @@ -366,64 +430,72 @@ "System": "系統 - 定義AI的行為規則和背景設定", "User": "用戶 - 模擬用戶的輸入和請求" }, + "Source": "此提示詞的來源或引用(例如檔案路徑、URL 等)", + "SourceTitle": "來源", "Tags": "標籤列表", "TagsTitle": "標籤", "Text": "提示詞內容,可以包含維基文本支持的語法,例如<<變數名>>。", "TextTitle": "文本", "Title": "提示詞" }, - "PromptDynamicModification": { - "DynamicModificationTypes": { - } - }, - "PromptPart": { - }, "ProviderModel": { - "Description": "提供商和模型配置", - "EmbeddingModel": "用於語義搜索和向量操作的嵌入模型名稱", - "EmbeddingModelTitle": "嵌入模型", - "ImageGenerationModel": "用於文字生成圖像操作的圖像生成模型名稱", - "ImageGenerationModelTitle": "圖像生成模型", - "Model": "AI 模型名稱", - "ModelTitle": "模型", - "Provider": "AI 提供商名稱", - "ProviderTitle": "提供商", - "SpeechModel": "用於文字轉語音操作的語音生成模型名稱", - "SpeechModelTitle": "語音模型", - "Title": "提供商模型", - "TranscriptionsModel": "用於語音轉文字操作的語音識別模型名稱", - "TranscriptionsModelTitle": "語音識別模型" - }, - "RAG": { - "Removal": { - }, - "SourceTypes": { - } }, "Response": { + "Caption": "回應標題,用於識別該回應條目", + "CaptionTitle": "標題", "Description": "外部API的響應,通常作為響應動態修改的目標,結構與提示詞的一樣,可以填寫預置內容,也可以作為占位符或容器,由 ResponseDynamicModification 填入外部API的響應的具體內容。", + "Id": "回應配置的唯一識別符,便於引用", + "IdTitle": "ID", "Title": "響應" }, - "ResponseDynamicModification": { - "DynamicModificationTypes": { + "TiddlyWikiPlugin": { + "ActionsTag": { + "Description": "標記 Action Tiddler 的標籤,AI 可以通過這個標籤找到可執行的動作", + "Title": "Actions 標籤" }, - "ResponseProcessingTypes": { + "DataSourceTag": { + "Description": "標記數據源條目的標籤,AI 可以通過這個標籤了解數據的獲取和篩選方法", + "Title": "DataSource 標籤" + }, + "DescribeTag": { + "Description": "標記簡短描述的標籤,這些描述會自動載入到提示詞中,幫助 AI 理解可用功能", + "Title": "描述 標籤" + }, + "Description": "載入特定插件的數據源和動作說明。系統會自動載入 Describe 標籤的條目作為背景資訊。", + "EnableCache": { + "Description": "啟用快取以避免每次生成提示詞時重新查詢 wiki。停用快取時將清除並重新載入描述內容。", + "Title": "啟用快取" + }, + "Title": "TiddlyWiki 插件", + "Tool": { + "Parameters": { + "pluginTitle": { + "Description": "要載入詳情的插件標題。系統會搜尋包含該標題的 DataSource 和 Actions 條目。", + "Title": "外掛程式標題" + } + } + }, + "WorkspaceNameOrID": { + "Description": "要載入插件資訊的工作區名稱或ID(預設為 'wiki')", + "Title": "工作區名稱或ID" } }, - "ToolCalling": { - }, - "Trigger": { - "Model": { - } - }, - "Wiki": { + "Tool": { + "Caption": "簡短描述(用於 UI 展示)", + "CaptionTitle": "標題", + "Content": "外掛內容或說明", + "ContentTitle": "內容", + "ForbidOverrides": "是否禁止在執行時覆蓋此插件的參數", + "ForbidOverridesTitle": "禁止覆蓋", + "Id": "外掛實例 ID(同一 handler 內唯一)", + "IdTitle": "插件實例 ID", + "ToolId": "選擇要使用的工具類型", + "ToolIdTitle": "工具類型" }, "WikiOperation": { "Description": "在維基工作區中執行 條目操作(添加、刪除或設置文本)", "Title": "Wiki 操作", "Tool": { - "Examples": { - }, "Parameters": { "extraMeta": { "Description": "額外元數據的 JSON 字串,如標籤和欄位,預設為 \"{}\"", @@ -445,18 +517,16 @@ "Description": "條目的標題", "Title": "條目標題" }, + "variables": { + "Description": "傳遞給 action tiddler 的變量,JSON 格式字符串,默認為 \"{}\"", + "Title": "變數" + }, "workspaceName": { "Description": "要操作的工作區名稱或ID", "Title": "工作區名稱" } } }, - "ToolListPosition": { - "Position": "相對於目標元素的插入位置(before/after)", - "PositionTitle": "插入位置", - "TargetId": "要插入工具列表的目標元素的ID", - "TargetIdTitle": "目標ID" - }, "ToolResultDuration": "工具執行結果在對話中保持可見的輪數,超過此輪數後結果將變灰顯示", "ToolResultDurationTitle": "工具結果持續輪數" }, @@ -495,12 +565,6 @@ }, "UpdateEmbeddings": { "Description": "為Wiki工作區生成或更新向量嵌入索引,用於語義搜尋", - "Parameters": { - "forceUpdate": { - }, - "workspaceName": { - } - }, "forceUpdate": { "Description": "是否強制重新生成嵌入索引,覆蓋已有的嵌入數據(如果設置為 true 則忽略增量更新)。", "Title": "強制更新" @@ -511,15 +575,16 @@ } } }, - "ToolListPosition": { - "Position": "相對於目標位置的插入位置", - "PositionTitle": "插入位置", - "TargetId": "目標元素的ID,工具列表將相對於此元素插入", - "TargetIdTitle": "目標ID" - }, - "ToolListPositionTitle": "工具列表位置", "ToolResultDuration": "工具執行結果在對話中保持可見的輪數,超過此輪數後結果將變灰顯示", "ToolResultDurationTitle": "工具結果持續輪數" + }, + "WorkspacesList": { + "Description": "將可用的 Wiki 工作區列表注入到提示詞中", + "Position": "插入位置:before 為前置,after 為後置", + "PositionTitle": "插入位置", + "TargetId": "目標提示詞的 ID,列表將相對於此提示詞插入", + "TargetIdTitle": "目標 ID", + "Title": "工作區列表" } }, "Search": { @@ -545,7 +610,10 @@ } }, "Tool": { - "Plugin": { + "Git": { + "Error": { + "WorkspaceNotFound": "工作區名稱或ID\"{{workspaceName}}\"不存在" + } }, "Schema": { "Description": "描述", @@ -554,12 +622,19 @@ "Parameters": "參數", "Required": "必需" }, + "TiddlyWikiPlugin": { + "Error": { + "PluginTitleRequired": "插件標題不能為空", + "WorkspaceNotFound": "工作區名稱或ID\"{{workspaceNameOrID}}\"不存在" + } + }, "WikiOperation": { "Error": { "WorkspaceNotExist": "工作區{{workspaceID}}不存在", "WorkspaceNotFound": "工作區名稱或ID\"{{workspaceName}}\"不存在。可用工作區:{{availableWorkspaces}}" }, "Success": { + "ActionInvoked": "成功在維基工作區\"{{workspaceName}}\"中執行了動作條目\"{{actionTitle}}\"", "Added": "成功在維基工作區\"{{workspaceName}}\"中添加了條目\"{{title}}\"", "Deleted": "成功從維基工作區\"{{workspaceName}}\"中刪除了條目\"{{title}}\"", "Updated": "成功在維基工作區\"{{workspaceName}}\"中設置了條目\"{{title}}\"的文本" diff --git a/localization/locales/zh-Hant/translation.json b/localization/locales/zh-Hant/translation.json index 920e5f68..0d4db22b 100644 --- a/localization/locales/zh-Hant/translation.json +++ b/localization/locales/zh-Hant/translation.json @@ -71,6 +71,7 @@ "TagName": "標籤名", "TagNameHelp": "加上此標籤的筆記將會自動被放入這個子知識庫內(可先不填,之後右鍵點擊這個工作區的圖示選擇編輯工作區修改)", "TagNameHelpForMain": "帶有此標籤的新條目將優先保存在此工作區", + "TagNameInputWarning": "⚠️ 請按Enter確認標籤輸入", "ThisPathIsNotAWikiFolder": "該目錄不是一個知識庫文件夾 \"{{wikiPath}}\"", "UseFilter": "使用篩選器", "UseFilterHelp": "用篩選器運算式而不是標籤來匹配條目,決定是否存入當前工作區", @@ -217,6 +218,9 @@ "WikiRootTiddler": "知識庫根條目", "WikiRootTiddlerDescription": "知識庫的根條目(root-tiddler)決定了系統的核心行為,修改前請閱讀官方文件來了解", "WikiRootTiddlerItems": { + "all": "全量載入", + "lazy-all": "按需載入圖片和文字", + "lazy-images": "按需載入圖片" } }, "Error": { @@ -259,7 +263,6 @@ "AndMoreFiles": "還有 {{count}} 個文件", "Author": "作者", "BinaryFileCannotDisplay": "二進位檔案無法顯示為文字", - "ClearSearch": "清除搜尋", "Committing": "提交中...", "ContentView": "檔案內容", "CopyFilePath": "複製檔案路徑", @@ -278,7 +281,6 @@ "FailedToLoadDiff": "載入差異失敗", "FileNameSearch": "檔案名稱", "FileSearchPlaceholder": "例如:pages、*.tsx、config.json", - "Files": "檔案", "FilesChanged": "變更了 {{count}} 個文件", "FilesChanged_other": "{{count}} 個檔案有變更", "Hash": "雜湊值", @@ -582,8 +584,6 @@ "Save": "保存", "Schema": { "ProviderModel": { - "FreeModel": "用於生成總結標題、生成備份標題時使用的免費小模型", - "FreeModelTitle": "免費模型" } }, "Scripting": { diff --git a/package.json b/package.json index f4aeee26..c05c6762 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "packageManager": "pnpm@10.24.0", "scripts": { "start:init": "pnpm run clean && pnpm run init:git-submodule && pnpm run build:plugin && cross-env NODE_ENV=development tsx scripts/developmentMkdir.ts && pnpm run start:dev", + "start:dev:mcp": "cross-env NODE_ENV=development DEBUG_WORKER=true electron-forge start -- --remote-debugging-port=9222", "start:dev": "cross-env NODE_ENV=development electron-forge start", "clean": "pnpm run clean:cache && rimraf -- ./out ./logs ./userData-dev ./userData-test ./wiki-dev ./wiki-test ./node_modules/tiddlywiki/plugins/linonetwo", "clean:cache": "rimraf -- ./node_modules/.vite .vite", @@ -188,6 +189,7 @@ "unplugin-swc": "^1.5.8", "vite": "^7.2.4", "vite-bundle-analyzer": "^1.2.3", + "vite-plugin-monaco-editor": "^1.1.0", "vitest": "^3.2.4" }, "pnpm": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c03dde0f..24698590 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -444,6 +444,9 @@ importers: vite-bundle-analyzer: specifier: ^1.2.3 version: 1.2.3 + vite-plugin-monaco-editor: + specifier: ^1.1.0 + version: 1.1.0(monaco-editor@0.55.1) vitest: specifier: ^3.2.4 version: 3.2.4(@types/node@24.10.1)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.2.0)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) @@ -7384,6 +7387,11 @@ packages: engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true + vite-plugin-monaco-editor@1.1.0: + resolution: {integrity: sha512-IvtUqZotrRoVqwT0PBBDIZPNraya3BxN/bfcNfnxZ5rkJiGcNtO5eAOWWSgT7zullIAEqQwxMU83yL9J5k7gww==} + peerDependencies: + monaco-editor: '>=0.33.0' + vite@7.2.4: resolution: {integrity: sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==} engines: {node: ^20.19.0 || >=22.12.0} @@ -15638,6 +15646,10 @@ snapshots: - tsx - yaml + vite-plugin-monaco-editor@1.1.0(monaco-editor@0.55.1): + dependencies: + monaco-editor: 0.55.1 + vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1): dependencies: esbuild: 0.25.12 diff --git a/src/__tests__/__mocks__/services-container.ts b/src/__tests__/__mocks__/services-container.ts index f417993d..bc31c8bc 100644 --- a/src/__tests__/__mocks__/services-container.ts +++ b/src/__tests__/__mocks__/services-container.ts @@ -80,7 +80,7 @@ export const serviceInstances: { } as Partial; })(), externalAPI: { - getAIConfig: vi.fn(async () => ({ api: { model: 'test-model', provider: 'test-provider' }, modelParameters: {} })), + getAIConfig: vi.fn(async () => ({ default: { model: 'test-model', provider: 'test-provider' }, modelParameters: {} })), getAIProviders: vi.fn(async () => []), generateFromAI: vi.fn(async function*() { // harmless await for linter diff --git a/src/__tests__/__mocks__/services-i18n.ts b/src/__tests__/__mocks__/services-i18n.ts new file mode 100644 index 00000000..c0c3e224 --- /dev/null +++ b/src/__tests__/__mocks__/services-i18n.ts @@ -0,0 +1,17 @@ +/** + * Mock for @services/libs/i18n + */ +import { vi } from 'vitest'; + +export const i18n = { + t: vi.fn((key: string) => { + // Return the key itself as fallback + return key; + }), + changeLanguage: vi.fn(), + language: 'en', +}; + +export const placeholder = { + t: (key: string) => key, +}; diff --git a/src/__tests__/setup-vitest.ts b/src/__tests__/setup-vitest.ts index 3dd37f20..79700836 100644 --- a/src/__tests__/setup-vitest.ts +++ b/src/__tests__/setup-vitest.ts @@ -12,6 +12,7 @@ import './__mocks__/services-container'; import { vi } from 'vitest'; vi.mock('react-i18next', () => import('./__mocks__/react-i18next')); vi.mock('@services/libs/log', () => import('./__mocks__/services-log')); +vi.mock('@services/libs/i18n', () => import('./__mocks__/services-i18n')); /** * Mock the `electron` module for testing diff --git a/src/constants/defaultTiddlerNames.ts b/src/constants/defaultTiddlerNames.ts index b1aa5678..dbb869d1 100644 --- a/src/constants/defaultTiddlerNames.ts +++ b/src/constants/defaultTiddlerNames.ts @@ -1 +1,7 @@ +import { t } from '@services/libs/i18n/placeholder'; + export const rootTiddlers = ['$:/core/save/all', '$:/core/save/lazy-images', '$:/core/save/lazy-all']; +// Keep i18n ally think these keys exist, otherwise it will delete them during "check usage" +t('EditWorkspace.WikiRootTiddlerItems.all'); +t('EditWorkspace.WikiRootTiddlerItems.lazy-images'); +t('EditWorkspace.WikiRootTiddlerItems.lazy-all'); diff --git a/src/global.d.ts b/src/global.d.ts new file mode 100644 index 00000000..d28549dc --- /dev/null +++ b/src/global.d.ts @@ -0,0 +1,34 @@ +// Global type declarations for CSS imports + +// Catch-all for CSS files +declare module '*.css' { + const content: Record; + export default content; +} + +// Specific declarations for font files +declare module '@fontsource/roboto/300.css' { + const content: Record; + export default content; +} + +declare module '@fontsource/roboto/400.css' { + const content: Record; + export default content; +} + +declare module '@fontsource/roboto/500.css' { + const content: Record; + export default content; +} + +declare module '@fontsource/roboto/700.css' { + const content: Record; + export default content; +} + +// Simplebar CSS +declare module 'simplebar/dist/simplebar.min.css' { + const content: Record; + export default content; +} diff --git a/src/helpers/monacoConfig.ts b/src/helpers/monacoConfig.ts new file mode 100644 index 00000000..e3b59f33 --- /dev/null +++ b/src/helpers/monacoConfig.ts @@ -0,0 +1,19 @@ +import { loader } from '@monaco-editor/react'; +import * as monaco from 'monaco-editor'; + +/** + * Configure Monaco Editor to use local files instead of CDN + * This prevents slow loading times caused by network requests + * + * Note: vite-plugin-monaco-editor handles the worker files, + * we just need to tell @monaco-editor/react to use the local package + */ +export function initMonacoEditor(): void { + // Use the local monaco-editor package instead of loading from CDN + loader.config({ monaco }); + + // Pre-initialize to avoid lazy loading delay + loader.init().catch((error: unknown) => { + console.error('Failed to initialize Monaco Editor:', error); + }); +} diff --git a/src/pages/Agent/store/agentChatStore/actions/previewActions.ts b/src/pages/Agent/store/agentChatStore/actions/previewActions.ts index e5831cb7..8a4db384 100644 --- a/src/pages/Agent/store/agentChatStore/actions/previewActions.ts +++ b/src/pages/Agent/store/agentChatStore/actions/previewActions.ts @@ -11,15 +11,18 @@ export const previewActionsMiddleware: StateCreator ({ - openPreviewDialog: () => { - set({ previewDialogOpen: true }); + openPreviewDialog: (options) => { + set({ + previewDialogOpen: true, + previewDialogBaseMode: options?.baseMode ?? 'preview', + }); }, closePreviewDialog: () => { set({ previewDialogOpen: false, + previewDialogBaseMode: 'preview', lastUpdated: null, - expandedArrayItems: new Map(), formFieldsToScrollTo: [], }); }, @@ -31,34 +34,6 @@ export const previewActionsMiddleware: StateCreator { set({ formFieldsToScrollTo: fieldPaths }); }, - setArrayItemExpanded: (itemId: string, expanded: boolean) => { - const { expandedArrayItems } = get(); - const newMap = new Map(expandedArrayItems); - if (expanded) { - newMap.set(itemId, true); - } else { - newMap.delete(itemId); - } - set({ expandedArrayItems: newMap }); - }, - isArrayItemExpanded: (itemId: string) => { - const { expandedArrayItems } = get(); - return expandedArrayItems.get(itemId) ?? false; - }, - expandPathToTarget: (targetPath: string[]) => { - const { expandedArrayItems } = get(); - const newMap = new Map(expandedArrayItems); - - // For a path like ['prompts', 'system', 'children', 'default-main'] - // We need to expand each ID that represents an array item: 'system' and 'default-main' - for (let index = 1; index < targetPath.length; index += 1) { - if (targetPath[index]) { - newMap.set(targetPath[index], true); - } - } - - set({ expandedArrayItems: newMap }); - }, updatePreviewProgress: (progress: number, step: string, currentPlugin?: string) => { set({ diff --git a/src/pages/Agent/store/agentChatStore/index.ts b/src/pages/Agent/store/agentChatStore/index.ts index 36c152d8..875f5a18 100644 --- a/src/pages/Agent/store/agentChatStore/index.ts +++ b/src/pages/Agent/store/agentChatStore/index.ts @@ -19,6 +19,7 @@ export const useAgentChatStore = create()((set, get, api) => // Preview dialog state previewDialogOpen: false, + previewDialogBaseMode: 'preview', previewDialogTab: 'tree', previewLoading: false, previewProgress: 0, @@ -27,7 +28,6 @@ export const useAgentChatStore = create()((set, get, api) => previewResult: null, lastUpdated: null, formFieldsToScrollTo: [], - expandedArrayItems: new Map(), }; // Merge all actions and initial state diff --git a/src/pages/Agent/store/agentChatStore/types.ts b/src/pages/Agent/store/agentChatStore/types.ts index 5ec79e02..82eb1c7e 100644 --- a/src/pages/Agent/store/agentChatStore/types.ts +++ b/src/pages/Agent/store/agentChatStore/types.ts @@ -27,6 +27,7 @@ export interface AgentChatBaseState { // Preview dialog specific state export interface PreviewDialogState { previewDialogOpen: boolean; + previewDialogBaseMode: 'preview' | 'edit'; previewDialogTab: 'flat' | 'tree'; previewLoading: boolean; previewProgress: number; // 0-1, processing progress @@ -38,7 +39,6 @@ export interface PreviewDialogState { } | null; lastUpdated: Date | null; formFieldsToScrollTo: string[]; - expandedArrayItems: Map; } // Basic actions interface @@ -139,7 +139,7 @@ export interface PreviewActions { /** * Opens the preview dialog */ - openPreviewDialog: () => void; + openPreviewDialog: (options?: { baseMode?: 'preview' | 'edit' }) => void; /** * Closes the preview dialog @@ -158,25 +158,6 @@ export interface PreviewActions { */ setFormFieldsToScrollTo: (fieldPaths: string[]) => void; - /** - * Sets the expansion state of a specific array item by its ID - * @param itemId The unique ID of the array item - * @param expanded Whether the item should be expanded - */ - setArrayItemExpanded: (itemId: string, expanded: boolean) => void; - - /** - * Checks if a specific array item is expanded by its ID - * @param itemId The unique ID of the array item - */ - isArrayItemExpanded: (itemId: string) => boolean; - - /** - * Expands all parent paths leading to a target field - * @param targetPath The target field path to expand to - */ - expandPathToTarget: (targetPath: string[]) => void; - /** * Updates preview progress state * @param progress Progress value from 0 to 1 diff --git a/src/pages/ChatTabContent/components/ChatHeader.tsx b/src/pages/ChatTabContent/components/ChatHeader.tsx index c8dd2c59..1c067135 100644 --- a/src/pages/ChatTabContent/components/ChatHeader.tsx +++ b/src/pages/ChatTabContent/components/ChatHeader.tsx @@ -1,5 +1,6 @@ import ArticleIcon from '@mui/icons-material/Article'; import BugReportIcon from '@mui/icons-material/BugReport'; +import EditIcon from '@mui/icons-material/Edit'; import TuneIcon from '@mui/icons-material/Tune'; import { Box, CircularProgress, IconButton } from '@mui/material'; import { styled } from '@mui/material/styles'; @@ -47,10 +48,11 @@ export const ChatHeader: React.FC = ({ const preference = usePreferenceObservable(); const [apiLogsDialogOpen, setApiLogsDialogOpen] = useState(false); - const { agent, previewDialogOpen, openPreviewDialog, closePreviewDialog, updateAgent } = useAgentChatStore( + const { agent, previewDialogOpen, previewDialogBaseMode, openPreviewDialog, closePreviewDialog, updateAgent } = useAgentChatStore( useShallow((state) => ({ agent: state.agent, previewDialogOpen: state.previewDialogOpen, + previewDialogBaseMode: state.previewDialogBaseMode, updateAgent: state.updateAgent, openPreviewDialog: state.openPreviewDialog, closePreviewDialog: state.closePreviewDialog, @@ -74,11 +76,22 @@ export const ChatHeader: React.FC = ({ { + openPreviewDialog(); + }} title={t('Prompt.Preview')} > + { + openPreviewDialog({ baseMode: 'edit' }); + }} + title={t('Prompt.Edit')} + > + + {showDebugButton && ( = ({ open={previewDialogOpen} onClose={closePreviewDialog} inputText={inputText} + initialBaseMode={previewDialogBaseMode} /> ({ /** * Flat prompt list component + * Memoized to prevent unnecessary re-renders */ -export const FlatPromptList = ({ flatPrompts }: { flatPrompts?: PreviewMessage[] }): React.ReactElement => { +export const FlatPromptList = memo(({ flatPrompts }: { flatPrompts?: PreviewMessage[] }): React.ReactElement => { const { t } = useTranslation('agent'); if (!flatPrompts?.length) { @@ -87,4 +88,5 @@ export const FlatPromptList = ({ flatPrompts }: { flatPrompts?: PreviewMessage[] ))} ); -}; +}); +FlatPromptList.displayName = 'FlatPromptList'; diff --git a/src/pages/ChatTabContent/components/LastUpdatedIndicator.tsx b/src/pages/ChatTabContent/components/LastUpdatedIndicator.tsx index 127c5139..478471ba 100644 --- a/src/pages/ChatTabContent/components/LastUpdatedIndicator.tsx +++ b/src/pages/ChatTabContent/components/LastUpdatedIndicator.tsx @@ -1,5 +1,5 @@ import { Box, Typography } from '@mui/material'; -import React from 'react'; +import React, { memo } from 'react'; import { useTranslation } from 'react-i18next'; export interface LastUpdatedIndicatorProps { @@ -9,8 +9,9 @@ export interface LastUpdatedIndicatorProps { /** * Component to display when the prompt preview was last updated * Shows timestamp and update method (manual, auto, or initial) + * Memoized to prevent unnecessary re-renders */ -export const LastUpdatedIndicator: React.FC = ({ lastUpdated }) => { +export const LastUpdatedIndicator: React.FC = memo(({ lastUpdated }) => { const { t } = useTranslation('agent'); if (!lastUpdated) return null; @@ -31,4 +32,5 @@ export const LastUpdatedIndicator: React.FC = ({ last ); -}; +}); +LastUpdatedIndicator.displayName = 'LastUpdatedIndicator'; diff --git a/src/pages/ChatTabContent/components/PromptPreviewDialog/EditView.tsx b/src/pages/ChatTabContent/components/PromptPreviewDialog/EditView.tsx index 4658ba2e..7dedf1a2 100644 --- a/src/pages/ChatTabContent/components/PromptPreviewDialog/EditView.tsx +++ b/src/pages/ChatTabContent/components/PromptPreviewDialog/EditView.tsx @@ -1,17 +1,20 @@ import { useAgentFrameworkConfigManagement } from '@/windows/Preferences/sections/ExternalAPI/useAgentFrameworkConfigManagement'; -import MonacoEditor from '@monaco-editor/react'; -import { Box, styled } from '@mui/material'; +import { Box, CircularProgress, styled } from '@mui/material'; import Tab from '@mui/material/Tab'; import Tabs from '@mui/material/Tabs'; import useDebouncedCallback from 'beautiful-react-hooks/useDebouncedCallback'; -import React, { FC, SyntheticEvent, useCallback, useEffect, useState } from 'react'; +import React, { FC, lazy, Suspense, SyntheticEvent, useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useShallow } from 'zustand/react/shallow'; import { AgentFrameworkConfig } from '@services/agentInstance/promptConcat/promptConcatSchema'; import { useAgentChatStore } from '../../../Agent/store/agentChatStore/index'; import { PromptConfigForm } from './PromptConfigForm'; +import { useArrayFieldStore } from './PromptConfigForm/store/arrayFieldStore'; + +// Lazy load Monaco Editor only when needed +const MonacoEditor = lazy(async () => await import('@monaco-editor/react')); const EditorTabs = styled(Tabs)` margin-bottom: ${({ theme }) => theme.spacing(2)}; @@ -30,15 +33,18 @@ export const EditView: FC = ({ const { t } = useTranslation('agent'); const agent = useAgentChatStore(state => state.agent); const [editorMode, setEditorMode] = useState<'form' | 'code'>('form'); + const [monacoInitialized, setMonacoInitialized] = useState(false); - const { formFieldsToScrollTo, setFormFieldsToScrollTo, expandPathToTarget } = useAgentChatStore( + const { formFieldsToScrollTo, setFormFieldsToScrollTo } = useAgentChatStore( useShallow((state) => ({ formFieldsToScrollTo: state.formFieldsToScrollTo, setFormFieldsToScrollTo: state.setFormFieldsToScrollTo, - expandPathToTarget: state.expandPathToTarget, })), ); + // Use a stable reference for expandItemsByPath + const expandItemsByPath = useArrayFieldStore(useCallback((state) => state.expandItemsByPath, [])); + const { loading: agentFrameworkConfigLoading, config: agentFrameworkConfig, @@ -49,26 +55,70 @@ export const EditView: FC = ({ agentId: agent?.id, }); - useEffect(() => { - if (formFieldsToScrollTo.length > 0 && editorMode === 'form') { - expandPathToTarget(formFieldsToScrollTo); + // Use a ref to track if we're currently processing a scroll request + const isProcessingScrollReference = React.useRef(false); + const savedPathReference = React.useRef([]); - const scrollTimeout = setTimeout(() => { - const targetId = formFieldsToScrollTo[formFieldsToScrollTo.length - 1]; + useEffect(() => { + if (formFieldsToScrollTo.length > 0 && editorMode === 'form' && agentFrameworkConfig && !isProcessingScrollReference.current) { + // Mark as processing and save the path + isProcessingScrollReference.current = true; + savedPathReference.current = [...formFieldsToScrollTo]; + const savedPath = savedPathReference.current; + + // NOTE: Don't clear formFieldsToScrollTo immediately! + // RootObjectFieldTemplate also listens to this to switch tabs. + // We'll clear it after the tab has had time to switch. + + // Path format: ['prompts', 'system', 'child-id', 'child-id'] or ['prompts', 'system'] + // - savedPath[0]: top-level key (prompts, plugins, response) + // - savedPath[1]: parent item id + // - savedPath[2+]: nested child item ids (if present) + + // Step 1: Wait for RootObjectFieldTemplate to switch tabs, then expand items + setTimeout(() => { + // Now clear the path after tab has switched + setFormFieldsToScrollTo([]); + + const topLevelKey = savedPath[0]; + if (savedPath.length > 1) { + const firstItemId = savedPath[1]; + expandItemsByPath(topLevelKey, [firstItemId]); + } + }, 100); + + // Step 2: After the parent expands and children render, expand nested items + // If path has more than 2 elements, we have nested children to expand + const hasNestedChildren = savedPath.length > 2; + if (hasNestedChildren) { + setTimeout(() => { + const topLevelKey = savedPath[0]; + const firstItemId = savedPath[1]; + const topLevelArray = agentFrameworkConfig[topLevelKey as keyof typeof agentFrameworkConfig]; + if (Array.isArray(topLevelArray)) { + const parentIndex = topLevelArray.findIndex((item: unknown) => { + const data = item as Record | null; + return data?.id === firstItemId || data?.caption === firstItemId || data?.title === firstItemId; + }); + + if (parentIndex !== -1) { + const nestedFieldPath = `${topLevelKey}_${parentIndex}_children`; + // Get the nested item IDs (from savedPath[2] onwards) + const nestedItemIds = savedPath.slice(2); + expandItemsByPath(nestedFieldPath, nestedItemIds); + } + } + }, 300); // Longer delay to wait for nested array to render + } + + // Step 3: Scroll to the target element (after nested items have expanded) + const scrollDelay = hasNestedChildren ? 500 : 200; + setTimeout(() => { + const targetId = savedPath[savedPath.length - 1]; // Find input element whose value exactly matches the target ID const targetElement = document.querySelector(`input[value="${targetId}"]`); if (targetElement) { - // Expand parent accordions - let current = targetElement.parentElement; - while (current) { - const accordion = current.querySelector('[aria-expanded="false"]'); - if (accordion instanceof HTMLElement) { - accordion.click(); - } - current = current.parentElement; - } - // Scroll to element and highlight setTimeout(() => { if (targetElement instanceof HTMLElement) { @@ -82,14 +132,11 @@ export const EditView: FC = ({ }, 300); } - setFormFieldsToScrollTo([]); - }, 100); - - return () => { - clearTimeout(scrollTimeout); - }; + // Mark processing as complete + isProcessingScrollReference.current = false; + }, scrollDelay); } - }, [formFieldsToScrollTo, editorMode, setFormFieldsToScrollTo, expandPathToTarget]); + }, [formFieldsToScrollTo, editorMode, expandItemsByPath, agentFrameworkConfig, setFormFieldsToScrollTo]); const { getPreviewPromptResult } = useAgentChatStore( useShallow((state) => ({ @@ -97,12 +144,17 @@ export const EditView: FC = ({ })), ); + // Keep local ref to track if preview should be updated + const isUserEditingReference = React.useRef(false); + const handleFormChange = useDebouncedCallback( async (updatedConfig: AgentFrameworkConfig) => { try { - // Ensure the config change is fully persisted before proceeding + // Always persist the config change to backend await handleConfigChange(updatedConfig); - if (agent?.agentDefId) { + + // Only update preview if user is actually editing (not just drag-reordering) + if (isUserEditingReference.current && agent?.agentDefId) { void getPreviewPromptResult(inputText, updatedConfig); } } catch (error) { @@ -110,13 +162,25 @@ export const EditView: FC = ({ } }, [handleConfigChange, agent?.agentDefId, getPreviewPromptResult, inputText], - 1000, - { leading: true }, + 500, + { leading: false, maxWait: 2000 }, ); - const handleEditorModeChange = useCallback((_event: SyntheticEvent, newValue: 'form' | 'code') => { + const handleInputChange = useCallback((changedFormData: AgentFrameworkConfig) => { + // Mark as user editing when form data changes + isUserEditingReference.current = true; + void handleFormChange(changedFormData); + }, [handleFormChange]); + + const handleEditorModeChange = useCallback(async (_event: SyntheticEvent, newValue: 'form' | 'code') => { setEditorMode(newValue); - }, []); + // Only initialize Monaco when switching to code mode + if (newValue === 'code' && !monacoInitialized) { + const { initMonacoEditor } = await import('@/helpers/monacoConfig'); + initMonacoEditor(); + setMonacoInitialized(true); + } + }, [monacoInitialized]); const handleEditorChange = useCallback((value: string | undefined) => { if (!value) return; @@ -164,26 +228,34 @@ export const EditView: FC = ({ )} {editorMode === 'code' && ( - + + + + } + > + + )} diff --git a/src/pages/ChatTabContent/components/PromptPreviewDialog/PreviewProgressBar.tsx b/src/pages/ChatTabContent/components/PromptPreviewDialog/PreviewProgressBar.tsx index 3e8acd1f..26664e46 100644 --- a/src/pages/ChatTabContent/components/PromptPreviewDialog/PreviewProgressBar.tsx +++ b/src/pages/ChatTabContent/components/PromptPreviewDialog/PreviewProgressBar.tsx @@ -1,8 +1,14 @@ import { Box, Chip, LinearProgress, Typography } from '@mui/material'; -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { useShallow } from 'zustand/react/shallow'; import { useAgentChatStore } from '../../../Agent/store/agentChatStore'; +/** + * Debounce delay before showing the progress bar (ms) + * Prevents flashing for quick operations + */ +const SHOW_DELAY_MS = 200; + interface PreviewProgressBarProps { /** * Whether to show the progress bar @@ -13,6 +19,7 @@ interface PreviewProgressBarProps { /** * Progress bar component for preview generation * Shows real-time progress and current processing step + * Uses debounce to prevent flashing for quick operations */ export const PreviewProgressBar: React.FC = ({ show }) => { const { @@ -29,7 +36,25 @@ export const PreviewProgressBar: React.FC = ({ show }) })), ); - if (!show || !previewLoading) { + // Debounce visibility to prevent flashing for quick operations + const [showDelayed, setShowDelayed] = useState(false); + + useEffect(() => { + if (show && previewLoading) { + // Delay showing the progress bar + const timer = setTimeout(() => { + setShowDelayed(true); + }, SHOW_DELAY_MS); + return () => { + clearTimeout(timer); + }; + } else { + // Hide immediately when loading is done + setShowDelayed(false); + } + }, [show, previewLoading]); + + if (!showDelayed) { return null; } diff --git a/src/pages/ChatTabContent/components/PromptPreviewDialog/PreviewTabsView.tsx b/src/pages/ChatTabContent/components/PromptPreviewDialog/PreviewTabsView.tsx index d58685c8..49cdcb84 100644 --- a/src/pages/ChatTabContent/components/PromptPreviewDialog/PreviewTabsView.tsx +++ b/src/pages/ChatTabContent/components/PromptPreviewDialog/PreviewTabsView.tsx @@ -2,7 +2,7 @@ import { Box, styled } from '@mui/material'; import Tab from '@mui/material/Tab'; import Tabs from '@mui/material/Tabs'; import { ModelMessage } from 'ai'; -import React, { useCallback, useMemo } from 'react'; +import React, { memo, useCallback, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useShallow } from 'zustand/react/shallow'; @@ -11,7 +11,6 @@ import { FlatPromptList } from '../FlatPromptList'; import { LastUpdatedIndicator } from '../LastUpdatedIndicator'; import { PromptTree } from '../PromptTree'; import { getFormattedContent } from '../types'; -import { LoadingView } from './LoadingView'; // Styled components const PreviewTabs = styled(Tabs)` @@ -40,41 +39,70 @@ interface PreviewTabsViewProps { } /** - * Preview tabs component with flat and tree views + * Memoized tree content to prevent re-renders when data hasn't changed */ -export const PreviewTabsView: React.FC = ({ +const TreeContent = memo<{ isFullScreen: boolean }>(({ isFullScreen }) => { + const processedPrompts = useAgentChatStore((state) => state.previewResult?.processedPrompts); + const lastUpdated = useAgentChatStore((state) => state.lastUpdated); + + return ( + + + + + ); +}); +TreeContent.displayName = 'TreeContent'; + +/** + * Memoized flat content to prevent re-renders when data hasn't changed + */ +const FlatContent = memo<{ isFullScreen: boolean }>(({ isFullScreen }) => { + const flatPrompts = useAgentChatStore((state) => state.previewResult?.flatPrompts); + const lastUpdated = useAgentChatStore((state) => state.lastUpdated); + + // Memoize formatted preview to prevent unnecessary recalculations + const formattedFlatPrompts = useMemo(() => { + return flatPrompts?.map((message: ModelMessage) => ({ + role: message.role as string, + content: getFormattedContent(message.content), + })); + }, [flatPrompts]); + + return ( + + + + + ); +}); +FlatContent.displayName = 'FlatContent'; + +/** + * Preview tabs component with flat and tree views + * Uses memoized sub-components to prevent unnecessary re-renders + */ +export const PreviewTabsView: React.FC = memo(({ isFullScreen, }) => { const { t } = useTranslation('agent'); const { previewDialogTab: tab, - previewLoading, - previewResult, - lastUpdated, setPreviewDialogTab, } = useAgentChatStore( useShallow((state) => ({ previewDialogTab: state.previewDialogTab, - previewLoading: state.previewLoading, - previewResult: state.previewResult, - lastUpdated: state.lastUpdated, setPreviewDialogTab: state.setPreviewDialogTab, })), ); - // Memoize formatted preview to prevent unnecessary recalculations - const formattedPreview = useMemo(() => { - return previewResult - ? { - flatPrompts: previewResult.flatPrompts.map((message: ModelMessage) => ({ - role: message.role as string, - content: getFormattedContent(message.content), - })), - processedPrompts: previewResult.processedPrompts, - } - : null; - }, [previewResult]); + // Use ref to track if we've ever had content (to avoid flashing on initial load) + const hasHadContentReference = useRef(false); + const previewResult = useAgentChatStore((state) => state.previewResult); + if (previewResult) { + hasHadContentReference.current = true; + } const handleTabChange = useCallback((_event: React.SyntheticEvent, value: string): void => { if (value === 'flat' || value === 'tree') { @@ -84,8 +112,10 @@ export const PreviewTabsView: React.FC = ({ } }, [setPreviewDialogTab]); - if (previewLoading) { - return ; + // Show nothing if we've never had content (initial loading state) + // But once we have content, always show it (even during updates) + if (!hasHadContentReference.current && !previewResult) { + return null; } return ( @@ -117,18 +147,8 @@ export const PreviewTabsView: React.FC = ({ - {tab === 'tree' && ( - - - - - )} - {tab === 'flat' && ( - - - - - )} + {tab === 'tree' && } + {tab === 'flat' && } ); -}; +}); diff --git a/src/pages/ChatTabContent/components/PromptPreviewDialog/PromptConfigForm/context/ArrayItemContext.tsx b/src/pages/ChatTabContent/components/PromptPreviewDialog/PromptConfigForm/context/ArrayItemContext.tsx index e2fc024e..a7e36473 100644 --- a/src/pages/ChatTabContent/components/PromptPreviewDialog/PromptConfigForm/context/ArrayItemContext.tsx +++ b/src/pages/ChatTabContent/components/PromptPreviewDialog/PromptConfigForm/context/ArrayItemContext.tsx @@ -1,10 +1,18 @@ -import React, { createContext, useContext } from 'react'; +import React, { createContext, useContext, useMemo } from 'react'; interface ArrayItemContextValue { /** Whether this field is rendered within an array item */ isInArrayItem: boolean; /** Whether the array item controls should show collapse/expand functionality */ arrayItemCollapsible: boolean; + /** The current item's form data */ + itemData?: unknown; + /** The index of the current item in the array */ + itemIndex?: number; + /** The stable field path for the parent array */ + arrayFieldPath?: string; + /** The path segments to locate the array in root form data */ + arrayFieldPathSegments?: Array; } const ArrayItemContext = createContext({ @@ -18,15 +26,32 @@ interface ArrayItemProviderProps { children: React.ReactNode; isInArrayItem: boolean; arrayItemCollapsible?: boolean; + itemData?: unknown; + itemIndex?: number; + arrayFieldPath?: string; + arrayFieldPathSegments?: Array; } export const ArrayItemProvider: React.FC = ({ children, isInArrayItem, arrayItemCollapsible = false, + itemData, + itemIndex, + arrayFieldPath, + arrayFieldPathSegments, }) => { + const value = useMemo(() => ({ + isInArrayItem, + arrayItemCollapsible, + itemData, + itemIndex, + arrayFieldPath, + arrayFieldPathSegments, + }), [isInArrayItem, arrayItemCollapsible, itemData, itemIndex, arrayFieldPath, arrayFieldPathSegments]); + return ( - + {children} ); diff --git a/src/pages/ChatTabContent/components/PromptPreviewDialog/PromptConfigForm/index.tsx b/src/pages/ChatTabContent/components/PromptPreviewDialog/PromptConfigForm/index.tsx index 2e8b1c96..03d61a3f 100644 --- a/src/pages/ChatTabContent/components/PromptPreviewDialog/PromptConfigForm/index.tsx +++ b/src/pages/ChatTabContent/components/PromptPreviewDialog/PromptConfigForm/index.tsx @@ -19,6 +19,7 @@ import { widgets } from './widgets'; */ export interface ExtendedFormContext { rootFormData?: Record; + onFormDataChange?: (formData: AgentFrameworkConfig) => void; } /** @@ -96,7 +97,8 @@ export const PromptConfigForm: React.FC = ({ const formContext = useMemo((): ExtendedFormContext => ({ rootFormData: formData, - }), [formData]); + onFormDataChange: onChange, + }), [formData, onChange]); if (loading) { return ( diff --git a/src/pages/ChatTabContent/components/PromptPreviewDialog/PromptConfigForm/store/arrayFieldStore.ts b/src/pages/ChatTabContent/components/PromptPreviewDialog/PromptConfigForm/store/arrayFieldStore.ts new file mode 100644 index 00000000..8652bbc1 --- /dev/null +++ b/src/pages/ChatTabContent/components/PromptPreviewDialog/PromptConfigForm/store/arrayFieldStore.ts @@ -0,0 +1,248 @@ +import { create } from 'zustand'; +import { immer } from 'zustand/middleware/immer'; + +/** + * Callback functions for moving items + */ +interface ItemMoveCallbacks { + onMoveUp: () => void; + onMoveDown: () => void; +} + +/** Counter for generating unique IDs */ +let itemIdCounter = 0; + +/** Generate a unique stable ID for an array item */ +function generateItemId(): string { + return `item-${Date.now()}-${itemIdCounter++}`; +} + +/** + * Store for managing array field state + * Tracks expanded state and ordering for each array field instance + */ +interface ArrayFieldState { + /** Map of field path -> array of expanded states for each item */ + expandedStates: Record; + /** Map of field path -> array of item data (for stable rendering) */ + itemsData: Record; + /** Map of field path -> array order (indices) - used for optimistic rendering during drag */ + itemsOrder: Record; + /** Map of field path -> index -> move callbacks */ + moveCallbacks: Record>; + /** Map of field path -> array of stable unique IDs for dnd-kit */ + stableItemIds: Record; + /** Map of field path -> whether there's a pending reorder (optimistic update in progress) */ + pendingReorder: Record; +} + +interface ArrayFieldActions { + /** Set expanded state for a specific item */ + setItemExpanded: (fieldPath: string, itemIndex: number, expanded: boolean) => void; + /** Initialize array field state */ + initializeField: (fieldPath: string, itemCount: number, itemsData: unknown[]) => void; + /** Move item to new position (optimistic update for drag-and-drop) */ + moveItem: (fieldPath: string, oldIndex: number, newIndex: number) => void; + /** Update items data when form data changes (from RJSF) */ + updateItemsData: (fieldPath: string, itemsData: unknown[]) => void; + /** Clean up field state when unmounted */ + cleanupField: (fieldPath: string) => void; + /** Register move callbacks for an item */ + registerMoveCallbacks: (fieldPath: string, itemIndex: number, callbacks: ItemMoveCallbacks) => void; + /** Expand items along a path by their IDs (caption field) */ + expandItemsByPath: (fieldPath: string, itemIds: string[]) => void; + /** Reset all state */ + reset: () => void; +} + +const initialState: ArrayFieldState = { + expandedStates: {}, + itemsData: {}, + itemsOrder: {}, + moveCallbacks: {}, + stableItemIds: {}, + pendingReorder: {}, +}; + +export const useArrayFieldStore = create()( + immer((set) => ({ + ...initialState, + + setItemExpanded: (fieldPath, itemIndex, expanded) => { + console.log('[arrayFieldStore] setItemExpanded called', { + fieldPath, + itemIndex, + expanded, + }); + set((state) => { + if (!state.expandedStates[fieldPath]) { + state.expandedStates[fieldPath] = []; + } + state.expandedStates[fieldPath][itemIndex] = expanded; + console.log('[arrayFieldStore] expandedStates updated', { + fieldPath, + newExpandedStates: state.expandedStates[fieldPath], + }); + }); + }, + + initializeField: (fieldPath, itemCount, itemsData) => { + set((state) => { + // Only initialize if not already present or if count changed + if (!state.expandedStates[fieldPath] || state.expandedStates[fieldPath].length !== itemCount) { + state.expandedStates[fieldPath] = Array.from({ length: itemCount }, () => false); + state.itemsOrder[fieldPath] = Array.from({ length: itemCount }, (_, index) => index); + state.stableItemIds[fieldPath] = Array.from({ length: itemCount }, () => generateItemId()); + } + state.itemsData[fieldPath] = itemsData; + }); + }, + + moveItem: (fieldPath, oldIndex, newIndex) => { + set((state) => { + const order = state.itemsOrder[fieldPath]; + const expanded = state.expandedStates[fieldPath]; + const stableIds = state.stableItemIds[fieldPath]; + if (!order || !expanded || !stableIds) return; + + // Mark that we have a pending reorder (optimistic update) + state.pendingReorder[fieldPath] = true; + + // Move in order array + const [movedOrderItem] = order.splice(oldIndex, 1); + order.splice(newIndex, 0, movedOrderItem); + + // Move in expanded states + const [movedExpandedItem] = expanded.splice(oldIndex, 1); + expanded.splice(newIndex, 0, movedExpandedItem); + + // Move in stable IDs array + const [movedStableId] = stableIds.splice(oldIndex, 1); + stableIds.splice(newIndex, 0, movedStableId); + }); + }, + + updateItemsData: (fieldPath, itemsData) => { + set((state) => { + const previousLength = state.itemsData[fieldPath]?.length ?? 0; + const newLength = itemsData.length; + const hasPendingReorder = state.pendingReorder[fieldPath] ?? false; + + state.itemsData[fieldPath] = itemsData; + + // Adjust order, expanded states, and stable IDs if length changed + if (newLength !== previousLength) { + if (newLength > previousLength) { + // Items added + const addedCount = newLength - previousLength; + if (!state.itemsOrder[fieldPath]) { + state.itemsOrder[fieldPath] = []; + } + if (!state.expandedStates[fieldPath]) { + state.expandedStates[fieldPath] = []; + } + if (!state.stableItemIds[fieldPath]) { + state.stableItemIds[fieldPath] = []; + } + for (let addedIndex = 0; addedIndex < addedCount; addedIndex++) { + state.itemsOrder[fieldPath].push(previousLength + addedIndex); + state.expandedStates[fieldPath].push(false); + state.stableItemIds[fieldPath].push(generateItemId()); + } + } else { + // Items removed + if (state.itemsOrder[fieldPath]) { + state.itemsOrder[fieldPath] = state.itemsOrder[fieldPath] + .filter((_originalIndex, position) => position < newLength) + .map((_originalIndex, position) => position); + } + if (state.expandedStates[fieldPath]) { + state.expandedStates[fieldPath].splice(newLength); + } + if (state.stableItemIds[fieldPath]) { + state.stableItemIds[fieldPath].splice(newLength); + } + } + // Clear pending reorder flag on length change + state.pendingReorder[fieldPath] = false; + } else if (hasPendingReorder && state.itemsOrder[fieldPath]) { + // Length is same and we had a pending reorder - RJSF has re-rendered with updated data + // Reset itemsOrder to natural order since items array now reflects the new order + state.itemsOrder[fieldPath] = Array.from({ length: newLength }, (_, index) => index); + state.pendingReorder[fieldPath] = false; + } + }); + }, + + cleanupField: (fieldPath) => { + set((state) => { + state.expandedStates = Object.fromEntries( + Object.entries(state.expandedStates).filter(([key]) => key !== fieldPath), + ); + state.itemsData = Object.fromEntries( + Object.entries(state.itemsData).filter(([key]) => key !== fieldPath), + ); + state.itemsOrder = Object.fromEntries( + Object.entries(state.itemsOrder).filter(([key]) => key !== fieldPath), + ); + state.moveCallbacks = Object.fromEntries( + Object.entries(state.moveCallbacks).filter(([key]) => key !== fieldPath), + ); + state.stableItemIds = Object.fromEntries( + Object.entries(state.stableItemIds).filter(([key]) => key !== fieldPath), + ); + state.pendingReorder = Object.fromEntries( + Object.entries(state.pendingReorder).filter(([key]) => key !== fieldPath), + ); + }); + }, + + registerMoveCallbacks: (fieldPath, itemIndex, callbacks) => { + set((state) => { + if (!state.moveCallbacks[fieldPath]) { + state.moveCallbacks[fieldPath] = {}; + } + state.moveCallbacks[fieldPath][itemIndex] = callbacks; + }); + }, + + expandItemsByPath: (fieldPath, itemIds) => { + set((state) => { + const items = state.itemsData[fieldPath]; + if (!items || !Array.isArray(items)) { + return; + } + + const expandedStates = state.expandedStates[fieldPath]; + if (!expandedStates) { + return; + } + + // For each ID in the path, find matching item and expand it + itemIds.forEach((itemId) => { + const itemIndex = items.findIndex((item) => { + if (!item || typeof item !== 'object') return false; + const data = item as Record; + // Match by id, caption, or title field + return data.id === itemId || data.caption === itemId || data.title === itemId; + }); + + if (itemIndex !== -1 && itemIndex < expandedStates.length) { + expandedStates[itemIndex] = true; + } + }); + }); + }, + + reset: () => { + set(initialState); + }, + })), +); + +/** + * Get move callbacks for an item - standalone function to avoid circular reference + */ +export function getMoveCallbacks(fieldPath: string, itemIndex: number): ItemMoveCallbacks | undefined { + return useArrayFieldStore.getState().moveCallbacks[fieldPath]?.[itemIndex]; +} diff --git a/src/pages/ChatTabContent/components/PromptPreviewDialog/PromptConfigForm/templates/ArrayFieldItemTemplate.tsx b/src/pages/ChatTabContent/components/PromptPreviewDialog/PromptConfigForm/templates/ArrayFieldItemTemplate.tsx index 9dc18503..6375c51c 100644 --- a/src/pages/ChatTabContent/components/PromptPreviewDialog/PromptConfigForm/templates/ArrayFieldItemTemplate.tsx +++ b/src/pages/ChatTabContent/components/PromptPreviewDialog/PromptConfigForm/templates/ArrayFieldItemTemplate.tsx @@ -1,26 +1,123 @@ +import { defaultAnimateLayoutChanges, useSortable } from '@dnd-kit/sortable'; +import type { AnimateLayoutChanges } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; import DragHandleIcon from '@mui/icons-material/DragHandle'; import ExpandLessIcon from '@mui/icons-material/ExpandLess'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import { Box, IconButton } from '@mui/material'; +import { Box, Checkbox, IconButton } from '@mui/material'; import { ArrayFieldItemTemplateProps, FormContextType, getTemplate, getUiOptions, RJSFSchema } from '@rjsf/utils'; -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { ArrayItemProvider } from '../context/ArrayItemContext'; +import { useShallow } from 'zustand/react/shallow'; +import { ArrayItemProvider, useArrayItemContext } from '../context/ArrayItemContext'; +import { ExtendedFormContext } from '../index'; +import { useArrayFieldStore } from '../store/arrayFieldStore'; /** - * Custom Array Field Item Template with collapse and drag-and-drop support - * In RJSF 6.x, this template is called by ArrayField to render each item + * Custom animateLayoutChanges that always animates when wasDragging is true. + * This ensures smooth transitions after drag ends when items are reordered. + */ +const animateLayoutChanges: AnimateLayoutChanges = (arguments_) => defaultAnimateLayoutChanges({ ...arguments_, wasDragging: true }); + +/** + * Custom Array Field Item Template with collapse and dnd-kit drag-and-drop support + * Uses zustand store for state management to avoid re-render flashing */ export function ArrayFieldItemTemplate( props: ArrayFieldItemTemplateProps, ): React.ReactElement { const { children, index, hasToolbar, buttonsProps, registry, uiSchema } = props; const { t } = useTranslation('agent'); - const [expanded, setExpanded] = useState(false); + const formContext = registry.formContext as ExtendedFormContext | undefined; + + // Get item context - includes the stable fieldPath from parent ArrayFieldTemplate + const arrayItemContext = useArrayItemContext(); + const { itemData } = arrayItemContext; + + // Use arrayFieldPath from context (set by parent ArrayFieldTemplate) + // This ensures consistent path between template and item + const fieldPath = arrayItemContext.arrayFieldPath ?? 'array'; + + // Get ALL relevant store data in one subscription + // Use useShallow to prevent unnecessary re-renders + const { + expandedStates, + stableItemIds: allStableItemIds, + setItemExpanded, + registerMoveCallbacks, + } = useArrayFieldStore( + useShallow((state) => ({ + expandedStates: state.expandedStates, + stableItemIds: state.stableItemIds, + setItemExpanded: state.setItemExpanded, + registerMoveCallbacks: state.registerMoveCallbacks, + })), + ); + + // Get data for this specific item + const expanded = expandedStates[fieldPath]?.[index] ?? false; + const stableItemId = allStableItemIds[fieldPath]?.[index] ?? `item-${index}`; + + // Register move callbacks so they can be accessed during drag operations + useEffect(() => { + if (buttonsProps.hasMoveUp || buttonsProps.hasMoveDown) { + registerMoveCallbacks(fieldPath, index, { + onMoveUp: buttonsProps.onMoveUpItem, + onMoveDown: buttonsProps.onMoveDownItem, + }); + } + }, [fieldPath, index, buttonsProps.onMoveUpItem, buttonsProps.onMoveDownItem, buttonsProps.hasMoveUp, buttonsProps.hasMoveDown, registerMoveCallbacks]); + + // Use dnd-kit sortable with stable ID from store + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: stableItemId, + animateLayoutChanges, + }); const handleToggleExpanded = useCallback(() => { - setExpanded((previous) => !previous); - }, []); + setItemExpanded(fieldPath, index, !expanded); + }, [fieldPath, index, expanded, setItemExpanded]); + + // 获取当前项的数据来显示 caption + const itemCaption = useMemo(() => { + if (itemData && typeof itemData === 'object') { + const data = itemData as Record; + const caption = data.caption || data.title || ''; + return typeof caption === 'string' ? caption : ''; + } + return ''; + }, [itemData]); + + const itemEnabled = useMemo(() => (itemData as Record | undefined)?.enabled !== false, [itemData]); + + const handleToggleEnabled = useCallback(() => { + const pathSegments: Array | null = Array.isArray(arrayItemContext.arrayFieldPathSegments) + ? (arrayItemContext.arrayFieldPathSegments) + : null; + if (!formContext?.onFormDataChange || !formContext.rootFormData || !pathSegments || pathSegments.length === 0 || index === undefined) { + return; + } + + const newRootData = structuredClone(formContext.rootFormData); + let parent: Record | undefined = newRootData as Record; + for (let pathIndex = 0; pathIndex < pathSegments.length - 1; pathIndex += 1) { + parent = parent?.[pathSegments[pathIndex]] as Record | undefined; + if (!parent) return; + } + + const arrayKey = pathSegments[pathSegments.length - 1]; + const targetArray = Array.isArray(parent?.[arrayKey]) ? [...(parent?.[arrayKey] as unknown[])] : undefined; + if (!targetArray || targetArray[index] === undefined) { + return; + } + + const currentItem = { ...(targetArray[index] as Record ?? {}) }; + currentItem.enabled = !itemEnabled; + targetArray[index] = currentItem; + parent[arrayKey] = targetArray; + + formContext.onFormDataChange(newRootData as never); + }, [arrayItemContext.arrayFieldPathSegments, formContext, index, itemEnabled]); // Get the ArrayFieldItemButtonsTemplate to render buttons const uiOptions = getUiOptions(uiSchema); @@ -30,77 +127,125 @@ export function ArrayFieldItemTemplate + + theme.transitions.create(['border-color', 'opacity'], { + duration: theme.transitions.duration.short, + }), + }} + > + {/* Header with controls */} + theme.transitions.create(['background-color'], { + duration: theme.transitions.duration.short, + }), + '&:hover': { + bgcolor: 'action.hover', + }, }} > - {/* Header with controls */} + {/* Drag handle */} - {/* Drag handle */} - - - - - {/* Item title */} - - - {t('PromptConfig.ItemIndex', { index: index + 1 })} - - - - {/* Expand/collapse button */} - - {expanded ? : } - - - {/* Action buttons (remove, move up/down, etc.) */} - {hasToolbar && } + - {/* Content */} - {expanded && ( - - {children} + { + event.stopPropagation(); + }} + slotProps={{ input: { 'aria-label': t('Prompt.Enabled', { defaultValue: 'Enabled' }) } }} + sx={{ p: 0.5, mr: 0.5 }} + /> + + {/* Item title - 显示 caption 或 index */} + + + {itemCaption || t('PromptConfig.ItemIndex', { index: index + 1 })} + + + + {/* Expand/collapse button */} + { + event.stopPropagation(); + handleToggleExpanded(); + }} + title={expanded ? t('PromptConfig.Collapse') : t('PromptConfig.Expand')} + sx={{ color: 'text.secondary' }} + > + {expanded ? : } + + + {/* Action buttons (remove, move up/down, etc.) */} + {hasToolbar && ( + { + event.stopPropagation(); + }} + > + )} - + + {/* Content */} + {expanded && ( + + + {children} + + + )} + ); } diff --git a/src/pages/ChatTabContent/components/PromptPreviewDialog/PromptConfigForm/templates/ArrayFieldTemplate.tsx b/src/pages/ChatTabContent/components/PromptPreviewDialog/PromptConfigForm/templates/ArrayFieldTemplate.tsx index f937cca3..9bbf3101 100644 --- a/src/pages/ChatTabContent/components/PromptPreviewDialog/PromptConfigForm/templates/ArrayFieldTemplate.tsx +++ b/src/pages/ChatTabContent/components/PromptPreviewDialog/PromptConfigForm/templates/ArrayFieldTemplate.tsx @@ -1,20 +1,206 @@ +import { closestCenter, DndContext, DragEndEvent, DragOverlay, DragStartEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; +import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { Box, Typography } from '@mui/material'; import { ArrayFieldTemplateProps } from '@rjsf/utils'; -import React from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { useShallow } from 'zustand/react/shallow'; import { ArrayAddButton, ArrayContainer, ArrayHeader, ArrayItemCount, EmptyState, HelpTooltip, StyledFieldLabel } from '../components'; +import { ArrayItemProvider } from '../context/ArrayItemContext'; +import { ExtendedFormContext } from '../index'; +import { useArrayFieldStore } from '../store/arrayFieldStore'; /** - * Enhanced Array Field Template - * In RJSF 6.x, items are pre-rendered ReactElements, so we just display them - * The drag-and-drop and collapse logic is handled in ArrayFieldItemTemplate + * Enhanced Array Field Template with zustand state management + * Uses store to manage expanded states independently from RJSF */ export const ArrayFieldTemplate: React.FC = (props) => { - const { items, onAddClick, canAdd, title, schema } = props; + const { items, onAddClick, canAdd, title, schema, registry } = props; + const formData = props.formData as unknown[] | undefined; + const fieldPathId = (props as unknown as { fieldPathId?: { path: (string | number)[]; $id?: string } }).fieldPathId; const { t } = useTranslation('agent'); + // Get formContext for direct data manipulation + const formContext = registry.formContext as ExtendedFormContext | undefined; + const description = schema.description; + // Generate a stable field path for this array + // Use fieldPathId from RJSF which contains the actual property path + const fieldPath = useMemo(() => { + // First try to use fieldPathId which contains the actual path like ["prompts"] or ["prompts", 0, "children"] + if (fieldPathId?.path && Array.isArray(fieldPathId.path) && fieldPathId.path.length > 0) { + return fieldPathId.path.join('_'); + } + // Fallback to fieldPathId.$id with root_ prefix removed + if (fieldPathId?.$id) { + return fieldPathId.$id.replace(/^root_/, ''); + } + return title || 'array'; + }, [fieldPathId?.path, fieldPathId?.$id, title]); + + // Get ALL store data and functions in one subscription to avoid multiple subscriptions + // Use useShallow to prevent unnecessary re-renders when the returned object has the same values + const { + initializeField, + updateItemsData, + cleanupField, + moveItem, + stableItemIds: allStableItemIds, + itemsOrder: allItemsOrder, + } = useArrayFieldStore( + useShallow((state) => ({ + initializeField: state.initializeField, + updateItemsData: state.updateItemsData, + cleanupField: state.cleanupField, + moveItem: state.moveItem, + stableItemIds: state.stableItemIds, + itemsOrder: state.itemsOrder, + })), + ); + + // Get data for this specific fieldPath + const stableItemIds = allStableItemIds[fieldPath] ?? []; + const itemsOrder = allItemsOrder[fieldPath] ?? []; + + // Track active drag item for overlay + const [activeId, setActiveId] = useState(null); + + // Initialize store when component mounts or items change + useEffect(() => { + const itemsData = Array.isArray(formData) ? formData : []; + initializeField(fieldPath, items.length, itemsData); + }, [fieldPath, items.length, initializeField]); + + // Update store when formData changes (from RJSF) + // Use ref to track previous formData and only update when content actually changes + const previousFormDataReference = React.useRef(undefined); + const previousLengthReference = React.useRef(-1); + + useEffect(() => { + const itemsData = Array.isArray(formData) ? formData : []; + const currentLength = itemsData.length; + + // Only update if: + // 1. This is the first render (previousLengthReference.current === -1) + // 2. Length changed + // 3. Content changed (only check if length is the same) + if (previousLengthReference.current === -1 || previousLengthReference.current !== currentLength) { + previousFormDataReference.current = itemsData; + previousLengthReference.current = currentLength; + updateItemsData(fieldPath, itemsData); + } else if (previousFormDataReference.current) { + // Length is the same, do a shallow comparison of items + let hasChanged = false; + for (let itemIndex = 0; itemIndex < itemsData.length; itemIndex++) { + if (itemsData[itemIndex] !== previousFormDataReference.current[itemIndex]) { + hasChanged = true; + break; + } + } + if (hasChanged) { + previousFormDataReference.current = itemsData; + updateItemsData(fieldPath, itemsData); + } + } + }, [formData, fieldPath, updateItemsData]); + + // Cleanup on unmount + useEffect(() => { + return () => { + cleanupField(fieldPath); + }; + }, [fieldPath, cleanupField]); + + // dnd-kit sensors with activation constraint + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }), + ); + + // Use stable IDs from store, fallback to index-based IDs if store not initialized yet + const itemIds = useMemo(() => { + if (stableItemIds.length === items.length) { + return stableItemIds; + } + // Fallback during initialization + return items.map((_, index) => `item-${index}`); + }, [stableItemIds, items.length]); + + const handleDragStart = useCallback((event: DragStartEvent) => { + setActiveId(String(event.active.id)); + }, []); + + const handleDragEnd = useCallback((event: DragEndEvent) => { + const { active, over } = event; + setActiveId(null); + + if (!over || active.id === over.id) { + return; + } + + // Find indices by looking up the stable IDs + const oldIndex = itemIds.indexOf(String(active.id)); + const newIndex = itemIds.indexOf(String(over.id)); + + if (oldIndex === -1 || newIndex === -1) { + return; + } + if (!formData || !formContext?.onFormDataChange || !formContext.rootFormData) { + return; + } + + // IMPORTANT: Update store state FIRST (synchronously) for optimistic rendering + // This moves stableItemIds, expandedStates, and itemsOrder together + // The component will immediately re-render with the new order, avoiding the + // ~500-700ms delay while waiting for RJSF to update formData + moveItem(fieldPath, oldIndex, newIndex); + + // Create the new array data with reordered items + const newArrayData = arrayMove([...formData], oldIndex, newIndex); + + // Update the root form data with the reordered array + // This triggers RJSF to re-render, but the UI already shows the new order + // thanks to the optimistic update above + const path = fieldPathId?.path; + if (!path || path.length === 0) { + // If no path, this array is the root (unlikely but handle it) + formContext.onFormDataChange(newArrayData as never); + return; + } + + // Deep clone and update the nested array + const newRootData = structuredClone(formContext.rootFormData); + let current: Record = newRootData; + + // Navigate to parent of the array + for (let pathIndex = 0; pathIndex < path.length - 1; pathIndex++) { + const key = path[pathIndex]; + current = current[key] as Record; + } + + // Set the array at the final path segment + const finalKey = path[path.length - 1]; + current[finalKey] = newArrayData; + + formContext.onFormDataChange(newRootData as never); + }, [formData, formContext, fieldPathId, itemIds, moveItem, fieldPath]); + + const handleDragCancel = useCallback(() => { + setActiveId(null); + }, []); + + // Find active item for drag overlay using stable IDs + const activeItem = useMemo(() => { + if (!activeId) return null; + const activeIndex = itemIds.indexOf(activeId); + if (activeIndex === -1) return null; + return items[activeIndex]; + }, [activeId, items, itemIds]); + return ( {title && ( @@ -43,9 +229,56 @@ export const ArrayFieldTemplate: React.FC = (props) => ) : ( - - {items} - + + + + {itemsOrder.map((originalIndex, renderPosition) => { + // itemsOrder[renderPosition] tells us which original item should be at this position + // This enables optimistic rendering - store updates immediately, RJSF updates later + const item = items[originalIndex]; + const itemData = formData?.[originalIndex]; + const stableId = itemIds[renderPosition]; + + if (!item) return null; + + return ( + + {item} + + ); + })} + + + + {activeItem + ? ( + + {activeItem} + + ) + : null} + + )} {canAdd && ( diff --git a/src/pages/ChatTabContent/components/PromptPreviewDialog/__tests__/PromptPreviewDialog.promptConcat.test.tsx b/src/pages/ChatTabContent/components/PromptPreviewDialog/__tests__/PromptPreviewDialog.promptConcat.test.tsx index 58e1b88a..f0f91ad2 100644 --- a/src/pages/ChatTabContent/components/PromptPreviewDialog/__tests__/PromptPreviewDialog.promptConcat.test.tsx +++ b/src/pages/ChatTabContent/components/PromptPreviewDialog/__tests__/PromptPreviewDialog.promptConcat.test.tsx @@ -50,7 +50,6 @@ describe('PromptPreviewDialog - Tool Information Rendering', () => { previewCurrentPlugin: null, lastUpdated: null, formFieldsToScrollTo: [], - expandedArrayItems: new Map(), }); // Clear all mock calls @@ -108,6 +107,48 @@ describe('PromptPreviewDialog - Tool Information Rendering', () => { expect(hasValidResults).toBe(true); }); + it('should not inject tool info for disabled plugins', async () => { + const baseConfig = defaultAgents[0].agentFrameworkConfig as Record; + const agentFrameworkConfig = structuredClone(baseConfig) as { plugins?: Array> }; + + if (Array.isArray(agentFrameworkConfig.plugins)) { + agentFrameworkConfig.plugins = agentFrameworkConfig.plugins.map((plugin) => { + if (plugin.toolId === 'wikiOperation') { + return { ...plugin, enabled: false }; + } + return plugin; + }); + } + + const messages = [{ id: 'test', role: 'user' as const, content: 'Hello world', created: new Date(), modified: new Date(), agentId: 'test' }]; + const observable = window.observables.agentInstance.concatPrompt({ agentFrameworkConfig } as never, messages); + + let finalState: unknown; + await new Promise((resolve) => { + observable.subscribe({ + next: (state) => { + const s = state as { isComplete?: boolean }; + if (s.isComplete) { + finalState = state; + } + }, + complete: () => { + resolve(); + }, + error: () => { + resolve(); + }, + }); + }); + + expect(isPreviewResult(finalState)).toBe(true); + if (!isPreviewResult(finalState)) return; + + const allPromptsText = JSON.stringify(finalState.processedPrompts); + expect(allPromptsText).not.toContain('wiki-operation'); + expect(allPromptsText).toContain('wiki-search'); + }); + // Type guard for preview result shape const isPreviewResult = (v: unknown): v is { flatPrompts: ModelMessage[]; processedPrompts: IPrompt[] } => { if (!v || typeof v !== 'object') return false; @@ -274,21 +315,21 @@ describe('PromptPreviewDialog - Tool Information Rendering', () => { expect(wikiOperationElement).toBeDefined(); const wikiOperationText = `${wikiOperationElement?.caption ?? ''} ${wikiOperationElement?.text ?? ''}`; expect(wikiOperationText).toContain('## wiki-operation'); - expect(wikiOperationText).toContain('在Wiki工作空间中执行操作'); + // Description may be English for new architecture + expect(wikiOperationText.toLowerCase()).toMatch(/perform operations|执行操作/); // Check for wiki search tool insertion (from wikiSearch plugin) let wikiSearchElement: IPrompt | undefined = childrenAfterBeforeTool.find((c: IPrompt) => { const body = `${c.caption ?? ''} ${c.text ?? ''}`; - return /Available Tools:/i.test(body) || /Tool ID:\s*wiki-search/i.test(body) || /wiki-search/i.test(body); + return /wiki-search/i.test(body); }); if (!wikiSearchElement) { - wikiSearchElement = findPromptNodeByText(result?.processedPrompts, /Available Tools:/i) || findPromptNodeByText(result?.processedPrompts, /Tool ID:\s*wiki-search/i) || - findPromptNodeByText(result?.processedPrompts, /wiki-search/i); + wikiSearchElement = findPromptNodeByText(result?.processedPrompts, /wiki-search/i); } expect(wikiSearchElement).toBeDefined(); const wikiSearchText = `${wikiSearchElement?.caption ?? ''} ${wikiSearchElement?.text ?? ''}`; - expect(wikiSearchText).toContain('Wiki search tool'); - expect(wikiSearchText).toContain('## wiki-search'); + // Check that wiki-search tool is mentioned in the content (either as tool ID or in description) + expect(wikiSearchText.toLowerCase()).toContain('wiki-search'); // Verify the order: before-tool -> workspaces -> wiki-operation -> wiki-search -> post-tool const postToolElement: IPrompt | undefined = toolsSection?.children?.find((c: IPrompt) => c.id === 'default-post-tool'); diff --git a/src/pages/ChatTabContent/components/PromptPreviewDialog/__tests__/PromptPreviewDialog.ui.test.tsx b/src/pages/ChatTabContent/components/PromptPreviewDialog/__tests__/PromptPreviewDialog.ui.test.tsx index 8ceb0be2..01bb824a 100644 --- a/src/pages/ChatTabContent/components/PromptPreviewDialog/__tests__/PromptPreviewDialog.ui.test.tsx +++ b/src/pages/ChatTabContent/components/PromptPreviewDialog/__tests__/PromptPreviewDialog.ui.test.tsx @@ -45,7 +45,6 @@ describe('PromptPreviewDialog - Tool Information Rendering', () => { previewCurrentPlugin: null, lastUpdated: null, formFieldsToScrollTo: [], - expandedArrayItems: new Map(), }); }); @@ -56,6 +55,12 @@ describe('PromptPreviewDialog - Tool Information Rendering', () => { }); it('should render dialog when open=true', async () => { + act(() => { + useAgentChatStore.setState({ + previewResult: { flatPrompts: [], processedPrompts: [] }, + }); + }); + render( { // IMPROVED: Example of testing with state changes using real store it('should handle loading states properly', async () => { + vi.useFakeTimers(); + // Set initial loading state using real store (wrap in act) act(() => { useAgentChatStore.setState({ @@ -95,6 +102,11 @@ describe('PromptPreviewDialog - Tool Information Rendering', () => { , ); + // PreviewProgressBar is debounced to avoid flashing + act(() => { + vi.advanceTimersByTime(250); + }); + // Should show loading indicator via visible text expect(screen.getByText('Starting...')).toBeInTheDocument(); expect(screen.getByText('⚡ Live preview - this is not the final version and is still loading')).toBeInTheDocument(); @@ -112,5 +124,7 @@ describe('PromptPreviewDialog - Tool Information Rendering', () => { const currentState = useAgentChatStore.getState(); expect(currentState.previewLoading).toBe(false); expect(currentState.previewProgress).toBe(1); + + vi.useRealTimers(); }); }); diff --git a/src/pages/ChatTabContent/components/PromptPreviewDialog/index.tsx b/src/pages/ChatTabContent/components/PromptPreviewDialog/index.tsx index 0275da41..fd79b42c 100644 --- a/src/pages/ChatTabContent/components/PromptPreviewDialog/index.tsx +++ b/src/pages/ChatTabContent/components/PromptPreviewDialog/index.tsx @@ -1,4 +1,5 @@ import { useAgentFrameworkConfigManagement } from '@/windows/Preferences/sections/ExternalAPI/useAgentFrameworkConfigManagement'; +import ArticleIcon from '@mui/icons-material/Article'; import CloseIcon from '@mui/icons-material/Close'; import EditIcon from '@mui/icons-material/Edit'; import FullscreenIcon from '@mui/icons-material/Fullscreen'; @@ -22,18 +23,22 @@ interface PromptPreviewDialogProps { open: boolean; onClose: () => void; inputText?: string; + initialBaseMode?: 'preview' | 'edit'; } export const PromptPreviewDialog: React.FC = ({ open, onClose, inputText = '', + initialBaseMode = 'preview', }) => { const { t } = useTranslation('agent'); const agent = useAgentChatStore(state => state.agent); const [isFullScreen, setIsFullScreen] = useState(false); - const [isEditMode, setIsEditMode] = useState(false); + const [baseMode, setBaseMode] = useState<'preview' | 'edit'>(initialBaseMode); + const [showSideBySide, setShowSideBySide] = useState(false); + const [baseModeBeforeSideBySide, setBaseModeBeforeSideBySide] = useState<'preview' | 'edit'>(initialBaseMode); const { loading: agentFrameworkConfigLoading, @@ -71,10 +76,19 @@ export const PromptPreviewDialog: React.FC = ({ }, []); const handleToggleEditMode = useCallback((): void => { - setIsEditMode(previous => !previous); - }, []); + setShowSideBySide(previous => { + if (!previous) { + // Entering side-by-side, save current baseMode + setBaseModeBeforeSideBySide(baseMode); + } else { + // Exiting side-by-side, restore previous baseMode + setBaseMode(baseModeBeforeSideBySide); + } + return !previous; + }); + }, [baseMode, baseModeBeforeSideBySide]); - // Listen for form field scroll targets to automatically switch to edit mode + // Listen for form field scroll targets to automatically switch to side-by-side mode const { formFieldsToScrollTo } = useAgentChatStore( useShallow((state) => ({ formFieldsToScrollTo: state.formFieldsToScrollTo, @@ -82,9 +96,29 @@ export const PromptPreviewDialog: React.FC = ({ ); useEffect(() => { if (formFieldsToScrollTo.length > 0) { - setIsEditMode(true); + // Save current baseMode before switching to side-by-side + setBaseModeBeforeSideBySide(baseMode); + setBaseMode('edit'); + setShowSideBySide(true); // Show side-by-side when clicking from PromptTree } - }, [formFieldsToScrollTo]); + }, [formFieldsToScrollTo, baseMode]); + + useEffect(() => { + if (open) { + setBaseMode(initialBaseMode); + setShowSideBySide(false); + } + }, [initialBaseMode, open]); + + const showPreview = showSideBySide || baseMode === 'preview'; + const showEdit = showSideBySide || baseMode === 'edit'; + const isSideBySide = showSideBySide; + + const sideBySideTooltip = isSideBySide + ? t('Prompt.ExitSideBySide') + : baseMode === 'edit' + ? t('Prompt.EnterPreviewSideBySide') + : t('Prompt.EnterEditSideBySide'); return ( = ({ {t('Prompt.Preview')} - + - {isEditMode ? : } + {isSideBySide ? : baseMode === 'edit' ? : } {isFullScreen ? : } @@ -148,42 +182,54 @@ export const PromptPreviewDialog: React.FC = ({ }), }} > - - {isEditMode - ? ( - - - - - - - + {showPreview && showEdit && ( + + + + - ) - : ( + + + + + )} + + {showPreview && !showEdit && ( + + - )} + + )} + + {showEdit && !showPreview && ( + + + + )} ); diff --git a/src/pages/ChatTabContent/components/PromptTree.tsx b/src/pages/ChatTabContent/components/PromptTree.tsx index e6c1466a..bda31c17 100644 --- a/src/pages/ChatTabContent/components/PromptTree.tsx +++ b/src/pages/ChatTabContent/components/PromptTree.tsx @@ -1,6 +1,6 @@ import { Box, styled, Typography } from '@mui/material'; import { IPrompt } from '@services/agentInstance/promptConcat/promptConcatSchema'; -import React from 'react'; +import React, { memo, useCallback } from 'react'; import { useShallow } from 'zustand/react/shallow'; import { useAgentChatStore } from '../../Agent/store/agentChatStore/index'; @@ -38,8 +38,9 @@ const EmptyState = styled(Box)(({ theme }) => ({ /** * Prompt tree node component for nested display + * Memoized to prevent unnecessary re-renders */ -export const PromptTreeNode = ({ +export const PromptTreeNode = memo(({ node, depth, fieldPath = [], @@ -48,20 +49,22 @@ export const PromptTreeNode = ({ depth: number; fieldPath?: string[]; }): React.ReactElement => { - const { setFormFieldsToScrollTo, expandPathToTarget } = useAgentChatStore( + if (node.enabled === false) { + return <>; + } + + const { setFormFieldsToScrollTo } = useAgentChatStore( useShallow((state) => ({ setFormFieldsToScrollTo: state.setFormFieldsToScrollTo, - expandPathToTarget: state.expandPathToTarget, })), ); - const handleNodeClick = (event: React.MouseEvent) => { + const handleNodeClick = useCallback((event: React.MouseEvent) => { event.stopPropagation(); const targetFieldPath = (node.source && node.source.length > 0) ? node.source : [...fieldPath, node.id]; setFormFieldsToScrollTo(targetFieldPath); - expandPathToTarget(targetFieldPath); - }; + }, [node.source, node.id, fieldPath, setFormFieldsToScrollTo]); return ( ); -}; +}); +PromptTreeNode.displayName = 'PromptTreeNode'; /** * Prompt tree component + * Memoized to prevent unnecessary re-renders */ -export const PromptTree = ({ prompts }: { prompts?: IPrompt[] }): React.ReactElement => { +export const PromptTree = memo(({ prompts }: { prompts?: IPrompt[] }): React.ReactElement => { if (!prompts?.length) { return No prompt tree to display; } + const enabledPrompts = prompts.filter((prompt) => prompt.enabled !== false); + return ( - {prompts.map((item) => { + {enabledPrompts.map((item) => { const fieldPath = ['prompts', item.id]; return ; })} ); -}; +}); +PromptTree.displayName = 'PromptTree'; diff --git a/src/pages/ChatTabContent/components/__tests__/PromptTree.test.tsx b/src/pages/ChatTabContent/components/__tests__/PromptTree.test.tsx new file mode 100644 index 00000000..cb8e64c1 --- /dev/null +++ b/src/pages/ChatTabContent/components/__tests__/PromptTree.test.tsx @@ -0,0 +1,60 @@ +/** + * Tests for PromptTree component + */ +import { ThemeProvider } from '@mui/material/styles'; +import { lightTheme } from '@services/theme/defaultTheme'; +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import type { IPrompt } from '@services/agentInstance/promptConcat/promptConcatSchema'; +import { PromptTree } from '../PromptTree'; + +const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + + {children} + +); + +describe('PromptTree', () => { + it('should hide children when parent is disabled', () => { + const prompts: IPrompt[] = [ + { + id: 'disabled-parent', + caption: 'Disabled Parent', + enabled: false, + children: [ + { + id: 'child-should-not-show', + caption: 'Child Should Not Show', + enabled: true, + text: 'invisible', + }, + ], + }, + { + id: 'enabled-parent', + caption: 'Enabled Parent', + enabled: true, + children: [ + { + id: 'child-should-show', + caption: 'Child Should Show', + enabled: true, + text: 'visible', + }, + ], + }, + ]; + + render( + + + , + ); + + expect(screen.queryByText('Disabled Parent')).not.toBeInTheDocument(); + expect(screen.queryByText('Child Should Not Show')).not.toBeInTheDocument(); + expect(screen.getByText('Enabled Parent')).toBeInTheDocument(); + expect(screen.getByText('Child Should Show')).toBeInTheDocument(); + }); +}); diff --git a/src/pages/Main/ContentLoading.tsx b/src/pages/Main/ContentLoading.tsx new file mode 100644 index 00000000..b36d806c --- /dev/null +++ b/src/pages/Main/ContentLoading.tsx @@ -0,0 +1,21 @@ +import { CircularProgress } from '@mui/material'; +import { styled } from '@mui/material/styles'; + +const LoadingRoot = styled('div')` + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + background-color: ${({ theme }) => theme.palette.background.default}; +`; + +export function ContentLoading(): React.JSX.Element { + return ( + + + + ); +} diff --git a/src/pages/Main/WorkspaceIconAndSelector/SortableWorkspaceSelectorList.tsx b/src/pages/Main/WorkspaceIconAndSelector/SortableWorkspaceSelectorList.tsx index f9b48759..5efdbe2e 100644 --- a/src/pages/Main/WorkspaceIconAndSelector/SortableWorkspaceSelectorList.tsx +++ b/src/pages/Main/WorkspaceIconAndSelector/SortableWorkspaceSelectorList.tsx @@ -1,12 +1,11 @@ -import { DndContext, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; +import { DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; import { restrictToVerticalAxis } from '@dnd-kit/modifiers'; import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; -import { useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { PageType } from '@/constants/pageTypes'; import { WindowNames } from '@services/windows/WindowProperties'; import { IWorkspace, IWorkspaceWithMetadata } from '@services/workspaces/interface'; -import { workspaceSorter } from '@services/workspaces/utilities'; import { SortableWorkspaceSelectorButton } from './SortableWorkspaceSelectorButton'; export interface ISortableListProps { @@ -26,53 +25,90 @@ export function SortableWorkspaceSelectorList({ workspacesList, showSideBarText, const isMiniWindow = window.meta().windowName === WindowNames.tidgiMiniWindow; + // Optimistic order state - stores workspace IDs in the order they should be displayed + // This updates immediately on drag end, before the backend confirms the change + const [optimisticOrder, setOptimisticOrder] = useState(null); + // Track if we're waiting for backend to confirm the reorder + const pendingReorderReference = useRef(false); + // Filter out 'add' workspace in mini window - const filteredWorkspacesList = useMemo(() => { + const baseFilteredList = useMemo(() => { if (isMiniWindow) { return workspacesList.filter((workspace) => workspace.pageType !== PageType.add); } return workspacesList; }, [isMiniWindow, workspacesList]); + // Apply optimistic order if present, otherwise use natural order from props + const filteredWorkspacesList = useMemo(() => { + if (optimisticOrder === null) { + // No optimistic order, sort by order property + return [...baseFilteredList].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); + } + // Apply optimistic order + const orderMap = new Map(optimisticOrder.map((id, index) => [id, index])); + return [...baseFilteredList].sort((a, b) => { + const orderA = orderMap.get(a.id) ?? a.order ?? 0; + const orderB = orderMap.get(b.id) ?? b.order ?? 0; + return orderA - orderB; + }); + }, [baseFilteredList, optimisticOrder]); + + // When workspacesList updates from backend, clear optimistic order if pending + useEffect(() => { + if (pendingReorderReference.current) { + pendingReorderReference.current = false; + setOptimisticOrder(null); + } + }, [workspacesList]); + const workspaceIDs = filteredWorkspacesList.map((workspace) => workspace.id); + const handleDragEnd = useCallback(async (event: DragEndEvent) => { + const { active, over } = event; + if (over === null || active.id === over.id) return; + + const activeId = String(active.id); + const overId = String(over.id); + + const oldIndex = filteredWorkspacesList.findIndex(workspace => workspace.id === activeId); + const newIndex = filteredWorkspacesList.findIndex(workspace => workspace.id === overId); + + if (oldIndex === -1 || newIndex === -1) return; + + // OPTIMISTIC UPDATE: Immediately update the display order + const newOrderedList = arrayMove(filteredWorkspacesList, oldIndex, newIndex); + const newOrder = newOrderedList.map(w => w.id); + setOptimisticOrder(newOrder); + pendingReorderReference.current = true; + + // Prepare data for backend update + const newWorkspaces: Record = {}; + newOrderedList.forEach((workspace, index) => { + newWorkspaces[workspace.id] = { ...workspace }; + newWorkspaces[workspace.id].order = index; + }); + + // Update backend (this will eventually trigger workspacesList update via Observable) + await window.service.workspace.setWorkspaces(newWorkspaces); + }, [filteredWorkspacesList]); + return ( { - if (over === null || active.id === over.id) return; - - const activeId = String(active.id); - const overId = String(over.id); - - const oldIndex = workspacesList.findIndex(workspace => workspace.id === activeId); - const newIndex = workspacesList.findIndex(workspace => workspace.id === overId); - - if (oldIndex === -1 || newIndex === -1) return; - - const newWorkspacesList = arrayMove(workspacesList, oldIndex, newIndex); - const newWorkspaces: Record = {}; - newWorkspacesList.forEach((workspace, index) => { - newWorkspaces[workspace.id] = workspace; - newWorkspaces[workspace.id].order = index; - }); - - await window.service.workspace.setWorkspaces(newWorkspaces); - }} + onDragEnd={handleDragEnd} > - {filteredWorkspacesList - .sort(workspaceSorter) - .map((workspace, index) => ( - - ))} + {filteredWorkspacesList.map((workspace, index) => ( + + ))} ); diff --git a/src/pages/Main/index.tsx b/src/pages/Main/index.tsx index d0a0c1e3..b862c27f 100644 --- a/src/pages/Main/index.tsx +++ b/src/pages/Main/index.tsx @@ -1,12 +1,13 @@ import { Helmet } from '@dr.pogodin/react-helmet'; import { styled } from '@mui/material/styles'; -import { lazy } from 'react'; +import { lazy, Suspense } from 'react'; import { useTranslation } from 'react-i18next'; import { Route, Switch } from 'wouter'; import { PageType } from '@/constants/pageTypes'; import { usePreferenceObservable } from '@services/preferences/hooks'; import { WindowNames } from '@services/windows/WindowProperties'; +import { ContentLoading } from './ContentLoading'; import FindInPage from './FindInPage'; import { SideBar } from './Sidebar'; import { useInitialPage } from './useInitialPage'; @@ -77,14 +78,16 @@ export default function Main(): React.JSX.Element { {showSidebar && } - - - - - - - - + }> + + + + + + + + + diff --git a/src/services/agentInstance/agentFrameworks/__tests__/taskAgent.test.ts b/src/services/agentInstance/agentFrameworks/__tests__/taskAgent.test.ts index 03d7eb11..233a16de 100644 --- a/src/services/agentInstance/agentFrameworks/__tests__/taskAgent.test.ts +++ b/src/services/agentInstance/agentFrameworks/__tests__/taskAgent.test.ts @@ -29,7 +29,7 @@ let testStreamResponses: Array<{ status: string; content: string; requestId: str // Import plugin components for direct testing import type { IPromptConcatTool } from '@services/agentInstance/promptConcat/promptConcatSchema'; import type { IDatabaseService } from '@services/database/interface'; -import { createAgentFrameworkHooks, createHooksWithTools, initializeToolSystem, PromptConcatHookContext } from '../../tools/index'; +import { createAgentFrameworkHooks, createHooksWithPlugins, initializePluginSystem, PromptConcatHookContext } from '../../tools/index'; import { wikiSearchTool } from '../../tools/wikiSearch'; import { basicPromptConcatHandler } from '../taskAgent'; import type { AgentFrameworkContext } from '../utilities/type'; @@ -42,7 +42,7 @@ describe('WikiSearch Plugin Integration & YieldNextRound Mechanism', () => { const { container } = await import('@services/container'); // Ensure built-in tool registry includes all built-in tools - await initializeToolSystem(); + await initializePluginSystem(); // Prepare a mock DataSource/repository so AgentInstanceService.initialize() can run const mockRepo = { @@ -127,8 +127,8 @@ describe('WikiSearch Plugin Integration & YieldNextRound Mechanism', () => { ], }; - // Create hooks and register tools as defined in agentFrameworkConfig - const { hooks: promptHooks } = await createHooksWithTools(agentFrameworkConfig); + // Create hooks and register plugins as defined in agentFrameworkConfig + const { hooks: promptHooks } = await createHooksWithPlugins(agentFrameworkConfig); // First run workspacesList tool to inject available workspaces (if present) const workspacesPlugin = agentFrameworkConfig.plugins?.find(p => p.toolId === 'workspacesList'); if (workspacesPlugin) { @@ -200,7 +200,7 @@ describe('WikiSearch Plugin Integration & YieldNextRound Mechanism', () => { }; // Use hooks registered with all plugins import { AgentFrameworkConfig } - const { hooks: responseHooks } = await createHooksWithTools(agentFrameworkConfig); + const { hooks: responseHooks } = await createHooksWithPlugins(agentFrameworkConfig); // Execute the response complete hook await responseHooks.responseComplete.promise(responseContext); // reuse containerForAssert from above assertions diff --git a/src/services/agentInstance/agentFrameworks/taskAgent.ts b/src/services/agentInstance/agentFrameworks/taskAgent.ts index c4eb3cfe..8c02d228 100644 --- a/src/services/agentInstance/agentFrameworks/taskAgent.ts +++ b/src/services/agentInstance/agentFrameworks/taskAgent.ts @@ -5,10 +5,10 @@ import serviceIdentifier from '@services/serviceIdentifier'; import { merge } from 'lodash'; import type { AgentInstanceLatestStatus, AgentInstanceMessage, IAgentInstanceService } from '../interface'; import { AgentFrameworkConfig, AgentPromptDescription, AiAPIConfig } from '../promptConcat/promptConcatSchema'; -import type { IPromptConcatTool } from '../promptConcat/promptConcatSchema/plugin'; +import type { IPromptConcatTool } from '../promptConcat/promptConcatSchema/tools'; import { responseConcat } from '../promptConcat/responseConcat'; import { getFinalPromptResult } from '../promptConcat/utilities'; -import { createHooksWithTools } from '../tools'; +import { createHooksWithPlugins } from '../tools'; import { YieldNextRoundTarget } from '../tools/types'; import { canceled, completed, error, working } from './utilities/statusUtilities'; import { AgentFrameworkContext } from './utilities/type'; @@ -32,7 +32,7 @@ export async function* basicPromptConcatHandler(context: AgentFrameworkContext) let currentRequestId: string | undefined; const lastUserMessage: AgentInstanceMessage | undefined = context.agent.messages[context.agent.messages.length - 1]; // Create and register handler hooks based on framework config - const { hooks: agentFrameworkHooks, toolConfigs } = await createHooksWithTools(context.agentDef.agentFrameworkConfig || {}); + const { hooks: agentFrameworkHooks, pluginConfigs } = await createHooksWithPlugins(context.agentDef.agentFrameworkConfig || {}); // Log the start of handler execution with context information logger.debug('Starting prompt handler execution', { @@ -175,7 +175,7 @@ export async function* basicPromptConcatHandler(context: AgentFrameworkContext) response, requestId: currentRequestId, isFinal: true, - toolConfig: (toolConfigs.length > 0 ? toolConfigs[0] : {}) as IPromptConcatTool, // First config for compatibility + toolConfig: (pluginConfigs.length > 0 ? pluginConfigs[0] : {}) as IPromptConcatTool, // First config for compatibility agentFrameworkConfig: context.agentDef.agentFrameworkConfig, // Pass complete config for tool access actions: undefined as { yieldNextRoundTo?: 'self' | 'human'; newUserMessage?: string } | undefined, }; diff --git a/src/services/agentInstance/agentFrameworks/taskAgents.json b/src/services/agentInstance/agentFrameworks/taskAgents.json index 90dd74dd..403d3819 100644 --- a/src/services/agentInstance/agentFrameworks/taskAgents.json +++ b/src/services/agentInstance/agentFrameworks/taskAgents.json @@ -115,6 +115,35 @@ } } }, + { + "id": "f2e3c4d5-6e7f-8g9h-0i1j-k2l3m4n5o6p7", + "caption": "TiddlyWiki AI标签工具", + "description": "读取带有标准AI标签的tiddler(数据源、描述、操作)", + "toolId": "tiddlywikiPlugin", + "tiddlyWikiPluginParam": { + "workspaceNameOrID": "wiki", + "dataSourceTag": "$:/tags/AI/Prompt/DataSource", + "describeTag": "$:/tags/AI/Prompt/Describe", + "actionsTag": "$:/tags/AI/Prompt/Actions", + "enableCache": true, + "toolListPosition": { + "position": "after", + "targetId": "default-before-tool" + } + } + }, + { + "id": "g3f4e5d6-7e8f-9g0h-1i2j-l3m4n5o6p7q8", + "caption": "Git版本历史工具", + "description": "搜索git提交记录并读取特定提交的文件内容", + "toolId": "git", + "gitParam": { + "toolListPosition": { + "position": "after", + "targetId": "default-before-tool" + } + } + }, { "id": "a0f1b2c3-4d5e-6f7g-8h9i-j0k1l2m3n4o5", "toolId": "fullReplacement", diff --git a/src/services/agentInstance/index.ts b/src/services/agentInstance/index.ts index b6314e02..34b87155 100644 --- a/src/services/agentInstance/index.ts +++ b/src/services/agentInstance/index.ts @@ -10,7 +10,7 @@ import type { AgentFramework, AgentFrameworkContext } from '@services/agentInsta import { promptConcatStream, PromptConcatStreamState } from '@services/agentInstance/promptConcat/promptConcat'; import type { AgentPromptDescription } from '@services/agentInstance/promptConcat/promptConcatSchema'; import { getPromptConcatAgentFrameworkConfigJsonSchema } from '@services/agentInstance/promptConcat/promptConcatSchema/jsonSchema'; -import { createHooksWithTools, initializeToolSystem } from '@services/agentInstance/tools'; +import { createHooksWithPlugins, initializePluginSystem } from '@services/agentInstance/tools'; import type { IDatabaseService } from '@services/database/interface'; import { AgentInstanceEntity, AgentInstanceMessageEntity } from '@services/database/schema/agent'; import { logger } from '@services/libs/log'; @@ -65,7 +65,7 @@ export class AgentInstanceService implements IAgentInstanceService { public async initializeFrameworks(): Promise { try { // Register tools to global registry once during initialization - await initializeToolSystem(); + await initializePluginSystem(); logger.debug('AgentInstance Tool system initialized and tools registered to global registry'); // Register built-in frameworks @@ -379,8 +379,8 @@ export class AgentInstanceService implements IAgentInstanceService { isCancelled: () => cancelToken.value, }; - // Create fresh hooks for this framework execution and register tools based on frameworkConfig - const { hooks: frameworkHooks } = await createHooksWithTools(agentDefinition.agentFrameworkConfig || {}); + // Create fresh hooks for this framework execution and register plugins based on frameworkConfig + const { hooks: frameworkHooks } = await createHooksWithPlugins(agentDefinition.agentFrameworkConfig || {}); // Trigger userMessageReceived hook with the configured tools await frameworkHooks.userMessageReceived.promise({ diff --git a/src/services/agentInstance/promptConcat/Readme.md b/src/services/agentInstance/promptConcat/Readme.md index 8e8178f5..4d421448 100644 --- a/src/services/agentInstance/promptConcat/Readme.md +++ b/src/services/agentInstance/promptConcat/Readme.md @@ -14,43 +14,103 @@ The `promptConcat` function uses a tapable hooks-based tool system. Built-in too - `processPrompts`: Modifies prompt tree during processing - `finalizePrompts`: Final processing before LLM call - `postProcess`: Handles response processing + - `responseComplete`: Called when AI response is done (for tool execution) -2. **Built-in Tools**: - - `fullReplacement`: Replaces content from various sources - - `dynamicPosition`: Inserts content at specific positions - - `retrievalAugmentedGeneration`: Retrieves content from wiki/external sources - - `modelContextProtocol`: Integrates with external MCP servers - - `toolCalling`: Processes function calls in responses +2. **Built-in Components**: + - **Internal Plugins** (for prompt processing): + - `fullReplacement`: Replaces content from various sources + - `dynamicPosition`: Inserts content at specific positions + - `workspacesList`: Inject available workspaces into prompts + - **LLM-Callable Tools** (AI can invoke): + - `wikiSearch`: Search wiki content with filter or vector search + - `wikiOperation`: Create, update, delete tiddlers, or invoke action tiddlers + - `git`: Search git commit history and read file content from specific commits + - `tiddlywikiPlugin`: Load TiddlyWiki plugin metadata (DataSource, Describe, Actions tags) + - `modelContextProtocol`: Integrates with external MCP servers 3. **Tool Registration**: - - Tools are registered by `toolId` field in the `plugins` array + - Tools created with `registerToolDefinition` are auto-registered - Each tool instance has its own configuration parameters - - Built-in tools are auto-registered on system initialization -### Tool Lifecycle +### Adding New Tools (New API) -1. **Registration**: Tools are registered during initialization -2. **Configuration**: Tools are loaded based on `agentFrameworkConfig.plugins` array -3. **Execution**: Hooks execute tools in registration order -4. **Error Handling**: Individual tool failures don't stop the pipeline +Use the `registerToolDefinition` function for a declarative, low-boilerplate approach: -### Adding New Tools +```typescript +import { z } from 'zod/v4'; +import { registerToolDefinition } from './defineTool'; -1. Create tool function in `tools/` directory -2. Register in `tools/index.ts` -3. Add `toolId` to schema enum -4. Add parameter schema if needed +// 1. Define config schema (user-configurable in UI) +const MyToolConfigSchema = z.object({ + targetId: z.string(), + enabled: z.boolean().optional().default(true), +}); -Each tool receives a hooks object and registers handlers for specific hook points. Tools can modify prompt trees, inject content, process responses, and trigger additional LLM calls. +// 2. Define LLM-callable tool schema (injected into prompts) +const MyLLMToolSchema = z.object({ + query: z.string(), + limit: z.number().optional().default(10), +}).meta({ + title: 'my-tool', // Tool name for LLM + description: 'Search for something', + examples: [{ query: 'example', limit: 5 }], +}); -### Example Tool Structure +// 3. Register the tool +const myToolDef = registerToolDefinition({ + toolId: 'myTool', + displayName: 'My Tool', + description: 'Does something useful', + configSchema: MyToolConfigSchema, + llmToolSchemas: { + 'my-tool': MyLLMToolSchema, + }, + + // Called during prompt processing + onProcessPrompts({ config, injectToolList, injectContent }) { + // Inject tool description into prompts + injectToolList({ + targetId: config.targetId, + position: 'after', + }); + }, + + // Called when AI response is complete + async onResponseComplete({ toolCall, executeToolCall }) { + if (toolCall?.toolId !== 'my-tool') return; + + await executeToolCall('my-tool', async (params) => { + // Execute the tool and return result + const result = await doSomething(params.query, params.limit); + return { success: true, data: result }; + }); + }, +}); + +export const myTool = myToolDef.tool; +``` + +### Handler Context Utilities + +The `defineTool` API provides helpful utilities: + +- `findPrompt(id)` - Find a prompt by ID in the tree +- `injectToolList(options)` - Inject LLM tool schemas at a position +- `injectContent(options)` - Inject arbitrary content +- `executeToolCall(toolName, executor)` - Execute and handle tool results +- `addToolResult(options)` - Manually add a tool result message +- `yieldToSelf()` - Signal the agent should continue with another round + +### Legacy Tool Structure + +For more complex scenarios, you can still use the raw tapable hooks: ```typescript export const myTool: PromptConcatTool = (hooks) => { hooks.processPrompts.tapAsync('myTool', async (context, callback) => { - const { tool, prompts, messages } = context; + const { toolConfig, prompts, messages } = context; // Tool logic here - callback(null, context); + callback(); }); }; ``` diff --git a/src/services/agentInstance/promptConcat/infrastructure/agentStatus.ts b/src/services/agentInstance/promptConcat/infrastructure/agentStatus.ts new file mode 100644 index 00000000..80fab114 --- /dev/null +++ b/src/services/agentInstance/promptConcat/infrastructure/agentStatus.ts @@ -0,0 +1,48 @@ +/** + * Agent Status Infrastructure + * + * Handles agent status updates and persistence. + * This is core infrastructure, not a user-configurable plugin. + */ +import { container } from '@services/container'; +import { logger } from '@services/libs/log'; +import serviceIdentifier from '@services/serviceIdentifier'; +import type { IAgentInstanceService } from '../../interface'; +import type { AgentStatusContext, PromptConcatHooks } from '../../tools/types'; + +/** + * Register agent status handlers to hooks + */ +export function registerAgentStatus(hooks: PromptConcatHooks): void { + // Handle agent status persistence + hooks.agentStatusChanged.tapAsync('agentStatus', async (context: AgentStatusContext, callback) => { + try { + const { agentFrameworkContext, status } = context; + + // Get the agent instance service to update status + const agentInstanceService = container.get(serviceIdentifier.AgentInstance); + + // Update agent status in database + await agentInstanceService.updateAgent(agentFrameworkContext.agent.id, { + status, + }); + + // Update the agent object for immediate use + agentFrameworkContext.agent.status = status; + + logger.debug('Agent status updated in database', { + agentId: agentFrameworkContext.agent.id, + state: status.state, + }); + + callback(); + } catch (error) { + logger.error('Agent status error in agentStatusChanged', { + error, + agentId: context.agentFrameworkContext.agent.id, + status: context.status, + }); + callback(); + } + }); +} diff --git a/src/services/agentInstance/promptConcat/infrastructure/index.ts b/src/services/agentInstance/promptConcat/infrastructure/index.ts new file mode 100644 index 00000000..45bddb91 --- /dev/null +++ b/src/services/agentInstance/promptConcat/infrastructure/index.ts @@ -0,0 +1,24 @@ +/** + * Core Infrastructure + * + * Core infrastructure components that are always active, not user-configurable. + * These handle message persistence, streaming updates, agent status, and UI sync. + */ +import type { PromptConcatHooks } from '../../tools/types'; +import { registerAgentStatus } from './agentStatus'; +import { registerMessagePersistence } from './messagePersistence'; +import { registerStreamingResponse } from './streamingResponse'; + +export { registerAgentStatus } from './agentStatus'; +export { registerMessagePersistence } from './messagePersistence'; +export { registerStreamingResponse } from './streamingResponse'; + +/** + * Register all core infrastructure to hooks. + * This should be called once when creating hooks for an agent. + */ +export function registerCoreInfrastructure(hooks: PromptConcatHooks): void { + registerMessagePersistence(hooks); + registerStreamingResponse(hooks); + registerAgentStatus(hooks); +} diff --git a/src/services/agentInstance/promptConcat/infrastructure/messagePersistence.ts b/src/services/agentInstance/promptConcat/infrastructure/messagePersistence.ts new file mode 100644 index 00000000..fe7a8b7d --- /dev/null +++ b/src/services/agentInstance/promptConcat/infrastructure/messagePersistence.ts @@ -0,0 +1,128 @@ +/** + * Message Persistence Infrastructure + * + * Handles persisting messages to the database. + * This is core infrastructure, not a user-configurable plugin. + */ +import { container } from '@services/container'; +import { logger } from '@services/libs/log'; +import serviceIdentifier from '@services/serviceIdentifier'; +import type { IAgentInstanceService } from '../../interface'; +import type { AIResponseContext, PromptConcatHooks, ToolExecutionContext, UserMessageContext } from '../../tools/types'; +import { createAgentMessage } from '../../utilities'; + +/** + * Register message persistence handlers to hooks + */ +export function registerMessagePersistence(hooks: PromptConcatHooks): void { + // Handle user message persistence + hooks.userMessageReceived.tapAsync('messagePersistence', async (context: UserMessageContext, callback) => { + try { + const { agentFrameworkContext, content, messageId } = context; + + // 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, + duration: undefined, // User messages persist indefinitely by default + }); + + // Add message to the agent's message array for immediate use + agentFrameworkContext.agent.messages.push(userMessage); + + // Get the agent instance service to access repositories + const agentInstanceService = container.get(serviceIdentifier.AgentInstance); + + // Save user message to database + await agentInstanceService.saveUserMessage(userMessage); + + logger.debug('User message persisted to database', { + messageId, + agentId: agentFrameworkContext.agent.id, + contentLength: content.text.length, + }); + + callback(); + } catch (error) { + logger.error('Message persistence error in userMessageReceived', { + error, + messageId: context.messageId, + agentId: context.agentFrameworkContext.agent.id, + }); + callback(); + } + }); + + // Handle AI response completion persistence + hooks.responseComplete.tapAsync('messagePersistence', async (context: AIResponseContext, callback) => { + try { + const { agentFrameworkContext, response } = context; + + if (response.status === 'done' && response.content) { + // Find the AI message that needs to be persisted + const aiMessage = agentFrameworkContext.agent.messages.find( + (message) => message.role === 'assistant' && message.metadata?.isComplete && !message.metadata?.isPersisted, + ); + + if (aiMessage) { + const agentInstanceService = container.get(serviceIdentifier.AgentInstance); + await agentInstanceService.saveUserMessage(aiMessage); + aiMessage.metadata = { ...aiMessage.metadata, isPersisted: true }; + + logger.debug('AI response message persisted', { + messageId: aiMessage.id, + contentLength: response.content.length, + }); + } + } + + callback(); + } catch (error) { + logger.error('Message persistence error in responseComplete', { error }); + callback(); + } + }); + + // Handle tool result messages persistence + hooks.toolExecuted.tapAsync('messagePersistence', async (context: ToolExecutionContext, callback) => { + try { + const { agentFrameworkContext } = context; + + // Find newly added tool result messages that need to be persisted + const newToolResultMessages = agentFrameworkContext.agent.messages.filter( + (message) => message.metadata?.isToolResult && !message.metadata.isPersisted, + ); + + if (newToolResultMessages.length === 0) { + callback(); + return; + } + + const agentInstanceService = container.get(serviceIdentifier.AgentInstance); + + for (const message of newToolResultMessages) { + try { + await agentInstanceService.saveUserMessage(message); + message.metadata = { ...message.metadata, isPersisted: true }; + + logger.debug('Tool result message persisted', { + messageId: message.id, + toolId: message.metadata.toolId, + }); + } catch (serviceError) { + logger.error('Failed to persist tool result message', { + error: serviceError, + messageId: message.id, + }); + } + } + + callback(); + } catch (error) { + logger.error('Message persistence error in toolExecuted', { error }); + callback(); + } + }); +} diff --git a/src/services/agentInstance/promptConcat/infrastructure/streamingResponse.ts b/src/services/agentInstance/promptConcat/infrastructure/streamingResponse.ts new file mode 100644 index 00000000..e4d29514 --- /dev/null +++ b/src/services/agentInstance/promptConcat/infrastructure/streamingResponse.ts @@ -0,0 +1,160 @@ +/** + * Streaming Response Infrastructure + * + * Handles streaming AI responses: creating/updating messages during streaming + * and finalizing them when complete. + * This is core infrastructure, not a user-configurable plugin. + */ +import { container } from '@services/container'; +import { logger } from '@services/libs/log'; +import serviceIdentifier from '@services/serviceIdentifier'; +import type { IAgentInstanceService } from '../../interface'; +import type { AIResponseContext, PromptConcatHooks } from '../../tools/types'; + +/** + * Register streaming response handlers to hooks + */ +export function registerStreamingResponse(hooks: PromptConcatHooks): void { + // Handle AI response updates during streaming + hooks.responseUpdate.tapAsync('streamingResponse', async (context: AIResponseContext, callback) => { + try { + const { agentFrameworkContext, response } = context; + + if (response.status === 'update' && response.content) { + // Find or create AI response message in agent's message array + let aiMessage = agentFrameworkContext.agent.messages.find( + (message) => message.role === 'assistant' && !message.metadata?.isComplete, + ); + + if (!aiMessage) { + // Create new AI message for streaming updates + const now = new Date(); + aiMessage = { + id: `ai-response-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + agentId: agentFrameworkContext.agent.id, + role: 'assistant', + content: response.content, + created: now, + modified: now, + metadata: { isComplete: false }, + duration: undefined, + }; + agentFrameworkContext.agent.messages.push(aiMessage); + + // Persist immediately so DB timestamp reflects conversation order + try { + const agentInstanceService = container.get(serviceIdentifier.AgentInstance); + await agentInstanceService.saveUserMessage(aiMessage); + aiMessage.metadata = { ...aiMessage.metadata, isPersisted: true }; + } catch (persistError) { + logger.warn('Failed to persist initial streaming AI message', { + error: persistError, + messageId: aiMessage.id, + }); + } + } else { + // Update existing message content + aiMessage.content = response.content; + aiMessage.modified = new Date(); + } + + // Update UI using the agent instance service + try { + const agentInstanceService = container.get(serviceIdentifier.AgentInstance); + agentInstanceService.debounceUpdateMessage(aiMessage, agentFrameworkContext.agent.id); + } catch (serviceError) { + logger.warn('Failed to update UI for streaming message', { + error: serviceError, + messageId: aiMessage.id, + }); + } + } + } catch (error) { + logger.error('Streaming response error in responseUpdate', { error }); + } finally { + callback(); + } + }); + + // Handle AI response completion + hooks.responseComplete.tapAsync('streamingResponse', async (context: AIResponseContext, callback) => { + try { + const { agentFrameworkContext, response } = context; + + if (response.status === 'done' && response.content) { + // Find and finalize AI response message + let aiMessage = agentFrameworkContext.agent.messages.find( + (message) => message.role === 'assistant' && !message.metadata?.isComplete && !message.metadata?.isToolResult, + ); + + if (aiMessage) { + // Mark as complete and update final content + aiMessage.content = response.content; + aiMessage.modified = new Date(); + aiMessage.metadata = { ...aiMessage.metadata, isComplete: true }; + } else { + // Create final message if streaming message wasn't found + const nowFinal = new Date(); + aiMessage = { + id: `ai-response-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + agentId: agentFrameworkContext.agent.id, + role: 'assistant', + content: response.content, + created: nowFinal, + modified: nowFinal, + metadata: { isComplete: true }, + duration: undefined, + }; + agentFrameworkContext.agent.messages.push(aiMessage); + } + + // Final UI update + try { + const agentInstanceService = container.get(serviceIdentifier.AgentInstance); + agentInstanceService.debounceUpdateMessage(aiMessage, agentFrameworkContext.agent.id); + } catch (serviceError) { + logger.warn('Failed to update UI for completed message', { + error: serviceError, + messageId: aiMessage.id, + }); + } + + logger.debug('AI response message completed', { + messageId: aiMessage.id, + finalContentLength: response.content.length, + }); + } + + callback(); + } catch (error) { + logger.error('Streaming response error in responseComplete', { error }); + callback(); + } + }); + + // Handle tool result UI updates + hooks.toolExecuted.tapAsync('streamingResponse-ui', async (context, callback) => { + try { + const { agentFrameworkContext } = context; + + // Find tool result messages that need UI update + const messagesNeedingUiUpdate = agentFrameworkContext.agent.messages.filter( + (message) => message.metadata?.isToolResult && !message.metadata?.uiUpdated, + ); + + if (messagesNeedingUiUpdate.length > 0) { + const agentInstanceService = container.get(serviceIdentifier.AgentInstance); + + for (const message of messagesNeedingUiUpdate) { + agentInstanceService.debounceUpdateMessage(message, agentFrameworkContext.agent.id); + message.metadata = { ...message.metadata, uiUpdated: true }; + } + } + + callback(); + } catch (error) { + logger.error('Streaming response error in toolExecuted UI update', { error }); + callback(); + } + }); +} diff --git a/src/services/agentInstance/promptConcat/modifiers/defineModifier.ts b/src/services/agentInstance/promptConcat/modifiers/defineModifier.ts new file mode 100644 index 00000000..27cf8105 --- /dev/null +++ b/src/services/agentInstance/promptConcat/modifiers/defineModifier.ts @@ -0,0 +1,301 @@ +/** + * Modifier Definition Framework + * + * Provides a declarative API for defining prompt modifiers with minimal boilerplate. + * Modifiers only transform the prompt tree - they don't involve LLM tool calling. + * + * Unlike LLM tools, modifiers: + * - Only work with processPrompts and postProcess hooks + * - Don't inject tool descriptions or handle tool calls + * - Are focused on prompt tree transformations + */ +import { logger } from '@services/libs/log'; +import type { z } from 'zod/v4'; +import type { AgentInstanceMessage } from '../../interface'; +import type { PostProcessContext, PromptConcatHookContext, PromptConcatHooks, PromptConcatTool } from '../../tools/types'; +import { findPromptById } from '../promptConcat'; +import type { IPrompt } from '../promptConcatSchema'; + +/** + * Modifier definition configuration + */ +export interface ModifierDefinition { + /** Unique modifier identifier */ + modifierId: string; + + /** Display name for UI */ + displayName: string; + + /** Description of what this modifier does */ + description: string; + + /** Schema for modifier configuration parameters */ + configSchema: TConfigSchema; + + /** + * Called during prompt processing phase. + * Use this to modify the prompt tree. + */ + onProcessPrompts?: (context: ModifierHandlerContext) => Promise | void; + + /** + * Called during post-processing phase. + * Use this to transform LLM responses. + */ + onPostProcess?: (context: PostProcessModifierContext) => Promise | void; +} + +/** + * Context passed to prompt processing handlers + */ +export interface ModifierHandlerContext { + /** The parsed configuration for this modifier instance */ + config: z.infer; + + /** Full modifier configuration object (includes extra fields like content, caption) */ + modifierConfig: PromptConcatHookContext['toolConfig']; + + /** Current prompt tree (mutable) */ + prompts: IPrompt[]; + + /** Message history */ + messages: AgentInstanceMessage[]; + + /** Agent framework context */ + agentFrameworkContext: PromptConcatHookContext['agentFrameworkContext']; + + /** Utility: Find a prompt by ID */ + findPrompt: (id: string) => ReturnType; + + /** Utility: Insert content at a position */ + insertContent: (options: InsertContentOptions) => void; + + /** Utility: Replace prompt content */ + replaceContent: (targetId: string, content: string | IPrompt[]) => boolean; +} + +/** + * Context passed to post-process handlers + */ +export interface PostProcessModifierContext extends Omit, 'prompts'> { + /** LLM response text */ + llmResponse: string; + + /** Processed responses array (mutable) */ + responses: PostProcessContext['responses']; +} + +/** + * Options for inserting content + */ +export interface InsertContentOptions { + /** Target prompt ID */ + targetId: string; + + /** Position: 'before'/'after' as sibling, 'child' adds to children */ + position: 'before' | 'after' | 'child'; + + /** Content to insert (string or prompt object) */ + content: string | IPrompt; + + /** Optional caption */ + caption?: string; + + /** Optional ID for the new prompt */ + id?: string; +} + +/** + * Create a modifier from a definition. + */ +export function defineModifier( + definition: ModifierDefinition, +): { + modifier: PromptConcatTool; + modifierId: string; + configSchema: TConfigSchema; + displayName: string; + description: string; +} { + const { modifierId, configSchema, onProcessPrompts, onPostProcess } = definition; + + // The parameter key in config (e.g., 'fullReplacementParam' for 'fullReplacement') + const parameterKey = `${modifierId}Param`; + + const modifier: PromptConcatTool = (hooks: PromptConcatHooks) => { + // Register processPrompts handler + if (onProcessPrompts) { + hooks.processPrompts.tapAsync(`${modifierId}-processPrompts`, async (context, callback) => { + try { + const { toolConfig, prompts, messages, agentFrameworkContext } = context; + + // Skip if this config doesn't match our modifierId + if (toolConfig.toolId !== modifierId) { + callback(); + return; + } + + // Get the typed config + const rawConfig = (toolConfig as Record)[parameterKey]; + if (!rawConfig) { + callback(); + return; + } + + // Parse and validate config + const config = configSchema.parse(rawConfig) as z.infer; + + // Build handler context with utilities + const handlerContext: ModifierHandlerContext = { + config, + modifierConfig: toolConfig, + prompts, + messages, + agentFrameworkContext, + + findPrompt: (id: string) => findPromptById(prompts, id), + + insertContent: (options: InsertContentOptions) => { + const target = findPromptById(prompts, options.targetId); + if (!target) { + logger.warn('Target prompt not found for content insertion', { + targetId: options.targetId, + modifierId, + }); + return; + } + + const newPrompt: IPrompt = typeof options.content === 'string' + ? { + id: options.id ?? `${modifierId}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + caption: options.caption ?? 'Inserted Content', + text: options.content, + } + : options.content; + + if (options.position === 'child') { + if (!target.prompt.children) { + target.prompt.children = []; + } + target.prompt.children.push(newPrompt); + } else if (options.position === 'before') { + target.parent.splice(target.index, 0, newPrompt); + } else { + target.parent.splice(target.index + 1, 0, newPrompt); + } + }, + + replaceContent: (targetId: string, content: string | IPrompt[]) => { + const target = findPromptById(prompts, targetId); + if (!target) { + logger.warn('Target prompt not found for replacement', { targetId, modifierId }); + return false; + } + + if (typeof content === 'string') { + target.prompt.text = content; + delete target.prompt.children; + } else { + delete target.prompt.text; + target.prompt.children = content; + } + return true; + }, + }; + + await onProcessPrompts(handlerContext); + callback(); + } catch (error) { + logger.error(`Error in ${modifierId} processPrompts handler`, { error }); + callback(); + } + }); + } + + // Register postProcess handler + if (onPostProcess) { + hooks.postProcess.tapAsync(`${modifierId}-postProcess`, async (context, callback) => { + try { + const { toolConfig, messages, agentFrameworkContext, llmResponse, responses } = context; + + if (toolConfig.toolId !== modifierId) { + callback(); + return; + } + + const rawConfig = (toolConfig as Record)[parameterKey]; + if (!rawConfig) { + callback(); + return; + } + + const config = configSchema.parse(rawConfig) as z.infer; + + const handlerContext: PostProcessModifierContext = { + config, + modifierConfig: toolConfig, + messages, + agentFrameworkContext, + llmResponse, + responses, + + findPrompt: () => undefined, // Not available in postProcess + + insertContent: () => { + logger.warn('insertContent is not available in postProcess phase'); + }, + + replaceContent: () => { + logger.warn('replaceContent is not available in postProcess phase'); + return false; + }, + }; + + await onPostProcess(handlerContext); + callback(); + } catch (error) { + logger.error(`Error in ${modifierId} postProcess handler`, { error }); + callback(); + } + }); + } + }; + + return { + modifier, + modifierId, + configSchema, + displayName: definition.displayName, + description: definition.description, + }; +} + +/** + * Registry for modifiers + */ +const modifierRegistry = new Map>(); + +/** + * Register a modifier definition + */ +export function registerModifier( + definition: ModifierDefinition, +): ReturnType> { + const modifierDefinition = defineModifier(definition); + modifierRegistry.set(modifierDefinition.modifierId, modifierDefinition as ReturnType); + return modifierDefinition; +} + +/** + * Get all registered modifiers + */ +export function getAllModifiers(): Map> { + return modifierRegistry; +} + +/** + * Get a modifier by ID + */ +export function getModifier(modifierId: string): ReturnType | undefined { + return modifierRegistry.get(modifierId); +} diff --git a/src/services/agentInstance/promptConcat/modifiers/dynamicPosition.ts b/src/services/agentInstance/promptConcat/modifiers/dynamicPosition.ts new file mode 100644 index 00000000..8daebff8 --- /dev/null +++ b/src/services/agentInstance/promptConcat/modifiers/dynamicPosition.ts @@ -0,0 +1,96 @@ +/** + * Dynamic Position Modifier + * + * Inserts content at a specific position relative to a target element. + */ +import { logger } from '@services/libs/log'; +import { identity } from 'lodash'; +import { z } from 'zod/v4'; +import type { IPrompt } from '../promptConcatSchema'; +import { registerModifier } from './defineModifier'; + +const t = identity; + +/** + * Dynamic Position Parameter Schema + */ +export const DynamicPositionParameterSchema = z.object({ + targetId: z.string().meta({ + title: t('Schema.Position.TargetIdTitle'), + description: t('Schema.Position.TargetId'), + }), + position: z.enum(['before', 'after', 'relative']).meta({ + title: t('Schema.Position.TypeTitle'), + description: t('Schema.Position.Type'), + }), +}).meta({ + title: t('Schema.Position.Title'), + description: t('Schema.Position.Description'), +}); + +export type DynamicPositionParameter = z.infer; + +export function getDynamicPositionParameterSchema() { + return DynamicPositionParameterSchema; +} + +/** + * Dynamic Position Modifier Definition + */ +const dynamicPositionDefinition = registerModifier({ + modifierId: 'dynamicPosition', + displayName: 'Dynamic Position', + description: 'Insert content at a specific position relative to a target element', + configSchema: DynamicPositionParameterSchema, + + onProcessPrompts({ config, modifierConfig, findPrompt }) { + // dynamicPosition requires content from modifierConfig + if (!modifierConfig.content) { + return; + } + + const { targetId, position } = config; + const found = findPrompt(targetId); + + if (!found) { + logger.warn('Target prompt not found for dynamicPosition', { + targetId, + modifierId: modifierConfig.id, + }); + return; + } + + const newPart: IPrompt = { + id: `dynamic-${modifierConfig.id}-${Date.now()}`, + caption: modifierConfig.caption ?? 'Dynamic Content', + text: modifierConfig.content, + }; + + switch (position) { + case 'before': + found.parent.splice(found.index, 0, newPart); + break; + case 'after': + found.parent.splice(found.index + 1, 0, newPart); + break; + case 'relative': + if (!found.prompt.children) { + found.prompt.children = []; + } + found.prompt.children.push(newPart); + break; + default: + logger.warn(`Unknown position: ${position as string}`); + return; + } + + logger.debug('Dynamic position insertion completed', { + targetId, + position, + contentLength: modifierConfig.content.length, + modifierId: modifierConfig.id, + }); + }, +}); + +export const dynamicPositionModifier = dynamicPositionDefinition.modifier; diff --git a/src/services/agentInstance/promptConcat/modifiers/fullReplacement.ts b/src/services/agentInstance/promptConcat/modifiers/fullReplacement.ts new file mode 100644 index 00000000..4552d33e --- /dev/null +++ b/src/services/agentInstance/promptConcat/modifiers/fullReplacement.ts @@ -0,0 +1,145 @@ +/** + * Full Replacement Modifier + * + * Replaces target prompt content with content from specified source. + * Supports: historyOfSession, llmResponse + */ +import { logger } from '@services/libs/log'; +import { cloneDeep, identity } from 'lodash'; +import { z } from 'zod/v4'; +import type { AgentResponse } from '../../tools/types'; +import { filterMessagesByDuration } from '../../utilities/messageDurationFilter'; +import { normalizeRole } from '../../utilities/normalizeRole'; +import type { IPrompt } from '../promptConcatSchema'; +import { registerModifier } from './defineModifier'; + +const t = identity; + +/** + * Full Replacement Parameter Schema + */ +export const FullReplacementParameterSchema = z.object({ + targetId: z.string().meta({ + title: t('Schema.FullReplacement.TargetIdTitle'), + description: t('Schema.FullReplacement.TargetId'), + }), + sourceType: z.enum(['historyOfSession', 'llmResponse']).meta({ + title: t('Schema.FullReplacement.SourceTypeTitle'), + description: t('Schema.FullReplacement.SourceType'), + }), +}).meta({ + title: t('Schema.FullReplacement.Title'), + description: t('Schema.FullReplacement.Description'), +}); + +export type FullReplacementParameter = z.infer; + +export function getFullReplacementParameterSchema() { + return FullReplacementParameterSchema; +} + +/** + * Full Replacement Modifier Definition + */ +const fullReplacementDefinition = registerModifier({ + modifierId: 'fullReplacement', + displayName: 'Full Replacement', + description: 'Replace target content with content from specified source', + configSchema: FullReplacementParameterSchema, + + onProcessPrompts({ config, modifierConfig, findPrompt, messages }) { + const { targetId, sourceType } = config; + const found = findPrompt(targetId); + + if (!found) { + logger.warn('Target prompt not found for fullReplacement', { + targetId, + modifierId: modifierConfig.id, + }); + return; + } + + // Only handle historyOfSession in processPrompts phase + if (sourceType !== 'historyOfSession') { + return; + } + + // Get all messages except the last user message being processed + const messagesCopy = cloneDeep(messages); + + // Find and remove the last user message (which is being processed in this round) + let lastUserMessageIndex = -1; + for (let index = messagesCopy.length - 1; index >= 0; index--) { + if (messagesCopy[index].role === 'user') { + lastUserMessageIndex = index; + break; + } + } + + if (lastUserMessageIndex >= 0) { + messagesCopy.splice(lastUserMessageIndex, 1); + logger.debug('Removed current user message from history', { + removedMessageId: messages[lastUserMessageIndex].id, + remainingMessages: messagesCopy.length, + }); + } + + // Apply duration filtering to exclude expired messages + const filteredHistory = filterMessagesByDuration(messagesCopy); + + if (filteredHistory.length > 0) { + found.prompt.children = []; + filteredHistory.forEach((message, index: number) => { + type PromptRole = NonNullable; + const role: PromptRole = normalizeRole(message.role); + delete found.prompt.text; + found.prompt.children!.push({ + id: `history-${index}`, + caption: `History message ${index + 1}`, + role, + text: message.content, + }); + }); + } else { + found.prompt.text = '无聊天历史。'; + } + + logger.debug('Full replacement completed in prompt phase', { targetId, sourceType }); + }, + + onPostProcess({ config, modifierConfig, llmResponse, responses }) { + const { targetId, sourceType } = config; + + if (sourceType !== 'llmResponse') { + return; + } + + if (!responses) { + logger.warn('No responses available in postProcess phase', { + targetId, + modifierId: modifierConfig.id, + }); + return; + } + + const found = responses.find((r: AgentResponse) => r.id === targetId); + + if (!found) { + logger.warn('Full replacement target not found in responses', { + targetId, + modifierId: modifierConfig.id, + }); + return; + } + + found.text = llmResponse; + + logger.debug('Full replacement completed in response phase', { + targetId, + sourceType, + modifierId: modifierConfig.id, + }); + }, +}); + +export const fullReplacementModifier = fullReplacementDefinition.modifier; diff --git a/src/services/agentInstance/promptConcat/modifiers/index.ts b/src/services/agentInstance/promptConcat/modifiers/index.ts new file mode 100644 index 00000000..f23d2d36 --- /dev/null +++ b/src/services/agentInstance/promptConcat/modifiers/index.ts @@ -0,0 +1,34 @@ +/** + * Prompt Modifiers + * + * Modifiers transform the prompt tree without involving LLM tool calling. + * They work in the processPrompts and postProcess phases only. + */ + +// Re-export defineModifier API +export { defineModifier, getAllModifiers, getModifier, registerModifier } from './defineModifier'; +export type { InsertContentOptions, ModifierDefinition, ModifierHandlerContext, PostProcessModifierContext } from './defineModifier'; + +// Export modifiers +export { fullReplacementModifier, FullReplacementParameterSchema, getFullReplacementParameterSchema } from './fullReplacement'; +export type { FullReplacementParameter } from './fullReplacement'; + +export { dynamicPositionModifier, DynamicPositionParameterSchema, getDynamicPositionParameterSchema } from './dynamicPosition'; +export type { DynamicPositionParameter } from './dynamicPosition'; + +// Import for registration side effects +import { getAllModifiers } from './defineModifier'; +import './fullReplacement'; +import './dynamicPosition'; + +/** + * Get all registered modifier functions as a map + */ +export function getModifierFunctions(): Map void> { + const modifiers = getAllModifiers(); + const result = new Map void>(); + for (const [id, definition] of modifiers) { + result.set(id, definition.modifier); + } + return result; +} diff --git a/src/services/agentInstance/promptConcat/promptConcat.ts b/src/services/agentInstance/promptConcat/promptConcat.ts index 83758a21..a364891b 100644 --- a/src/services/agentInstance/promptConcat/promptConcat.ts +++ b/src/services/agentInstance/promptConcat/promptConcat.ts @@ -18,9 +18,9 @@ import { ModelMessage } from 'ai'; import { cloneDeep } from 'lodash'; import { AgentFrameworkContext } from '../agentFrameworks/utilities/type'; import { AgentInstanceMessage } from '../interface'; -import { builtInTools, createAgentFrameworkHooks, PromptConcatHookContext } from '../tools'; +import { createAgentFrameworkHooks, pluginRegistry, PromptConcatHookContext } from '../tools'; import type { AgentPromptDescription, IPrompt } from './promptConcatSchema'; -import type { IPromptConcatTool } from './promptConcatSchema/plugin'; +import type { IPromptConcatTool } from './promptConcatSchema/tools'; /** * Context type specific for prompt concatenation operations @@ -210,13 +210,14 @@ export async function* promptConcatStream( const agentFrameworkConfig = agentConfig.agentFrameworkConfig; const promptConfigs = Array.isArray(agentFrameworkConfig?.prompts) ? agentFrameworkConfig.prompts : []; const toolConfigs = (Array.isArray(agentFrameworkConfig?.plugins) ? agentFrameworkConfig.plugins : []) as IPromptConcatTool[]; + const enabledToolConfigs = toolConfigs.filter((tool) => tool.enabled !== false); const promptsCopy = cloneDeep(promptConfigs); const sourcePaths = generateSourcePaths(promptsCopy, toolConfigs); const hooks = createAgentFrameworkHooks(); // Register tools that match the configuration - for (const tool of toolConfigs) { - const builtInTool = builtInTools.get(tool.toolId); + for (const tool of enabledToolConfigs) { + const builtInTool = pluginRegistry.get(tool.toolId); if (builtInTool) { builtInTool(hooks); logger.debug('Registered tool', { @@ -230,14 +231,15 @@ export async function* promptConcatStream( // Process each plugin through hooks with streaming let modifiedPrompts = promptsCopy; - const totalSteps = toolConfigs.length + 2; // plugins + finalize + flatten + const totalSteps = enabledToolConfigs.length + 2; // plugins + finalize + flatten - for (let index = 0; index < toolConfigs.length; index++) { + for (let index = 0; index < enabledToolConfigs.length; index++) { const context: PromptConcatHookContext = { agentFrameworkContext: agentFrameworkContext, messages, prompts: modifiedPrompts, - toolConfig: toolConfigs[index], + toolConfig: enabledToolConfigs[index], + pluginIndex: index, metadata: { sourcePaths }, }; try { @@ -256,13 +258,13 @@ export async function* promptConcatStream( processedPrompts: modifiedPrompts, flatPrompts: intermediateFlat, step: 'plugin', - currentPlugin: toolConfigs[index], + currentPlugin: enabledToolConfigs[index], progress: (index + 1) / totalSteps, isComplete: false, }; } catch (error) { logger.error('Plugin processing error', { - toolConfig: toolConfigs[index], + toolConfig: enabledToolConfigs[index], error, }); // Continue processing other plugins even if one fails @@ -274,7 +276,7 @@ export async function* promptConcatStream( processedPrompts: modifiedPrompts, flatPrompts: flattenPrompts(modifiedPrompts), step: 'finalize', - progress: (toolConfigs.length + 1) / totalSteps, + progress: (enabledToolConfigs.length + 1) / totalSteps, isComplete: false, }; @@ -298,7 +300,7 @@ export async function* promptConcatStream( processedPrompts: modifiedPrompts, flatPrompts: flattenPrompts(modifiedPrompts), step: 'flatten', - progress: (toolConfigs.length + 2) / totalSteps, + progress: (enabledToolConfigs.length + 2) / totalSteps, isComplete: false, }; diff --git a/src/services/agentInstance/promptConcat/promptConcatSchema/index.ts b/src/services/agentInstance/promptConcat/promptConcatSchema/index.ts index 08071d34..e9ed15b9 100644 --- a/src/services/agentInstance/promptConcat/promptConcatSchema/index.ts +++ b/src/services/agentInstance/promptConcat/promptConcatSchema/index.ts @@ -131,14 +131,9 @@ export type DefaultAgents = z.infer>; export type AgentPromptDescription = z.infer>; export type AiAPIConfig = z.infer; export type AgentFrameworkConfig = z.infer>; -// Backward compat aliases - deprecated, use AgentFrameworkConfig directly -export type HandlerConfig = AgentFrameworkConfig; // Re-export all schemas and types export * from './modelParameters'; -export * from './plugin'; export * from './prompts'; export * from './response'; - -// Export IPromptConcatTool as IPromptConcatPlugin for backward compatibility -export type { IPromptConcatTool as IPromptConcatPlugin } from './plugin'; +export * from './tools'; diff --git a/src/services/agentInstance/promptConcat/promptConcatSchema/plugin.ts b/src/services/agentInstance/promptConcat/promptConcatSchema/tools.ts similarity index 67% rename from src/services/agentInstance/promptConcat/promptConcatSchema/plugin.ts rename to src/services/agentInstance/promptConcat/promptConcatSchema/tools.ts index 57d6c643..6f79bc67 100644 --- a/src/services/agentInstance/promptConcat/promptConcatSchema/plugin.ts +++ b/src/services/agentInstance/promptConcat/promptConcatSchema/tools.ts @@ -1,26 +1,36 @@ // Import parameter types from plugin files +// Modifiers +import type { DynamicPositionParameter, FullReplacementParameter } from '../modifiers'; +// LLM Tools +import type { GitToolParameter } from '@services/agentInstance/tools/git'; import type { ModelContextProtocolParameter } from '@services/agentInstance/tools/modelContextProtocol'; -import type { DynamicPositionParameter, FullReplacementParameter } from '@services/agentInstance/tools/prompt'; +import type { TiddlyWikiPluginParameter } from '@services/agentInstance/tools/tiddlywikiPlugin'; import type { WikiOperationParameter } from '@services/agentInstance/tools/wikiOperation'; import type { WikiSearchParameter } from '@services/agentInstance/tools/wikiSearch'; import type { WorkspacesListParameter } from '@services/agentInstance/tools/workspacesList'; /** - * Type definition for prompt concat tool + * Type definition for prompt concat plugin (both modifiers and LLM tools) * This includes all possible parameter fields for type safety */ export type IPromptConcatTool = { + [key: string]: unknown; id: string; caption?: string; content?: string; + enabled?: boolean; forbidOverrides?: boolean; toolId: string; - // Tool-specific parameters + // Modifier parameters fullReplacementParam?: FullReplacementParameter; dynamicPositionParam?: DynamicPositionParameter; + + // LLM Tool parameters wikiOperationParam?: WikiOperationParameter; wikiSearchParam?: WikiSearchParameter; workspacesListParam?: WorkspacesListParameter; modelContextProtocolParam?: ModelContextProtocolParameter; + gitParam?: GitToolParameter; + tiddlyWikiPluginParam?: TiddlyWikiPluginParameter; }; diff --git a/src/services/agentInstance/promptConcat/responseConcat.ts b/src/services/agentInstance/promptConcat/responseConcat.ts index 8eaa6afb..778bd0d1 100644 --- a/src/services/agentInstance/promptConcat/responseConcat.ts +++ b/src/services/agentInstance/promptConcat/responseConcat.ts @@ -8,7 +8,7 @@ import { logger } from '@services/libs/log'; import { cloneDeep } from 'lodash'; import { AgentFrameworkContext } from '../agentFrameworks/utilities/type'; import { AgentInstanceMessage } from '../interface'; -import { builtInTools, createAgentFrameworkHooks } from '../tools'; +import { createAgentFrameworkHooks, pluginRegistry } from '../tools'; import { AgentResponse, PostProcessContext, YieldNextRoundTarget } from '../tools/types'; import type { IPromptConcatTool } from './promptConcatSchema'; import { AgentFrameworkConfig, AgentPromptDescription } from './promptConcatSchema'; @@ -41,13 +41,14 @@ export async function responseConcat( const { agentFrameworkConfig } = agentConfig; const responses: AgentFrameworkConfig['response'] = Array.isArray(agentFrameworkConfig?.response) ? (agentFrameworkConfig?.response || []) : []; const toolConfigs = (Array.isArray(agentFrameworkConfig.plugins) ? agentFrameworkConfig.plugins : []) as IPromptConcatTool[]; + const enabledToolConfigs = toolConfigs.filter((tool) => tool.enabled !== false); let modifiedResponses = cloneDeep(responses) as AgentResponse[]; // Create hooks instance const hooks = createAgentFrameworkHooks(); // Register all tools from configuration - for (const tool of toolConfigs) { - const builtInTool = builtInTools.get(tool.toolId); + for (const tool of enabledToolConfigs) { + const builtInTool = pluginRegistry.get(tool.toolId); if (builtInTool) { builtInTool(hooks); } else { @@ -59,7 +60,7 @@ export async function responseConcat( let yieldNextRoundTo: YieldNextRoundTarget | undefined; let toolCallInfo: ToolCallingMatch | undefined; - for (const tool of toolConfigs) { + for (const tool of enabledToolConfigs) { const responseContext: PostProcessContext = { agentFrameworkContext: context, messages, diff --git a/src/services/agentInstance/tools/__tests__/fullReplacementPlugin.duration.test.ts b/src/services/agentInstance/tools/__tests__/fullReplacementPlugin.duration.test.ts index f3ef08bf..d43bd592 100644 --- a/src/services/agentInstance/tools/__tests__/fullReplacementPlugin.duration.test.ts +++ b/src/services/agentInstance/tools/__tests__/fullReplacementPlugin.duration.test.ts @@ -10,8 +10,8 @@ import type { IPrompt } from '../../promptConcat/promptConcatSchema/prompts'; import { cloneDeep } from 'lodash'; import defaultAgents from '../../agentFrameworks/taskAgents.json'; +import { fullReplacementModifier } from '../../promptConcat/modifiers/fullReplacement'; import { createAgentFrameworkHooks, PromptConcatHookContext } from '../index'; -import { fullReplacementTool } from '../prompt'; // Use the real agent config const exampleAgent = defaultAgents[0]; @@ -113,7 +113,7 @@ describe('Full Replacement Plugin - Duration Mechanism', () => { }; const hooks = createAgentFrameworkHooks(); - fullReplacementTool(hooks); + fullReplacementModifier(hooks); // Execute the processPrompts hook await hooks.processPrompts.promise(context); @@ -126,8 +126,8 @@ describe('Full Replacement Plugin - Duration Mechanism', () => { const targetPrompt = historyPrompt!.children?.find(child => child.id === targetId); expect(targetPrompt).toBeDefined(); - // The fullReplacementTool puts filtered messages in children array - // Note: fullReplacementTool removes the last message (current user message) + // The fullReplacementModifier puts filtered messages in children array + // Note: fullReplacementModifier removes the last message (current user message) const children = (targetPrompt as unknown as { children?: IPrompt[] }).children || []; expect(children.length).toBe(2); // Only non-expired messages (user1, ai-response), excluding last user message @@ -201,7 +201,7 @@ describe('Full Replacement Plugin - Duration Mechanism', () => { }; const hooks = createAgentFrameworkHooks(); - fullReplacementTool(hooks); + fullReplacementModifier(hooks); await hooks.processPrompts.promise(context); @@ -283,7 +283,7 @@ describe('Full Replacement Plugin - Duration Mechanism', () => { }; const hooks = createAgentFrameworkHooks(); - fullReplacementTool(hooks); + fullReplacementModifier(hooks); await hooks.processPrompts.promise(context); diff --git a/src/services/agentInstance/tools/__tests__/messageManagementPlugin.test.ts b/src/services/agentInstance/tools/__tests__/messageManagementPlugin.test.ts index a02e50d8..de1c210a 100644 --- a/src/services/agentInstance/tools/__tests__/messageManagementPlugin.test.ts +++ b/src/services/agentInstance/tools/__tests__/messageManagementPlugin.test.ts @@ -10,8 +10,8 @@ import { DataSource } from 'typeorm'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import defaultAgents from '../../agentFrameworks/taskAgents.json'; import type { AgentInstanceMessage, IAgentInstanceService } from '../../interface'; +import { registerCoreInfrastructure } from '../../promptConcat/infrastructure'; import { createAgentFrameworkHooks } from '../index'; -import { messageManagementTool } from '../messageManagement'; import type { ToolExecutionContext, UserMessageContext } from '../types'; // Use the real agent config from taskAgents.json @@ -70,7 +70,7 @@ describe('Message Management Plugin - Real Database Integration', () => { // Initialize plugin hooks = createAgentFrameworkHooks(); - messageManagementTool(hooks); + registerCoreInfrastructure(hooks); }); afterEach(async () => { diff --git a/src/services/agentInstance/tools/__tests__/wikiOperationPlugin.test.ts b/src/services/agentInstance/tools/__tests__/wikiOperationPlugin.test.ts index 12b0744b..080921bf 100644 --- a/src/services/agentInstance/tools/__tests__/wikiOperationPlugin.test.ts +++ b/src/services/agentInstance/tools/__tests__/wikiOperationPlugin.test.ts @@ -128,24 +128,85 @@ describe('wikiOperationTool', () => { await hooks.processPrompts.promise(wikiOpContext); - const targetPrompt = prompts[0]; - // workspacesListTool and wikiOperationTool may both add children; assert the combined children text contains expected snippets - const childrenText = JSON.stringify(targetPrompt.children); - expect(childrenText).toContain('wiki-operation'); + // When position is 'after', the injected prompts become siblings (inserted after target) + // So we check prompts[1] and prompts[2] instead of children + const allPromptsText = JSON.stringify(prompts); + expect(allPromptsText).toContain('wiki-operation'); // Ensure the injected tool content documents the supported operations (enum values) - expect(childrenText).toContain(WikiChannel.addTiddler); - expect(childrenText).toContain(WikiChannel.setTiddlerText); - expect(childrenText).toContain(WikiChannel.deleteTiddler); + expect(allPromptsText).toContain(WikiChannel.addTiddler); + expect(allPromptsText).toContain(WikiChannel.setTiddlerText); + expect(allPromptsText).toContain(WikiChannel.deleteTiddler); // Ensure required parameter keys are present in the documentation - expect(childrenText).toContain('workspaceName'); - expect(childrenText).toContain('operation'); - expect(childrenText).toContain('title'); - expect(childrenText).toContain('text'); - expect(childrenText).toContain('extraMeta'); - expect(childrenText).toContain('options'); + expect(allPromptsText).toContain('workspaceName'); + expect(allPromptsText).toContain('operation'); + expect(allPromptsText).toContain('title'); + expect(allPromptsText).toContain('text'); + expect(allPromptsText).toContain('extraMeta'); + expect(allPromptsText).toContain('options'); }); describe('tool execution', () => { + it('should ignore tool calls when plugin is disabled', async () => { + const hooks = createAgentFrameworkHooks(); + wikiOperationTool(hooks); + + const agentFrameworkContext = makeAgentFrameworkContext(); + + const context = { + agentFrameworkContext, + agentFrameworkConfig: { + plugins: [ + { + toolId: 'wikiOperation', + enabled: false, + wikiOperationParam: { + toolResultDuration: 1, + }, + }, + ], + }, + response: { + status: 'done' as const, + content: 'AI response with tool call', + }, + actions: {}, + }; + + const createParams = { + workspaceName: 'Test Wiki 1', + operation: WikiChannel.addTiddler, + title: 'Test Note', + text: 'Test content', + extraMeta: JSON.stringify({ tags: ['tag1', 'tag2'] }), + options: JSON.stringify({}), + } as const; + context.response.content = `${JSON.stringify(createParams)}`; + + agentFrameworkContext.agent.messages.push({ + id: `m-${Date.now()}`, + agentId: agentFrameworkContext.agent.id, + role: 'assistant', + content: context.response.content, + modified: new Date(), + }); + + const responseCtx: AIResponseContext = { + agentFrameworkContext, + toolConfig: context.agentFrameworkConfig.plugins[0] as unknown as IPromptConcatTool, + agentFrameworkConfig: context.agentFrameworkConfig as { plugins?: Array<{ toolId: string; [key: string]: unknown }> }, + response: { requestId: 'r-disabled', content: context.response.content, status: 'done' } as AIStreamResponse, + requestId: 'r-disabled', + isFinal: true, + actions: {} as ToolActions, + }; + + await hooks.responseComplete.promise(responseCtx); + + expect(container.get>(serviceIdentifier.Wiki).wikiOperationInServer).not.toHaveBeenCalled(); + expect(agentFrameworkContext.agent.messages.some(m => m.metadata?.isToolResult)).toBe(false); + expect(responseCtx.actions?.yieldNextRoundTo).toBeUndefined(); + }); + it('should execute create operation successfully', async () => { const hooks = createAgentFrameworkHooks(); wikiOperationTool(hooks); diff --git a/src/services/agentInstance/tools/__tests__/wikiSearchPlugin.test.ts b/src/services/agentInstance/tools/__tests__/wikiSearchPlugin.test.ts index 3260bcee..dffc077a 100644 --- a/src/services/agentInstance/tools/__tests__/wikiSearchPlugin.test.ts +++ b/src/services/agentInstance/tools/__tests__/wikiSearchPlugin.test.ts @@ -19,8 +19,8 @@ import type { IPrompt } from '@services/agentInstance/promptConcat/promptConcatS import type { IPromptConcatTool } from '@services/agentInstance/promptConcat/promptConcatSchema'; import { cloneDeep } from 'lodash'; import defaultAgents from '../../agentFrameworks/taskAgents.json'; +import { registerCoreInfrastructure } from '../../promptConcat/infrastructure'; import { createAgentFrameworkHooks, PromptConcatHookContext } from '../index'; -import { messageManagementTool } from '../messageManagement'; import { wikiSearchTool } from '../wikiSearch'; // Mock i18n @@ -321,7 +321,7 @@ describe('Wiki Search Plugin - Comprehensive Tests', () => { expect(toolResultMessage.content).toContain('Tool: wiki-search'); expect(toolResultMessage.content).toContain('Important Note 1'); expect(toolResultMessage.metadata?.isToolResult).toBe(true); - expect(toolResultMessage.metadata?.isPersisted).toBe(false); // Should be false initially + // Note: isPersisted may be true due to immediate async persistence in new defineTool API expect(toolResultMessage.duration).toBe(1); // Tool result uses configurable toolResultDuration (default 1) // Check that previous user message is unchanged @@ -406,7 +406,7 @@ describe('Wiki Search Plugin - Comprehensive Tests', () => { expect(errorResultMessage.content).toContain('工作空间名称或ID'); expect(errorResultMessage.metadata?.isToolResult).toBe(true); expect(errorResultMessage.metadata?.isError).toBe(true); - expect(errorResultMessage.metadata?.isPersisted).toBe(false); // Should be false initially + // Note: isPersisted may be true due to immediate async persistence in new defineTool API expect(errorResultMessage.duration).toBe(1); // Now uses configurable toolResultDuration (default 1) }); @@ -602,10 +602,13 @@ describe('Wiki Search Plugin - Comprehensive Tests', () => { id: 'test-agent', agentDefId: 'test-agent-def', aiApiConfig: { - api: { + default: { provider: 'openai', model: 'gpt-4', - embeddingModel: 'text-embedding-ada-002', + }, + embedding: { + provider: 'openai', + model: 'text-embedding-ada-002', }, modelParameters: {}, }, @@ -665,10 +668,14 @@ describe('Wiki Search Plugin - Comprehensive Tests', () => { expect.any(String), // workspaceID 'How to use AI agents', expect.objectContaining({ - api: expect.objectContaining({ + default: expect.objectContaining({ provider: 'openai', model: 'gpt-4', }), + embedding: expect.objectContaining({ + provider: 'openai', + model: 'text-embedding-ada-002', + }), }), 10, 0.7, @@ -701,7 +708,7 @@ describe('Wiki Search Plugin - Comprehensive Tests', () => { id: 'test-agent', agentDefId: 'test-agent-def', aiApiConfig: { - api: { + default: { provider: 'openai', model: 'gpt-4', }, @@ -772,7 +779,7 @@ describe('Wiki Search Plugin - Comprehensive Tests', () => { id: 'test-agent', agentDefId: 'test-agent-def', aiApiConfig: { - api: { + default: { provider: 'openai', model: 'gpt-4', }, @@ -891,7 +898,7 @@ describe('Wiki Search Plugin - Comprehensive Tests', () => { const hooks = createAgentFrameworkHooks(); wikiSearchTool(hooks); - messageManagementTool(hooks); + registerCoreInfrastructure(hooks); await hooks.responseComplete.promise(context); @@ -901,7 +908,7 @@ describe('Wiki Search Plugin - Comprehensive Tests', () => { const toolResultMessage = agentFrameworkContext.agent.messages[1] as AgentInstanceMessage; expect(toolResultMessage.metadata?.isToolResult).toBe(true); - expect(toolResultMessage.metadata?.isPersisted).toBe(true); // Should be true after messageManagementTool processing + expect(toolResultMessage.metadata?.isPersisted).toBe(true); // Should be true after infrastructure processing }); it('should prevent regression: tool result not filtered in second round', async () => { diff --git a/src/services/agentInstance/tools/defineTool.ts b/src/services/agentInstance/tools/defineTool.ts new file mode 100644 index 00000000..7447da36 --- /dev/null +++ b/src/services/agentInstance/tools/defineTool.ts @@ -0,0 +1,705 @@ +/* eslint-disable @typescript-eslint/no-unnecessary-type-conversion */ +/** + * Tool Definition Framework + * + * Provides a declarative API for defining LLM agent tools with minimal boilerplate. + * Tools are defined using a configuration object that specifies: + * - Schema for configuration parameters (shown in UI for users to configure) + * - Schema for LLM-callable tool parameters (injected into prompts) + * - Hook handlers for different lifecycle events + * + * This replaces the verbose tapAsync pattern with a cleaner functional approach. + */ +import { type ToolCallingMatch } from '@services/agentDefinition/interface'; +import { matchToolCalling } from '@services/agentDefinition/responsePatternUtility'; +import { container } from '@services/container'; +import { logger } from '@services/libs/log'; +import serviceIdentifier from '@services/serviceIdentifier'; +import type { z } from 'zod/v4'; +import type { AgentInstanceMessage, IAgentInstanceService } from '../interface'; +import { findPromptById } from '../promptConcat/promptConcat'; +import type { IPrompt } from '../promptConcat/promptConcatSchema'; +import { schemaToToolContent } from '../utilities/schemaToToolContent'; +import { registerToolParameterSchema } from './schemaRegistry'; +import type { AIResponseContext, PostProcessContext, PromptConcatHookContext, PromptConcatHooks, PromptConcatTool } from './types'; + +/** + * Tool definition configuration + */ +export interface ToolDefinition< + TConfigSchema extends z.ZodType = z.ZodType, + TLLMToolSchemas extends Record = Record, +> { + /** Unique tool identifier - must match the toolId used in agent configuration */ + toolId: string; + + /** Display name for UI */ + displayName: string; + + /** Description of what this tool does */ + description: string; + + /** Schema for tool configuration parameters (user-configurable in UI) */ + configSchema: TConfigSchema; + + /** + * Optional schemas for LLM-callable tools. + * Each key is the tool name (e.g., 'wiki-search'), value is the parameter schema. + * The schema's title meta will be used as the tool name in prompts. + */ + llmToolSchemas?: TLLMToolSchemas; + + /** + * Called during prompt processing phase. + * Use this to inject tool descriptions, modify prompts, etc. + */ + onProcessPrompts?: (context: ToolHandlerContext) => Promise | void; + + /** + * Called after LLM generates a response. + * Use this to parse tool calls, execute tools, etc. + */ + onResponseComplete?: (context: ResponseHandlerContext) => Promise | void; + + /** + * Called during post-processing phase. + * Use this to transform LLM responses, etc. + */ + onPostProcess?: (context: PostProcessHandlerContext) => Promise | void; +} + +/** + * Context passed to prompt processing handlers + */ +export interface ToolHandlerContext { + /** The parsed configuration for this tool instance */ + config: z.infer; + + /** Full tool configuration object */ + toolConfig: PromptConcatHookContext['toolConfig']; + + /** Current prompt tree (mutable) */ + prompts: IPrompt[]; + + /** Message history */ + messages: AgentInstanceMessage[]; + + /** Agent framework context */ + agentFrameworkContext: PromptConcatHookContext['agentFrameworkContext']; + + /** Utility: Find a prompt by ID */ + findPrompt: (id: string) => ReturnType; + + /** Utility: Inject a tool list at a target position */ + injectToolList: (options: InjectToolListOptions) => void; + + /** Utility: Inject content at a target position */ + injectContent: (options: InjectContentOptions) => void; +} + +/** + * Context passed to response handlers + */ +export interface ResponseHandlerContext< + TConfigSchema extends z.ZodType, + TLLMToolSchemas extends Record, +> extends Omit, 'prompts' | 'config'> { + /** The parsed configuration for this tool instance (may be undefined if no config provided) */ + config: z.infer | undefined; + + /** AI response content */ + response: AIResponseContext['response']; + + /** Parsed tool call from response (if any) */ + toolCall: ToolCallingMatch | null; + + /** Full agent framework config for accessing other tool configs */ + agentFrameworkConfig: AIResponseContext['agentFrameworkConfig']; + + /** Utility: Execute a tool call and handle the result */ + executeToolCall: ( + toolName: TToolName, + executor: (parameters: z.infer) => Promise, + ) => Promise; + + /** Utility: Add a tool result message */ + addToolResult: (options: AddToolResultOptions) => void; + + /** Utility: Signal that the agent should continue with another round */ + yieldToSelf: () => void; + + /** Raw hooks for advanced usage */ + hooks: PromptConcatHooks; + + /** Request ID for tracking */ + requestId?: string; +} + +/** + * Context passed to post-process handlers + */ +export interface PostProcessHandlerContext extends Omit, never> { + /** LLM response text */ + llmResponse: string; + + /** Processed responses array (mutable) */ + responses: PostProcessContext['responses']; +} + +/** + * Options for injecting tool list into prompts + */ +export interface InjectToolListOptions { + /** Target prompt ID to inject relative to */ + targetId: string; + + /** Position relative to target: 'before'/'after' inserts as sibling, 'child' adds to children */ + position: 'before' | 'after' | 'child'; + + /** Tool schemas to inject (will use all llmToolSchemas if not specified) */ + toolSchemas?: z.ZodType[]; + + /** Optional caption for the injected prompt */ + caption?: string; +} + +/** + * Options for injecting content into prompts + */ +export interface InjectContentOptions { + /** Target prompt ID to inject relative to */ + targetId: string; + + /** Position relative to target */ + position: 'before' | 'after' | 'child'; + + /** Content to inject */ + content: string; + + /** Caption for the injected prompt */ + caption?: string; + + /** Optional ID for the injected prompt */ + id?: string; +} + +/** + * Options for adding tool result messages + */ +export interface AddToolResultOptions { + /** Tool name */ + toolName: string; + + /** Tool parameters */ + parameters: unknown; + + /** Result content */ + result: string; + + /** Whether this is an error result */ + isError?: boolean; + + /** How many rounds this result should be visible */ + duration?: number; +} + +/** + * Result from tool execution + */ +export interface ToolExecutionResult { + success: boolean; + data?: string; + error?: string; + metadata?: Record; +} + +/** + * Create a tool from a definition. + * Returns both the tool function and metadata for registration. + */ +export function defineTool< + TConfigSchema extends z.ZodType, + TLLMToolSchemas extends Record = Record, +>(definition: ToolDefinition): { + tool: PromptConcatTool; + toolId: string; + configSchema: TConfigSchema; + llmToolSchemas: TLLMToolSchemas | undefined; + displayName: string; + description: string; +} { + const { toolId, configSchema, llmToolSchemas, onProcessPrompts, onResponseComplete, onPostProcess } = definition; + + // The parameter key in toolConfig (e.g., 'wikiSearchParam' for 'wikiSearch') + const parameterKey = `${toolId}Param`; + + const tool: PromptConcatTool = (hooks) => { + // Register processPrompts handler + if (onProcessPrompts) { + hooks.processPrompts.tapAsync(`${toolId}-processPrompts`, async (context, callback) => { + try { + const { toolConfig, prompts, messages, agentFrameworkContext } = context; + + // Skip if this tool config doesn't match our toolId + if (toolConfig.toolId !== toolId) { + callback(); + return; + } + + if (toolConfig.enabled === false) { + callback(); + return; + } + + // Get the typed config + const rawConfig: unknown = toolConfig[parameterKey]; + if (!rawConfig) { + callback(); + return; + } + + // Parse and validate config + const config = configSchema.parse(rawConfig) as z.infer; + + // Build handler context with utilities + const handlerContext: ToolHandlerContext = { + config, + toolConfig, + prompts, + messages, + agentFrameworkContext, + + findPrompt: (id: string) => findPromptById(prompts, id), + + injectToolList: (options: InjectToolListOptions) => { + const target = findPromptById(prompts, options.targetId); + if (!target) { + logger.warn(`Target prompt not found for tool list injection`, { + targetId: options.targetId, + toolId, + }); + return; + } + + // Generate tool content from schemas + const schemas = options.toolSchemas ?? (llmToolSchemas ? Object.values(llmToolSchemas) : []); + const toolContent = schemas.map((schema) => schemaToToolContent(schema)).join('\n\n'); + + // Build source path pointing to the plugin configuration + // Format: ['plugins', pluginId] so clicking navigates to plugins tab + const pluginIndex = context.pluginIndex; + const source = pluginIndex !== undefined ? ['plugins', toolConfig.id] : undefined; + + const toolPrompt: IPrompt = { + id: `${toolId}-tool-list-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + text: toolContent, + caption: options.caption ?? `${definition.displayName} Tools`, + enabled: true, + source, + }; + + if (options.position === 'child') { + // Add to target's children + if (!target.prompt.children) { + target.prompt.children = []; + } + target.prompt.children.push(toolPrompt); + } else if (options.position === 'before') { + target.parent.splice(target.index, 0, toolPrompt); + } else { + target.parent.splice(target.index + 1, 0, toolPrompt); + } + + logger.debug(`Tool list injected`, { + targetId: options.targetId, + position: options.position, + toolId, + }); + }, + + injectContent: (options: InjectContentOptions) => { + const target = findPromptById(prompts, options.targetId); + if (!target) { + logger.warn(`Target prompt not found for content injection`, { + targetId: options.targetId, + toolId, + }); + return; + } + + // Build source path pointing to the plugin configuration + const pluginIndex = context.pluginIndex; + const source = pluginIndex !== undefined ? ['plugins', toolConfig.id] : undefined; + + const contentPrompt: IPrompt = { + id: options.id ?? `${toolId}-content-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + text: options.content, + caption: options.caption ?? 'Injected Content', + enabled: true, + source, + }; + + if (options.position === 'child') { + if (!target.prompt.children) { + target.prompt.children = []; + } + target.prompt.children.push(contentPrompt); + } else if (options.position === 'before') { + target.parent.splice(target.index, 0, contentPrompt); + } else { + target.parent.splice(target.index + 1, 0, contentPrompt); + } + + logger.debug(`Content injected`, { + targetId: options.targetId, + position: options.position, + toolId, + }); + }, + }; + + await onProcessPrompts(handlerContext); + callback(); + } catch (error) { + logger.error(`Error in ${toolId} processPrompts handler`, { error }); + callback(); + } + }); + } + + // Register responseComplete handler + if (onResponseComplete) { + hooks.responseComplete.tapAsync(`${toolId}-responseComplete`, async (context, callback) => { + try { + const { agentFrameworkContext, response, agentFrameworkConfig, requestId, toolConfig: directToolConfig } = context as AIResponseContext & { + toolConfig?: PromptConcatHookContext['toolConfig']; + }; + + // Find our tool's config - first try agentFrameworkConfig.plugins, then fall back to direct toolConfig + const configuredToolConfig = agentFrameworkConfig?.plugins?.find((p) => p.toolId === toolId); + const ourToolConfig = configuredToolConfig ?? (directToolConfig?.toolId === toolId ? directToolConfig : undefined); + + // Skip if this tool is not configured for this agent + if (!ourToolConfig) { + callback(); + return; + } + + if (ourToolConfig.enabled === false) { + callback(); + return; + } + + // Skip if response is not complete + if (response.status !== 'done' || !response.content) { + callback(); + return; + } + + // Parse tool call from response + const toolMatch = matchToolCalling(response.content); + const toolCall = toolMatch.found ? toolMatch : null; + + // Try to parse config (may be empty for tools that only handle LLM tool calls) + const rawConfig: unknown = ourToolConfig[parameterKey]; + let config: z.infer | undefined; + if (rawConfig) { + try { + config = configSchema.parse(rawConfig) as z.infer; + } catch (parseError) { + logger.warn(`Failed to parse config for ${toolId}`, { parseError }); + } + } + + // Build handler context + const handlerContext: ResponseHandlerContext = { + config, + toolConfig: ourToolConfig, + messages: agentFrameworkContext.agent.messages, + agentFrameworkContext, + response, + toolCall, + agentFrameworkConfig, + hooks, + requestId, + + findPrompt: () => undefined, // Not available in response phase + + injectToolList: () => { + logger.warn('injectToolList is not available in response phase'); + }, + + injectContent: () => { + logger.warn('injectContent is not available in response phase'); + }, + + executeToolCall: async ( + toolName: TToolName, + executor: (parameters: z.infer) => Promise, + ): Promise => { + if (!toolCall || toolCall.toolId !== toolName) { + return false; + } + + const toolSchema = llmToolSchemas?.[toolName]; + if (!toolSchema) { + logger.error(`No schema found for tool: ${String(toolName)}`); + return false; + } + + try { + // Validate parameters + const validatedParameters = toolSchema.parse(toolCall.parameters); + + // Execute the tool + const result = await executor(validatedParameters); + + // Add result message + const toolResultDuration = (config as { toolResultDuration?: number } | undefined)?.toolResultDuration ?? 1; + handlerContext.addToolResult({ + toolName: toolName, + parameters: validatedParameters, + result: result.success ? (result.data ?? 'Success') : (result.error ?? 'Unknown error'), + isError: !result.success, + duration: toolResultDuration, + }); + + // Set up next round + handlerContext.yieldToSelf(); + + // Signal tool execution to other plugins + await hooks.toolExecuted.promise({ + agentFrameworkContext, + toolResult: result, + toolInfo: { + toolId: String(toolName), + parameters: validatedParameters as Record, + originalText: toolCall.originalText, + }, + requestId, + }); + + return true; + } catch (error) { + logger.error(`Tool execution failed: ${String(toolName)}`, { error }); + + // Add error result + handlerContext.addToolResult({ + toolName: toolName, + parameters: toolCall.parameters, + result: error instanceof Error ? error.message : String(error), + isError: true, + duration: 2, + }); + + handlerContext.yieldToSelf(); + + await hooks.toolExecuted.promise({ + agentFrameworkContext, + toolResult: { + success: false, + error: error instanceof Error ? error.message : String(error), + }, + toolInfo: { + toolId: toolName, + parameters: toolCall.parameters || {}, + }, + }); + + return true; + } + }, + + addToolResult: (options: AddToolResultOptions) => { + const now = new Date(); + const toolResultText = ` +Tool: ${options.toolName} +Parameters: ${JSON.stringify(options.parameters)} +${options.isError ? 'Error' : 'Result'}: ${options.result} +`; + + const toolResultMessage: AgentInstanceMessage = { + id: `tool-result-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + agentId: agentFrameworkContext.agent.id, + role: 'tool', + content: toolResultText, + created: now, + modified: now, + duration: options.duration ?? 1, + metadata: { + isToolResult: true, + isError: options.isError ?? false, + toolId: options.toolName, + toolParameters: options.parameters, + isPersisted: false, + isComplete: true, + }, + }; + + agentFrameworkContext.agent.messages.push(toolResultMessage); + + // Persist immediately + void (async () => { + try { + const agentInstanceService = container.get(serviceIdentifier.AgentInstance); + await agentInstanceService.saveUserMessage(toolResultMessage); + toolResultMessage.metadata = { ...toolResultMessage.metadata, isPersisted: true }; + } catch (error) { + logger.warn('Failed to persist tool result', { error, messageId: toolResultMessage.id }); + } + })(); + + logger.debug('Tool result added', { + toolName: options.toolName, + isError: options.isError, + messageId: toolResultMessage.id, + }); + }, + + yieldToSelf: () => { + if (!context.actions) { + context.actions = {}; + } + context.actions.yieldNextRoundTo = 'self'; + + // Also set duration on the AI message containing the tool call and update UI immediately + const aiMessages = agentFrameworkContext.agent.messages.filter((m) => m.role === 'assistant'); + if (aiMessages.length > 0) { + const latestAiMessage = aiMessages[aiMessages.length - 1]; + // Only update if this message matches the current response (contains the tool call) + if (latestAiMessage.content === response.content) { + latestAiMessage.duration = 1; + latestAiMessage.metadata = { + ...latestAiMessage.metadata, + containsToolCall: true, + toolId: toolCall?.toolId, + }; + + // Persist and update UI immediately (no debounce delay) + void (async () => { + try { + const agentInstanceService = container.get(serviceIdentifier.AgentInstance); + if (!latestAiMessage.created) latestAiMessage.created = new Date(); + await agentInstanceService.saveUserMessage(latestAiMessage); + latestAiMessage.metadata = { ...latestAiMessage.metadata, isPersisted: true }; + // Update UI with no delay + agentInstanceService.debounceUpdateMessage(latestAiMessage, agentFrameworkContext.agent.id, 0); + } catch (error) { + logger.warn('Failed to persist AI message with tool call', { error, messageId: latestAiMessage.id }); + } + })(); + } + } + }, + }; + + await onResponseComplete(handlerContext); + callback(); + } catch (error) { + logger.error(`Error in ${toolId} responseComplete handler`, { error }); + callback(); + } + }); + } + + // Register postProcess handler + if (onPostProcess) { + hooks.postProcess.tapAsync(`${toolId}-postProcess`, async (context, callback) => { + try { + const { toolConfig, prompts, messages, agentFrameworkContext, llmResponse, responses } = context; + + if (toolConfig.toolId !== toolId) { + callback(); + return; + } + + if (toolConfig.enabled === false) { + callback(); + return; + } + + const rawConfig: unknown = toolConfig[parameterKey]; + if (!rawConfig) { + callback(); + return; + } + + const config = configSchema.parse(rawConfig) as z.infer; + + const handlerContext: PostProcessHandlerContext = { + config, + toolConfig, + prompts, + messages, + agentFrameworkContext, + llmResponse, + responses, + + findPrompt: (id: string) => findPromptById(prompts, id), + + injectToolList: () => { + logger.warn('injectToolList is not recommended in postProcess phase'); + }, + + injectContent: () => { + logger.warn('injectContent is not recommended in postProcess phase'); + }, + }; + + await onPostProcess(handlerContext); + callback(); + } catch (error) { + logger.error(`Error in ${toolId} postProcess handler`, { error }); + callback(); + } + }); + } + }; + + return { + tool, + toolId, + configSchema, + llmToolSchemas, + displayName: definition.displayName, + description: definition.description, + }; +} + +/** + * Registry for tools created with defineTool + */ +const toolRegistry = new Map>(); + +/** + * Register a tool definition + */ +export function registerToolDefinition< + TConfigSchema extends z.ZodType, + TLLMToolSchemas extends Record, +>(definition: ToolDefinition): ReturnType> { + const toolDefinition = defineTool(definition); + + // Register tool parameter schema and metadata for dynamic schema generation + registerToolParameterSchema(toolDefinition.toolId, toolDefinition.configSchema, { + displayName: toolDefinition.displayName, + description: toolDefinition.description, + }); + + toolRegistry.set(toolDefinition.toolId, toolDefinition as ReturnType); + return toolDefinition; +} + +/** + * Get all registered tool definitions + */ +export function getAllToolDefinitions(): Map> { + return toolRegistry; +} + +/** + * Get a tool definition by ID + */ +export function getToolDefinition(toolId: string): ReturnType | undefined { + return toolRegistry.get(toolId); +} diff --git a/src/services/agentInstance/tools/git.ts b/src/services/agentInstance/tools/git.ts new file mode 100644 index 00000000..7e140ec0 --- /dev/null +++ b/src/services/agentInstance/tools/git.ts @@ -0,0 +1,255 @@ +/** + * Git Tool + * Provides git log search (message/file/date range) and file content retrieval for commits. + */ +import { container } from '@services/container'; +import type { IGitLogOptions, IGitService } from '@services/git/interface'; +import { i18n } from '@services/libs/i18n'; +import { t } from '@services/libs/i18n/placeholder'; +import { logger } from '@services/libs/log'; +import serviceIdentifier from '@services/serviceIdentifier'; +import type { IWorkspaceService } from '@services/workspaces/interface'; +import { isWikiWorkspace } from '@services/workspaces/interface'; +import { z } from 'zod/v4'; +import { registerToolDefinition, type ToolExecutionResult } from './defineTool'; + +export const GitToolParameterSchema = z.object({ + toolListPosition: z.object({ + targetId: z.string().meta({ + title: t('Schema.Common.ToolListPosition.TargetIdTitle'), + description: t('Schema.Common.ToolListPosition.TargetId'), + }), + position: z.enum(['before', 'after', 'child']).default('child').meta({ + title: t('Schema.Common.ToolListPosition.PositionTitle'), + description: t('Schema.Common.ToolListPosition.Position'), + }), + }).optional().meta({ + title: t('Schema.Common.ToolListPositionTitle'), + description: t('Schema.Common.ToolListPosition.Description'), + }), +}).meta({ + title: t('Schema.Git.Title'), + description: t('Schema.Git.Description'), +}); + +export type GitToolParameter = z.infer; + +export function getGitToolParameterSchema() { + return GitToolParameterSchema; +} + +const GitLogToolSchema = z.object({ + workspaceName: z.string().meta({ + title: t('Schema.Git.Tool.Parameters.workspaceName.Title'), + description: t('Schema.Git.Tool.Parameters.workspaceName.Description'), + }), + searchMode: z.enum(['message', 'file', 'dateRange', 'none']).default('message').meta({ + title: t('Schema.Git.Tool.Parameters.searchMode.Title'), + description: t('Schema.Git.Tool.Parameters.searchMode.Description'), + }), + searchQuery: z.string().optional().meta({ + title: t('Schema.Git.Tool.Parameters.searchQuery.Title'), + description: t('Schema.Git.Tool.Parameters.searchQuery.Description'), + }), + filePath: z.string().optional().meta({ + title: t('Schema.Git.Tool.Parameters.filePath.Title'), + description: t('Schema.Git.Tool.Parameters.filePath.Description'), + }), + since: z.string().optional().meta({ + title: t('Schema.Git.Tool.Parameters.since.Title'), + description: t('Schema.Git.Tool.Parameters.since.Description'), + }), + until: z.string().optional().meta({ + title: t('Schema.Git.Tool.Parameters.until.Title'), + description: t('Schema.Git.Tool.Parameters.until.Description'), + }), + page: z.number().int().positive().default(1).meta({ + title: t('Schema.Git.Tool.Parameters.page.Title'), + description: t('Schema.Git.Tool.Parameters.page.Description'), + }), + pageSize: z.number().int().positive().default(20).meta({ + title: t('Schema.Git.Tool.Parameters.pageSize.Title'), + description: t('Schema.Git.Tool.Parameters.pageSize.Description'), + }), +}).meta({ + title: 'git-log', + description: 'Search git commits by message, file path, or date range', + examples: [ + { + workspaceName: 'My Wiki', + searchMode: 'message', + searchQuery: 'fix bug', + page: 1, + pageSize: 10, + }, + { + workspaceName: 'My Wiki', + searchMode: 'file', + filePath: 'tiddlers/Index.tid', + page: 1, + pageSize: 10, + }, + { + workspaceName: 'My Wiki', + searchMode: 'dateRange', + since: '2024-01-01T00:00:00Z', + until: '2024-12-31T23:59:59Z', + }, + ], +}); + +type GitLogParameters = z.infer; + +const GitReadFileToolSchema = z.object({ + workspaceName: z.string().meta({ + title: t('Schema.Git.Tool.Parameters.workspaceName.Title'), + description: t('Schema.Git.Tool.Parameters.workspaceName.Description'), + }), + commitHash: z.string().meta({ + title: t('Schema.Git.Tool.Parameters.commitHash.Title'), + description: t('Schema.Git.Tool.Parameters.commitHash.Description'), + }), + filePath: z.string().meta({ + title: t('Schema.Git.Tool.Parameters.filePath.Title'), + description: t('Schema.Git.Tool.Parameters.filePath.Description'), + }), + maxLines: z.number().int().positive().default(500).meta({ + title: t('Schema.Git.Tool.Parameters.maxLines.Title'), + description: t('Schema.Git.Tool.Parameters.maxLines.Description'), + }), +}).meta({ + title: 'git-read-file', + description: 'Read a specific file content from a given commit', + examples: [ + { + workspaceName: 'My Wiki', + commitHash: 'abc123', + filePath: 'tiddlers/Index.tid', + maxLines: 200, + }, + ], +}); + +type GitReadFileParameters = z.infer; + +type ResolveWorkspaceResult = + | { success: true; workspaceID: string; wikiFolderLocation: string; workspaceName: string } + | { success: false; error: string }; + +async function resolveWorkspace(workspaceName: string): Promise { + const workspaceService = container.get(serviceIdentifier.Workspace); + const workspaces = await workspaceService.getWorkspacesAsList(); + const target = workspaces.find((ws) => ws.name === workspaceName || ws.id === workspaceName); + if (!target || !isWikiWorkspace(target)) { + return { + success: false, + error: i18n.t('Tool.Git.Error.WorkspaceNotFound', { workspaceName }), + }; + } + return { success: true, workspaceID: target.id, wikiFolderLocation: target.wikiFolderLocation, workspaceName: target.name }; +} + +async function executeGitLog(parameters: GitLogParameters): Promise { + const resolved = await resolveWorkspace(parameters.workspaceName); + if (!resolved.success) { + throw new Error(resolved.error); + } + const { workspaceID, wikiFolderLocation, workspaceName } = resolved; + + const gitService = container.get(serviceIdentifier.Git); + const options: IGitLogOptions = { + searchMode: parameters.searchMode, + searchQuery: parameters.searchQuery, + filePath: parameters.filePath, + since: parameters.since, + until: parameters.until, + page: parameters.page, + pageSize: parameters.pageSize, + }; + + logger.debug('Executing git log search', { workspaceID, options }); + const result = await gitService.getGitLog(wikiFolderLocation, options); + + return { + success: true, + data: JSON.stringify({ + workspaceID, + workspaceName, + totalCount: result.totalCount, + currentBranch: result.currentBranch, + entries: result.entries, + }), + metadata: { workspaceID, workspaceName, searchMode: parameters.searchMode, page: parameters.page, pageSize: parameters.pageSize }, + }; +} + +async function executeGitReadFile(parameters: GitReadFileParameters): Promise { + const resolved = await resolveWorkspace(parameters.workspaceName); + if (!resolved.success) { + throw new Error(resolved.error); + } + const { workspaceID, wikiFolderLocation, workspaceName } = resolved; + + const gitService = container.get(serviceIdentifier.Git); + logger.debug('Reading file from commit', { workspaceID, filePath: parameters.filePath, commitHash: parameters.commitHash }); + + const fileResult = await gitService.getFileContent( + wikiFolderLocation, + parameters.commitHash, + parameters.filePath, + parameters.maxLines, + ); + + return { + success: true, + data: JSON.stringify({ + workspaceID, + workspaceName, + commitHash: parameters.commitHash, + filePath: parameters.filePath, + content: fileResult.content, + isTruncated: fileResult.isTruncated, + }), + metadata: { workspaceID, workspaceName, commitHash: parameters.commitHash, filePath: parameters.filePath }, + }; +} + +const gitToolDefinition = registerToolDefinition({ + toolId: 'git', + displayName: t('Schema.Git.Title'), + description: t('Schema.Git.Description'), + configSchema: GitToolParameterSchema, + llmToolSchemas: { + 'git-log': GitLogToolSchema, + 'git-read-file': GitReadFileToolSchema, + }, + + onProcessPrompts({ config, injectToolList, toolConfig }) { + const toolListPosition = config.toolListPosition; + if (!toolListPosition?.targetId) return; + + injectToolList({ + targetId: toolListPosition.targetId, + position: toolListPosition.position || 'child', + caption: 'Git Tools', + }); + + logger.debug('Git tool list injected', { targetId: toolListPosition.targetId, toolId: toolConfig.id }); + }, + + async onResponseComplete({ toolCall, executeToolCall, agentFrameworkContext }) { + if (!toolCall) return; + if (agentFrameworkContext.isCancelled()) return; + + if (toolCall.toolId === 'git-log') { + await executeToolCall('git-log', executeGitLog); + return; + } + + if (toolCall.toolId === 'git-read-file') { + await executeToolCall('git-read-file', executeGitReadFile); + } + }, +}); + +export const gitTool = gitToolDefinition.tool; diff --git a/src/services/agentInstance/tools/index.ts b/src/services/agentInstance/tools/index.ts index 6c4abbd6..e2e3f839 100644 --- a/src/services/agentInstance/tools/index.ts +++ b/src/services/agentInstance/tools/index.ts @@ -1,20 +1,41 @@ +/** + * Agent Framework Plugin System + * + * This module provides a unified registration and hook system for: + * 1. Modifiers - Transform the prompt tree (fullReplacement, dynamicPosition) + * 2. LLM Tools - Inject tool descriptions and handle AI tool calls (wikiSearch, wikiOperation, etc.) + * 3. Core Infrastructure - Message persistence, streaming, status (always enabled) + * + * All plugins are configured via the `plugins` array in agentFrameworkConfig. + * Each plugin has a `toolId` that identifies it and a corresponding `xxxParam` object for configuration. + */ import { logger } from '@services/libs/log'; import { AsyncSeriesHook, AsyncSeriesWaterfallHook } from 'tapable'; + +import { registerCoreInfrastructure } from '../promptConcat/infrastructure'; +import { getAllModifiers } from '../promptConcat/modifiers'; +import { getAllToolDefinitions } from './defineTool'; import { registerToolParameterSchema } from './schemaRegistry'; -import { AgentResponse, PromptConcatHookContext, PromptConcatHooks, PromptConcatTool, ResponseHookContext } from './types'; +import { PromptConcatHooks, PromptConcatTool } from './types'; // Re-export types for convenience -export type { AgentResponse, PromptConcatHookContext, PromptConcatHooks, PromptConcatTool, ResponseHookContext }; -// Backward compatibility aliases -export type { PromptConcatTool as PromptConcatPlugin }; +export type { AgentResponse, PostProcessContext, PromptConcatHookContext, PromptConcatHooks, PromptConcatTool, ResponseHookContext } from './types'; + +// Re-export defineTool API for LLM tools +export { defineTool, getAllToolDefinitions, registerToolDefinition } from './defineTool'; +export type { ResponseHandlerContext, ToolDefinition, ToolExecutionResult, ToolHandlerContext } from './defineTool'; + +// Re-export modifier API +export { defineModifier, getAllModifiers, registerModifier } from '../promptConcat/modifiers'; +export type { InsertContentOptions, ModifierDefinition, ModifierHandlerContext } from '../promptConcat/modifiers'; /** - * Registry for built-in framework tools + * Registry for all plugins (modifiers + LLM tools) */ -export const builtInTools = new Map(); +export const pluginRegistry = new Map(); /** - * Create unified hooks instance for the complete agent framework tool system + * Create unified hooks instance for the agent framework */ export function createAgentFrameworkHooks(): PromptConcatHooks { return { @@ -32,159 +53,89 @@ export function createAgentFrameworkHooks(): PromptConcatHooks { } /** - * Get all available tools + * Register plugins to hooks based on framework configuration */ -async function getAllTools() { - const [ - promptToolsModule, - wikiSearchModule, - wikiOperationModule, - workspacesListModule, - messageManagementModule, - ] = await Promise.all([ - import('./prompt'), - import('./wikiSearch'), - import('./wikiOperation'), - import('./workspacesList'), - import('./messageManagement'), - ]); - - return { - messageManagement: messageManagementModule.messageManagementTool, - fullReplacement: promptToolsModule.fullReplacementTool, - wikiSearch: wikiSearchModule.wikiSearchTool, - wikiOperation: wikiOperationModule.wikiOperationTool, - workspacesList: workspacesListModule.workspacesListTool, - }; -} - -/** - * Register tools to hooks based on framework configuration - * @param hooks - The hooks instance to register tools to - * @param agentFrameworkConfig - The framework configuration containing tool settings - */ -export async function registerToolsToHooksFromConfig( +export async function registerPluginsToHooks( hooks: PromptConcatHooks, agentFrameworkConfig: { plugins?: Array<{ toolId: string; [key: string]: unknown }> }, ): Promise { - // Always register core tools that are needed for basic functionality - const messageManagementModule = await import('./messageManagement'); - messageManagementModule.messageManagementTool(hooks); - logger.debug('Registered messageManagementTool to hooks'); + // Always register core infrastructure first (message persistence, streaming, status) + registerCoreInfrastructure(hooks); + logger.debug('Registered core infrastructure to hooks'); - // Register tools based on framework configuration + // Register plugins based on framework configuration if (agentFrameworkConfig.plugins) { - for (const toolConfig of agentFrameworkConfig.plugins) { - const { toolId } = toolConfig; + for (const pluginConfig of agentFrameworkConfig.plugins) { + const { toolId } = pluginConfig; - // Get tool from global registry (supports both built-in and dynamic tools) - const tool = builtInTools.get(toolId); - if (tool) { - tool(hooks); - logger.debug(`Registered tool ${toolId} to hooks`); + const plugin = pluginRegistry.get(toolId); + if (plugin) { + plugin(hooks); + logger.debug(`Registered plugin ${toolId} to hooks`); } else { - logger.warn(`Tool not found in registry: ${toolId}`); + logger.warn(`Plugin not found in registry: ${toolId}`); } } } } /** - * Initialize tool system - register all built-in tools to global registry + * Initialize plugin system - register all built-in modifiers and LLM tools * This should be called once during service initialization */ -export async function initializeToolSystem(): Promise { - // Import tool schemas and register them - const [ - promptToolsModule, - wikiSearchModule, - wikiOperationModule, - workspacesListModule, - modelContextProtocolModule, - ] = await Promise.all([ - import('./prompt'), +export async function initializePluginSystem(): Promise { + // Import all plugin modules to trigger registration + await Promise.all([ + // LLM Tools import('./wikiSearch'), import('./wikiOperation'), import('./workspacesList'), + import('./git'), + import('./tiddlywikiPlugin'), import('./modelContextProtocol'), + // Modifiers (imported via modifiers/index.ts) + import('../promptConcat/modifiers'), ]); - // Register tool parameter schemas - registerToolParameterSchema( - 'fullReplacement', - promptToolsModule.getFullReplacementParameterSchema(), - { - displayName: 'Full Replacement', - description: 'Replace target content with content from specified source', - }, - ); + // Register modifiers from the modifier registry + const modifiers = getAllModifiers(); + for (const [modifierId, modifierDefinition] of modifiers) { + pluginRegistry.set(modifierId, modifierDefinition.modifier); + registerToolParameterSchema(modifierId, modifierDefinition.configSchema, { + displayName: modifierDefinition.displayName, + description: modifierDefinition.description, + }); + logger.debug(`Registered modifier: ${modifierId}`); + } - registerToolParameterSchema( - 'dynamicPosition', - promptToolsModule.getDynamicPositionParameterSchema(), - { - displayName: 'Dynamic Position', - description: 'Insert content at a specific position relative to a target element', - }, - ); + // Register LLM tools from the tool registry + const llmTools = getAllToolDefinitions(); + for (const [toolId, toolDefinition] of llmTools) { + pluginRegistry.set(toolId, toolDefinition.tool); + registerToolParameterSchema(toolId, toolDefinition.configSchema, { + displayName: toolDefinition.displayName, + description: toolDefinition.description, + }); + logger.debug(`Registered LLM tool: ${toolId}`); + } - registerToolParameterSchema( - 'wikiSearch', - wikiSearchModule.getWikiSearchParameterSchema(), - { - displayName: 'Wiki Search', - description: 'Search content in wiki workspaces and manage vector embeddings', - }, - ); - - registerToolParameterSchema( - 'wikiOperation', - wikiOperationModule.getWikiOperationParameterSchema(), - { - displayName: 'Wiki Operation', - description: 'Perform operations on wiki workspaces (create, update, delete tiddlers)', - }, - ); - - registerToolParameterSchema( - 'workspacesList', - workspacesListModule.getWorkspacesListParameterSchema(), - { - displayName: 'Workspaces List', - description: 'Inject available wiki workspaces list into prompts', - }, - ); - - registerToolParameterSchema( - 'modelContextProtocol', - modelContextProtocolModule.getModelContextProtocolParameterSchema(), - { - displayName: 'Model Context Protocol', - description: 'MCP (Model Context Protocol) integration', - }, - ); - - const tools = await getAllTools(); - // Register all built-in tools to global registry for discovery - builtInTools.set('messageManagement', tools.messageManagement); - builtInTools.set('fullReplacement', tools.fullReplacement); - builtInTools.set('wikiSearch', tools.wikiSearch); - builtInTools.set('wikiOperation', tools.wikiOperation); - builtInTools.set('workspacesList', tools.workspacesList); - logger.debug('All built-in tools and schemas registered successfully'); + logger.debug('Plugin system initialized', { + totalPlugins: pluginRegistry.size, + modifiers: modifiers.size, + llmTools: llmTools.size, + }); } /** - * Create hooks and register tools based on framework configuration - * This creates a new hooks instance and registers tools for that specific context + * Create hooks and register plugins based on framework configuration */ -export async function createHooksWithTools( +export async function createHooksWithPlugins( agentFrameworkConfig: { plugins?: Array<{ toolId: string; [key: string]: unknown }> }, -): Promise<{ hooks: PromptConcatHooks; toolConfigs: Array<{ toolId: string; [key: string]: unknown }> }> { +): Promise<{ hooks: PromptConcatHooks; pluginConfigs: Array<{ toolId: string; [key: string]: unknown }> }> { const hooks = createAgentFrameworkHooks(); - await registerToolsToHooksFromConfig(hooks, agentFrameworkConfig); + await registerPluginsToHooks(hooks, agentFrameworkConfig); return { hooks, - toolConfigs: agentFrameworkConfig.plugins || [], + pluginConfigs: agentFrameworkConfig.plugins ?? [], }; } diff --git a/src/services/agentInstance/tools/messageManagement.ts b/src/services/agentInstance/tools/messageManagement.ts deleted file mode 100644 index 305887db..00000000 --- a/src/services/agentInstance/tools/messageManagement.ts +++ /dev/null @@ -1,269 +0,0 @@ -/** - * Message management plugin - * Unified plugin for handling message persistence, streaming updates, and UI synchronization - * Combines functionality from persistencePlugin and aiResponseHistoryPlugin - */ -import { container } from '@services/container'; -import { logger } from '@services/libs/log'; -import serviceIdentifier from '@services/serviceIdentifier'; -import type { IAgentInstanceService } from '../interface'; -import { createAgentMessage } from '../utilities'; -import type { AgentStatusContext, AIResponseContext, PromptConcatTool, ToolExecutionContext, UserMessageContext } from './types'; - -/** - * Message management plugin - * Handles all message-related operations: persistence, streaming, UI updates, and duration-based filtering - */ -export const messageManagementTool: PromptConcatTool = (hooks) => { - // Handle user message persistence - hooks.userMessageReceived.tapAsync('messageManagementTool', async (context: UserMessageContext, callback) => { - try { - const { agentFrameworkContext, content, messageId } = context; - - // 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, - duration: undefined, // User messages persist indefinitely by default - }); - - // Add message to the agent's message array for immediate use (do this before persistence so plugins see it) - agentFrameworkContext.agent.messages.push(userMessage); - - // Get the agent instance service to access repositories - const agentInstanceService = container.get(serviceIdentifier.AgentInstance); - - // Save user message to database (if persistence fails, we still keep the in-memory message) - await agentInstanceService.saveUserMessage(userMessage); - - logger.debug('User message persisted to database', { - messageId, - agentId: agentFrameworkContext.agent.id, - contentLength: content.text.length, - }); - - callback(); - } catch (error) { - logger.error('Message management plugin error in userMessageReceived', { - error, - messageId: context.messageId, - agentId: context.agentFrameworkContext.agent.id, - }); - callback(); - } - }); - - // Handle agent status persistence - hooks.agentStatusChanged.tapAsync('messageManagementTool', async (context: AgentStatusContext, callback) => { - try { - const { agentFrameworkContext, status } = context; - - // Get the agent instance service to update status - const agentInstanceService = container.get(serviceIdentifier.AgentInstance); - - // Update agent status in database - await agentInstanceService.updateAgent(agentFrameworkContext.agent.id, { - status, - }); - - // Update the agent object for immediate use - agentFrameworkContext.agent.status = status; - - logger.debug('Agent status updated in database', { - agentId: agentFrameworkContext.agent.id, - state: status.state, - }); - - callback(); - } catch (error) { - logger.error('Message management plugin error in agentStatusChanged', { - error, - agentId: context.agentFrameworkContext.agent.id, - status: context.status, - }); - callback(); - } - }); - - // Handle AI response updates during streaming - hooks.responseUpdate.tapAsync('messageManagementTool', async (context: AIResponseContext, callback) => { - try { - const { agentFrameworkContext, response } = context; - - if (response.status === 'update' && response.content) { - // Find or create AI response message in agent's message array - let aiMessage = agentFrameworkContext.agent.messages.find( - (message) => message.role === 'assistant' && !message.metadata?.isComplete, - ); - - if (!aiMessage) { - // Create new AI message for streaming updates - const now = new Date(); - aiMessage = { - id: `ai-response-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, - agentId: agentFrameworkContext.agent.id, - role: 'assistant', - content: response.content, - created: now, - modified: now, - metadata: { isComplete: false }, - duration: undefined, // AI responses persist indefinitely by default - }; - agentFrameworkContext.agent.messages.push(aiMessage); - // Persist immediately so DB timestamp reflects conversation order - try { - const agentInstanceService = container.get(serviceIdentifier.AgentInstance); - await agentInstanceService.saveUserMessage(aiMessage); - aiMessage.metadata = { ...aiMessage.metadata, isPersisted: true }; - } catch (persistError) { - logger.warn('Failed to persist initial streaming AI message', { - error: persistError, - messageId: aiMessage.id, - }); - } - } else { - // Update existing message content - aiMessage.content = response.content; - aiMessage.modified = new Date(); - } - - // Update UI using the agent instance service - try { - const agentInstanceService = container.get(serviceIdentifier.AgentInstance); - agentInstanceService.debounceUpdateMessage(aiMessage, agentFrameworkContext.agent.id); - } catch (serviceError) { - logger.warn('Failed to update UI for streaming message', { - error: serviceError, - messageId: aiMessage.id, - }); - } - } - } catch (error) { - logger.error('Message management plugin error in responseUpdate', { - error, - }); - } finally { - callback(); - } - }); - - // Handle AI response completion - hooks.responseComplete.tapAsync('messageManagementTool', async (context: AIResponseContext, callback) => { - try { - const { agentFrameworkContext, response } = context; - - if (response.status === 'done' && response.content) { - // Find and finalize AI response message - let aiMessage = agentFrameworkContext.agent.messages.find( - (message) => message.role === 'assistant' && !message.metadata?.isComplete && !message.metadata?.isToolResult, - ); - - if (aiMessage) { - // Mark as complete and update final content - aiMessage.content = response.content; - aiMessage.modified = new Date(); - aiMessage.metadata = { ...aiMessage.metadata, isComplete: true }; - } else { - // Create final message if streaming message wasn't found - const nowFinal = new Date(); - aiMessage = { - id: `ai-response-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, - agentId: agentFrameworkContext.agent.id, - role: 'assistant', - content: response.content, - created: nowFinal, - modified: nowFinal, - metadata: { - isComplete: true, - }, - duration: undefined, // Default duration for AI responses - }; - agentFrameworkContext.agent.messages.push(aiMessage); - } - - // Get the agent instance service for persistence and UI updates - const agentInstanceService = container.get(serviceIdentifier.AgentInstance); - - // Save final AI message to database using the same method as user messages - await agentInstanceService.saveUserMessage(aiMessage); - - // Final UI update - try { - agentInstanceService.debounceUpdateMessage(aiMessage, agentFrameworkContext.agent.id); - } catch (serviceError) { - logger.warn('Failed to update UI for completed message', { - error: serviceError, - messageId: aiMessage.id, - }); - } - - logger.debug('AI response message completed and persisted', { - messageId: aiMessage.id, - finalContentLength: response.content.length, - }); - } - - callback(); - } catch (error) { - logger.error('Message management plugin error in responseComplete', { - error, - }); - callback(); - } - }); - - // Handle tool result messages persistence and UI updates - hooks.toolExecuted.tapAsync('messageManagementTool', async (context: ToolExecutionContext, callback) => { - try { - const { agentFrameworkContext } = context; - - // Find newly added tool result messages that need to be persisted - const newToolResultMessages = agentFrameworkContext.agent.messages.filter( - (message) => message.metadata?.isToolResult && !message.metadata.isPersisted, - ); - - const agentInstanceService = container.get(serviceIdentifier.AgentInstance); - - // Save tool result messages to database and update UI - for (const message of newToolResultMessages) { - try { - // Save to database using the same method as user messages - await agentInstanceService.saveUserMessage(message); - - // Update UI - agentInstanceService.debounceUpdateMessage(message, agentFrameworkContext.agent.id); - - // Mark as persisted to avoid duplicate saves - message.metadata = { ...message.metadata, isPersisted: true, uiUpdated: true }; - - logger.debug('Tool result message persisted to database', { - messageId: message.id, - toolId: message.metadata.toolId, - duration: message.duration, - }); - } catch (serviceError) { - logger.error('Failed to persist tool result message', { - error: serviceError, - messageId: message.id, - }); - } - } - - if (newToolResultMessages.length > 0) { - logger.debug('Tool result messages processed', { - count: newToolResultMessages.length, - messageIds: newToolResultMessages.map(m => m.id), - }); - } - - callback(); - } catch (error) { - logger.error('Message management plugin error in toolExecuted', { - error, - }); - callback(); - } - }); -}; diff --git a/src/services/agentInstance/tools/prompt.ts b/src/services/agentInstance/tools/prompt.ts deleted file mode 100644 index 0ec5ecba..00000000 --- a/src/services/agentInstance/tools/prompt.ts +++ /dev/null @@ -1,298 +0,0 @@ -/** - * Built-in plugins for prompt concatenation - */ -import { identity } from 'lodash'; -import { z } from 'zod/v4'; - -import { logger } from '@services/libs/log'; -import { cloneDeep } from 'lodash'; -import { findPromptById } from '../promptConcat/promptConcat'; -import type { IPrompt } from '../promptConcat/promptConcatSchema'; -import { filterMessagesByDuration } from '../utilities/messageDurationFilter'; -import { normalizeRole } from '../utilities/normalizeRole'; -import { AgentResponse, PromptConcatTool, ResponseHookContext } from './types'; - -const t = identity; - -/** - * Full Replacement Parameter Schema - * Configuration parameters for the full replacement plugin - */ -export const FullReplacementParameterSchema = z.object({ - targetId: z.string().meta({ - title: t('Schema.FullReplacement.TargetIdTitle'), - description: t('Schema.FullReplacement.TargetId'), - }), - sourceType: z.enum(['historyOfSession', 'llmResponse']).meta({ - title: t('Schema.FullReplacement.SourceTypeTitle'), - description: t('Schema.FullReplacement.SourceType'), - }), -}).meta({ - title: t('Schema.FullReplacement.Title'), - description: t('Schema.FullReplacement.Description'), -}); - -/** - * Dynamic Position Parameter Schema - * Configuration parameters for the dynamic position plugin - */ -export const DynamicPositionParameterSchema = z.object({ - targetId: z.string().meta({ - title: t('Schema.Position.TargetIdTitle'), - description: t('Schema.Position.TargetId'), - }), - position: z.enum(['before', 'after', 'relative']).meta({ - title: t('Schema.Position.TypeTitle'), - description: t('Schema.Position.Type'), - }), -}).meta({ - title: t('Schema.Position.Title'), - description: t('Schema.Position.Description'), -}); - -/** - * Type definitions - */ -export type FullReplacementParameter = z.infer; -export type DynamicPositionParameter = z.infer; - -/** - * Get the full replacement parameter schema - * @returns The schema for full replacement parameters - */ -export function getFullReplacementParameterSchema() { - return FullReplacementParameterSchema; -} - -/** - * Get the dynamic position parameter schema - * @returns The schema for dynamic position parameters - */ -export function getDynamicPositionParameterSchema() { - return DynamicPositionParameterSchema; -} - -/** - * Full replacement plugin - * Replaces target content with content from specified source - */ -export const fullReplacementTool: PromptConcatTool = (hooks) => { - // Normalize an AgentInstanceMessage role to Prompt role - hooks.processPrompts.tapAsync('fullReplacementTool', async (context, callback) => { - const { toolConfig, prompts, messages } = context; - - if (toolConfig.toolId !== 'fullReplacement' || !toolConfig.fullReplacementParam) { - callback(); - return; - } - - const fullReplacementConfig = toolConfig.fullReplacementParam; - if (!fullReplacementConfig) { - callback(); - return; - } - - const { targetId, sourceType } = fullReplacementConfig; - const found = findPromptById(prompts, targetId); - - if (!found) { - logger.warn('Target prompt not found for fullReplacement', { - targetId, - toolId: toolConfig.id, - }); - callback(); - return; - } - - // Get all messages except the last user message being processed - // We need to find and exclude only the current user message being processed, not just the last message - const messagesCopy = cloneDeep(messages); - - // Find the last user message (which is the one being processed in this round) - let lastUserMessageIndex = -1; - for (let index = messagesCopy.length - 1; index >= 0; index--) { - if (messagesCopy[index].role === 'user') { - lastUserMessageIndex = index; - break; - } - } - - // Remove only the last user message if found (this is the current message being processed) - if (lastUserMessageIndex >= 0) { - messagesCopy.splice(lastUserMessageIndex, 1); - logger.debug('Removed current user message from history', { - removedMessageId: messages[lastUserMessageIndex].id, - remainingMessages: messagesCopy.length, - }); - } else { - logger.debug('No user message found to remove from history', { - totalMessages: messagesCopy.length, - messageRoles: messagesCopy.map(m => m.role), - }); - } - - // Apply duration filtering to exclude expired messages from AI context - const filteredHistory = filterMessagesByDuration(messagesCopy); - - switch (sourceType) { - case 'historyOfSession': - if (filteredHistory.length > 0) { - // Insert filtered history messages as Prompt children (full Prompt type) - found.prompt.children = []; - filteredHistory.forEach((message, index: number) => { - // Map AgentInstanceMessage role to Prompt role via normalizeRole - type PromptRole = NonNullable; - const role: PromptRole = normalizeRole(message.role); - delete found.prompt.text; - found.prompt.children!.push({ - id: `history-${index}`, - caption: `History message ${index + 1}`, - role, - text: message.content, - }); - }); - } else { - found.prompt.text = '无聊天历史。'; - } - break; - case 'llmResponse': - // This is handled in response phase - break; - default: - logger.warn(`Unknown sourceType: ${sourceType as string}`); - callback(); - return; - } - - logger.debug('Full replacement completed in prompt phase', { - targetId, - sourceType, - }); - - callback(); - }); - - // Handle response phase for llmResponse source type - hooks.postProcess.tapAsync('fullReplacementTool', async (context, callback) => { - const responseContext = context as ResponseHookContext; - const { toolConfig, llmResponse, responses } = responseContext; - - if (toolConfig.toolId !== 'fullReplacement' || !toolConfig.fullReplacementParam) { - callback(); - return; - } - - const fullReplacementParameter = toolConfig.fullReplacementParam; - if (!fullReplacementParameter) { - callback(); - return; - } - - const { targetId, sourceType } = fullReplacementParameter; - - // Only handle llmResponse in response phase - if (sourceType !== 'llmResponse') { - callback(); - return; - } - - // Find the target response by ID - const found = responses.find((r: AgentResponse) => r.id === targetId); - - if (!found) { - logger.warn('Full replacement target not found in responses', { - targetId, - toolId: toolConfig.id, - }); - callback(); - return; - } - - // Replace target content with LLM response - logger.debug('Replacing target with LLM response', { - targetId, - responseLength: llmResponse.length, - toolId: toolConfig.id, - }); - - found.text = llmResponse; - - logger.debug('Full replacement completed in response phase', { - targetId, - sourceType, - toolId: toolConfig.id, - }); - - callback(); - }); -}; - -/** - * Dynamic position plugin - * Inserts content at a specific position relative to a target element - */ -export const dynamicPositionTool: PromptConcatTool = (hooks) => { - hooks.processPrompts.tapAsync('dynamicPositionTool', async (context, callback) => { - const { toolConfig, prompts } = context; - - if (toolConfig.toolId !== 'dynamicPosition' || !toolConfig.dynamicPositionParam || !toolConfig.content) { - callback(); - return; - } - - const dynamicPositionConfig = toolConfig.dynamicPositionParam; - if (!dynamicPositionConfig) { - callback(); - return; - } - - const { targetId, position } = dynamicPositionConfig; - const found = findPromptById(prompts, targetId); - - if (!found) { - logger.warn('Target prompt not found for dynamicPosition', { - targetId, - toolId: toolConfig.id, - }); - callback(); - return; - } - - // Create new prompt part - const newPart: IPrompt = { - id: `dynamic-${toolConfig.id}-${Date.now()}`, - caption: toolConfig.caption || 'Dynamic Content', - text: toolConfig.content, - }; - - // Insert based on position - switch (position) { - case 'before': - found.parent.splice(found.index, 0, newPart); - break; - case 'after': - found.parent.splice(found.index + 1, 0, newPart); - break; - case 'relative': - // Simplified implementation, only adds to target's children - if (!found.prompt.children) { - found.prompt.children = []; - } - found.prompt.children.push(newPart); - break; - default: - logger.warn(`Unknown position: ${position as string}`); - callback(); - return; - } - - logger.debug('Dynamic position insertion completed', { - targetId, - position, - contentLength: toolConfig.content.length, - toolId: toolConfig.id, - }); - - callback(); - }); -}; diff --git a/src/services/agentInstance/tools/schemaRegistry.ts b/src/services/agentInstance/tools/schemaRegistry.ts index 16b7bc4f..06d0cf84 100644 --- a/src/services/agentInstance/tools/schemaRegistry.ts +++ b/src/services/agentInstance/tools/schemaRegistry.ts @@ -87,6 +87,10 @@ export function createDynamicPromptConcatToolSchema(): z.ZodType { title: t('Schema.Tool.ContentTitle'), description: t('Schema.Tool.Content'), }), + enabled: z.boolean().optional().default(true).meta({ + title: t('Schema.Tool.EnabledTitle'), + description: t('Schema.Tool.Enabled'), + }), forbidOverrides: z.boolean().optional().default(false).meta({ title: t('Schema.Tool.ForbidOverridesTitle'), description: t('Schema.Tool.ForbidOverrides'), diff --git a/src/services/agentInstance/tools/tiddlywikiPlugin.ts b/src/services/agentInstance/tools/tiddlywikiPlugin.ts new file mode 100644 index 00000000..2eeaafb5 --- /dev/null +++ b/src/services/agentInstance/tools/tiddlywikiPlugin.ts @@ -0,0 +1,268 @@ +/** + * TiddlyWiki Plugin Tool + * + * Per https://github.com/TiddlyWiki/TiddlyWiki5/issues/9378, this tool helps AI understand and use available wiki functionality. + * + * Design: + * - Configuration (TiddlyWikiPluginParameterSchema): Specifies workspaceID and the three tag names for flexibility + * - Tool execution (TiddlyWikiPluginToolSchema): Provides two capabilities: + * 1. Auto-loaded Describe tags (shown in prompt prefix) - short descriptions of available functionality + * 2. Optional plugin loading (via tool call) - AI can load DataSource/Actions for specific plugins by title + * + * AI Workflow: + * 1. System prompt includes auto-loaded Describe entries (descriptions of what's available) + * 2. AI decides if it needs more details, calls tiddlywiki-plugin tool with a plugin title to load: + * - DataSource: data and filtering logic for that plugin + * - Actions: the action tiddler implementation details + * 3. AI then uses wiki-operation tool with invokeActionString operation, passing: + * - title: The action tiddler title (from the Actions description) + * - variables: JSON with variables needed by the action (from AI's reasoning) + * + * Flexibility: AI doesn't need to use this tool if it understands TiddlyWiki well enough to directly + * call wikiOperation with custom tiddler titles and action implementations. + */ +import { WikiChannel } from '@/constants/channels'; +import { container } from '@services/container'; +import { i18n } from '@services/libs/i18n'; +import { t } from '@services/libs/i18n/placeholder'; +import { logger } from '@services/libs/log'; +import serviceIdentifier from '@services/serviceIdentifier'; +import type { IWikiService } from '@services/wiki/interface'; +import type { IWorkspaceService } from '@services/workspaces/interface'; +import { isWikiWorkspace } from '@services/workspaces/interface'; +import type { ITiddlerFields } from 'tiddlywiki'; +import { z } from 'zod/v4'; +import { registerToolDefinition, type ToolExecutionResult } from './defineTool'; + +export const TiddlyWikiPluginParameterSchema = z.object({ + workspaceNameOrID: z.string().default('wiki').meta({ + title: t('Schema.TiddlyWikiPlugin.WorkspaceNameOrID.Title'), + description: t('Schema.TiddlyWikiPlugin.WorkspaceNameOrID.Description'), + }), + dataSourceTag: z.string().default('$:/tags/AI/Prompt/DataSource').meta({ + title: t('Schema.TiddlyWikiPlugin.DataSourceTag.Title'), + description: t('Schema.TiddlyWikiPlugin.DataSourceTag.Description'), + }), + describeTag: z.string().default('$:/tags/AI/Prompt/Describe').meta({ + title: t('Schema.TiddlyWikiPlugin.DescribeTag.Title'), + description: t('Schema.TiddlyWikiPlugin.DescribeTag.Description'), + }), + actionsTag: z.string().default('$:/tags/AI/Prompt/Actions').meta({ + title: t('Schema.TiddlyWikiPlugin.ActionsTag.Title'), + description: t('Schema.TiddlyWikiPlugin.ActionsTag.Description'), + }), + enableCache: z.boolean().default(true).meta({ + title: t('Schema.TiddlyWikiPlugin.EnableCache.Title'), + description: t('Schema.TiddlyWikiPlugin.EnableCache.Description'), + }), + toolListPosition: z.object({ + targetId: z.string().meta({ + title: t('Schema.Common.ToolListPosition.TargetIdTitle'), + description: t('Schema.Common.ToolListPosition.TargetId'), + }), + position: z.enum(['before', 'after', 'child']).default('child').meta({ + title: t('Schema.Common.ToolListPosition.PositionTitle'), + description: t('Schema.Common.ToolListPosition.Position'), + }), + }).optional().meta({ + title: t('Schema.Common.ToolListPositionTitle'), + description: t('Schema.Common.ToolListPosition.Description'), + }), +}).meta({ + title: t('Schema.TiddlyWikiPlugin.Title'), + description: t('Schema.TiddlyWikiPlugin.Description'), +}); + +export type TiddlyWikiPluginParameter = z.infer; + +export function getTiddlyWikiPluginParameterSchema() { + return TiddlyWikiPluginParameterSchema; +} + +const TiddlyWikiPluginToolSchema = z.object({ + pluginTitle: z.string().meta({ + title: t('Schema.TiddlyWikiPlugin.Tool.Parameters.pluginTitle.Title'), + description: t('Schema.TiddlyWikiPlugin.Tool.Parameters.pluginTitle.Description'), + }), +}).meta({ + title: 'tiddlywiki-plugin', + description: + 'Load DataSource and Actions details for a specific plugin by title. Use DataSource to understand data/filters, use Actions to see what action tiddlers are available and how to call them with invokeActionString via wiki-operation tool.', + examples: [ + { pluginTitle: 'Calendar' }, + { pluginTitle: 'Tasks' }, + ], +}); + +type TiddlyWikiPluginParameters = z.infer; + +/** + * Cache for Describe entries to avoid repeated wiki queries + * Key: workspaceID, Value: formatted describe content + */ +const describeCache = new Map(); + +/** + * Clear the describe cache for a specific workspace or all workspaces + */ +function clearDescribeCache(workspaceID?: string): void { + if (workspaceID) { + describeCache.delete(workspaceID); + logger.debug('Cleared TiddlyWiki plugin describe cache', { workspaceID }); + } else { + describeCache.clear(); + logger.debug('Cleared all TiddlyWiki plugin describe cache'); + } +} + +async function executeTiddlyWikiPlugin(parameters: TiddlyWikiPluginParameters, config: TiddlyWikiPluginParameter): Promise { + const wikiService = container.get(serviceIdentifier.Wiki); + const workspaceService = container.get(serviceIdentifier.Workspace); + + if (!parameters.pluginTitle) { + throw new Error(i18n.t('Tool.TiddlyWikiPlugin.Error.PluginTitleRequired')); + } + + // Resolve workspace name or ID to workspace ID + const workspaces = await workspaceService.getWorkspacesAsList(); + const targetWorkspace = workspaces.find((ws) => ws.name === config.workspaceNameOrID || ws.id === config.workspaceNameOrID); + if (!targetWorkspace || !isWikiWorkspace(targetWorkspace)) { + throw new Error(i18n.t('Tool.TiddlyWikiPlugin.Error.WorkspaceNotFound', { workspaceNameOrID: config.workspaceNameOrID })); + } + const workspaceID = targetWorkspace.id; + + logger.debug('Loading plugin details', { workspaceID, pluginTitle: parameters.pluginTitle }); + + // Fetch DataSource tiddlers matching the plugin title + const dataSourceFilter = `[tag[${config.dataSourceTag}]search[${parameters.pluginTitle}]]`; + const dataSources = await wikiService.wikiOperationInServer(WikiChannel.getTiddlersAsJson, workspaceID, [dataSourceFilter]); + + // Fetch Actions tiddlers matching the plugin title + const actionsFilter = `[tag[${config.actionsTag}]search[${parameters.pluginTitle}]]`; + const actions = await wikiService.wikiOperationInServer(WikiChannel.getTiddlersAsJson, workspaceID, [actionsFilter]); + + return { + success: true, + data: JSON.stringify({ + workspaceID, + pluginTitle: parameters.pluginTitle, + dataSources: (dataSources || []).map((tid: ITiddlerFields) => ({ + title: tid.title, + text: tid.text, + tags: tid.tags, + })), + }), + metadata: { workspaceID, pluginTitle: parameters.pluginTitle, dataSourceCount: dataSources?.length || 0, actionCount: actions?.length || 0 }, + }; +} + +const tiddlyWikiPluginDefinition = registerToolDefinition({ + toolId: 'tiddlywikiPlugin', + displayName: t('Schema.TiddlyWikiPlugin.Title'), + description: t('Schema.TiddlyWikiPlugin.Description'), + configSchema: TiddlyWikiPluginParameterSchema, + llmToolSchemas: { + 'tiddlywiki-plugin': TiddlyWikiPluginToolSchema, + }, + + onProcessPrompts: async ({ config, injectToolList, injectContent, toolConfig }) => { + const toolListPosition = config.toolListPosition; + if (!toolListPosition?.targetId) return; + + try { + // Resolve workspace + const workspaceService = container.get(serviceIdentifier.Workspace); + const workspaces = await workspaceService.getWorkspacesAsList(); + const targetWorkspace = workspaces.find((ws) => ws.name === config.workspaceNameOrID || ws.id === config.workspaceNameOrID); + if (!targetWorkspace || !isWikiWorkspace(targetWorkspace)) { + // If workspace not found, still inject tool but without content + injectToolList({ + targetId: toolListPosition.targetId, + position: toolListPosition.position || 'child', + caption: `TiddlyWiki Plugins (workspace not found: ${config.workspaceNameOrID})`, + }); + return; + } + + const workspaceID = targetWorkspace.id; + + // Check cache first (if enabled) + let describeContent = config.enableCache ? describeCache.get(workspaceID) : undefined; + + if (!describeContent) { + // Load Describe entries from wiki + const wikiService = container.get(serviceIdentifier.Wiki); + const describeFilter = `[tag[${config.describeTag}]]`; + const describes = await wikiService.wikiOperationInServer(WikiChannel.getTiddlersAsJson, workspaceID, [describeFilter]); + + // Format Describe entries as content + describeContent = (describes || []) + .map((tid: ITiddlerFields) => { + const title = tid.title || '(untitled)'; + const text = tid.text || '(no description)'; + return `## ${title}\n\n${text}`; + }) + .join('\n\n---\n\n'); + + // Cache the result if enabled + if (config.enableCache && describeContent) { + describeCache.set(workspaceID, describeContent); + } + + logger.debug('TiddlyWiki plugin Describe entries loaded', { + targetId: toolListPosition.targetId, + workspaceID, + describeCount: describes?.length || 0, + cached: false, + toolId: toolConfig.id, + }); + } else { + logger.debug('TiddlyWiki plugin Describe entries loaded from cache', { + targetId: toolListPosition.targetId, + workspaceID, + cached: true, + toolId: toolConfig.id, + }); + } + + // Clear cache if disabled (user wants to refresh) + if (!config.enableCache) { + clearDescribeCache(workspaceID); + } + + // Inject both tool list and content + injectToolList({ + targetId: toolListPosition.targetId, + position: toolListPosition.position || 'child', + caption: 'Available TiddlyWiki Plugins', + }); + + if (describeContent) { + injectContent({ + targetId: toolListPosition.targetId, + position: toolListPosition.position || 'child', + content: describeContent, + }); + } + } catch (error) { + logger.error('Failed to load TiddlyWiki plugin Describe entries', { error, config }); + // Still inject tool even if loading fails + injectToolList({ + targetId: toolListPosition.targetId, + position: toolListPosition.position || 'child', + caption: 'TiddlyWiki Plugins (loading error)', + }); + } + }, + + async onResponseComplete({ toolCall, executeToolCall, agentFrameworkContext, config }) { + if (!toolCall || toolCall.toolId !== 'tiddlywiki-plugin') return; + if (agentFrameworkContext.isCancelled()) return; + + // At this point, config should be available from the context + const typedConfig = config as TiddlyWikiPluginParameter; + await executeToolCall('tiddlywiki-plugin', (parameters) => executeTiddlyWikiPlugin(parameters, typedConfig)); + }, +}); + +export const tiddlywikiPluginTool = tiddlyWikiPluginDefinition.tool; diff --git a/src/services/agentInstance/tools/types.ts b/src/services/agentInstance/tools/types.ts index e34e0cdb..b5f7d211 100644 --- a/src/services/agentInstance/tools/types.ts +++ b/src/services/agentInstance/tools/types.ts @@ -44,6 +44,8 @@ export interface PromptConcatHookContext extends BaseToolContext { prompts: IPrompt[]; /** Tool configuration */ toolConfig: IPromptConcatTool; + /** Index of the current plugin in the plugins array (for source tracking) */ + pluginIndex?: number; } /** @@ -63,7 +65,7 @@ export interface AIResponseContext extends BaseToolContext { /** Tool configuration - for backward compatibility */ toolConfig: IPromptConcatTool; /** Complete framework configuration - allows tools to access all configs */ - agentFrameworkConfig?: { plugins?: Array<{ toolId: string; [key: string]: unknown }> }; + agentFrameworkConfig?: { plugins?: IPromptConcatTool[] }; /** AI streaming response */ response: AIStreamResponse; /** Current request ID */ diff --git a/src/services/agentInstance/tools/wikiOperation.ts b/src/services/agentInstance/tools/wikiOperation.ts index 21602c3d..4c21f002 100644 --- a/src/services/agentInstance/tools/wikiOperation.ts +++ b/src/services/agentInstance/tools/wikiOperation.ts @@ -1,10 +1,9 @@ /** - * Wiki Operation plugin + * Wiki Operation Tool * Handles wiki operation tool list injection, tool calling detection and response processing * Supports creating, updating, and deleting tiddlers in wiki workspaces */ import { WikiChannel } from '@/constants/channels'; -import { matchToolCalling } from '@services/agentDefinition/responsePatternUtility'; import { container } from '@services/container'; import { i18n } from '@services/libs/i18n'; import { t } from '@services/libs/i18n/placeholder'; @@ -13,28 +12,24 @@ import serviceIdentifier from '@services/serviceIdentifier'; import type { IWikiService } from '@services/wiki/interface'; import type { IWorkspaceService } from '@services/workspaces/interface'; import { z } from 'zod/v4'; -import type { AgentInstanceMessage, IAgentInstanceService } from '../interface'; -import { findPromptById } from '../promptConcat/promptConcat'; -import { schemaToToolContent } from '../utilities/schemaToToolContent'; -import type { PromptConcatTool } from './types'; +import { registerToolDefinition, type ToolExecutionResult } from './defineTool'; /** - * Wiki Operation Parameter Schema - * Configuration parameters for the wiki operation plugin + * Wiki Operation Config Schema (user-configurable in UI) */ export const WikiOperationParameterSchema = z.object({ toolListPosition: z.object({ targetId: z.string().meta({ - title: t('Schema.WikiOperation.ToolListPosition.TargetIdTitle'), - description: t('Schema.WikiOperation.ToolListPosition.TargetId'), + title: t('Schema.Common.ToolListPosition.TargetIdTitle'), + description: t('Schema.Common.ToolListPosition.TargetId'), }), position: z.enum(['before', 'after']).meta({ - title: t('Schema.WikiOperation.ToolListPosition.PositionTitle'), - description: t('Schema.WikiOperation.ToolListPosition.Position'), + title: t('Schema.Common.ToolListPosition.PositionTitle'), + description: t('Schema.Common.ToolListPosition.Position'), }), }).optional().meta({ - title: t('Schema.WikiOperation.ToolListPositionTitle'), - description: t('Schema.WikiOperation.ToolListPosition'), + title: t('Schema.Common.ToolListPositionTitle'), + description: t('Schema.Common.ToolListPosition.Description'), }), toolResultDuration: z.number().optional().default(1).meta({ title: t('Schema.WikiOperation.ToolResultDurationTitle'), @@ -45,32 +40,25 @@ export const WikiOperationParameterSchema = z.object({ description: t('Schema.WikiOperation.Description'), }); -/** - * Type definition for wiki operation parameters - */ export type WikiOperationParameter = z.infer; -/** - * Get the wiki operation parameter schema - * @returns The schema for wiki operation parameters - */ export function getWikiOperationParameterSchema() { return WikiOperationParameterSchema; } /** - * Parameter schema for Wiki operation tool + * LLM-callable tool schema for wiki operations */ -const WikiOperationToolParameterSchema = z.object({ +const WikiOperationToolSchema = z.object({ workspaceName: z.string().meta({ title: t('Schema.WikiOperation.Tool.Parameters.workspaceName.Title'), description: t('Schema.WikiOperation.Tool.Parameters.workspaceName.Description'), }), - operation: z.enum([WikiChannel.addTiddler, WikiChannel.deleteTiddler, WikiChannel.setTiddlerText]).meta({ + operation: z.enum([WikiChannel.addTiddler, WikiChannel.deleteTiddler, WikiChannel.setTiddlerText, 'invokeActionString']).meta({ title: t('Schema.WikiOperation.Tool.Parameters.operation.Title'), description: t('Schema.WikiOperation.Tool.Parameters.operation.Description'), }), - title: z.string().meta({ + title: z.string().optional().meta({ title: t('Schema.WikiOperation.Tool.Parameters.title.Title'), description: t('Schema.WikiOperation.Tool.Parameters.title.Description'), }), @@ -86,347 +74,160 @@ const WikiOperationToolParameterSchema = z.object({ title: t('Schema.WikiOperation.Tool.Parameters.options.Title'), description: t('Schema.WikiOperation.Tool.Parameters.options.Description'), }), -}) - .meta({ - title: 'wiki-operation', - description: '在Wiki工作空间中执行操作(添加、删除或设置Tiddler文本)', - examples: [ - { workspaceName: '我的知识库', operation: WikiChannel.addTiddler, title: '示例笔记', text: '示例内容', extraMeta: '{}', options: '{}' }, - { workspaceName: '我的知识库', operation: WikiChannel.setTiddlerText, title: '现有笔记', text: '更新后的内容', extraMeta: '{}', options: '{}' }, - { workspaceName: '我的知识库', operation: WikiChannel.deleteTiddler, title: '要删除的笔记', extraMeta: '{}', options: '{}' }, - ], - }); + variables: z.string().optional().default('{}').meta({ + title: t('Schema.WikiOperation.Tool.Parameters.variables.Title'), + description: t('Schema.WikiOperation.Tool.Parameters.variables.Description'), + }), +}).meta({ + title: 'wiki-operation', + description: 'Perform operations on wiki workspaces (create, update, delete tiddlers, or invoke action tiddlers)', + examples: [ + { workspaceName: 'My Wiki', operation: WikiChannel.addTiddler, title: 'Example Note', text: 'Example content', extraMeta: '{}', options: '{}' }, + { workspaceName: 'My Wiki', operation: WikiChannel.setTiddlerText, title: 'Existing Note', text: 'Updated content', extraMeta: '{}', options: '{}' }, + { workspaceName: 'My Wiki', operation: WikiChannel.deleteTiddler, title: 'Note to Delete', extraMeta: '{}', options: '{}' }, + { workspaceName: 'My Wiki', operation: 'invokeActionString', title: 'SomeActionTiddler', variables: '{"result": "value", "status": "success"}' }, + ], +}); + +type WikiOperationToolParameters = z.infer; /** - * Wiki Operation plugin - Prompt processing - * Handles tool list injection for wiki operation functionality + * Execute wiki operation */ -export const wikiOperationTool: PromptConcatTool = (hooks) => { - // First tapAsync: Tool list injection - hooks.processPrompts.tapAsync('wikiOperationTool-toolList', async (context, callback) => { - const { toolConfig, prompts } = context; +async function executeWikiOperation(parameters: WikiOperationToolParameters): Promise { + const { workspaceName, operation, title, text, extraMeta, options: optionsString, variables } = parameters; - if (toolConfig.toolId !== 'wikiOperation' || !toolConfig.wikiOperationParam) { - callback(); + const workspaceService = container.get(serviceIdentifier.Workspace); + const wikiService = container.get(serviceIdentifier.Wiki); + + // Look up workspace + const workspaces = await workspaceService.getWorkspacesAsList(); + const targetWorkspace = workspaces.find((ws) => ws.name === workspaceName || ws.id === workspaceName); + + if (!targetWorkspace) { + throw new Error( + i18n.t('Tool.WikiOperation.Error.WorkspaceNotFound', { + workspaceName, + availableWorkspaces: workspaces.map((w) => `${w.name} (${w.id})`).join(', '), + }), + ); + } + + const workspaceID = targetWorkspace.id; + + if (!(await workspaceService.exists(workspaceID))) { + throw new Error(i18n.t('Tool.WikiOperation.Error.WorkspaceNotExist', { workspaceID })); + } + + const options = JSON.parse(optionsString || '{}') as Record; + + logger.debug('Executing wiki operation', { workspaceID, workspaceName, operation, title }); + + let result: string; + + switch (operation) { + case WikiChannel.addTiddler: { + if (!title) { + throw new Error('title is required for addTiddler operation'); + } + await wikiService.wikiOperationInServer(WikiChannel.addTiddler, workspaceID, [ + title, + text || '', + extraMeta || '{}', + JSON.stringify({ withDate: true, ...options }), + ]); + result = i18n.t('Tool.WikiOperation.Success.Added', { title, workspaceName }); + break; + } + + case WikiChannel.deleteTiddler: { + if (!title) { + throw new Error('title is required for deleteTiddler operation'); + } + await wikiService.wikiOperationInServer(WikiChannel.deleteTiddler, workspaceID, [title]); + result = i18n.t('Tool.WikiOperation.Success.Deleted', { title, workspaceName }); + break; + } + + case WikiChannel.setTiddlerText: { + if (!title) { + throw new Error('title is required for setTiddlerText operation'); + } + await wikiService.wikiOperationInServer(WikiChannel.setTiddlerText, workspaceID, [title, text || '']); + result = i18n.t('Tool.WikiOperation.Success.Updated', { title, workspaceName }); + break; + } + + case 'invokeActionString': { + if (!title) { + throw new Error('title is required for invokeActionString operation - specify the action tiddler title to execute'); + } + let variablePayload: Record = {}; + if (variables) { + const parsedVariables: unknown = JSON.parse(variables); + variablePayload = typeof parsedVariables === 'object' && parsedVariables !== null + ? parsedVariables as Record + : {}; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await wikiService.wikiOperationInServer('invokeActionString' as any, workspaceID, [title, variablePayload]); + result = i18n.t('Tool.WikiOperation.Success.ActionInvoked', { actionTitle: title, workspaceName }); + break; + } + + default: { + const exhaustiveCheck: never = operation; + throw new Error(`Unsupported operation: ${String(exhaustiveCheck)}`); + } + } + + return { + success: true, + data: result, + metadata: { workspaceID, workspaceName, operation, title }, + }; +} + +/** + * Wiki Operation Tool Definition + */ +const wikiOperationDefinition = registerToolDefinition({ + toolId: 'wikiOperation', + displayName: t('Schema.WikiOperation.Title'), + description: t('Schema.WikiOperation.Description'), + configSchema: WikiOperationParameterSchema, + llmToolSchemas: { + 'wiki-operation': WikiOperationToolSchema, + }, + + onProcessPrompts({ config, toolConfig, injectToolList }) { + const toolListPosition = config.toolListPosition; + if (!toolListPosition?.targetId) return; + + injectToolList({ + targetId: toolListPosition.targetId, + position: toolListPosition.position || 'child', + caption: 'Wiki Operation Tool', + }); + + logger.debug('Wiki operation tool list injected', { + targetId: toolListPosition.targetId, + position: toolListPosition.position, + toolId: toolConfig.id, + }); + }, + + async onResponseComplete({ toolCall, executeToolCall, agentFrameworkContext }) { + if (!toolCall || toolCall.toolId !== 'wiki-operation') return; + + // Check cancellation + if (agentFrameworkContext.isCancelled()) { + logger.debug('Wiki operation cancelled', { agentId: agentFrameworkContext.agent.id }); return; } - const wikiOperationParameter = toolConfig.wikiOperationParam; + await executeToolCall('wiki-operation', executeWikiOperation); + }, +}); - try { - // Handle tool list injection if toolListPosition is configured - const toolListPosition = wikiOperationParameter.toolListPosition; - if (toolListPosition?.targetId) { - const toolListTarget = findPromptById(prompts, toolListPosition.targetId); - if (!toolListTarget) { - logger.warn('Tool list target prompt not found', { - targetId: toolListPosition.targetId, - toolId: toolConfig.id, - }); - callback(); - return; - } - - // Get available wikis - now handled by workspacesListPlugin - // The workspaces list will be injected separately by workspacesListPlugin - - // Build tool content using shared utility (schema contains title/examples meta) - const wikiOperationToolContent = schemaToToolContent(WikiOperationToolParameterSchema); - - // Insert the tool content based on position - if (toolListPosition.position === 'after') { - if (!toolListTarget.prompt.children) { - toolListTarget.prompt.children = []; - } - const insertIndex = toolListTarget.prompt.children.length; - toolListTarget.prompt.children.splice(insertIndex, 0, { - id: `wiki-operation-tool-${toolConfig.id}`, - caption: 'Wiki Operation Tool', - text: wikiOperationToolContent, - }); - } else if (toolListPosition.position === 'before') { - if (!toolListTarget.prompt.children) { - toolListTarget.prompt.children = []; - } - toolListTarget.prompt.children.unshift({ - id: `wiki-operation-tool-${toolConfig.id}`, - caption: 'Wiki Operation Tool', - text: wikiOperationToolContent, - }); - } else { - // Default to appending text - toolListTarget.prompt.text = (toolListTarget.prompt.text || '') + wikiOperationToolContent; - } - - logger.debug('Wiki operation tool list injected', { - targetId: toolListPosition.targetId, - position: toolListPosition.position, - toolId: toolConfig.id, - }); - } - - callback(); - } catch (error) { - logger.error('Error in wiki operation tool list injection', { - error, - toolId: toolConfig.id, - }); - callback(); - } - }); - - // 2. Tool execution when AI response is complete - hooks.responseComplete.tapAsync('wikiOperationTool-handler', async (context, callback) => { - try { - const { agentFrameworkContext, response, agentFrameworkConfig } = context; - - // Find this plugin's configuration import { AgentFrameworkConfig } - const wikiOperationToolConfig = agentFrameworkConfig?.plugins?.find((p: { toolId: string; [key: string]: unknown }) => p.toolId === 'wikiOperation'); - const wikiOperationParameter = wikiOperationToolConfig?.wikiOperationParam as { toolResultDuration?: number } | undefined; - const toolResultDuration = wikiOperationParameter?.toolResultDuration || 1; // Default to 1 round - - if (response.status !== 'done' || !response.content) { - callback(); - return; - } - - // Check for wiki operation tool calls in the AI response - const toolMatch = matchToolCalling(response.content); - - if (!toolMatch.found || toolMatch.toolId !== 'wiki-operation') { - callback(); - return; - } - - logger.debug('Wiki operation tool call detected', { - toolId: toolMatch.toolId, - agentId: agentFrameworkContext.agent.id, - }); - - // Set duration=1 for the AI message containing the tool call - // Find the most recent AI message (should be the one containing the tool call) - const aiMessages = agentFrameworkContext.agent.messages.filter((message: AgentInstanceMessage) => message.role === 'assistant'); - if (aiMessages.length > 0) { - const latestAiMessage = aiMessages[aiMessages.length - 1]; - latestAiMessage.duration = toolResultDuration; - logger.debug('Set AI message duration for tool call', { - messageId: latestAiMessage.id, - duration: toolResultDuration, - agentId: agentFrameworkContext.agent.id, - }); - } - - // Execute the wiki operation tool call - try { - logger.debug('Parsing wiki operation tool parameters', { - toolMatch: toolMatch.parameters, - agentId: agentFrameworkContext.agent.id, - }); - - // Use parameters returned by matchToolCalling directly. Let zod schema validate. - const validatedParameters = WikiOperationToolParameterSchema.parse(toolMatch.parameters as Record); - const { workspaceName, operation, title, text, extraMeta, options: optionsString } = validatedParameters; - const options = JSON.parse(optionsString || '{}') as Record; - // Get workspace service - const workspaceService = container.get(serviceIdentifier.Workspace); - const wikiService = container.get(serviceIdentifier.Wiki); - - // Look up workspace ID from workspace name or ID - const workspaces = await workspaceService.getWorkspacesAsList(); - const targetWorkspace = workspaces.find(ws => ws.name === workspaceName || ws.id === workspaceName); - if (!targetWorkspace) { - throw new Error( - i18n.t('Tool.WikiOperation.Error.WorkspaceNotFound', { - workspaceName, - availableWorkspaces: workspaces.map(w => `${w.name} (${w.id})`).join(', '), - }) || `Workspace with name or ID "${workspaceName}" does not exist. Available workspaces: ${workspaces.map(w => `${w.name} (${w.id})`).join(', ')}`, - ); - } - const workspaceID = targetWorkspace.id; - - if (!await workspaceService.exists(workspaceID)) { - throw new Error(i18n.t('Tool.WikiOperation.Error.WorkspaceNotExist', { workspaceID })); - } - - logger.debug('Executing wiki operation', { - workspaceID, - workspaceName, - operation, - title, - agentId: agentFrameworkContext.agent.id, - }); - - let result: string; - - // Execute the appropriate wiki operation directly - switch (operation) { - case WikiChannel.addTiddler: { - await wikiService.wikiOperationInServer(WikiChannel.addTiddler, workspaceID, [ - title, - text || '', - extraMeta || '{}', - JSON.stringify({ withDate: true, ...options }), - ]); - result = i18n.t('Tool.WikiOperation.Success.Added', { title, workspaceName }); - break; - } - - case WikiChannel.deleteTiddler: { - await wikiService.wikiOperationInServer(WikiChannel.deleteTiddler, workspaceID, [title]); - result = i18n.t('Tool.WikiOperation.Success.Deleted', { title, workspaceName }); - break; - } - - case WikiChannel.setTiddlerText: { - await wikiService.wikiOperationInServer(WikiChannel.setTiddlerText, workspaceID, [title, text || '']); - result = i18n.t('Tool.WikiOperation.Success.Updated', { title, workspaceName }); - break; - } - - default: { - const exhaustiveCheck: never = operation; - throw new Error(`Unsupported operation: ${String(exhaustiveCheck)}`); - } - } - - logger.debug('Wiki operation tool execution completed successfully', { - workspaceID, - operation, - title, - agentId: agentFrameworkContext.agent.id, - }); - - // Format the tool result for display - const toolResultText = `\nTool: wiki-operation\nParameters: ${JSON.stringify(validatedParameters)}\nResult: ${result}\n`; - - // Set up actions to continue the conversation with tool results - if (!context.actions) { - context.actions = {}; - } - context.actions.yieldNextRoundTo = 'self'; - - logger.debug('Wiki operation setting yieldNextRoundTo=self', { - toolId: 'wiki-operation', - agentId: agentFrameworkContext.agent.id, - messageCount: agentFrameworkContext.agent.messages.length, - toolResultPreview: toolResultText.slice(0, 200), - }); - - // Immediately add the tool result message to history BEFORE calling toolExecuted - const toolResultTime = new Date(); - const toolResultMessage: AgentInstanceMessage = { - id: `tool-result-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, - agentId: agentFrameworkContext.agent.id, - role: 'tool', // Tool result message - content: toolResultText, - modified: toolResultTime, - duration: toolResultDuration, // Use configurable duration - default 1 round for tool results - metadata: { - isToolResult: true, - isError: false, - toolId: 'wiki-operation', - toolParameters: validatedParameters, - isPersisted: false, // Required by messageManagementPlugin to identify new tool results - isComplete: true, // Mark as complete to prevent messageManagementPlugin from overwriting content - artificialOrder: Date.now() + 10, // Additional ordering hint - }, - }; - agentFrameworkContext.agent.messages.push(toolResultMessage); - - // Persist tool result immediately so DB ordering matches in-memory order - try { - const agentInstanceService = container.get(serviceIdentifier.AgentInstance); - await agentInstanceService.saveUserMessage(toolResultMessage); - toolResultMessage.metadata = { ...toolResultMessage.metadata, isPersisted: true }; - } catch (persistError) { - logger.warn('Failed to persist tool result immediately in wikiOperationPlugin', { - error: persistError, - messageId: toolResultMessage.id, - }); - } - - // Signal that tool was executed AFTER adding and persisting the message - await hooks.toolExecuted.promise({ - agentFrameworkContext, - toolResult: { - success: true, - data: result, - metadata: { toolCount: 1 }, - }, - toolInfo: { - toolId: 'wiki-operation', - parameters: validatedParameters, - originalText: toolMatch.originalText || '', - }, - requestId: context.requestId, - }); - - logger.debug('Wiki operation tool execution completed', { - toolResultText, - actions: context.actions, - toolResultMessageId: toolResultMessage.id, - aiMessageDuration: aiMessages[aiMessages.length - 1]?.duration, - }); - } catch (error) { - logger.error('Wiki operation tool execution failed', { - error, - agentId: agentFrameworkContext.agent.id, - toolParameters: toolMatch.parameters, - }); - - // Set up error response for next round - if (!context.actions) { - context.actions = {}; - } - context.actions.yieldNextRoundTo = 'self'; - const errorMessage = ` -Tool: wiki-operation -Error: ${error instanceof Error ? error.message : String(error)} -`; - - // Add error message to history BEFORE calling toolExecuted - // Use the current time; order will be determined by save order - const errorResultTime = new Date(); - const errorResultMessage: AgentInstanceMessage = { - id: `tool-error-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, - agentId: agentFrameworkContext.agent.id, - role: 'tool', // Tool error message - content: errorMessage, - modified: errorResultTime, - duration: 2, // Error messages are visible to AI for 2 rounds: immediate + next round to allow explanation - metadata: { - isToolResult: true, - isError: true, - toolId: 'wiki-operation', - isPersisted: false, // Required by messageManagementPlugin to identify new tool results - isComplete: true, // Mark as complete to prevent messageManagementPlugin from overwriting content - }, - }; - agentFrameworkContext.agent.messages.push(errorResultMessage); - - // Signal that tool was executed (with error) AFTER adding the message - await hooks.toolExecuted.promise({ - agentFrameworkContext, - toolResult: { - success: false, - error: error instanceof Error ? error.message : String(error), - }, - toolInfo: { - toolId: 'wiki-operation', - parameters: toolMatch.parameters || {}, - }, - }); - - logger.debug('Wiki operation tool execution failed but error result added', { - errorResultMessageId: errorResultMessage.id, - aiMessageDuration: aiMessages[aiMessages.length - 1]?.duration, - }); - } - - callback(); - } catch (error) { - logger.error('Error in wiki operation plugin response handler', { error }); - callback(); - } - }); -}; +export const wikiOperationTool = wikiOperationDefinition.tool; diff --git a/src/services/agentInstance/tools/wikiSearch.ts b/src/services/agentInstance/tools/wikiSearch.ts index a91a4e6e..a0f71d20 100644 --- a/src/services/agentInstance/tools/wikiSearch.ts +++ b/src/services/agentInstance/tools/wikiSearch.ts @@ -1,9 +1,8 @@ /** - * Wiki Search plugin + * Wiki Search Tool * Handles wiki search tool list injection, tool calling detection and response processing */ import { WikiChannel } from '@/constants/channels'; -import { matchToolCalling } from '@services/agentDefinition/responsePatternUtility'; import { container } from '@services/container'; import { i18n } from '@services/libs/i18n'; import { t } from '@services/libs/i18n/placeholder'; @@ -14,46 +13,29 @@ import type { IWikiEmbeddingService } from '@services/wikiEmbedding/interface'; import type { IWorkspaceService } from '@services/workspaces/interface'; import type { ITiddlerFields } from 'tiddlywiki'; import { z } from 'zod/v4'; -import type { AgentInstanceMessage, IAgentInstanceService } from '../interface'; -import { findPromptById } from '../promptConcat/promptConcat'; import type { AiAPIConfig } from '../promptConcat/promptConcatSchema'; -import type { IPrompt } from '../promptConcat/promptConcatSchema'; -import { schemaToToolContent } from '../utilities/schemaToToolContent'; -import type { PromptConcatTool } from './types'; +import { registerToolDefinition, type ToolExecutionResult } from './defineTool'; /** - * Wiki Search Parameter Schema - * Configuration parameters for the wiki search plugin + * Wiki Search Config Schema (user-configurable in UI) */ export const WikiSearchParameterSchema = z.object({ - position: z.enum(['relative', 'absolute', 'before', 'after']).meta({ - title: t('Schema.Position.TypeTitle'), - description: t('Schema.Position.Type'), - }), - targetId: z.string().meta({ - title: t('Schema.Position.TargetIdTitle'), - description: t('Schema.Position.TargetId'), - }), - bottom: z.number().optional().meta({ - title: t('Schema.Position.BottomTitle'), - description: t('Schema.Position.Bottom'), - }), sourceType: z.enum(['wiki']).meta({ title: t('Schema.WikiSearch.SourceTypeTitle'), description: t('Schema.WikiSearch.SourceType'), }), toolListPosition: z.object({ targetId: z.string().meta({ - title: t('Schema.WikiSearch.ToolListPosition.TargetIdTitle'), - description: t('Schema.WikiSearch.ToolListPosition.TargetId'), + title: t('Schema.Common.ToolListPosition.TargetIdTitle'), + description: t('Schema.Common.ToolListPosition.TargetId'), }), position: z.enum(['before', 'after']).meta({ - title: t('Schema.WikiSearch.ToolListPosition.PositionTitle'), - description: t('Schema.WikiSearch.ToolListPosition.Position'), + title: t('Schema.Common.ToolListPosition.PositionTitle'), + description: t('Schema.Common.ToolListPosition.Position'), }), }).optional().meta({ - title: t('Schema.WikiSearch.ToolListPositionTitle'), - description: t('Schema.WikiSearch.ToolListPosition'), + title: t('Schema.Common.ToolListPositionTitle'), + description: t('Schema.Common.ToolListPosition.Description'), }), toolResultDuration: z.number().optional().default(1).meta({ title: t('Schema.WikiSearch.ToolResultDurationTitle'), @@ -64,23 +46,16 @@ export const WikiSearchParameterSchema = z.object({ description: t('Schema.WikiSearch.Description'), }); -/** - * Type definition for wiki search parameters - */ export type WikiSearchParameter = z.infer; -/** - * Get the wiki search parameter schema - * @returns The schema for wiki search parameters - */ export function getWikiSearchParameterSchema() { return WikiSearchParameterSchema; } /** - * Parameter schema for Wiki search tool + * LLM-callable tool schema for wiki search */ -const WikiSearchToolParameterSchema = z.object({ +const WikiSearchToolSchema = z.object({ workspaceName: z.string().meta({ title: t('Schema.WikiSearch.Tool.Parameters.workspaceName.Title'), description: t('Schema.WikiSearch.Tool.Parameters.workspaceName.Description'), @@ -114,12 +89,12 @@ const WikiSearchToolParameterSchema = z.object({ ], }); -type WikiSearchToolParameter = z.infer; +type WikiSearchToolParameters = z.infer; /** - * Parameter schema for Wiki update embeddings tool + * LLM-callable tool schema for updating embeddings */ -const WikiUpdateEmbeddingsToolParameterSchema = z.object({ +const WikiUpdateEmbeddingsToolSchema = z.object({ workspaceName: z.string().meta({ title: t('Schema.WikiSearch.Tool.UpdateEmbeddings.workspaceName.Title'), description: t('Schema.WikiSearch.Tool.UpdateEmbeddings.workspaceName.Description'), @@ -128,160 +103,93 @@ const WikiUpdateEmbeddingsToolParameterSchema = z.object({ title: t('Schema.WikiSearch.Tool.UpdateEmbeddings.forceUpdate.Title'), description: t('Schema.WikiSearch.Tool.UpdateEmbeddings.forceUpdate.Description'), }), -}) - .meta({ - title: 'wiki-update-embeddings', - description: t('Schema.WikiSearch.Tool.UpdateEmbeddings.Description'), - examples: [ - { workspaceName: '我的知识库', forceUpdate: false }, - { workspaceName: 'wiki', forceUpdate: true }, - ], - }); +}).meta({ + title: 'wiki-update-embeddings', + description: t('Schema.WikiSearch.Tool.UpdateEmbeddings.Description'), + examples: [ + { workspaceName: '我的知识库', forceUpdate: false }, + { workspaceName: 'wiki', forceUpdate: true }, + ], +}); -type WikiUpdateEmbeddingsToolParameter = z.infer; +type WikiUpdateEmbeddingsToolParameters = z.infer; /** - * Execute wiki search tool + * Execute wiki search */ -async function executeWikiSearchTool( - parameters: WikiSearchToolParameter, - context?: { agentId?: string; messageId?: string; config?: AiAPIConfig }, -): Promise<{ success: boolean; data?: string; error?: string; metadata?: Record }> { - try { - const { workspaceName, searchType = 'filter', filter, query, limit = 10, threshold = 0.7 } = parameters; +async function executeWikiSearch( + parameters: WikiSearchToolParameters, + aiConfig?: AiAPIConfig, +): Promise { + const { workspaceName, searchType = 'filter', filter, query, limit = 10, threshold = 0.7 } = parameters; - // Get workspace service + try { const workspaceService = container.get(serviceIdentifier.Workspace); const wikiService = container.get(serviceIdentifier.Wiki); - // Look up workspace ID from workspace name or ID + // Look up workspace const workspaces = await workspaceService.getWorkspacesAsList(); - const targetWorkspace = workspaces.find(ws => ws.name === workspaceName || ws.id === workspaceName); + const targetWorkspace = workspaces.find((ws) => ws.name === workspaceName || ws.id === workspaceName); if (!targetWorkspace) { return { success: false, error: i18n.t('Tool.WikiSearch.Error.WorkspaceNotFound', { workspaceName, - availableWorkspaces: workspaces.map(w => `${w.name} (${w.id})`).join(', '), + availableWorkspaces: workspaces.map((w) => `${w.name} (${w.id})`).join(', '), }), }; } const workspaceID = targetWorkspace.id; - if (!await workspaceService.exists(workspaceID)) { - return { - success: false, - error: i18n.t('Tool.WikiSearch.Error.WorkspaceNotExist', { workspaceID }), - }; + if (!(await workspaceService.exists(workspaceID))) { + return { success: false, error: i18n.t('Tool.WikiSearch.Error.WorkspaceNotExist', { workspaceID }) }; } - logger.debug('Executing wiki search', { - workspaceID, - workspaceName, - searchType, - filter, - query, - agentId: context?.agentId, - }); + logger.debug('Executing wiki search', { workspaceID, workspaceName, searchType, filter, query }); - // Execute search based on type - let results: Array<{ title: string; text?: string; fields?: ITiddlerFields; similarity?: number }> = []; - let searchMetadata: Record = { - workspaceID, - workspaceName, - searchType, - }; + const results: Array<{ title: string; text?: string; fields?: ITiddlerFields; similarity?: number }> = []; + let searchMetadata: Record = { workspaceID, workspaceName, searchType }; if (searchType === 'vector') { - // Vector search if (!query) { - return { - success: false, - error: i18n.t('Tool.WikiSearch.Error.VectorSearchRequiresQuery'), - }; + return { success: false, error: i18n.t('Tool.WikiSearch.Error.VectorSearchRequiresQuery') }; } - - if (!context?.config) { - return { - success: false, - error: i18n.t('Tool.WikiSearch.Error.VectorSearchRequiresConfig'), - }; + if (!aiConfig) { + return { success: false, error: i18n.t('Tool.WikiSearch.Error.VectorSearchRequiresConfig') }; } const wikiEmbeddingService = container.get(serviceIdentifier.WikiEmbedding); try { - const vectorResults = await wikiEmbeddingService.searchSimilar( - workspaceID, - query, - context.config, - limit, - threshold, - ); + const vectorResults = await wikiEmbeddingService.searchSimilar(workspaceID, query, aiConfig, limit, threshold); if (vectorResults.length === 0) { return { success: true, data: i18n.t('Tool.WikiSearch.Success.NoVectorResults', { query, workspaceName, threshold }), - metadata: { - ...searchMetadata, - query, - limit, - threshold, - resultCount: 0, - }, + metadata: { ...searchMetadata, query, limit, threshold, resultCount: 0 }, }; } - // Convert vector search results to standard format - results = vectorResults.map(vr => ({ - title: vr.record.tiddlerTitle, - text: '', // Vector search returns chunks, full text needs separate retrieval - similarity: vr.similarity, - })); - - // Retrieve full tiddler content for vector results - const fullContentResults: typeof results = []; - for (const result of results) { + // Get full content for results + for (const vr of vectorResults) { try { - const tiddlerFields = await wikiService.wikiOperationInServer( - WikiChannel.getTiddlersAsJson, - workspaceID, - [result.title], - ); - if (tiddlerFields.length > 0) { - fullContentResults.push({ - ...result, - text: tiddlerFields[0].text, - fields: tiddlerFields[0], - }); - } else { - fullContentResults.push(result); - } - } catch (error) { - logger.warn(`Error retrieving full tiddler content for ${result.title}`, { - error, + const tiddlerFields = await wikiService.wikiOperationInServer(WikiChannel.getTiddlersAsJson, workspaceID, [vr.record.tiddlerTitle]); + results.push({ + title: vr.record.tiddlerTitle, + text: tiddlerFields[0]?.text, + fields: tiddlerFields[0], + similarity: vr.similarity, }); - fullContentResults.push(result); + } catch { + results.push({ title: vr.record.tiddlerTitle, similarity: vr.similarity }); } } - results = fullContentResults; - searchMetadata = { - ...searchMetadata, - query, - limit, - threshold, - resultCount: results.length, - }; + searchMetadata = { ...searchMetadata, query, limit, threshold, resultCount: results.length }; } catch (error) { - logger.error('Vector search failed', { - error, - workspaceID, - query, - }); return { success: false, error: i18n.t('Tool.WikiSearch.Error.VectorSearchFailed', { @@ -290,12 +198,9 @@ async function executeWikiSearchTool( }; } } else { - // Traditional filter search + // Filter search if (!filter) { - return { - success: false, - error: i18n.t('Tool.WikiSearch.Error.FilterSearchRequiresFilter'), - }; + return { success: false, error: i18n.t('Tool.WikiSearch.Error.FilterSearchRequiresFilter') }; } const tiddlerTitles = await wikiService.wikiOperationInServer(WikiChannel.runFilter, workspaceID, [filter]); @@ -304,56 +209,26 @@ async function executeWikiSearchTool( return { success: true, data: i18n.t('Tool.WikiSearch.Success.NoResults', { filter, workspaceName }), - metadata: { - ...searchMetadata, - filter, - resultCount: 0, - }, + metadata: { ...searchMetadata, filter, resultCount: 0 }, }; } - // Retrieve full tiddler content for each tiddler for (const title of tiddlerTitles) { try { const tiddlerFields = await wikiService.wikiOperationInServer(WikiChannel.getTiddlersAsJson, workspaceID, [title]); - if (tiddlerFields.length > 0) { - results.push({ - title, - text: tiddlerFields[0].text, - fields: tiddlerFields[0], - }); - } else { - results.push({ title }); - } - } catch (error) { - logger.warn(`Error retrieving tiddler content for ${title}`, { - error, - }); + results.push({ title, text: tiddlerFields[0]?.text, fields: tiddlerFields[0] }); + } catch { results.push({ title }); } } - searchMetadata = { - ...searchMetadata, - filter, - resultCount: tiddlerTitles.length, - returnedCount: results.length, - }; + searchMetadata = { ...searchMetadata, filter, resultCount: results.length }; } - // Format results as text with content - let content = ''; - if (searchType === 'vector') { - content = i18n.t('Tool.WikiSearch.Success.VectorCompleted', { - totalResults: results.length, - query, - }); - } else { - content = i18n.t('Tool.WikiSearch.Success.Completed', { - totalResults: results.length, - shownResults: results.length, - }) + '\n\n'; - } + // Format results + let content = searchType === 'vector' + ? i18n.t('Tool.WikiSearch.Success.VectorCompleted', { totalResults: results.length, query }) + : i18n.t('Tool.WikiSearch.Success.Completed', { totalResults: results.length, shownResults: results.length }) + '\n\n'; for (const result of results) { content += `**Tiddler: ${result.title}**`; @@ -361,26 +236,12 @@ async function executeWikiSearchTool( content += ` (Similarity: ${(result.similarity * 100).toFixed(1)}%)`; } content += '\n\n'; - if (result.text) { - content += '```tiddlywiki\n'; - content += result.text; - content += '\n```\n\n'; - } else { - content += '(Content not available)\n\n'; - } + content += result.text ? `\`\`\`tiddlywiki\n${result.text}\n\`\`\`\n\n` : '(Content not available)\n\n'; } - return { - success: true, - data: content, - metadata: searchMetadata, - }; + return { success: true, data: content, metadata: searchMetadata }; } catch (error) { - logger.error('Wiki search tool execution error', { - error, - parameters, - }); - + logger.error('Wiki search failed', { error, params: parameters }); return { success: false, error: i18n.t('Tool.WikiSearch.Error.ExecutionFailed', { @@ -391,90 +252,57 @@ async function executeWikiSearchTool( } /** - * Execute wiki update embeddings tool + * Execute wiki update embeddings */ -async function executeWikiUpdateEmbeddingsTool( - parameters: WikiUpdateEmbeddingsToolParameter, - context?: { agentId?: string; messageId?: string; aiConfig?: unknown }, -): Promise<{ success: boolean; data?: string; error?: string; metadata?: Record }> { - try { - const { workspaceName, forceUpdate = false } = parameters; +async function executeWikiUpdateEmbeddings( + parameters: WikiUpdateEmbeddingsToolParameters, + aiConfig?: AiAPIConfig, +): Promise { + const { workspaceName, forceUpdate = false } = parameters; - // Get workspace service + try { const workspaceService = container.get(serviceIdentifier.Workspace); const wikiEmbeddingService = container.get(serviceIdentifier.WikiEmbedding); - // Look up workspace ID from workspace name or ID const workspaces = await workspaceService.getWorkspacesAsList(); - const targetWorkspace = workspaces.find(ws => ws.name === workspaceName || ws.id === workspaceName); + const targetWorkspace = workspaces.find((ws) => ws.name === workspaceName || ws.id === workspaceName); if (!targetWorkspace) { return { success: false, error: i18n.t('Tool.WikiSearch.UpdateEmbeddings.Error.WorkspaceNotFound', { workspaceName, - availableWorkspaces: workspaces.map(w => `${w.name} (${w.id})`).join(', '), + availableWorkspaces: workspaces.map((w) => `${w.name} (${w.id})`).join(', '), }), }; } const workspaceID = targetWorkspace.id; - if (!await workspaceService.exists(workspaceID)) { - return { - success: false, - error: i18n.t('Tool.WikiSearch.UpdateEmbeddings.Error.WorkspaceNotExist', { workspaceID }), - }; + if (!(await workspaceService.exists(workspaceID))) { + return { success: false, error: i18n.t('Tool.WikiSearch.UpdateEmbeddings.Error.WorkspaceNotExist', { workspaceID }) }; } - // Check if AI config is available - if (!context?.aiConfig) { - return { - success: false, - error: i18n.t('Tool.WikiSearch.UpdateEmbeddings.Error.NoAIConfig'), - }; + if (!aiConfig) { + return { success: false, error: i18n.t('Tool.WikiSearch.UpdateEmbeddings.Error.NoAIConfig') }; } - logger.debug('Executing wiki embedding generation', { - workspaceID, - workspaceName, - forceUpdate, - agentId: context?.agentId, - }); + logger.debug('Executing wiki embedding generation', { workspaceID, workspaceName, forceUpdate }); - // Generate embeddings - await wikiEmbeddingService.generateEmbeddings( - workspaceID, - context.aiConfig as Parameters[1], - forceUpdate, - ); - - // Get stats after generation + await wikiEmbeddingService.generateEmbeddings(workspaceID, aiConfig, forceUpdate); const stats = await wikiEmbeddingService.getEmbeddingStats(workspaceID); - const result = i18n.t('Tool.WikiSearch.UpdateEmbeddings.Success.Generated', { - workspaceName, - totalEmbeddings: stats.totalEmbeddings, - totalNotes: stats.totalNotes, - }); - return { success: true, - data: result, - metadata: { - workspaceID, + data: i18n.t('Tool.WikiSearch.UpdateEmbeddings.Success.Generated', { workspaceName, totalEmbeddings: stats.totalEmbeddings, totalNotes: stats.totalNotes, - forceUpdate, - }, + }), + metadata: { workspaceID, workspaceName, ...stats, forceUpdate }, }; } catch (error) { - logger.error('Wiki update embeddings tool execution error', { - error, - parameters, - }); - + logger.error('Wiki update embeddings failed', { error, params: parameters }); return { success: false, error: i18n.t('Tool.WikiSearch.UpdateEmbeddings.Error.ExecutionFailed', { @@ -485,325 +313,52 @@ async function executeWikiUpdateEmbeddingsTool( } /** - * Wiki Search plugin - Prompt processing - * Handles tool list injection for wiki search and update embeddings functionality + * Wiki Search Tool Definition */ -export const wikiSearchTool: PromptConcatTool = (hooks) => { - // First tapAsync: Tool list injection - hooks.processPrompts.tapAsync('wikiSearchTool-toolList', async (context, callback) => { - const { toolConfig, prompts } = context; +const wikiSearchDefinition = registerToolDefinition({ + toolId: 'wikiSearch', + displayName: t('Schema.WikiSearch.Title'), + description: t('Schema.WikiSearch.Description'), + configSchema: WikiSearchParameterSchema, + llmToolSchemas: { + 'wiki-search': WikiSearchToolSchema, + 'wiki-update-embeddings': WikiUpdateEmbeddingsToolSchema, + }, - if (toolConfig.toolId !== 'wikiSearch' || !toolConfig.wikiSearchParam) { - callback(); + onProcessPrompts({ config, toolConfig, injectToolList }) { + const toolListPosition = config.toolListPosition; + if (!toolListPosition?.targetId) return; + + injectToolList({ + targetId: toolListPosition.targetId, + position: toolListPosition.position || 'child', + caption: 'Wiki search tool', + }); + + logger.debug('Wiki search tool list injected', { + targetId: toolListPosition.targetId, + position: toolListPosition.position, + toolId: toolConfig.id, + }); + }, + + async onResponseComplete({ toolCall, executeToolCall, agentFrameworkContext }) { + if (!toolCall) return; + + // Check cancellation + if (agentFrameworkContext.isCancelled()) { + logger.debug('Wiki search cancelled', { agentId: agentFrameworkContext.agent.id }); return; } - const wikiSearchParameter = toolConfig.wikiSearchParam; + const aiConfig = agentFrameworkContext.agent.aiApiConfig as AiAPIConfig | undefined; - try { - // Handle tool list injection if toolListPosition is configured - const toolListPosition = wikiSearchParameter.toolListPosition; - if (toolListPosition?.targetId) { - const toolListTarget = findPromptById(prompts, toolListPosition.targetId); - if (!toolListTarget) { - logger.warn('Tool list target prompt not found', { - targetId: toolListPosition.targetId, - toolId: toolConfig.id, - }); - callback(); - return; - } - - // Get available wikis - now handled by workspacesListPlugin - // The workspaces list will be injected separately by workspacesListPlugin - - // Inject both wiki-search and wiki-update-embeddings tools - const wikiSearchToolContent = schemaToToolContent(WikiSearchToolParameterSchema); - const wikiUpdateEmbeddingsToolContent = schemaToToolContent(WikiUpdateEmbeddingsToolParameterSchema); - - // Combine both tools into one prompt - const combinedToolContent = `${wikiSearchToolContent}\n\n${wikiUpdateEmbeddingsToolContent}`; - - const toolPrompt: IPrompt = { - id: `wiki-tool-list-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, - text: combinedToolContent, - tags: ['toolList', 'wikiSearch', 'wikiEmbedding'], - // Use singular caption to match test expectations - caption: 'Wiki search tool', - enabled: true, - }; - - // Insert at specified position - if (toolListPosition.position === 'before') { - toolListTarget.parent.splice(toolListTarget.index, 0, toolPrompt); - } else { - toolListTarget.parent.splice(toolListTarget.index + 1, 0, toolPrompt); - } - - logger.debug('Wiki tool list injected successfully', { - targetId: toolListPosition.targetId, - position: toolListPosition.position, - toolCount: 2, // wiki-search and wiki-update-embeddings - toolId: toolConfig.id, - }); - } - - callback(); - } catch (error) { - logger.error('Error in wiki search tool list injection', { - error, - toolId: toolConfig.id, - }); - callback(); + if (toolCall.toolId === 'wiki-search') { + await executeToolCall('wiki-search', (parameters) => executeWikiSearch(parameters, aiConfig)); + } else if (toolCall.toolId === 'wiki-update-embeddings') { + await executeToolCall('wiki-update-embeddings', (parameters) => executeWikiUpdateEmbeddings(parameters, aiConfig)); } - }); + }, +}); - // 2. Tool execution when AI response is complete - hooks.responseComplete.tapAsync('wikiSearchTool-handler', async (context, callback) => { - try { - const { agentFrameworkContext, response, agentFrameworkConfig } = context; - - // Find this plugin's configuration import { AgentFrameworkConfig } - const wikiSearchToolConfig = agentFrameworkConfig?.plugins?.find((p: { toolId: string; [key: string]: unknown }) => p.toolId === 'wikiSearch'); - const wikiSearchParameter = wikiSearchToolConfig?.wikiSearchParam as { toolResultDuration?: number } | undefined; - const toolResultDuration = wikiSearchParameter?.toolResultDuration || 1; // Default to 1 round - - if (response.status !== 'done' || !response.content) { - callback(); - return; - } - - // Check for wiki search or update embeddings tool calls in the AI response - const toolMatch = matchToolCalling(response.content); - - if (!toolMatch.found || (toolMatch.toolId !== 'wiki-search' && toolMatch.toolId !== 'wiki-update-embeddings')) { - callback(); - return; - } - - logger.debug('Wiki tool call detected', { - toolId: toolMatch.toolId, - agentId: agentFrameworkContext.agent.id, - }); - - // Set duration=1 for the AI message containing the tool call - // Find the most recent AI message (should be the one containing the tool call) - const aiMessages = agentFrameworkContext.agent.messages.filter((message: AgentInstanceMessage) => message.role === 'assistant'); - if (aiMessages.length > 0) { - const latestAiMessage = aiMessages[aiMessages.length - 1]; - if (latestAiMessage.content === response.content) { - latestAiMessage.duration = 1; - latestAiMessage.metadata = { - ...latestAiMessage.metadata, - containsToolCall: true, - toolId: toolMatch.toolId, - }; - - // Notify frontend about the duration change immediately (no debounce delay) - const agentInstanceService = container.get(serviceIdentifier.AgentInstance); - // Persist the AI message right away so DB ordering reflects this message before tool results - try { - if (!latestAiMessage.created) latestAiMessage.created = new Date(); - await agentInstanceService.saveUserMessage(latestAiMessage); - latestAiMessage.metadata = { ...latestAiMessage.metadata, isPersisted: true }; - } catch (error) { - logger.warn('Failed to persist AI message containing tool call immediately', { - error, - messageId: latestAiMessage.id, - }); - } - - // Also update UI immediately - agentInstanceService.debounceUpdateMessage(latestAiMessage, agentFrameworkContext.agent.id, 0); // No delay - - logger.debug('Set duration=1 for AI tool call message', { - messageId: latestAiMessage.id, - toolId: toolMatch.toolId, - }); - } - } - - // Execute the appropriate tool - try { - // Check if cancelled before starting tool execution - if (agentFrameworkContext.isCancelled()) { - logger.debug('Wiki tool cancelled before execution', { - toolId: toolMatch.toolId, - agentId: agentFrameworkContext.agent.id, - }); - callback(); - return; - } - - // Validate parameters and execute based on tool type - let result: { success: boolean; data?: string; error?: string; metadata?: Record }; - let validatedParameters: WikiSearchToolParameter | WikiUpdateEmbeddingsToolParameter; - - if (toolMatch.toolId === 'wiki-search') { - validatedParameters = WikiSearchToolParameterSchema.parse(toolMatch.parameters); - result = await executeWikiSearchTool( - validatedParameters, - { - agentId: agentFrameworkContext.agent.id, - messageId: agentFrameworkContext.agent.messages[agentFrameworkContext.agent.messages.length - 1]?.id, - config: agentFrameworkContext.agent.aiApiConfig as AiAPIConfig | undefined, - }, - ); - } else { - // wiki-update-embeddings - validatedParameters = WikiUpdateEmbeddingsToolParameterSchema.parse(toolMatch.parameters); - result = await executeWikiUpdateEmbeddingsTool( - validatedParameters, - { - agentId: agentFrameworkContext.agent.id, - messageId: agentFrameworkContext.agent.messages[agentFrameworkContext.agent.messages.length - 1]?.id, - aiConfig: agentFrameworkContext.agent.aiApiConfig, - }, - ); - } - - // Check if cancelled after tool execution - if (agentFrameworkContext.isCancelled()) { - logger.debug('Wiki tool cancelled after execution', { - toolId: toolMatch.toolId, - agentId: agentFrameworkContext.agent.id, - }); - callback(); - return; - } - - // Format the tool result for display - let toolResultText: string; - let isError = false; - - if (result.success && result.data) { - toolResultText = `\nTool: ${toolMatch.toolId}\nParameters: ${JSON.stringify(validatedParameters)}\nResult: ${result.data}\n`; - } else { - isError = true; - toolResultText = `\nTool: ${toolMatch.toolId}\nParameters: ${JSON.stringify(validatedParameters)}\nError: ${result.error}\n`; - } - - // Set up actions to continue the conversation with tool results - const responseContext = context; - if (!responseContext.actions) { - responseContext.actions = {}; - } - responseContext.actions.yieldNextRoundTo = 'self'; - - logger.debug('Wiki search setting yieldNextRoundTo=self', { - toolId: 'wiki-search', - agentId: agentFrameworkContext.agent.id, - messageCount: agentFrameworkContext.agent.messages.length, - toolResultPreview: toolResultText.slice(0, 200), - }); - - // Immediately add the tool result message to history BEFORE calling toolExecuted - const nowTool = new Date(); - const toolResultMessage: AgentInstanceMessage = { - id: `tool-result-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, - agentId: agentFrameworkContext.agent.id, - role: 'tool', // Tool result message - content: toolResultText, - created: nowTool, - modified: nowTool, - duration: toolResultDuration, // Use configurable duration - default 1 round for tool results - metadata: { - isToolResult: true, - isError, - toolId: 'wiki-search', - toolParameters: validatedParameters, - isPersisted: false, // Required by messageManagementPlugin to identify new tool results - isComplete: true, // Mark as complete to prevent messageManagementPlugin from overwriting content - artificialOrder: Date.now() + 10, // Additional ordering hint - }, - }; - agentFrameworkContext.agent.messages.push(toolResultMessage); - - // Do not persist immediately here. Let messageManagementPlugin handle persistence - - // Signal that tool was executed AFTER adding and persisting the message - await hooks.toolExecuted.promise({ - agentFrameworkContext, - toolResult: { - success: true, - data: result.success ? result.data : result.error, - metadata: { toolCount: 1 }, - }, - toolInfo: { - toolId: 'wiki-search', - parameters: validatedParameters, - originalText: toolMatch.originalText, - }, - requestId: context.requestId, - }); - - logger.debug('Wiki search tool execution completed', { - toolResultText, - actions: responseContext.actions, - toolResultMessageId: toolResultMessage.id, - aiMessageDuration: aiMessages[aiMessages.length - 1]?.duration, - }); - } catch (error) { - logger.error('Wiki search tool execution failed', { - error, - toolCall: toolMatch, - }); - - // Set up error response for next round - const responseContext = context; - if (!responseContext.actions) { - responseContext.actions = {}; - } - responseContext.actions.yieldNextRoundTo = 'self'; - const errorMessage = ` -Tool: wiki-search -Error: ${error instanceof Error ? error.message : String(error)} -`; - - // Add error message to history BEFORE calling toolExecuted - // Use the current time; order will be determined by save order - const nowError = new Date(); - const errorResultMessage: AgentInstanceMessage = { - id: `tool-error-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, - agentId: agentFrameworkContext.agent.id, - role: 'tool', // Tool error message - content: errorMessage, - created: nowError, - modified: nowError, - duration: 2, // Error messages are visible to AI for 2 rounds: immediate + next round to allow explanation - metadata: { - isToolResult: true, - isError: true, - toolId: 'wiki-search', - isPersisted: false, // Required by messageManagementPlugin to identify new tool results - isComplete: true, // Mark as complete to prevent messageManagementPlugin from overwriting content - }, - }; - agentFrameworkContext.agent.messages.push(errorResultMessage); - - // Do not persist immediately; let messageManagementPlugin handle it during toolExecuted - await hooks.toolExecuted.promise({ - agentFrameworkContext, - toolResult: { - success: false, - error: error instanceof Error ? error.message : String(error), - }, - toolInfo: { - toolId: 'wiki-search', - parameters: {}, - }, - }); - - logger.debug('Wiki search tool execution failed but error result added', { - errorResultMessageId: errorResultMessage.id, - aiMessageDuration: aiMessages[aiMessages.length - 1]?.duration, - }); - } - - callback(); - } catch (error) { - logger.error('Error in wiki search handler plugin', { error }); - callback(); - } - }); -}; +export const wikiSearchTool = wikiSearchDefinition.tool; diff --git a/src/services/agentInstance/tools/workspacesList.ts b/src/services/agentInstance/tools/workspacesList.ts index ed195968..cae10729 100644 --- a/src/services/agentInstance/tools/workspacesList.ts +++ b/src/services/agentInstance/tools/workspacesList.ts @@ -1,15 +1,20 @@ /** - * Workspaces List plugin - * Handles injection of available wiki workspaces list into prompts + * Workspaces List Tool + * Injects available wiki workspaces list into prompts */ +import { container } from '@services/container'; +import { logger } from '@services/libs/log'; +import serviceIdentifier from '@services/serviceIdentifier'; +import type { IWorkspaceService } from '@services/workspaces/interface'; +import { isWikiWorkspace } from '@services/workspaces/interface'; import { identity } from 'lodash'; import { z } from 'zod/v4'; +import { registerToolDefinition } from './defineTool'; const t = identity; /** * Workspaces List Parameter Schema - * Configuration parameters for the workspaces list plugin */ export const WorkspacesListParameterSchema = z.object({ targetId: z.string().meta({ @@ -25,115 +30,72 @@ export const WorkspacesListParameterSchema = z.object({ description: t('Schema.WorkspacesList.Description'), }); -/** - * Type definition for workspaces list parameters - */ export type WorkspacesListParameter = z.infer; -/** - * Get the workspaces list parameter schema - * @returns The schema for workspaces list parameters - */ export function getWorkspacesListParameterSchema() { return WorkspacesListParameterSchema; } -import { container } from '@services/container'; -import { logger } from '@services/libs/log'; -import serviceIdentifier from '@services/serviceIdentifier'; -import type { IWorkspaceService } from '@services/workspaces/interface'; -import { isWikiWorkspace } from '@services/workspaces/interface'; - -import { findPromptById } from '../promptConcat/promptConcat'; -import type { PromptConcatTool } from './types'; - /** - * Workspaces List plugin - Prompt processing - * Handles injection of available wiki workspaces list + * Workspaces List Tool Definition */ -export const workspacesListTool: PromptConcatTool = (hooks) => { - // Tool list injection - hooks.processPrompts.tapAsync('workspacesListTool-injection', async (context, callback) => { - const { toolConfig, prompts } = context; +const workspacesListDefinition = registerToolDefinition({ + toolId: 'workspacesList', + displayName: t('Schema.WorkspacesList.Title'), + description: t('Schema.WorkspacesList.Description'), + configSchema: WorkspacesListParameterSchema, - if (toolConfig.toolId !== 'workspacesList' || !toolConfig.workspacesListParam) { - callback(); + async onProcessPrompts({ config, toolConfig, findPrompt }) { + if (!config.targetId) return; + + const target = findPrompt(config.targetId); + if (!target) { + logger.warn('Workspaces list target prompt not found', { + targetId: config.targetId, + toolId: toolConfig.id, + }); return; } - const workspacesListParameter = toolConfig.workspacesListParam; + // Get available wikis + const workspaceService = container.get(serviceIdentifier.Workspace); + const workspaces = await workspaceService.getWorkspacesAsList(); + const wikiWorkspaces = workspaces.filter(isWikiWorkspace); - try { - // Handle workspaces list injection if targetId is configured - if (workspacesListParameter?.targetId) { - const target = findPromptById(prompts, workspacesListParameter.targetId); - if (!target) { - logger.warn('Workspaces list target prompt not found', { - targetId: workspacesListParameter.targetId, - toolId: toolConfig.id, - }); - callback(); - return; - } - - // Get available wikis - const workspaceService = container.get(serviceIdentifier.Workspace); - const workspaces = await workspaceService.getWorkspacesAsList(); - const wikiWorkspaces = workspaces.filter(isWikiWorkspace); - - if (wikiWorkspaces.length > 0) { - // Use fixed list format for simplicity - const workspacesList = wikiWorkspaces - .map(workspace => `- ${workspace.name} (ID: ${workspace.id})`) - .join('\n'); - - const workspacesListContent = `Available Wiki Workspaces:\n${workspacesList}`; - - // Insert the workspaces list content based on position - if (workspacesListParameter.position === 'after') { - if (!target.prompt.children) { - target.prompt.children = []; - } - const insertIndex = target.prompt.children.length; - target.prompt.children.splice(insertIndex, 0, { - id: `workspaces-list-${toolConfig.id}`, - caption: 'Available Workspaces', - text: workspacesListContent, - }); - } else if (workspacesListParameter.position === 'before') { - if (!target.prompt.children) { - target.prompt.children = []; - } - target.prompt.children.unshift({ - id: `workspaces-list-${toolConfig.id}`, - caption: 'Available Workspaces', - text: workspacesListContent, - }); - } else { - // Default to appending text - target.prompt.text = (target.prompt.text || '') + '\n' + workspacesListContent; - } - - logger.debug('Workspaces list injected successfully', { - targetId: workspacesListParameter.targetId, - position: workspacesListParameter.position, - toolId: toolConfig.id, - workspaceCount: wikiWorkspaces.length, - }); - } else { - logger.debug('No wiki workspaces found to inject', { - toolId: toolConfig.id, - }); - } - } - - callback(); - } catch (error) { - logger.error('Error in workspaces list injection', { - error, - toolId: toolConfig.id, - }); - callback(); + if (wikiWorkspaces.length === 0) { + logger.debug('No wiki workspaces found to inject', { toolId: toolConfig.id }); + return; } - }); -}; + + const workspacesList = wikiWorkspaces + .map((workspace) => `- ${workspace.name} (ID: ${workspace.id})`) + .join('\n'); + const workspacesListContent = `Available Wiki Workspaces:\n${workspacesList}`; + + // Insert based on position + if (!target.prompt.children) { + target.prompt.children = []; + } + + const newPrompt = { + id: `workspaces-list-${toolConfig.id}`, + caption: 'Available Workspaces', + text: workspacesListContent, + }; + + if (config.position === 'before') { + target.prompt.children.unshift(newPrompt); + } else { + target.prompt.children.push(newPrompt); + } + + logger.debug('Workspaces list injected successfully', { + targetId: config.targetId, + position: config.position, + toolId: toolConfig.id, + workspaceCount: wikiWorkspaces.length, + }); + }, +}); + +export const workspacesListTool = workspacesListDefinition.tool; diff --git a/src/services/agentInstance/utilities/__tests__/schemaToToolContent.test.ts b/src/services/agentInstance/utilities/__tests__/schemaToToolContent.test.ts index d1446f0b..86e9a645 100644 --- a/src/services/agentInstance/utilities/__tests__/schemaToToolContent.test.ts +++ b/src/services/agentInstance/utilities/__tests__/schemaToToolContent.test.ts @@ -1,28 +1,31 @@ /** * Tests for schemaToToolContent utility */ -import { describe, expect, it, vi } from 'vitest'; +import { i18n } from '@services/libs/i18n'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { z } from 'zod/v4'; import { schemaToToolContent } from '../schemaToToolContent'; -// Mock i18n -vi.mock('@services/libs/i18n', () => ({ - i18n: { - t: vi.fn((key: string) => { - const translations: Record = { - 'Tool.Schema.Required': '必需', - 'Tool.Schema.Optional': '可选', - 'Tool.Schema.Description': '描述', - 'Tool.Schema.Parameters': '参数', - 'Tool.Schema.Examples': '使用示例', - }; - return translations[key] || key; - }), - }, -})); - describe('schemaToToolContent', () => { + beforeEach(() => { + // Setup i18n mock for each test + + vi.mocked(i18n.t).mockImplementation( + ((...args: unknown[]) => { + const key = String(args[0]); + const translations: Record = { + 'Tool.Schema.Required': '必需', + 'Tool.Schema.Optional': '可选', + 'Tool.Schema.Description': '描述', + 'Tool.Schema.Parameters': '参数', + 'Tool.Schema.Examples': '使用示例', + }; + return translations[key] ?? key; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any, + ); + }); it('should generate tool content from schema with title and description', () => { const testSchema = z.object({ name: z.string().describe('The name parameter'), diff --git a/src/services/externalAPI/__tests__/externalAPI.logging.test.ts b/src/services/externalAPI/__tests__/externalAPI.logging.test.ts index c4e0357f..c9dce801 100644 --- a/src/services/externalAPI/__tests__/externalAPI.logging.test.ts +++ b/src/services/externalAPI/__tests__/externalAPI.logging.test.ts @@ -31,6 +31,14 @@ describe('ExternalAPIService logging', () => { it('records streaming logs when provider has apiKey (API success)', async () => { const externalAPI = container.get(serviceIdentifier.ExternalAPI); + const db = container.get(serviceIdentifier.Database); + + // Set up provider config BEFORE initialization + const aiSettings: AIGlobalSettings = { + providers: [{ provider: 'test-provider', apiKey: 'fake', models: [{ name: 'test-model' }] }], + defaultConfig: { default: { provider: 'test-provider', model: 'test-model' }, modelParameters: { temperature: 0.7, systemPrompt: '', topP: 0.95 } }, + }; + db.setSetting('aiSettings', aiSettings); // spy the provider stream to avoid real network and to be deterministic const callProvider = await import('../callProviderAPI'); @@ -44,14 +52,6 @@ describe('ExternalAPIService logging', () => { await externalAPI.initialize(); - const db = container.get(serviceIdentifier.Database); - const aiSettings: AIGlobalSettings = { - providers: [{ provider: 'test-provider', apiKey: 'fake', models: [{ name: 'test-model' }] }], - defaultConfig: { default: { provider: 'test-provider', model: 'test-model' }, modelParameters: { temperature: 0.7, systemPrompt: '', topP: 0.95 } }, - }; - // Mock getSetting to return our test AI settings - vi.spyOn(db, 'getSetting').mockImplementation((k: string) => (k === 'aiSettings' ? aiSettings : undefined)); - const messages: ModelMessage[] = [{ role: 'user', content: 'hi' }]; const config = await externalAPI.getAIConfig(); @@ -74,16 +74,16 @@ describe('ExternalAPIService logging', () => { it('records streaming error when apiKey missing (error path)', async () => { const svc = container.get(serviceIdentifier.ExternalAPI); - await svc.initialize(); - const db = container.get(serviceIdentifier.Database); + + // Set up provider config WITHOUT apiKey BEFORE initialization to trigger error const aiSettings: AIGlobalSettings = { - // Provider without apiKey should trigger an error providers: [{ provider: 'test-provider', models: [{ name: 'test-model' }] }], // No apiKey defaultConfig: { default: { provider: 'test-provider', model: 'test-model' }, modelParameters: { temperature: 0.7, systemPrompt: '', topP: 0.95 } }, }; - // Mock getSetting to return our test AI settings - vi.spyOn(db, 'getSetting').mockImplementation((k: string) => (k === 'aiSettings' ? aiSettings : undefined)); + db.setSetting('aiSettings', aiSettings); + + await svc.initialize(); const messages: ModelMessage[] = [{ role: 'user', content: 'hi' }]; const config = await svc.getAIConfig(); diff --git a/src/services/externalAPI/defaultProviders.ts b/src/services/externalAPI/defaultProviders.ts index 6c700716..f88bc478 100644 --- a/src/services/externalAPI/defaultProviders.ts +++ b/src/services/externalAPI/defaultProviders.ts @@ -151,7 +151,7 @@ export default { }, ], defaultConfig: { - api: { + default: { provider: 'siliconflow', model: 'Qwen/Qwen2.5-7B-Instruct', }, diff --git a/src/services/externalAPI/index.ts b/src/services/externalAPI/index.ts index b2e80407..b14fd270 100644 --- a/src/services/externalAPI/index.ts +++ b/src/services/externalAPI/index.ts @@ -66,7 +66,7 @@ export class ExternalAPIService implements IExternalAPIService { }, }; - // Observable to emit config changes + // Observable to emit config changes - will be updated when settings are loaded public defaultConfig$ = new BehaviorSubject(this.userSettings.defaultConfig); public providers$ = new BehaviorSubject(this.userSettings.providers); @@ -74,6 +74,9 @@ export class ExternalAPIService implements IExternalAPIService { * Initialize the external API service */ public async initialize(): Promise { + // Load settings from database first + this.ensureSettingsLoaded(); + /** * Initialize database connection for API logging */ @@ -91,6 +94,10 @@ export class ExternalAPIService implements IExternalAPIService { const savedSettings = this.databaseService.getSetting('aiSettings'); this.userSettings = savedSettings ?? this.userSettings; this.settingsLoaded = true; + + // Update Observables with loaded settings + this.defaultConfig$.next(this.userSettings.defaultConfig); + this.providers$.next(this.userSettings.providers); } private ensureSettingsLoaded(): void { @@ -353,7 +360,7 @@ export class ExternalAPIService implements IExternalAPIService { async deleteFieldFromDefaultAIConfig(fieldPath: string): Promise { this.ensureSettingsLoaded(); - // Support nested field deletion like 'api.embeddingModel' + // Support field deletion like 'embedding', 'speech', 'default' const pathParts = fieldPath.split('.'); let current: Record = this.userSettings.defaultConfig; diff --git a/src/services/externalAPI/interface.ts b/src/services/externalAPI/interface.ts index e3cbe097..a4ad3742 100644 --- a/src/services/externalAPI/interface.ts +++ b/src/services/externalAPI/interface.ts @@ -340,7 +340,7 @@ export interface IExternalAPIService { /** * Delete a field from default AI configuration - * @param fieldPath - Dot-separated path to the field (e.g., 'api.embeddingModel') + * @param fieldPath - Dot-separated path to the field (e.g., 'embedding', 'speech', 'default') */ deleteFieldFromDefaultAIConfig(fieldPath: string): Promise; diff --git a/src/services/git/gitOperations.ts b/src/services/git/gitOperations.ts index ed2eb135..7721c4d1 100644 --- a/src/services/git/gitOperations.ts +++ b/src/services/git/gitOperations.ts @@ -442,14 +442,11 @@ export async function getFileBinaryContent( if (result.exitCode !== 0) { const errorMessage = Buffer.isBuffer(result.stderr) ? result.stderr.toString('utf-8') : String(result.stderr); - console.error('[getFileBinaryContent] Git error:', errorMessage); throw new Error(`Failed to get binary file content: ${errorMessage}`); } // When encoding is 'buffer', stdout is a Buffer (dugite 3.x) const buffer = Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(String(result.stdout), 'binary'); - console.log('[getFileBinaryContent] Buffer size:', buffer.length); - return bufferToDataUrl(buffer, filePath); } diff --git a/src/services/wiki/wikiOperations/executor/scripts/common.ts b/src/services/wiki/wikiOperations/executor/scripts/common.ts index 1d7f5c55..c5a9a4ee 100644 --- a/src/services/wiki/wikiOperations/executor/scripts/common.ts +++ b/src/services/wiki/wikiOperations/executor/scripts/common.ts @@ -76,4 +76,20 @@ export const wikiOperationScripts = { const event = new Event('TidGi-invokeActionByTag'); return $tw.rootWidget.invokeActionsByTag(${JSON.stringify(tag)},event,${stringifiedData}); `, + /** + * Invoke a specific action tiddler by title with variables + * This is more precise than invokeActionsByTag as it executes exactly one action + * + * @param title - The title of the action tiddler to execute + * @param stringifiedData - Stringified JSON object containing variables to pass to the action + */ + invokeActionString: (title: string, stringifiedData: string) => ` + const actionText = $tw.wiki.getTiddlerText(${JSON.stringify(title)}); + if (!actionText) { + throw new Error('Action tiddler not found: ' + ${JSON.stringify(title)}); + } + const event = new Event('TidGi-invokeActionString'); + Object.assign(event, ${stringifiedData}); + return $tw.rootWidget.invokeActionString(actionText, event, ${stringifiedData}); + `, } as const; diff --git a/src/services/wiki/wikiOperations/executor/wikiOperationInBrowser.ts b/src/services/wiki/wikiOperations/executor/wikiOperationInBrowser.ts index 03bab911..04312691 100644 --- a/src/services/wiki/wikiOperations/executor/wikiOperationInBrowser.ts +++ b/src/services/wiki/wikiOperations/executor/wikiOperationInBrowser.ts @@ -62,6 +62,9 @@ export const wikiOperations = { [WikiChannel.invokeActionsByTag]: async (_nonceReceived: number, tag: string, stringifiedData: string) => { await executeTWJavaScriptWhenIdle(wikiOperationScripts[WikiChannel.invokeActionsByTag](tag, stringifiedData)); }, + invokeActionString: async (_nonceReceived: number, title: string, stringifiedData: string) => { + await executeTWJavaScriptWhenIdle(wikiOperationScripts.invokeActionString(title, stringifiedData)); + }, [WikiChannel.deleteTiddler]: async (_nonceReceived: number, title: string) => { await executeTWJavaScriptWhenIdle(wikiOperationScripts[WikiChannel.deleteTiddler](title)); }, diff --git a/src/services/wiki/wikiOperations/executor/wikiOperationInServer.ts b/src/services/wiki/wikiOperations/executor/wikiOperationInServer.ts index 55c79b4c..8abc75f6 100644 --- a/src/services/wiki/wikiOperations/executor/wikiOperationInServer.ts +++ b/src/services/wiki/wikiOperations/executor/wikiOperationInServer.ts @@ -86,6 +86,9 @@ export class WikiOperationsInWikiWorker { [WikiChannel.invokeActionsByTag]: async (tag: string, data: Record) => { await this.executeTWJavaScriptWhenIdle(wikiOperationScripts[WikiChannel.invokeActionsByTag](tag, JSON.stringify(data))); }, + invokeActionString: async (title: string, data: Record) => { + await this.executeTWJavaScriptWhenIdle(wikiOperationScripts.invokeActionString(title, JSON.stringify(data))); + }, [WikiChannel.deleteTiddler]: async (title: string) => { await this.executeTWJavaScriptWhenIdle(wikiOperationScripts[WikiChannel.deleteTiddler](title)); }, diff --git a/src/services/wiki/wikiOperations/sender/sendWikiOperationsToBrowser.ts b/src/services/wiki/wikiOperations/sender/sendWikiOperationsToBrowser.ts index 20fdc9fe..dbce6087 100644 --- a/src/services/wiki/wikiOperations/sender/sendWikiOperationsToBrowser.ts +++ b/src/services/wiki/wikiOperations/sender/sendWikiOperationsToBrowser.ts @@ -3,6 +3,8 @@ * ERROR in Circular dependency detected: src/services/libs/log/index.ts -> src/services/libs/log/rendererTransport.ts -> src/services/wiki/wikiOperations.ts -> src/services/libs/log/index.ts */ +/* eslint-disable @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-argument */ + import { WikiChannel } from '@/constants/channels'; import { WikiStateKey } from '@/constants/wiki'; import { container } from '@services/container'; @@ -75,5 +77,10 @@ export const getSendWikiOperationsToBrowser = (workspaceID: string) => [WikiChannel.invokeActionsByTag]: async (tag: string, data: Record): Promise => { return await sendAndWait(WikiChannel.invokeActionsByTag, workspaceID, [tag, JSON.stringify(data)]); }, + // invokeActionString is a temporary operation not yet in WikiChannel enum + + invokeActionString: async (title: string, data: Record): Promise => { + return await sendAndWait('invokeActionString' as any, workspaceID, [title, JSON.stringify(data)]); + }, }) as const; export type ISendWikiOperationsToBrowser = ReturnType; diff --git a/src/services/workspaces/index.ts b/src/services/workspaces/index.ts index c7588ba4..3f9b0f23 100644 --- a/src/services/workspaces/index.ts +++ b/src/services/workspaces/index.ts @@ -293,9 +293,6 @@ export class Workspace implements IWorkspaceService { }`, ); } - const wikiService = container.get(serviceIdentifier.Wiki); - // Sub-wiki configuration is now handled by FileSystemAdaptor in watch-filesystem plugin - await wikiService.wikiStartup(newWorkspaceConfig); } } diff --git a/src/windows/AddWorkspace/CloneWikiForm.tsx b/src/windows/AddWorkspace/CloneWikiForm.tsx index f64688f4..963f4578 100644 --- a/src/windows/AddWorkspace/CloneWikiForm.tsx +++ b/src/windows/AddWorkspace/CloneWikiForm.tsx @@ -1,6 +1,6 @@ import FolderIcon from '@mui/icons-material/Folder'; import { Autocomplete, AutocompleteRenderInputParams, MenuItem, Typography } from '@mui/material'; -import React from 'react'; +import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { isWikiWorkspace } from '@services/workspaces/interface'; @@ -12,11 +12,18 @@ import type { IWikiWorkspaceFormProps } from './useForm'; export function CloneWikiForm({ form, isCreateMainWorkspace, errorInWhichComponent, errorInWhichComponentSetter }: IWikiWorkspaceFormProps): React.JSX.Element { const { t } = useTranslation(); + const [tagInputValue, setTagInputValue] = useState(''); useValidateCloneWiki(isCreateMainWorkspace, form, errorInWhichComponentSetter); // Fetch all tags from main wiki for autocomplete suggestions const availableTags = useAvailableTags(form.mainWikiToLink.id, !isCreateMainWorkspace); + const tagHelperText = tagInputValue.trim().length > 0 + ? t('AddWorkspace.TagNameInputWarning') + : (isCreateMainWorkspace + ? t('AddWorkspace.TagNameHelpForMain') + : t('AddWorkspace.TagNameHelp')); + return ( @@ -88,8 +95,12 @@ export function CloneWikiForm({ form, isCreateMainWorkspace, errorInWhichCompone freeSolo options={availableTags} value={form.tagNames} + onInputChange={(_event, newInputValue) => { + setTagInputValue(newInputValue); + }} onChange={(_event, newValue) => { form.tagNamesSetter(newValue); + setTagInputValue(''); }} slotProps={{ chip: { @@ -98,10 +109,10 @@ export function CloneWikiForm({ form, isCreateMainWorkspace, errorInWhichCompone }} renderInput={(parameters: AutocompleteRenderInputParams) => ( )} /> diff --git a/src/windows/AddWorkspace/ExistedWikiForm.tsx b/src/windows/AddWorkspace/ExistedWikiForm.tsx index 15192af4..84f71ed6 100644 --- a/src/windows/AddWorkspace/ExistedWikiForm.tsx +++ b/src/windows/AddWorkspace/ExistedWikiForm.tsx @@ -18,10 +18,17 @@ export function ExistedWikiForm({ errorInWhichComponentSetter, }: IWikiWorkspaceFormProps & { isCreateSyncedWorkspace: boolean }): React.JSX.Element { const { t } = useTranslation(); + const [tagInputValue, setTagInputValue] = useState(''); // Fetch all tags from main wiki for autocomplete suggestions const availableTags = useAvailableTags(form.mainWikiToLink.id, !isCreateMainWorkspace); + const tagHelperText = tagInputValue.trim().length > 0 + ? t('AddWorkspace.TagNameInputWarning') + : (isCreateMainWorkspace + ? t('AddWorkspace.TagNameHelpForMain') + : t('AddWorkspace.TagNameHelp')); + const { wikiFolderLocation, wikiFolderNameSetter, @@ -137,8 +144,12 @@ export function ExistedWikiForm({ freeSolo options={availableTags} value={tagNames} + onInputChange={(_event, newInputValue) => { + setTagInputValue(newInputValue); + }} onChange={(_event, newValue) => { tagNamesSetter(newValue); + setTagInputValue(''); }} slotProps={{ chip: { @@ -150,7 +161,7 @@ export function ExistedWikiForm({ {...parameters} error={errorInWhichComponent.tagNames} label={t('AddWorkspace.TagName')} - helperText={t('AddWorkspace.TagNameHelp')} + helperText={tagHelperText} /> )} /> diff --git a/src/windows/AddWorkspace/NewWikiForm.tsx b/src/windows/AddWorkspace/NewWikiForm.tsx index 4c0c39d5..9d105f9a 100644 --- a/src/windows/AddWorkspace/NewWikiForm.tsx +++ b/src/windows/AddWorkspace/NewWikiForm.tsx @@ -1,5 +1,6 @@ import FolderIcon from '@mui/icons-material/Folder'; import { Autocomplete, AutocompleteRenderInputParams, MenuItem, Typography } from '@mui/material'; +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { isWikiWorkspace } from '@services/workspaces/interface'; @@ -17,11 +18,18 @@ export function NewWikiForm({ errorInWhichComponentSetter, }: IWikiWorkspaceFormProps & { isCreateSyncedWorkspace: boolean }): React.JSX.Element { const { t } = useTranslation(); + const [tagInputValue, setTagInputValue] = useState(''); useValidateNewWiki(isCreateMainWorkspace, isCreateSyncedWorkspace, form, errorInWhichComponentSetter); // Fetch all tags from main wiki for autocomplete suggestions const availableTags = useAvailableTags(form.mainWikiToLink.id, !isCreateMainWorkspace); + const tagHelperText = tagInputValue.trim().length > 0 + ? t('AddWorkspace.TagNameInputWarning') + : (isCreateMainWorkspace + ? t('AddWorkspace.TagNameHelpForMain') + : t('AddWorkspace.TagNameHelp')); + return ( @@ -94,8 +102,12 @@ export function NewWikiForm({ freeSolo options={availableTags} value={form.tagNames} + onInputChange={(_event, newInputValue) => { + setTagInputValue(newInputValue); + }} onChange={(_event, newValue) => { form.tagNamesSetter(newValue); + setTagInputValue(''); }} slotProps={{ chip: { @@ -107,7 +119,7 @@ export function NewWikiForm({ error={errorInWhichComponent.tagNames} {...parameters} label={t('AddWorkspace.TagName')} - helperText={t('AddWorkspace.TagNameHelp')} + helperText={tagHelperText} slotProps={{ htmlInput: { ...parameters.inputProps, 'data-testid': 'tagname-autocomplete-input' } }} /> )} diff --git a/src/windows/AddWorkspace/useAvailableTags.ts b/src/windows/AddWorkspace/useAvailableTags.ts index eb55726d..79d72a2f 100644 --- a/src/windows/AddWorkspace/useAvailableTags.ts +++ b/src/windows/AddWorkspace/useAvailableTags.ts @@ -17,7 +17,7 @@ export function useAvailableTags(workspaceID: string | undefined, enabled: boole const tags = await window.service.wiki.wikiOperationInServer( WikiChannel.runFilter, workspaceID, - ['[all[tags]]'], + ['[all[tags]!is[system]]'], ); if (Array.isArray(tags)) { setAvailableTags(tags); diff --git a/src/windows/AddWorkspace/useForm.ts b/src/windows/AddWorkspace/useForm.ts index e74a2f6a..c7e2f1b6 100644 --- a/src/windows/AddWorkspace/useForm.ts +++ b/src/windows/AddWorkspace/useForm.ts @@ -1,6 +1,5 @@ // ...existing code... import { useEffect, useMemo, useState } from 'react'; -import { useTranslation } from 'react-i18next'; import { usePromiseValue } from '@/helpers/useServiceValue'; import { useStorageServiceUserInfoObservable } from '@services/auth/hooks'; @@ -22,8 +21,6 @@ export function useIsCreateSyncedWorkspace(): [boolean, React.Dispatch await window.service.workspace.getWorkspacesAsList(), []); const [wikiPort, wikiPortSetter] = useState(5212); @@ -90,8 +87,8 @@ export function useWikiWorkspaceForm(options?: { fromExisted: boolean }) { /** full path of created wiki folder */ const wikiFolderLocation = usePromiseValue( - async () => await window.service.native.path('join', parentFolderLocation ?? t('Error') ?? 'Error', wikiFolderName), - 'Error', + async () => await window.service.native.path('join', parentFolderLocation, wikiFolderName), + '', [parentFolderLocation, wikiFolderName], ); diff --git a/src/windows/EditWorkspace/SubWorkspaceRouting.tsx b/src/windows/EditWorkspace/SubWorkspaceRouting.tsx index 345aa9ce..0b681f41 100644 --- a/src/windows/EditWorkspace/SubWorkspaceRouting.tsx +++ b/src/windows/EditWorkspace/SubWorkspaceRouting.tsx @@ -1,21 +1,22 @@ import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import { AccordionDetails, Autocomplete, AutocompleteRenderInputParams, List, ListItem, ListItemText, Switch, Tooltip, Typography } from '@mui/material'; -import React from 'react'; +import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import type { IWikiWorkspace } from '@services/workspaces/interface'; +import { useAvailableTags } from '../AddWorkspace/useAvailableTags'; import { OptionsAccordion, OptionsAccordionSummary, TextField } from './styles'; interface SubWorkspaceRoutingProps { workspace: IWikiWorkspace; workspaceSetter: (newValue: IWikiWorkspace, requestSaveAndRestart?: boolean) => void; - availableTags: string[]; isSubWiki: boolean; } export function SubWorkspaceRouting(props: SubWorkspaceRoutingProps): React.JSX.Element { const { t } = useTranslation(); - const { workspace, workspaceSetter, availableTags, isSubWiki } = props; + const { workspace, workspaceSetter, isSubWiki } = props; + const [tagInputValue, setTagInputValue] = useState(''); const { tagNames, @@ -25,6 +26,11 @@ export function SubWorkspaceRouting(props: SubWorkspaceRoutingProps): React.JSX. ignoreSymlinks, } = workspace; + const tagHelperText = tagInputValue.trim().length > 0 + ? t('AddWorkspace.TagNameInputWarning') + : (isSubWiki ? t('AddWorkspace.TagNameHelp') : t('AddWorkspace.TagNameHelpForMain')); + + const availableTags = useAvailableTags(workspace.mainWikiID ?? undefined, true); return ( @@ -41,9 +47,13 @@ export function SubWorkspaceRouting(props: SubWorkspaceRoutingProps): React.JSX. freeSolo options={availableTags} value={tagNames} + onInputChange={(_event: React.SyntheticEvent, newInputValue: string) => { + setTagInputValue(newInputValue); + }} onChange={(_event: React.SyntheticEvent, newValue: string[]) => { void _event; workspaceSetter({ ...workspace, tagNames: newValue }, true); + setTagInputValue(''); }} slotProps={{ chip: { @@ -54,7 +64,7 @@ export function SubWorkspaceRouting(props: SubWorkspaceRoutingProps): React.JSX. )} /> diff --git a/src/windows/EditWorkspace/index.tsx b/src/windows/EditWorkspace/index.tsx index 80642dd4..ffd7cf72 100644 --- a/src/windows/EditWorkspace/index.tsx +++ b/src/windows/EditWorkspace/index.tsx @@ -11,7 +11,6 @@ import { useForm } from './useForm'; import { RestartSnackbarType, useRestartSnackbar } from '@/components/RestartSnackbar'; import { isWikiWorkspace, nonConfigFields } from '@services/workspaces/interface'; import { isEqual, omit } from 'lodash'; -import { useAvailableTags } from '../AddWorkspace/useAvailableTags'; import { AppearanceOptions } from './AppearanceOptions'; import { MiscOptions } from './MiscOptions'; import { SaveAndSyncOptions } from './SaveAndSyncOptions'; @@ -31,10 +30,6 @@ export default function EditWorkspace(): React.JSX.Element { const { name } = workspace ?? {}; const isSubWiki = isWiki ? workspace.isSubWiki : false; - const mainWikiToLink = isWiki ? workspace.mainWikiToLink : null; - - // Fetch all tags from main wiki for autocomplete suggestions - const availableTags = useAvailableTags(mainWikiToLink ?? undefined, isSubWiki); // Check if there are sub-workspaces for this main workspace const hasSubWorkspaces = usePromiseValue(async () => { @@ -75,7 +70,6 @@ export default function EditWorkspace(): React.JSX.Element { )} diff --git a/src/windows/GitLog/CommitDetailsPanel.tsx b/src/windows/GitLog/CommitDetailsPanel.tsx index 6cca6d70..879f5711 100644 --- a/src/windows/GitLog/CommitDetailsPanel.tsx +++ b/src/windows/GitLog/CommitDetailsPanel.tsx @@ -10,7 +10,7 @@ import { styled } from '@mui/material/styles'; import Tab from '@mui/material/Tab'; import Tabs from '@mui/material/Tabs'; import Typography from '@mui/material/Typography'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { getFileStatusStyles, type GitFileStatus } from './fileStatusStyles'; @@ -85,6 +85,13 @@ export function CommitDetailsPanel( // Use files from commit entry (already loaded in useGitLogData) const fileChanges = commit?.files ?? []; + // Auto-select the first file if none is selected + useEffect(() => { + if (fileChanges.length > 0 && !selectedFile && onFileSelect) { + onFileSelect(fileChanges[0].path); + } + }, [commit, fileChanges, selectedFile, onFileSelect]); + const handleRevert = async () => { if (!commit || isReverting) return; @@ -100,7 +107,6 @@ export function CommitDetailsPanel( // Pass the commit message to revertCommit for better revert message await window.service.git.revertCommit(workspace.wikiFolderLocation, commit.hash, commit.message); - console.log('Revert success'); // Notify parent to select the new revert commit if (onRevertSuccess) { onRevertSuccess(); @@ -129,7 +135,6 @@ export function CommitDetailsPanel( dir: workspace.wikiFolderLocation, commitOnly: true, }); - console.log('Commit success'); // Notify parent to select the new commit if (onCommitSuccess) { onCommitSuccess(); @@ -143,11 +148,7 @@ export function CommitDetailsPanel( const handleCopyHash = () => { if (!commit) return; - navigator.clipboard.writeText(commit.hash).then(() => { - console.log('Hash copied'); - }).catch((error: unknown) => { - console.error('Failed to copy hash:', error); - }); + void navigator.clipboard.writeText(commit.hash); }; const handleOpenInGitHub = async () => { diff --git a/src/windows/Preferences/sections/ExternalAPI/__tests__/addProviderIntegration.test.tsx b/src/windows/Preferences/sections/ExternalAPI/__tests__/addProviderIntegration.test.tsx index 4c885b37..87e74a4a 100644 --- a/src/windows/Preferences/sections/ExternalAPI/__tests__/addProviderIntegration.test.tsx +++ b/src/windows/Preferences/sections/ExternalAPI/__tests__/addProviderIntegration.test.tsx @@ -51,11 +51,11 @@ describe('ExternalAPI Add Provider with Embedding Model', () => { Object.defineProperty(window.service.externalAPI, 'getAIConfig', { value: vi.fn().mockResolvedValue({ - api: { + default: { provider: 'existing-provider', model: 'gpt-4o', - // No embeddingModel initially }, + // No embedding initially modelParameters: { temperature: 0.7, systemPrompt: 'You are a helpful assistant.', @@ -87,7 +87,7 @@ describe('ExternalAPI Add Provider with Embedding Model', () => { // Mock observables for externalAPI const mockConfig: AiAPIConfig = { - api: { + default: { provider: 'existing-provider', model: 'gpt-4o', }, diff --git a/src/windows/Preferences/sections/ExternalAPI/__tests__/index.test.tsx b/src/windows/Preferences/sections/ExternalAPI/__tests__/index.test.tsx index eb7882aa..977fdf95 100644 --- a/src/windows/Preferences/sections/ExternalAPI/__tests__/index.test.tsx +++ b/src/windows/Preferences/sections/ExternalAPI/__tests__/index.test.tsx @@ -23,21 +23,71 @@ const mockEmbeddingModel: ModelInfo = { features: ['embedding' as ModelFeature], }; +const mockSpeechModel: ModelInfo = { + name: 'gpt-speech', + caption: 'GPT Speech', + features: ['speech' as ModelFeature], +}; + +const mockImageModel: ModelInfo = { + name: 'dall-e', + caption: 'DALL-E', + features: ['imageGeneration' as ModelFeature], +}; + +const mockTranscriptionsModel: ModelInfo = { + name: 'whisper', + caption: 'Whisper', + features: ['transcriptions' as ModelFeature], +}; + +const mockFreeModel: ModelInfo = { + name: 'gpt-free', + caption: 'GPT Free', + features: ['free' as ModelFeature], +}; + const mockProvider: AIProviderConfig = { provider: 'openai', apiKey: 'sk-test', baseURL: 'https://api.openai.com/v1', - models: [mockLanguageModel, mockEmbeddingModel], + models: [ + mockLanguageModel, + mockEmbeddingModel, + mockSpeechModel, + mockImageModel, + mockTranscriptionsModel, + mockFreeModel, + ], providerClass: 'openai', isPreset: false, enabled: true, }; const mockAIConfig = { - api: { + default: { provider: 'openai', model: 'gpt-4', - embeddingModel: 'text-embedding-3-small', + }, + embedding: { + provider: 'openai', + model: 'text-embedding-3-small', + }, + speech: { + provider: 'openai', + model: 'gpt-speech', + }, + imageGeneration: { + provider: 'openai', + model: 'dall-e', + }, + transcriptions: { + provider: 'openai', + model: 'whisper', + }, + free: { + provider: 'openai', + model: 'gpt-free', }, modelParameters: { temperature: 0.7, @@ -155,11 +205,11 @@ describe('ExternalAPI Component', () => { // Mock config with no embedding model Object.defineProperty(window.service.externalAPI, 'getAIConfig', { value: vi.fn().mockResolvedValue({ - api: { + default: { provider: 'openai', model: 'gpt-4', - // No embeddingModel }, + // No embedding modelParameters: { temperature: 0.7, systemPrompt: 'You are a helpful assistant.', @@ -180,12 +230,9 @@ describe('ExternalAPI Component', () => { if (clearButton) { await user.click(clearButton as HTMLElement); - // Verify both model and provider fields are deleted when no embedding model exists + // Verify default field is deleted await waitFor(() => { - expect(window.service.externalAPI.deleteFieldFromDefaultAIConfig).toHaveBeenCalledWith('api.model'); - }); - await waitFor(() => { - expect(window.service.externalAPI.deleteFieldFromDefaultAIConfig).toHaveBeenCalledWith('api.provider'); + expect(window.service.externalAPI.deleteFieldFromDefaultAIConfig).toHaveBeenCalledWith('default'); }); // Also verify that handleConfigChange was called to update local state @@ -203,23 +250,25 @@ describe('ExternalAPI Component', () => { // Verify the delete API was called await waitFor(() => { - expect(window.service.externalAPI.deleteFieldFromDefaultAIConfig).toHaveBeenCalledWith('api.model'); - expect(window.service.externalAPI.deleteFieldFromDefaultAIConfig).toHaveBeenCalledWith('api.provider'); + expect(window.service.externalAPI.deleteFieldFromDefaultAIConfig).toHaveBeenCalledWith('default'); }); } } }); - it('should only clear model field when embedding model exists', async () => { + it('should only clear default field when embedding model exists', async () => { const user = userEvent.setup(); - // Mock config with embedding model - this should preserve the provider + // Mock config with embedding model Object.defineProperty(window.service.externalAPI, 'getAIConfig', { value: vi.fn().mockResolvedValue({ - api: { + default: { provider: 'openai', model: 'gpt-4', - embeddingModel: 'text-embedding-3-small', // Has embedding model + }, + embedding: { + provider: 'openai', + model: 'text-embedding-3-small', }, modelParameters: { temperature: 0.7, @@ -241,14 +290,11 @@ describe('ExternalAPI Component', () => { if (clearButton) { await user.click(clearButton as HTMLElement); - // Should only delete model, NOT provider (because embedding model uses the provider) + // Should delete default field await waitFor(() => { - expect(window.service.externalAPI.deleteFieldFromDefaultAIConfig).toHaveBeenCalledWith('api.model'); + expect(window.service.externalAPI.deleteFieldFromDefaultAIConfig).toHaveBeenCalledWith('default'); }); - // Should NOT delete provider when embedding model exists - expect(window.service.externalAPI.deleteFieldFromDefaultAIConfig).not.toHaveBeenCalledWith('api.provider'); - // Verify that handleConfigChange was called await waitFor(() => { expect(window.service.externalAPI.updateDefaultAIConfig).toHaveBeenCalled(); @@ -262,10 +308,8 @@ describe('ExternalAPI Component', () => { await user.keyboard('{Escape}'); await waitFor(() => { - expect(window.service.externalAPI.deleteFieldFromDefaultAIConfig).toHaveBeenCalledWith('api.model'); + expect(window.service.externalAPI.deleteFieldFromDefaultAIConfig).toHaveBeenCalledWith('default'); }); - - expect(window.service.externalAPI.deleteFieldFromDefaultAIConfig).not.toHaveBeenCalledWith('api.provider'); } } }); @@ -285,7 +329,7 @@ describe('ExternalAPI Component', () => { // Verify the delete API was called await waitFor(() => { - expect(window.service.externalAPI.deleteFieldFromDefaultAIConfig).toHaveBeenCalledWith('api.embeddingModel'); + expect(window.service.externalAPI.deleteFieldFromDefaultAIConfig).toHaveBeenCalledWith('embedding'); }); // Also verify that handleConfigChange was called to update local state @@ -303,7 +347,7 @@ describe('ExternalAPI Component', () => { // Verify the delete API was called await waitFor(() => { - expect(window.service.externalAPI.deleteFieldFromDefaultAIConfig).toHaveBeenCalledWith('api.embeddingModel'); + expect(window.service.externalAPI.deleteFieldFromDefaultAIConfig).toHaveBeenCalledWith('embedding'); }); } } @@ -316,19 +360,15 @@ describe('ExternalAPI Component', () => { // Create a simple test for ModelSelector clear functionality const { ModelSelector } = await import('../components/ModelSelector'); - const testConfig = { - api: { - provider: 'openai', - model: 'text-embedding-3-small', - embeddingModel: 'text-embedding-3-small', - }, - modelParameters: {}, + const testModel = { + provider: 'openai', + model: 'text-embedding-3-small', }; render( { } }); + it('should display default models from backend config on initial load', async () => { + await renderExternalAPI(); + + // Wait for all comboboxes to be rendered + const comboboxes = screen.getAllByRole('combobox'); + + // We have 6 model selectors (default, embedding, speech, imageGeneration, transcriptions, free) + expect(comboboxes).toHaveLength(6); + + // Check that default model is displayed (first combobox) + expect(comboboxes[0]).toHaveValue('gpt-4'); + + // Check that embedding model is displayed (second combobox) + expect(comboboxes[1]).toHaveValue('text-embedding-3-small'); + + // Check that speech model is displayed (third combobox) + expect(comboboxes[2]).toHaveValue('gpt-speech'); + + // Check that image generation model is displayed (fourth combobox) + expect(comboboxes[3]).toHaveValue('dall-e'); + + // Check that transcriptions model is displayed (fifth combobox) + expect(comboboxes[4]).toHaveValue('whisper'); + + // Check that free model is displayed (sixth combobox) + expect(comboboxes[5]).toHaveValue('gpt-free'); + }); + it('should render provider configuration section', async () => { await renderExternalAPI(); diff --git a/src/windows/Preferences/sections/ExternalAPI/__tests__/useAIConfigManagement.test.ts b/src/windows/Preferences/sections/ExternalAPI/__tests__/useAIConfigManagement.test.ts index 4fa583a0..511e2a61 100644 --- a/src/windows/Preferences/sections/ExternalAPI/__tests__/useAIConfigManagement.test.ts +++ b/src/windows/Preferences/sections/ExternalAPI/__tests__/useAIConfigManagement.test.ts @@ -7,7 +7,7 @@ import { useAIConfigManagement } from '../useAIConfigManagement'; describe('useAIConfigManagement', () => { const mockAIConfig: AiAPIConfig = { - api: { + default: { provider: 'openai', model: 'gpt-4', }, diff --git a/src/windows/Preferences/sections/ExternalAPI/components/ModelSelector.tsx b/src/windows/Preferences/sections/ExternalAPI/components/ModelSelector.tsx index 089a93d2..757d2313 100644 --- a/src/windows/Preferences/sections/ExternalAPI/components/ModelSelector.tsx +++ b/src/windows/Preferences/sections/ExternalAPI/components/ModelSelector.tsx @@ -1,32 +1,24 @@ import { Autocomplete } from '@mui/material'; -import { AiAPIConfig } from '@services/agentInstance/promptConcat/promptConcatSchema'; +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'; interface ModelSelectorProps { - selectedConfig: AiAPIConfig | null; + selectedModel: ModelSelection | undefined; modelOptions: Array<[AIProviderConfig, ModelInfo]>; onChange: (provider: string, model: string) => void; onClear?: () => void; onlyShowEnabled?: boolean; } -/** - * Type guard to check if config has api field - */ -const hasApiField = (config: AiAPIConfig | null): config is AiAPIConfig & { api: { provider: string; model: string } } => { - return config !== null && 'api' in config && typeof config.api === 'object' && config.api !== null && - 'provider' in config.api && 'model' in config.api; -}; - -export function ModelSelector({ selectedConfig, modelOptions, onChange, onClear, onlyShowEnabled }: ModelSelectorProps) { +export function ModelSelector({ selectedModel, modelOptions, onChange, onClear, onlyShowEnabled }: ModelSelectorProps) { const { t } = useTranslation('agent'); - const selectedValue = hasApiField(selectedConfig) && selectedConfig.api.model && selectedConfig.api.provider && - selectedConfig.api.model !== '' && selectedConfig.api.provider !== '' - ? modelOptions.find(m => m[0].provider === selectedConfig.api.provider && m[1].name === selectedConfig.api.model) || null + const selectedValue = selectedModel && selectedModel.model && selectedModel.provider && + selectedModel.model !== '' && selectedModel.provider !== '' + ? modelOptions.find(m => m[0].provider === selectedModel.provider && m[1].name === selectedModel.model) || null : null; const filteredModelOptions = onlyShowEnabled diff --git a/src/windows/Preferences/sections/ExternalAPI/components/ProviderConfig.tsx b/src/windows/Preferences/sections/ExternalAPI/components/ProviderConfig.tsx index b7a3acfa..c7a94f0a 100644 --- a/src/windows/Preferences/sections/ExternalAPI/components/ProviderConfig.tsx +++ b/src/windows/Preferences/sections/ExternalAPI/components/ProviderConfig.tsx @@ -1,7 +1,8 @@ import AddIcon from '@mui/icons-material/Add'; import { Alert, Box, Button, Snackbar, Tab, Tabs } from '@mui/material'; import { styled } from '@mui/material/styles'; -import { Dispatch, SetStateAction, SyntheticEvent, useEffect, useMemo, useState } from 'react'; +import { debounce } from 'lodash'; +import { Dispatch, SetStateAction, SyntheticEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ListItemText } from '@/components/ListItem'; @@ -82,18 +83,26 @@ export function ProviderConfig({ const [selectedDefaultModel, setSelectedDefaultModel] = useState(''); const [availableDefaultModels, setAvailableDefaultModels] = useState([]); + // Track if we're currently updating from user input to prevent Observable overwrite + const isUserInputting = useRef>({}); + // Update local providers and initialize form states useEffect(() => { const forms: Record = {}; providers.forEach(provider => { - forms[provider.provider] = { - apiKey: provider.apiKey || '', - baseURL: provider.baseURL || '', - models: [...provider.models], - newModel: { name: '', caption: '', features: ['language' as ModelFeature] }, - }; + // Only update form if user is not currently inputting for this provider + if (!isUserInputting.current[provider.provider]) { + forms[provider.provider] = { + apiKey: provider.apiKey || '', + baseURL: provider.baseURL || '', + models: [...provider.models], + newModel: { name: '', caption: '', features: ['language' as ModelFeature] }, + }; + } }); - setProviderForms(forms); + if (Object.keys(forms).length > 0) { + setProviderForms(previous => ({ ...previous, ...forms })); + } }, [providers]); // Update available default providers @@ -128,26 +137,44 @@ export function ProviderConfig({ setSelectedTabIndex(newValue); }; - const handleFormChange = async (providerName: string, field: keyof AIProviderConfig, value: string) => { - try { - setProviderForms(previous => { - const currentForm = previous[providerName]; - if (!currentForm) return previous; + // Debounced save function - return { - ...previous, - [providerName]: { - ...currentForm, - [field]: value, - } as ProviderFormState, - }; - }); - await window.service.externalAPI.updateProvider(providerName, { [field]: value }); - showMessage(t('Preference.SettingsSaved'), 'success'); - } catch (error) { - void window.service.native.log('error', 'Failed to update provider', { function: 'ProviderConfig.handleFormChange', error }); - showMessage(t('Preference.FailedToSaveSettings'), 'error'); - } + const debouncedSave = useCallback( + debounce(async (providerName: string, field: keyof AIProviderConfig, value: string) => { + try { + await window.service.externalAPI.updateProvider(providerName, { [field]: value }); + showMessage(t('Preference.SettingsSaved'), 'success'); + // Clear inputting flag after save + isUserInputting.current[providerName] = false; + } catch (error) { + void window.service.native.log('error', 'Failed to update provider', { function: 'ProviderConfig.debouncedSave', error }); + showMessage(t('Preference.FailedToSaveSettings'), 'error'); + isUserInputting.current[providerName] = false; + } + }, 1000), + [t], + ); + + const handleFormChange = async (providerName: string, field: keyof AIProviderConfig, value: string) => { + // Mark that user is inputting for this provider + isUserInputting.current[providerName] = true; + + // Update local form state immediately for responsive UI + setProviderForms(previous => { + const currentForm = previous[providerName]; + if (!currentForm) return previous; + + return { + ...previous, + [providerName]: { + ...currentForm, + [field]: value, + } as ProviderFormState, + }; + }); + + // Debounced save to backend + void debouncedSave(providerName, field, value); }; const handleProviderEnabledChange = async (providerName: string, enabled: boolean) => { diff --git a/src/windows/Preferences/sections/ExternalAPI/components/__tests__/ProviderConfig.test.tsx b/src/windows/Preferences/sections/ExternalAPI/components/__tests__/ProviderConfig.test.tsx index af786cbc..bc39f72b 100644 --- a/src/windows/Preferences/sections/ExternalAPI/components/__tests__/ProviderConfig.test.tsx +++ b/src/windows/Preferences/sections/ExternalAPI/components/__tests__/ProviderConfig.test.tsx @@ -62,7 +62,7 @@ describe('ProviderConfig Component', () => { Object.defineProperty(window.service.externalAPI, 'getAIConfig', { value: vi.fn().mockResolvedValue({ - api: { + default: { provider: 'openai', model: 'gpt-4', }, @@ -194,7 +194,7 @@ describe('ProviderConfig Component', () => { // Mock AI config to simulate no existing embedding model Object.defineProperty(window.service.externalAPI, 'getAIConfig', { value: vi.fn().mockResolvedValue({ - api: { + default: { provider: '', model: '', }, diff --git a/src/windows/Preferences/sections/ExternalAPI/index.tsx b/src/windows/Preferences/sections/ExternalAPI/index.tsx index 964aa9d8..aba5252a 100644 --- a/src/windows/Preferences/sections/ExternalAPI/index.tsx +++ b/src/windows/Preferences/sections/ExternalAPI/index.tsx @@ -107,72 +107,13 @@ export function ExternalAPI(props: Partial): React.JSX.Element { await handleConfigChange(updatedConfig); }; - // Create default model config for ModelSelector - const defaultModelConfig = config && config.default - ? { - api: { - provider: config.default.provider, - model: config.default.model, - }, - modelParameters: config.modelParameters, - } - : null; - - // Create embedding config from current AI config - // Use the provider that actually has the embedding model - const embeddingConfig = config && config.embedding - ? { - api: { - provider: config.embedding.provider, - model: config.embedding.model, - }, - modelParameters: config.modelParameters, - } - : null; - - // Create speech config from current AI config - const speechConfig = config && config.speech - ? { - api: { - provider: config.speech.provider, - model: config.speech.model, - }, - modelParameters: config.modelParameters, - } - : null; - - // Create image generation config from current AI config - const imageGenerationConfig = config && config.imageGeneration - ? { - api: { - provider: config.imageGeneration.provider, - model: config.imageGeneration.model, - }, - modelParameters: config.modelParameters, - } - : null; - - // Create transcriptions config from current AI config - const transcriptionsConfig = config && config.transcriptions - ? { - api: { - provider: config.transcriptions.provider, - model: config.transcriptions.model, - }, - modelParameters: config.modelParameters, - } - : null; - - // Create free model config from current AI config - const freeModelConfig = config && config.free - ? { - api: { - provider: config.free.provider, - model: config.free.model, - }, - modelParameters: config.modelParameters, - } - : null; + // Extract model selections directly from config + const defaultModelConfig = config?.default; + const embeddingConfig = config?.embedding; + const speechConfig = config?.speech; + const imageGenerationConfig = config?.imageGeneration; + const transcriptionsConfig = config?.transcriptions; + const freeModelConfig = config?.free; const handleFreeModelClear = async () => { if (!config) return; @@ -201,7 +142,7 @@ export function ExternalAPI(props: Partial): React.JSX.Element { secondary={t('Preference.DefaultAIModelSelectionDescription')} /> provider.models .filter(model => Array.isArray(model.features) && model.features.includes('language')) @@ -218,7 +159,7 @@ export function ExternalAPI(props: Partial): React.JSX.Element { secondary={t('Preference.DefaultEmbeddingModelSelectionDescription')} /> provider.models .filter(model => Array.isArray(model.features) && model.features.includes('embedding')) @@ -235,7 +176,7 @@ export function ExternalAPI(props: Partial): React.JSX.Element { secondary={t('Preference.DefaultSpeechModelSelectionDescription')} /> provider.models .filter(model => Array.isArray(model.features) && model.features.includes('speech')) @@ -252,7 +193,7 @@ export function ExternalAPI(props: Partial): React.JSX.Element { secondary={t('Preference.DefaultImageGenerationModelSelectionDescription')} /> provider.models .filter(model => Array.isArray(model.features) && model.features.includes('imageGeneration')) @@ -269,7 +210,7 @@ export function ExternalAPI(props: Partial): React.JSX.Element { secondary={t('Preference.DefaultTranscriptionsModelSelectionDescription')} /> provider.models .filter(model => Array.isArray(model.features) && model.features.includes('transcriptions')) @@ -286,7 +227,7 @@ export function ExternalAPI(props: Partial): React.JSX.Element { secondary={t('Preference.DefaultFreeModelSelectionDescription')} /> provider.models .filter(model => Array.isArray(model.features) && model.features.includes('free')) diff --git a/vite.renderer.config.ts b/vite.renderer.config.ts index 5e4f9dbe..227452cd 100644 --- a/vite.renderer.config.ts +++ b/vite.renderer.config.ts @@ -2,6 +2,7 @@ import react from '@vitejs/plugin-react'; import path from 'path'; import { defineConfig } from 'vite'; import { analyzer } from 'vite-bundle-analyzer'; +import monacoEditorPlugin from 'vite-plugin-monaco-editor'; export default defineConfig({ plugins: [ @@ -9,6 +10,7 @@ export default defineConfig({ ? [analyzer({ analyzerMode: 'static', openAnalyzer: false, fileName: 'bundle-analyzer-renderer' })] : []), react(), + monacoEditorPlugin({}), ], resolve: { alias: { @@ -16,12 +18,23 @@ export default defineConfig({ '@services': path.resolve(__dirname, './src/services'), }, }, + optimizeDeps: { + include: ['monaco-editor'], + }, build: { // Output to .vite/renderer for consistency outDir: '.vite/renderer', // Specify the HTML entry point rollupOptions: { input: path.resolve(__dirname, 'index.html'), + output: { + manualChunks: { + 'monaco-editor': ['monaco-editor'], + }, + }, + }, + commonjsOptions: { + include: [/monaco-editor/, /node_modules/], }, }, server: {