Refactor/inputbar (#10332)
* Refactor inputbar system with configurable scope-based architecture - **Implement scope-based configuration** for chat, agent sessions, and mini-window with feature toggles - **Add tool registry system** with dependency injection for modular inputbar tools - **Create shared state management** via InputbarToolsProvider for consistent state handling - **Migrate existing tools** to registry-based definitions with proper scope filtering The changes introduce a flexible inputbar architecture that supports different use cases through scope-based configuration while maintaining feature parity and improving code organization. * Remove unused import and refactor tool rendering - Delete obsolete '@renderer/pages/home/Inputbar/tools' import from Inputbar.tsx - Extract ToolButton component to render tools outside useMemo dependency cycle - Store tool definitions in config for deferred rendering with current context - Fix potential stale closure issues in tool rendering by rebuilding context on each render * Wrap ToolButton in React.memo and optimize quick panel menu updates - Memoize ToolButton component to prevent unnecessary re-renders when tool key remains unchanged - Replace direct menu state updates with version-based triggering to batch registry changes - Add useEffect to consolidate menu updates and reduce redundant flat operations * chore style * refactor(InputbarToolsProvider): simplify quick panel menu update logic * Improve QuickPanel behavior and input handling - Default select first item when panel symbol changes to enhance user experience - Add Tab key support for selecting template variables in input field - Refactor QuickPanel trigger logic with better symbol tracking and boundary checks - Fix typo in translation key for model selection menu item * Refactor import statements to use type-only imports - Convert inline type imports to explicit type imports in Inputbar.tsx and types.ts - Replace combined type/value imports with separate type imports in InputbarToolsProvider and tools - Remove unnecessary menu version state and effect in InputbarToolsProvider * Refactor InputbarTools context to separate state and dispatch concerns - Split single context into separate state and dispatch contexts to optimize re-renders - Introduce derived state for `couldMentionNotVisionModel` based on file types - Encapsulate Quick Panel API in stable object with memoized functions - Add internal dispatch context for Inputbar-specific state setters * Refactor Inputbar to use split context hooks and optimize QuickPanel - Replace monolithic `useInputbarTools` with separate state, dispatch, and internal dispatch hooks - Move text state from context to local component state in InputbarInner - Optimize QuickPanel trigger registration to use ref pattern, avoiding frequent re-registrations * Refactor QuickPanel API to separate concerns between tools and inputbar - Split QuickPanel API into `toolsRegistry` for tool registration and `triggers` for inputbar triggering - Remove unused QuickPanel state variables and clean up dependencies - Update tool context to use new API structure with proper type safety * Optimize the state management of QuickPanel and Inputbar, add text update functionality, and improve the tool registration logic. * chore * Add reusable React hooks and InputbarCore component for chat input - Create `useInputText`, `useKeyboardHandler`, and `useTextareaResize` hooks for text management, keyboard shortcuts, and auto-resizing - Implement `InputbarCore` component with modular toolbar sections, drag-drop support, and textarea customization - Add `useFileDragDrop` and `usePasteHandler` hooks for file uploads and paste handling with type filtering * Refactor Inputbar to use custom hooks for text and textarea management - Replace manual text state with useInputText hook for text management and empty state - Replace textarea resize logic with useTextareaResize hook for automatic height adjustment - Add comprehensive refactoring documentation with usage examples and guidelines * Refactor inputbar drag-drop and paste handling into custom hooks - Extract paste handling logic into usePasteHandler hook - Extract drag-drop file handling into useFileDragDrop hook - Remove inline drag-drop state and handlers, use hook interfaces - Clean up dependencies and callback optimizations * Refactor Inputbar component to use InputbarCore composition - Extract complex UI logic into InputbarCore component for better separation of concerns - Remove intermediate wrapper component and action ref forwarding pattern - Consolidate focus/blur handlers and simplify component structure * Refactor Inputbar to expose actions via ref for external control - Extract action handlers into ProviderActionHandlers interface and expose via ref - Split component into Inputbar wrapper and InputbarInner implementation - Update useEffect to sync inner component actions with ref for external access * feat: inputbar core * refactor: Update QuickPanel integration across various tools * refactor: migrate to antd * chore: format * fix: clean code * clean code * fix i18n * fix: i18n * relative path * model type * 🤖 Weekly Automated Update: Nov 09, 2025 (#11209) feat(bot): Weekly automated script run Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com> Co-authored-by: SuYao <sy20010504@gmail.com> * format * fix * fix: format * use ripgrep * update with input * add common filters * fix build issue * format * fix error * smooth change * adjust * support listing dir * keep list files when focus and blur * support draft save * Optimize the rendering logic of session messages and input bars, and simplify conditional judgments. * Upgrade to agentId * format * 🐛 fix: force quick triggers for agent sessions * revert * fix migrate * fix: filter * fix: trigger * chore packages * feat: 添加过滤和排序功能,支持自定义函数 * fix cursor bug * fix format --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: beyondkmp <beyondkmp@gmail.com> Co-authored-by: kangfenmao <kangfenmao@qq.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
diff --git a/dist/index.js b/dist/index.js
|
diff --git a/dist/index.js b/dist/index.js
|
||||||
index cac044aab0255fa72f68b36ecd2c5b12d424c379..ad6ee8ecfc5cbc3ec43ba59a44eda21e8e4d353f 100644
|
index ff305b112779b718f21a636a27b1196125a332d9..cf32ff5086d4d9e56f8fe90c98724559083bafc3 100644
|
||||||
--- a/dist/index.js
|
--- a/dist/index.js
|
||||||
+++ b/dist/index.js
|
+++ b/dist/index.js
|
||||||
@@ -471,7 +471,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
|
@@ -471,7 +471,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
|
||||||
@@ -12,7 +12,7 @@ index cac044aab0255fa72f68b36ecd2c5b12d424c379..ad6ee8ecfc5cbc3ec43ba59a44eda21e
|
|||||||
|
|
||||||
// src/google-generative-ai-options.ts
|
// src/google-generative-ai-options.ts
|
||||||
diff --git a/dist/index.mjs b/dist/index.mjs
|
diff --git a/dist/index.mjs b/dist/index.mjs
|
||||||
index 0793085005d7968638d355f2f1e127939d965165..1c8bf852baf025d56dc35a0691eb95967de7e5c8 100644
|
index 57659290f1cec74878a385626ad75b2a4d5cd3fc..d04e5927ec3725b6ffdb80868bfa1b5a48849537 100644
|
||||||
--- a/dist/index.mjs
|
--- a/dist/index.mjs
|
||||||
+++ b/dist/index.mjs
|
+++ b/dist/index.mjs
|
||||||
@@ -477,7 +477,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
|
@@ -477,7 +477,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
diff --git a/sdk.mjs b/sdk.mjs
|
diff --git a/sdk.mjs b/sdk.mjs
|
||||||
index 10162e5b1624f8ce667768943347a6e41089ad2f..32568ae08946590e382270c88d85fba81187568e 100755
|
index 8cc6aaf0b25bcdf3c579ec95cde12d419fcb2a71..3b3b8beaea5ad2bbac26a15f792058306d0b059f 100755
|
||||||
--- a/sdk.mjs
|
--- a/sdk.mjs
|
||||||
+++ b/sdk.mjs
|
+++ b/sdk.mjs
|
||||||
@@ -6213,7 +6213,7 @@ function createAbortController(maxListeners = DEFAULT_MAX_LISTENERS) {
|
@@ -6213,7 +6213,7 @@ function createAbortController(maxListeners = DEFAULT_MAX_LISTENERS) {
|
||||||
@@ -11,7 +11,7 @@ index 10162e5b1624f8ce667768943347a6e41089ad2f..32568ae08946590e382270c88d85fba8
|
|||||||
import { createInterface } from "readline";
|
import { createInterface } from "readline";
|
||||||
|
|
||||||
// ../src/utils/fsOperations.ts
|
// ../src/utils/fsOperations.ts
|
||||||
@@ -6487,14 +6487,11 @@ class ProcessTransport {
|
@@ -6505,14 +6505,11 @@ class ProcessTransport {
|
||||||
const errorMessage = isNativeBinary(pathToClaudeCodeExecutable) ? `Claude Code native binary not found at ${pathToClaudeCodeExecutable}. Please ensure Claude Code is installed via native installer or specify a valid path with options.pathToClaudeCodeExecutable.` : `Claude Code executable not found at ${pathToClaudeCodeExecutable}. Is options.pathToClaudeCodeExecutable set?`;
|
const errorMessage = isNativeBinary(pathToClaudeCodeExecutable) ? `Claude Code native binary not found at ${pathToClaudeCodeExecutable}. Please ensure Claude Code is installed via native installer or specify a valid path with options.pathToClaudeCodeExecutable.` : `Claude Code executable not found at ${pathToClaudeCodeExecutable}. Is options.pathToClaudeCodeExecutable set?`;
|
||||||
throw new ReferenceError(errorMessage);
|
throw new ReferenceError(errorMessage);
|
||||||
}
|
}
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
"release:aicore": "yarn workspace @cherrystudio/ai-core version patch --immediate && yarn workspace @cherrystudio/ai-core npm publish --access public"
|
"release:aicore": "yarn workspace @cherrystudio/ai-core version patch --immediate && yarn workspace @cherrystudio/ai-core npm publish --access public"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.25#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.25-08bbabb5d3.patch",
|
"@anthropic-ai/claude-agent-sdk": "patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.30#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.30-b50a299674.patch",
|
||||||
"@libsql/client": "0.14.0",
|
"@libsql/client": "0.14.0",
|
||||||
"@libsql/win32-x64-msvc": "^0.4.7",
|
"@libsql/win32-x64-msvc": "^0.4.7",
|
||||||
"@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch",
|
"@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch",
|
||||||
@@ -107,7 +107,7 @@
|
|||||||
"@agentic/searxng": "^7.3.3",
|
"@agentic/searxng": "^7.3.3",
|
||||||
"@agentic/tavily": "^7.3.3",
|
"@agentic/tavily": "^7.3.3",
|
||||||
"@ai-sdk/amazon-bedrock": "^3.0.53",
|
"@ai-sdk/amazon-bedrock": "^3.0.53",
|
||||||
"@ai-sdk/google-vertex": "^3.0.61",
|
"@ai-sdk/google-vertex": "^3.0.62",
|
||||||
"@ai-sdk/huggingface": "patch:@ai-sdk/huggingface@npm%3A0.0.8#~/.yarn/patches/@ai-sdk-huggingface-npm-0.0.8-d4d0aaac93.patch",
|
"@ai-sdk/huggingface": "patch:@ai-sdk/huggingface@npm%3A0.0.8#~/.yarn/patches/@ai-sdk-huggingface-npm-0.0.8-d4d0aaac93.patch",
|
||||||
"@ai-sdk/mistral": "^2.0.23",
|
"@ai-sdk/mistral": "^2.0.23",
|
||||||
"@ai-sdk/perplexity": "^2.0.17",
|
"@ai-sdk/perplexity": "^2.0.17",
|
||||||
@@ -394,7 +394,6 @@
|
|||||||
"undici": "6.21.2",
|
"undici": "6.21.2",
|
||||||
"vite": "npm:rolldown-vite@7.1.5",
|
"vite": "npm:rolldown-vite@7.1.5",
|
||||||
"tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
|
"tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
|
||||||
"@ai-sdk/google@npm:2.0.23": "patch:@ai-sdk/google@npm%3A2.0.23#~/.yarn/patches/@ai-sdk-google-npm-2.0.23-81682e07b0.patch",
|
|
||||||
"@ai-sdk/openai@npm:^2.0.52": "patch:@ai-sdk/openai@npm%3A2.0.52#~/.yarn/patches/@ai-sdk-openai-npm-2.0.52-b36d949c76.patch",
|
"@ai-sdk/openai@npm:^2.0.52": "patch:@ai-sdk/openai@npm%3A2.0.52#~/.yarn/patches/@ai-sdk-openai-npm-2.0.52-b36d949c76.patch",
|
||||||
"@img/sharp-darwin-arm64": "0.34.3",
|
"@img/sharp-darwin-arm64": "0.34.3",
|
||||||
"@img/sharp-darwin-x64": "0.34.3",
|
"@img/sharp-darwin-x64": "0.34.3",
|
||||||
@@ -406,9 +405,9 @@
|
|||||||
"@langchain/openai@npm:>=0.1.0 <0.6.0": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch",
|
"@langchain/openai@npm:>=0.1.0 <0.6.0": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch",
|
||||||
"@langchain/openai@npm:^0.3.16": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch",
|
"@langchain/openai@npm:^0.3.16": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch",
|
||||||
"@langchain/openai@npm:>=0.2.0 <0.7.0": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch",
|
"@langchain/openai@npm:>=0.2.0 <0.7.0": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch",
|
||||||
"@ai-sdk/google@npm:2.0.30": "patch:@ai-sdk/google@npm%3A2.0.30#~/.yarn/patches/@ai-sdk-google-npm-2.0.30-3b31632362.patch",
|
|
||||||
"@ai-sdk/openai@npm:2.0.64": "patch:@ai-sdk/openai@npm%3A2.0.64#~/.yarn/patches/@ai-sdk-openai-npm-2.0.64-48f99f5bf3.patch",
|
"@ai-sdk/openai@npm:2.0.64": "patch:@ai-sdk/openai@npm%3A2.0.64#~/.yarn/patches/@ai-sdk-openai-npm-2.0.64-48f99f5bf3.patch",
|
||||||
"@ai-sdk/openai@npm:^2.0.42": "patch:@ai-sdk/openai@npm%3A2.0.64#~/.yarn/patches/@ai-sdk-openai-npm-2.0.64-48f99f5bf3.patch"
|
"@ai-sdk/openai@npm:^2.0.42": "patch:@ai-sdk/openai@npm%3A2.0.64#~/.yarn/patches/@ai-sdk-openai-npm-2.0.64-48f99f5bf3.patch",
|
||||||
|
"@ai-sdk/google@npm:2.0.31": "patch:@ai-sdk/google@npm%3A2.0.31#~/.yarn/patches/@ai-sdk-google-npm-2.0.31-b0de047210.patch"
|
||||||
},
|
},
|
||||||
"packageManager": "yarn@4.9.1",
|
"packageManager": "yarn@4.9.1",
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
"@ai-sdk/anthropic": "^2.0.43",
|
"@ai-sdk/anthropic": "^2.0.43",
|
||||||
"@ai-sdk/azure": "^2.0.66",
|
"@ai-sdk/azure": "^2.0.66",
|
||||||
"@ai-sdk/deepseek": "^1.0.27",
|
"@ai-sdk/deepseek": "^1.0.27",
|
||||||
|
"@ai-sdk/google": "patch:@ai-sdk/google@npm%3A2.0.31#~/.yarn/patches/@ai-sdk-google-npm-2.0.31-b0de047210.patch",
|
||||||
"@ai-sdk/openai": "patch:@ai-sdk/openai@npm%3A2.0.64#~/.yarn/patches/@ai-sdk-openai-npm-2.0.64-48f99f5bf3.patch",
|
"@ai-sdk/openai": "patch:@ai-sdk/openai@npm%3A2.0.64#~/.yarn/patches/@ai-sdk-openai-npm-2.0.64-48f99f5bf3.patch",
|
||||||
"@ai-sdk/openai-compatible": "^1.0.26",
|
"@ai-sdk/openai-compatible": "^1.0.26",
|
||||||
"@ai-sdk/provider": "^2.0.0",
|
"@ai-sdk/provider": "^2.0.0",
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ export enum IpcChannel {
|
|||||||
Fs_ReadText = 'fs:readText',
|
Fs_ReadText = 'fs:readText',
|
||||||
File_OpenWithRelativePath = 'file:openWithRelativePath',
|
File_OpenWithRelativePath = 'file:openWithRelativePath',
|
||||||
File_IsTextFile = 'file:isTextFile',
|
File_IsTextFile = 'file:isTextFile',
|
||||||
|
File_ListDirectory = 'file:listDirectory',
|
||||||
File_GetDirectoryStructure = 'file:getDirectoryStructure',
|
File_GetDirectoryStructure = 'file:getDirectoryStructure',
|
||||||
File_CheckFileName = 'file:checkFileName',
|
File_CheckFileName = 'file:checkFileName',
|
||||||
File_ValidateNotesDirectory = 'file:validateNotesDirectory',
|
File_ValidateNotesDirectory = 'file:validateNotesDirectory',
|
||||||
|
|||||||
1
resources/database/drizzle/0002_wealthy_naoko.sql
Normal file
1
resources/database/drizzle/0002_wealthy_naoko.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `sessions` ADD `slash_commands` text;
|
||||||
346
resources/database/drizzle/meta/0002_snapshot.json
Normal file
346
resources/database/drizzle/meta/0002_snapshot.json
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "0cf3d79e-69bf-4dba-8df4-996b9b67d2e8",
|
||||||
|
"prevId": "dabab6db-a2cd-4e96-b06e-6cb87d445a87",
|
||||||
|
"tables": {
|
||||||
|
"agents": {
|
||||||
|
"name": "agents",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"accessible_paths": {
|
||||||
|
"name": "accessible_paths",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"instructions": {
|
||||||
|
"name": "instructions",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"name": "model",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"plan_model": {
|
||||||
|
"name": "plan_model",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"small_model": {
|
||||||
|
"name": "small_model",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"mcps": {
|
||||||
|
"name": "mcps",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"allowed_tools": {
|
||||||
|
"name": "allowed_tools",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"configuration": {
|
||||||
|
"name": "configuration",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"session_messages": {
|
||||||
|
"name": "session_messages",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"session_id": {
|
||||||
|
"name": "session_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"name": "content",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"agent_session_id": {
|
||||||
|
"name": "agent_session_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "''"
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"name": "metadata",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"migrations": {
|
||||||
|
"name": "migrations",
|
||||||
|
"columns": {
|
||||||
|
"version": {
|
||||||
|
"name": "version",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"tag": {
|
||||||
|
"name": "tag",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"executed_at": {
|
||||||
|
"name": "executed_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"name": "sessions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"agent_type": {
|
||||||
|
"name": "agent_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"agent_id": {
|
||||||
|
"name": "agent_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"accessible_paths": {
|
||||||
|
"name": "accessible_paths",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"instructions": {
|
||||||
|
"name": "instructions",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"name": "model",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"plan_model": {
|
||||||
|
"name": "plan_model",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"small_model": {
|
||||||
|
"name": "small_model",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"mcps": {
|
||||||
|
"name": "mcps",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"allowed_tools": {
|
||||||
|
"name": "allowed_tools",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"slash_commands": {
|
||||||
|
"name": "slash_commands",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"configuration": {
|
||||||
|
"name": "configuration",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,13 @@
|
|||||||
"when": 1758187378775,
|
"when": 1758187378775,
|
||||||
"tag": "0001_woozy_captain_flint",
|
"tag": "0001_woozy_captain_flint",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1762526423527,
|
||||||
|
"tag": "0002_wealthy_naoko",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -551,6 +551,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
|||||||
ipcMain.handle(IpcChannel.File_BinaryImage, fileManager.binaryImage.bind(fileManager))
|
ipcMain.handle(IpcChannel.File_BinaryImage, fileManager.binaryImage.bind(fileManager))
|
||||||
ipcMain.handle(IpcChannel.File_OpenWithRelativePath, fileManager.openFileWithRelativePath.bind(fileManager))
|
ipcMain.handle(IpcChannel.File_OpenWithRelativePath, fileManager.openFileWithRelativePath.bind(fileManager))
|
||||||
ipcMain.handle(IpcChannel.File_IsTextFile, fileManager.isTextFile.bind(fileManager))
|
ipcMain.handle(IpcChannel.File_IsTextFile, fileManager.isTextFile.bind(fileManager))
|
||||||
|
ipcMain.handle(IpcChannel.File_ListDirectory, fileManager.listDirectory.bind(fileManager))
|
||||||
ipcMain.handle(IpcChannel.File_GetDirectoryStructure, fileManager.getDirectoryStructure.bind(fileManager))
|
ipcMain.handle(IpcChannel.File_GetDirectoryStructure, fileManager.getDirectoryStructure.bind(fileManager))
|
||||||
ipcMain.handle(IpcChannel.File_CheckFileName, fileManager.fileNameGuard.bind(fileManager))
|
ipcMain.handle(IpcChannel.File_CheckFileName, fileManager.fileNameGuard.bind(fileManager))
|
||||||
ipcMain.handle(IpcChannel.File_ValidateNotesDirectory, fileManager.validateNotesDirectory.bind(fileManager))
|
ipcMain.handle(IpcChannel.File_ValidateNotesDirectory, fileManager.validateNotesDirectory.bind(fileManager))
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type { FSWatcher } from 'chokidar'
|
|||||||
import chokidar from 'chokidar'
|
import chokidar from 'chokidar'
|
||||||
import * as crypto from 'crypto'
|
import * as crypto from 'crypto'
|
||||||
import type { OpenDialogOptions, OpenDialogReturnValue, SaveDialogOptions, SaveDialogReturnValue } from 'electron'
|
import type { OpenDialogOptions, OpenDialogReturnValue, SaveDialogOptions, SaveDialogReturnValue } from 'electron'
|
||||||
|
import { app } from 'electron'
|
||||||
import { dialog, net, shell } from 'electron'
|
import { dialog, net, shell } from 'electron'
|
||||||
import * as fs from 'fs'
|
import * as fs from 'fs'
|
||||||
import { writeFileSync } from 'fs'
|
import { writeFileSync } from 'fs'
|
||||||
@@ -30,6 +31,73 @@ import WordExtractor from 'word-extractor'
|
|||||||
|
|
||||||
const logger = loggerService.withContext('FileStorage')
|
const logger = loggerService.withContext('FileStorage')
|
||||||
|
|
||||||
|
// Get ripgrep binary path
|
||||||
|
const getRipgrepBinaryPath = (): string | null => {
|
||||||
|
try {
|
||||||
|
const arch = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||||
|
const platform = process.platform === 'darwin' ? 'darwin' : process.platform === 'win32' ? 'win32' : 'linux'
|
||||||
|
let ripgrepBinaryPath = path.join(
|
||||||
|
__dirname,
|
||||||
|
'../../node_modules/@anthropic-ai/claude-agent-sdk/vendor/ripgrep',
|
||||||
|
`${arch}-${platform}`,
|
||||||
|
process.platform === 'win32' ? 'rg.exe' : 'rg'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (app.isPackaged) {
|
||||||
|
ripgrepBinaryPath = ripgrepBinaryPath.replace(/\.asar([\\/])/, '.asar.unpacked$1')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fs.existsSync(ripgrepBinaryPath)) {
|
||||||
|
return ripgrepBinaryPath
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to locate ripgrep binary:', error as Error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute ripgrep with captured output
|
||||||
|
*/
|
||||||
|
function executeRipgrep(args: string[]): Promise<{ exitCode: number; output: string }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const ripgrepBinaryPath = getRipgrepBinaryPath()
|
||||||
|
|
||||||
|
if (!ripgrepBinaryPath) {
|
||||||
|
reject(new Error('Ripgrep binary not available'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { spawn } = require('child_process')
|
||||||
|
const child = spawn(ripgrepBinaryPath, ['--no-config', '--ignore-case', ...args], {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe']
|
||||||
|
})
|
||||||
|
|
||||||
|
let output = ''
|
||||||
|
let errorOutput = ''
|
||||||
|
|
||||||
|
child.stdout.on('data', (data: Buffer) => {
|
||||||
|
output += data.toString()
|
||||||
|
})
|
||||||
|
|
||||||
|
child.stderr.on('data', (data: Buffer) => {
|
||||||
|
errorOutput += data.toString()
|
||||||
|
})
|
||||||
|
|
||||||
|
child.on('close', (code: number) => {
|
||||||
|
resolve({
|
||||||
|
exitCode: code || 0,
|
||||||
|
output: output || errorOutput
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
child.on('error', (error: Error) => {
|
||||||
|
reject(error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
interface FileWatcherConfig {
|
interface FileWatcherConfig {
|
||||||
watchExtensions?: string[]
|
watchExtensions?: string[]
|
||||||
ignoredPatterns?: (string | RegExp)[]
|
ignoredPatterns?: (string | RegExp)[]
|
||||||
@@ -54,6 +122,26 @@ const DEFAULT_WATCHER_CONFIG: Required<FileWatcherConfig> = {
|
|||||||
eventChannel: 'file-change'
|
eventChannel: 'file-change'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DirectoryListOptions {
|
||||||
|
recursive?: boolean
|
||||||
|
maxDepth?: number
|
||||||
|
includeHidden?: boolean
|
||||||
|
includeFiles?: boolean
|
||||||
|
includeDirectories?: boolean
|
||||||
|
maxEntries?: number
|
||||||
|
searchPattern?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_DIRECTORY_LIST_OPTIONS: Required<DirectoryListOptions> = {
|
||||||
|
recursive: true,
|
||||||
|
maxDepth: 3,
|
||||||
|
includeHidden: false,
|
||||||
|
includeFiles: true,
|
||||||
|
includeDirectories: true,
|
||||||
|
maxEntries: 10,
|
||||||
|
searchPattern: '.'
|
||||||
|
}
|
||||||
|
|
||||||
class FileStorage {
|
class FileStorage {
|
||||||
private storageDir = getFilesDir()
|
private storageDir = getFilesDir()
|
||||||
private notesDir = getNotesDir()
|
private notesDir = getNotesDir()
|
||||||
@@ -748,6 +836,284 @@ class FileStorage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public listDirectory = async (
|
||||||
|
_: Electron.IpcMainInvokeEvent,
|
||||||
|
dirPath: string,
|
||||||
|
options?: DirectoryListOptions
|
||||||
|
): Promise<string[]> => {
|
||||||
|
const mergedOptions: Required<DirectoryListOptions> = {
|
||||||
|
...DEFAULT_DIRECTORY_LIST_OPTIONS,
|
||||||
|
...options
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedPath = path.resolve(dirPath)
|
||||||
|
|
||||||
|
const stat = await fs.promises.stat(resolvedPath).catch((error) => {
|
||||||
|
logger.error(`[IPC - Error] Failed to access directory: ${resolvedPath}`, error as Error)
|
||||||
|
throw error
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!stat.isDirectory()) {
|
||||||
|
throw new Error(`Path is not a directory: ${resolvedPath}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use ripgrep for file listing with relevance-based sorting
|
||||||
|
if (!getRipgrepBinaryPath()) {
|
||||||
|
throw new Error('Ripgrep binary not available')
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.listDirectoryWithRipgrep(resolvedPath, mergedOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search directories by name pattern
|
||||||
|
*/
|
||||||
|
private async searchDirectories(
|
||||||
|
resolvedPath: string,
|
||||||
|
options: Required<DirectoryListOptions>,
|
||||||
|
currentDepth: number = 0
|
||||||
|
): Promise<string[]> {
|
||||||
|
if (!options.includeDirectories) return []
|
||||||
|
if (!options.recursive && currentDepth > 0) return []
|
||||||
|
if (options.maxDepth > 0 && currentDepth >= options.maxDepth) return []
|
||||||
|
|
||||||
|
const directories: string[] = []
|
||||||
|
const excludedDirs = new Set([
|
||||||
|
'node_modules',
|
||||||
|
'.git',
|
||||||
|
'.idea',
|
||||||
|
'.vscode',
|
||||||
|
'dist',
|
||||||
|
'build',
|
||||||
|
'.next',
|
||||||
|
'.nuxt',
|
||||||
|
'coverage',
|
||||||
|
'.cache'
|
||||||
|
])
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = await fs.promises.readdir(resolvedPath, { withFileTypes: true })
|
||||||
|
const searchPatternLower = options.searchPattern.toLowerCase()
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue
|
||||||
|
|
||||||
|
// Skip hidden directories unless explicitly included
|
||||||
|
if (!options.includeHidden && entry.name.startsWith('.')) continue
|
||||||
|
|
||||||
|
// Skip excluded directories
|
||||||
|
if (excludedDirs.has(entry.name)) continue
|
||||||
|
|
||||||
|
const fullPath = path.join(resolvedPath, entry.name).replace(/\\/g, '/')
|
||||||
|
|
||||||
|
// Check if directory name matches search pattern
|
||||||
|
if (options.searchPattern === '.' || entry.name.toLowerCase().includes(searchPatternLower)) {
|
||||||
|
directories.push(fullPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively search subdirectories
|
||||||
|
if (options.recursive && currentDepth < options.maxDepth) {
|
||||||
|
const subDirs = await this.searchDirectories(fullPath, options, currentDepth + 1)
|
||||||
|
directories.push(...subDirs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Failed to search directories in: ${resolvedPath}`, error as Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return directories
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search files by filename pattern
|
||||||
|
*/
|
||||||
|
private async searchByFilename(resolvedPath: string, options: Required<DirectoryListOptions>): Promise<string[]> {
|
||||||
|
const files: string[] = []
|
||||||
|
const directories: string[] = []
|
||||||
|
|
||||||
|
// Search for files using ripgrep
|
||||||
|
if (options.includeFiles) {
|
||||||
|
const args: string[] = ['--files']
|
||||||
|
|
||||||
|
// Handle hidden files
|
||||||
|
if (!options.includeHidden) {
|
||||||
|
args.push('--glob', '!.*')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use --iglob to let ripgrep filter filenames (case-insensitive)
|
||||||
|
if (options.searchPattern && options.searchPattern !== '.') {
|
||||||
|
args.push('--iglob', `*${options.searchPattern}*`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exclude common hidden directories and large directories
|
||||||
|
args.push('-g', '!**/node_modules/**')
|
||||||
|
args.push('-g', '!**/.git/**')
|
||||||
|
args.push('-g', '!**/.idea/**')
|
||||||
|
args.push('-g', '!**/.vscode/**')
|
||||||
|
args.push('-g', '!**/.DS_Store')
|
||||||
|
args.push('-g', '!**/dist/**')
|
||||||
|
args.push('-g', '!**/build/**')
|
||||||
|
args.push('-g', '!**/.next/**')
|
||||||
|
args.push('-g', '!**/.nuxt/**')
|
||||||
|
args.push('-g', '!**/coverage/**')
|
||||||
|
args.push('-g', '!**/.cache/**')
|
||||||
|
|
||||||
|
// Handle max depth
|
||||||
|
if (!options.recursive) {
|
||||||
|
args.push('--max-depth', '1')
|
||||||
|
} else if (options.maxDepth > 0) {
|
||||||
|
args.push('--max-depth', options.maxDepth.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the directory path
|
||||||
|
args.push(resolvedPath)
|
||||||
|
|
||||||
|
const { exitCode, output } = await executeRipgrep(args)
|
||||||
|
|
||||||
|
// Exit code 0 means files found, 1 means no files found (still success), 2+ means error
|
||||||
|
if (exitCode >= 2) {
|
||||||
|
throw new Error(`Ripgrep failed with exit code ${exitCode}: ${output}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse ripgrep output (no need to filter by filename - ripgrep already did it)
|
||||||
|
files.push(
|
||||||
|
...output
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => line.trim())
|
||||||
|
.map((line) => line.replace(/\\/g, '/'))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for directories
|
||||||
|
if (options.includeDirectories) {
|
||||||
|
directories.push(...(await this.searchDirectories(resolvedPath, options)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine and sort: directories first (alphabetically), then files (alphabetically)
|
||||||
|
const sortedDirectories = directories.sort((a, b) => {
|
||||||
|
const aName = path.basename(a)
|
||||||
|
const bName = path.basename(b)
|
||||||
|
return aName.localeCompare(bName)
|
||||||
|
})
|
||||||
|
|
||||||
|
const sortedFiles = files.sort((a, b) => {
|
||||||
|
const aName = path.basename(a)
|
||||||
|
const bName = path.basename(b)
|
||||||
|
return aName.localeCompare(bName)
|
||||||
|
})
|
||||||
|
|
||||||
|
return [...sortedDirectories, ...sortedFiles].slice(0, options.maxEntries)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search files by content pattern
|
||||||
|
*/
|
||||||
|
private async searchByContent(resolvedPath: string, options: Required<DirectoryListOptions>): Promise<string[]> {
|
||||||
|
const args: string[] = ['-l']
|
||||||
|
|
||||||
|
// Handle hidden files
|
||||||
|
if (!options.includeHidden) {
|
||||||
|
args.push('--glob', '!.*')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exclude common hidden directories and large directories
|
||||||
|
args.push('-g', '!**/node_modules/**')
|
||||||
|
args.push('-g', '!**/.git/**')
|
||||||
|
args.push('-g', '!**/.idea/**')
|
||||||
|
args.push('-g', '!**/.vscode/**')
|
||||||
|
args.push('-g', '!**/.DS_Store')
|
||||||
|
args.push('-g', '!**/dist/**')
|
||||||
|
args.push('-g', '!**/build/**')
|
||||||
|
args.push('-g', '!**/.next/**')
|
||||||
|
args.push('-g', '!**/.nuxt/**')
|
||||||
|
args.push('-g', '!**/coverage/**')
|
||||||
|
args.push('-g', '!**/.cache/**')
|
||||||
|
|
||||||
|
// Handle max depth
|
||||||
|
if (!options.recursive) {
|
||||||
|
args.push('--max-depth', '1')
|
||||||
|
} else if (options.maxDepth > 0) {
|
||||||
|
args.push('--max-depth', options.maxDepth.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle max count
|
||||||
|
if (options.maxEntries > 0) {
|
||||||
|
args.push('--max-count', options.maxEntries.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add search pattern (search in content)
|
||||||
|
args.push(options.searchPattern)
|
||||||
|
|
||||||
|
// Add the directory path
|
||||||
|
args.push(resolvedPath)
|
||||||
|
|
||||||
|
const { exitCode, output } = await executeRipgrep(args)
|
||||||
|
|
||||||
|
// Exit code 0 means files found, 1 means no files found (still success), 2+ means error
|
||||||
|
if (exitCode >= 2) {
|
||||||
|
throw new Error(`Ripgrep failed with exit code ${exitCode}: ${output}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse ripgrep output (already sorted by relevance)
|
||||||
|
const results = output
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => line.trim())
|
||||||
|
.map((line) => line.replace(/\\/g, '/'))
|
||||||
|
.slice(0, options.maxEntries)
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listDirectoryWithRipgrep(
|
||||||
|
resolvedPath: string,
|
||||||
|
options: Required<DirectoryListOptions>
|
||||||
|
): Promise<string[]> {
|
||||||
|
const maxEntries = options.maxEntries
|
||||||
|
|
||||||
|
// Step 1: Search by filename first
|
||||||
|
logger.debug('Searching by filename pattern', { pattern: options.searchPattern, path: resolvedPath })
|
||||||
|
const filenameResults = await this.searchByFilename(resolvedPath, options)
|
||||||
|
|
||||||
|
logger.debug('Found matches by filename', { count: filenameResults.length })
|
||||||
|
|
||||||
|
// If we have enough filename matches, return them
|
||||||
|
if (filenameResults.length >= maxEntries) {
|
||||||
|
return filenameResults.slice(0, maxEntries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: If filename matches are less than maxEntries, search by content to fill up
|
||||||
|
logger.debug('Filename matches insufficient, searching by content to fill up', {
|
||||||
|
filenameCount: filenameResults.length,
|
||||||
|
needed: maxEntries - filenameResults.length
|
||||||
|
})
|
||||||
|
|
||||||
|
// Adjust maxEntries for content search to get enough results
|
||||||
|
const contentOptions = {
|
||||||
|
...options,
|
||||||
|
maxEntries: maxEntries - filenameResults.length + 20 // Request extra to account for duplicates
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentResults = await this.searchByContent(resolvedPath, contentOptions)
|
||||||
|
|
||||||
|
logger.debug('Found matches by content', { count: contentResults.length })
|
||||||
|
|
||||||
|
// Combine results: filename matches first, then content matches (deduplicated)
|
||||||
|
const combined = [...filenameResults]
|
||||||
|
const filenameSet = new Set(filenameResults)
|
||||||
|
|
||||||
|
for (const filePath of contentResults) {
|
||||||
|
if (!filenameSet.has(filePath)) {
|
||||||
|
combined.push(filePath)
|
||||||
|
if (combined.length >= maxEntries) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('Combined results', { total: combined.length, filenameCount: filenameResults.length })
|
||||||
|
return combined.slice(0, maxEntries)
|
||||||
|
}
|
||||||
|
|
||||||
public validateNotesDirectory = async (_: Electron.IpcMainInvokeEvent, dirPath: string): Promise<boolean> => {
|
public validateNotesDirectory = async (_: Electron.IpcMainInvokeEvent, dirPath: string): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
if (!dirPath || typeof dirPath !== 'string') {
|
if (!dirPath || typeof dirPath !== 'string') {
|
||||||
|
|||||||
@@ -36,7 +36,14 @@ export abstract class BaseService {
|
|||||||
protected static db: LibSQLDatabase<typeof schema> | null = null
|
protected static db: LibSQLDatabase<typeof schema> | null = null
|
||||||
protected static isInitialized = false
|
protected static isInitialized = false
|
||||||
protected static initializationPromise: Promise<void> | null = null
|
protected static initializationPromise: Promise<void> | null = null
|
||||||
protected jsonFields: string[] = ['tools', 'mcps', 'configuration', 'accessible_paths', 'allowed_tools']
|
protected jsonFields: string[] = [
|
||||||
|
'tools',
|
||||||
|
'mcps',
|
||||||
|
'configuration',
|
||||||
|
'accessible_paths',
|
||||||
|
'allowed_tools',
|
||||||
|
'slash_commands'
|
||||||
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize database with retry logic and proper error handling
|
* Initialize database with retry logic and proper error handling
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const sessionsTable = sqliteTable('sessions', {
|
|||||||
|
|
||||||
mcps: text('mcps'), // JSON array of MCP tool IDs
|
mcps: text('mcps'), // JSON array of MCP tool IDs
|
||||||
allowed_tools: text('allowed_tools'), // JSON array of allowed tool IDs (whitelist)
|
allowed_tools: text('allowed_tools'), // JSON array of allowed tool IDs (whitelist)
|
||||||
|
slash_commands: text('slash_commands'), // JSON array of slash command objects from SDK init
|
||||||
|
|
||||||
configuration: text('configuration'), // JSON, extensible settings
|
configuration: text('configuration'), // JSON, extensible settings
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { UpdateSessionResponse } from '@types'
|
import { loggerService } from '@logger'
|
||||||
|
import type { SlashCommand, UpdateSessionResponse } from '@types'
|
||||||
import {
|
import {
|
||||||
AgentBaseSchema,
|
AgentBaseSchema,
|
||||||
type AgentEntity,
|
type AgentEntity,
|
||||||
@@ -13,6 +14,10 @@ import { and, count, desc, eq, type SQL } from 'drizzle-orm'
|
|||||||
import { BaseService } from '../BaseService'
|
import { BaseService } from '../BaseService'
|
||||||
import { agentsTable, type InsertSessionRow, type SessionRow, sessionsTable } from '../database/schema'
|
import { agentsTable, type InsertSessionRow, type SessionRow, sessionsTable } from '../database/schema'
|
||||||
import type { AgentModelField } from '../errors'
|
import type { AgentModelField } from '../errors'
|
||||||
|
import { pluginService } from '../plugins/PluginService'
|
||||||
|
import { builtinSlashCommands } from './claudecode/commands'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('SessionService')
|
||||||
|
|
||||||
export class SessionService extends BaseService {
|
export class SessionService extends BaseService {
|
||||||
private static instance: SessionService | null = null
|
private static instance: SessionService | null = null
|
||||||
@@ -29,6 +34,52 @@ export class SessionService extends BaseService {
|
|||||||
await BaseService.initialize()
|
await BaseService.initialize()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Override BaseService.listSlashCommands to merge builtin and plugin commands
|
||||||
|
*/
|
||||||
|
async listSlashCommands(agentType: string, agentId?: string): Promise<SlashCommand[]> {
|
||||||
|
const commands: SlashCommand[] = []
|
||||||
|
|
||||||
|
// Add builtin slash commands
|
||||||
|
if (agentType === 'claude-code') {
|
||||||
|
commands.push(...builtinSlashCommands)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add local command plugins from .claude/commands/
|
||||||
|
if (agentId) {
|
||||||
|
try {
|
||||||
|
const installedPlugins = await pluginService.listInstalled(agentId)
|
||||||
|
|
||||||
|
// Filter for command type plugins
|
||||||
|
const commandPlugins = installedPlugins.filter((p) => p.type === 'command')
|
||||||
|
|
||||||
|
// Convert plugin metadata to SlashCommand format
|
||||||
|
for (const plugin of commandPlugins) {
|
||||||
|
const commandName = plugin.metadata.filename.replace(/\.md$/i, '')
|
||||||
|
commands.push({
|
||||||
|
command: `/${commandName}`,
|
||||||
|
description: plugin.metadata.description
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Listed slash commands', {
|
||||||
|
agentType,
|
||||||
|
agentId,
|
||||||
|
builtinCount: builtinSlashCommands.length,
|
||||||
|
localCount: commandPlugins.length,
|
||||||
|
totalCount: commands.length
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to list local command plugins', {
|
||||||
|
agentId,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return commands
|
||||||
|
}
|
||||||
|
|
||||||
async createSession(
|
async createSession(
|
||||||
agentId: string,
|
agentId: string,
|
||||||
req: Partial<CreateSessionRequest> = {}
|
req: Partial<CreateSessionRequest> = {}
|
||||||
@@ -111,7 +162,13 @@ export class SessionService extends BaseService {
|
|||||||
|
|
||||||
const session = this.deserializeJsonFields(result[0]) as GetAgentSessionResponse
|
const session = this.deserializeJsonFields(result[0]) as GetAgentSessionResponse
|
||||||
session.tools = await this.listMcpTools(session.agent_type, session.mcps)
|
session.tools = await this.listMcpTools(session.agent_type, session.mcps)
|
||||||
session.slash_commands = await this.listSlashCommands(session.agent_type)
|
|
||||||
|
// If slash_commands is not in database yet (e.g., first invoke before init message),
|
||||||
|
// fall back to builtin + local commands. Otherwise, use the merged commands from database.
|
||||||
|
if (!session.slash_commands || session.slash_commands.length === 0) {
|
||||||
|
session.slash_commands = await this.listSlashCommands(session.agent_type, agentId)
|
||||||
|
}
|
||||||
|
|
||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'
|
import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
import { ClaudeStreamState, transformSDKMessageToStreamParts } from '../transform'
|
import { ClaudeStreamState, stripLocalCommandTags, transformSDKMessageToStreamParts } from '../transform'
|
||||||
|
|
||||||
const baseStreamMetadata = {
|
const baseStreamMetadata = {
|
||||||
parent_tool_use_id: null,
|
parent_tool_use_id: null,
|
||||||
@@ -10,6 +10,19 @@ const baseStreamMetadata = {
|
|||||||
|
|
||||||
const uuid = (n: number) => `00000000-0000-0000-0000-${n.toString().padStart(12, '0')}`
|
const uuid = (n: number) => `00000000-0000-0000-0000-${n.toString().padStart(12, '0')}`
|
||||||
|
|
||||||
|
describe('stripLocalCommandTags', () => {
|
||||||
|
it('removes stdout wrapper while preserving inner text', () => {
|
||||||
|
const input = 'before <local-command-stdout>echo "hi"</local-command-stdout> after'
|
||||||
|
expect(stripLocalCommandTags(input)).toBe('before echo "hi" after')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('strips multiple stdout/stderr blocks and leaves other content intact', () => {
|
||||||
|
const input =
|
||||||
|
'<local-command-stdout>line1</local-command-stdout>\nkeep\n<local-command-stderr>Error</local-command-stderr>'
|
||||||
|
expect(stripLocalCommandTags(input)).toBe('line1\nkeep\nError')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('Claude → AiSDK transform', () => {
|
describe('Claude → AiSDK transform', () => {
|
||||||
it('handles tool call streaming lifecycle', () => {
|
it('handles tool call streaming lifecycle', () => {
|
||||||
const state = new ClaudeStreamState()
|
const state = new ClaudeStreamState()
|
||||||
|
|||||||
@@ -1,25 +1,12 @@
|
|||||||
import type { SlashCommand } from '@types'
|
import type { SlashCommand } from '@types'
|
||||||
|
|
||||||
export const builtinSlashCommands: SlashCommand[] = [
|
export const builtinSlashCommands: SlashCommand[] = [
|
||||||
{ command: '/add-dir', description: 'Add additional working directories' },
|
|
||||||
{ command: '/agents', description: 'Manage custom AI subagents for specialized tasks' },
|
|
||||||
{ command: '/bug', description: 'Report bugs (sends conversation to Anthropic)' },
|
|
||||||
{ command: '/clear', description: 'Clear conversation history' },
|
{ command: '/clear', description: 'Clear conversation history' },
|
||||||
{ command: '/compact', description: 'Compact conversation with optional focus instructions' },
|
{ command: '/compact', description: 'Compact conversation with optional focus instructions' },
|
||||||
{ command: '/config', description: 'View/modify configuration' },
|
{ command: '/context', description: 'Visualize current context usage as a colored grid' },
|
||||||
{ command: '/cost', description: 'Show token usage statistics' },
|
{
|
||||||
{ command: '/doctor', description: 'Checks the health of your Claude Code installation' },
|
command: '/cost',
|
||||||
{ command: '/help', description: 'Get usage help' },
|
description: 'Show token usage statistics (see cost tracking guide for subscription-specific details)'
|
||||||
{ command: '/init', description: 'Initialize project with CLAUDE.md guide' },
|
},
|
||||||
{ command: '/login', description: 'Switch Anthropic accounts' },
|
{ command: '/todos', description: 'List current todo items' }
|
||||||
{ command: '/logout', description: 'Sign out from your Anthropic account' },
|
|
||||||
{ command: '/mcp', description: 'Manage MCP server connections and OAuth authentication' },
|
|
||||||
{ command: '/memory', description: 'Edit CLAUDE.md memory files' },
|
|
||||||
{ command: '/model', description: 'Select or change the AI model' },
|
|
||||||
{ command: '/permissions', description: 'View or update permissions' },
|
|
||||||
{ command: '/pr_comments', description: 'View pull request comments' },
|
|
||||||
{ command: '/review', description: 'Request code review' },
|
|
||||||
{ command: '/status', description: 'View account and system statuses' },
|
|
||||||
{ command: '/terminal-setup', description: 'Install Shift+Enter key binding for newlines (iTerm2 and VSCode only)' },
|
|
||||||
{ command: '/vim', description: 'Enter vim mode for alternating insert and command modes' }
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { app } from 'electron'
|
|||||||
|
|
||||||
import type { GetAgentSessionResponse } from '../..'
|
import type { GetAgentSessionResponse } from '../..'
|
||||||
import type { AgentServiceInterface, AgentStream, AgentStreamEvent } from '../../interfaces/AgentStreamInterface'
|
import type { AgentServiceInterface, AgentStream, AgentStreamEvent } from '../../interfaces/AgentStreamInterface'
|
||||||
|
import { sessionService } from '../SessionService'
|
||||||
import { promptForToolApproval } from './tool-permissions'
|
import { promptForToolApproval } from './tool-permissions'
|
||||||
import { ClaudeStreamState, transformSDKMessageToStreamParts } from './transform'
|
import { ClaudeStreamState, transformSDKMessageToStreamParts } from './transform'
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ const require_ = createRequire(import.meta.url)
|
|||||||
const logger = loggerService.withContext('ClaudeCodeService')
|
const logger = loggerService.withContext('ClaudeCodeService')
|
||||||
const DEFAULT_AUTO_ALLOW_TOOLS = new Set(['Read', 'Glob', 'Grep'])
|
const DEFAULT_AUTO_ALLOW_TOOLS = new Set(['Read', 'Glob', 'Grep'])
|
||||||
const shouldAutoApproveTools = process.env.CHERRY_AUTO_ALLOW_TOOLS === '1'
|
const shouldAutoApproveTools = process.env.CHERRY_AUTO_ALLOW_TOOLS === '1'
|
||||||
|
const NO_RESUME_COMMANDS = ['/clear']
|
||||||
|
|
||||||
type UserInputMessage = {
|
type UserInputMessage = {
|
||||||
type: 'user'
|
type: 'user'
|
||||||
@@ -197,7 +199,7 @@ class ClaudeCodeService implements AgentServiceInterface {
|
|||||||
options.strictMcpConfig = true
|
options.strictMcpConfig = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lastAgentSessionId) {
|
if (lastAgentSessionId && !NO_RESUME_COMMANDS.some((cmd) => prompt.includes(cmd))) {
|
||||||
options.resume = lastAgentSessionId
|
options.resume = lastAgentSessionId
|
||||||
// TODO: use fork session when we support branching sessions
|
// TODO: use fork session when we support branching sessions
|
||||||
// options.forkSession = true
|
// options.forkSession = true
|
||||||
@@ -220,7 +222,15 @@ class ClaudeCodeService implements AgentServiceInterface {
|
|||||||
|
|
||||||
// Start async processing on the next tick so listeners can subscribe first
|
// Start async processing on the next tick so listeners can subscribe first
|
||||||
setImmediate(() => {
|
setImmediate(() => {
|
||||||
this.processSDKQuery(userInputStream, closeUserStream, options, aiStream, errorChunks).catch((error) => {
|
this.processSDKQuery(
|
||||||
|
userInputStream,
|
||||||
|
closeUserStream,
|
||||||
|
options,
|
||||||
|
aiStream,
|
||||||
|
errorChunks,
|
||||||
|
session.agent_id,
|
||||||
|
session.id
|
||||||
|
).catch((error) => {
|
||||||
logger.error('Unhandled Claude Code stream error', {
|
logger.error('Unhandled Claude Code stream error', {
|
||||||
error: error instanceof Error ? { name: error.name, message: error.message } : String(error)
|
error: error instanceof Error ? { name: error.name, message: error.message } : String(error)
|
||||||
})
|
})
|
||||||
@@ -329,7 +339,9 @@ class ClaudeCodeService implements AgentServiceInterface {
|
|||||||
closePromptStream: () => void,
|
closePromptStream: () => void,
|
||||||
options: Options,
|
options: Options,
|
||||||
stream: ClaudeCodeStream,
|
stream: ClaudeCodeStream,
|
||||||
errorChunks: string[]
|
errorChunks: string[],
|
||||||
|
agentId: string,
|
||||||
|
sessionId: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const jsonOutput: SDKMessage[] = []
|
const jsonOutput: SDKMessage[] = []
|
||||||
let hasCompleted = false
|
let hasCompleted = false
|
||||||
@@ -342,6 +354,62 @@ class ClaudeCodeService implements AgentServiceInterface {
|
|||||||
|
|
||||||
jsonOutput.push(message)
|
jsonOutput.push(message)
|
||||||
|
|
||||||
|
// Handle init message - merge builtin and SDK slash_commands
|
||||||
|
if (message.type === 'system' && message.subtype === 'init') {
|
||||||
|
const sdkSlashCommands = message.slash_commands || []
|
||||||
|
logger.info('Received init message with slash commands', {
|
||||||
|
sessionId,
|
||||||
|
commands: sdkSlashCommands
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get builtin + local slash commands from BaseService
|
||||||
|
const existingCommands = await sessionService.listSlashCommands('claude-code', agentId)
|
||||||
|
|
||||||
|
// Convert SDK slash_commands (string[]) to SlashCommand[] format
|
||||||
|
// Ensure all commands start with '/'
|
||||||
|
const sdkCommands = sdkSlashCommands.map((cmd) => {
|
||||||
|
const normalizedCmd = cmd.startsWith('/') ? cmd : `/${cmd}`
|
||||||
|
return {
|
||||||
|
command: normalizedCmd,
|
||||||
|
description: undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Merge: existing commands (builtin + local) + SDK commands, deduplicate by command name
|
||||||
|
const commandMap = new Map<string, { command: string; description?: string }>()
|
||||||
|
|
||||||
|
for (const cmd of existingCommands) {
|
||||||
|
commandMap.set(cmd.command, cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const cmd of sdkCommands) {
|
||||||
|
if (!commandMap.has(cmd.command)) {
|
||||||
|
commandMap.set(cmd.command, cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedCommands = Array.from(commandMap.values())
|
||||||
|
|
||||||
|
// Update session in database
|
||||||
|
await sessionService.updateSession(agentId, sessionId, {
|
||||||
|
slash_commands: mergedCommands
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info('Updated session with merged slash commands', {
|
||||||
|
sessionId,
|
||||||
|
existingCount: existingCommands.length,
|
||||||
|
sdkCount: sdkCommands.length,
|
||||||
|
totalCount: mergedCommands.length
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to update session slash_commands', {
|
||||||
|
sessionId,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (message.type === 'assistant' || message.type === 'user') {
|
if (message.type === 'assistant' || message.type === 'user') {
|
||||||
logger.silly('claude response', {
|
logger.silly('claude response', {
|
||||||
message,
|
message,
|
||||||
@@ -378,7 +446,6 @@ class ClaudeCodeService implements AgentServiceInterface {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
hasCompleted = true
|
|
||||||
const duration = Date.now() - startTime
|
const duration = Date.now() - startTime
|
||||||
|
|
||||||
logger.debug('SDK query completed successfully', {
|
logger.debug('SDK query completed successfully', {
|
||||||
|
|||||||
@@ -73,13 +73,21 @@ const emptyUsage: LanguageModelUsage = {
|
|||||||
*/
|
*/
|
||||||
const generateMessageId = (): string => `msg_${uuidv4().replace(/-/g, '')}`
|
const generateMessageId = (): string => `msg_${uuidv4().replace(/-/g, '')}`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes any local command stdout/stderr XML wrappers that should never surface to the UI.
|
||||||
|
*/
|
||||||
|
export const stripLocalCommandTags = (text: string): string => {
|
||||||
|
return text.replace(/<local-command-(stdout|stderr)>(.*?)<\/local-command-\1>/gs, '$2')
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filters out command-* tags from text content to prevent internal command
|
* Filters out command-* tags from text content to prevent internal command
|
||||||
* messages from appearing in the user-facing UI.
|
* messages from appearing in the user-facing UI.
|
||||||
* Removes tags like <command-message>...</command-message> and <command-name>...</command-name>
|
* Removes tags like <command-message>...</command-message> and <command-name>...</command-name>
|
||||||
*/
|
*/
|
||||||
const filterCommandTags = (text: string): string => {
|
const filterCommandTags = (text: string): string => {
|
||||||
return text.replace(/<command-[^>]+>.*?<\/command-[^>]+>/gs, '').trim()
|
const withoutLocalCommandTags = stripLocalCommandTags(text)
|
||||||
|
return withoutLocalCommandTags.replace(/<command-[^>]+>.*?<\/command-[^>]+>/gs, '').trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -102,6 +110,7 @@ const sdkMessageToProviderMetadata = (message: SDKMessage): ProviderMetadata =>
|
|||||||
* blocks across calls so that incremental deltas can be correlated correctly.
|
* blocks across calls so that incremental deltas can be correlated correctly.
|
||||||
*/
|
*/
|
||||||
export function transformSDKMessageToStreamParts(sdkMessage: SDKMessage, state: ClaudeStreamState): AgentStreamPart[] {
|
export function transformSDKMessageToStreamParts(sdkMessage: SDKMessage, state: ClaudeStreamState): AgentStreamPart[] {
|
||||||
|
logger.silly('Transforming SDKMessage', { message: sdkMessage })
|
||||||
switch (sdkMessage.type) {
|
switch (sdkMessage.type) {
|
||||||
case 'assistant':
|
case 'assistant':
|
||||||
return handleAssistantMessage(sdkMessage, state)
|
return handleAssistantMessage(sdkMessage, state)
|
||||||
@@ -135,7 +144,8 @@ function handleAssistantMessage(
|
|||||||
const isStreamingActive = state.hasActiveStep()
|
const isStreamingActive = state.hasActiveStep()
|
||||||
|
|
||||||
if (typeof content === 'string') {
|
if (typeof content === 'string') {
|
||||||
if (!content) {
|
const sanitizedContent = stripLocalCommandTags(content)
|
||||||
|
if (!sanitizedContent) {
|
||||||
return chunks
|
return chunks
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,7 +167,7 @@ function handleAssistantMessage(
|
|||||||
chunks.push({
|
chunks.push({
|
||||||
type: 'text-delta',
|
type: 'text-delta',
|
||||||
id: textId,
|
id: textId,
|
||||||
text: content,
|
text: sanitizedContent,
|
||||||
providerMetadata
|
providerMetadata
|
||||||
})
|
})
|
||||||
chunks.push({
|
chunks.push({
|
||||||
@@ -178,7 +188,10 @@ function handleAssistantMessage(
|
|||||||
switch (block.type) {
|
switch (block.type) {
|
||||||
case 'text':
|
case 'text':
|
||||||
if (!isStreamingActive) {
|
if (!isStreamingActive) {
|
||||||
textBlocks.push(block.text)
|
const sanitizedText = stripLocalCommandTags(block.text)
|
||||||
|
if (sanitizedText) {
|
||||||
|
textBlocks.push(sanitizedText)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
case 'tool_use':
|
case 'tool_use':
|
||||||
@@ -537,6 +550,10 @@ function handleContentBlockDelta(
|
|||||||
logger.warn('Received text_delta for unknown block', { index })
|
logger.warn('Received text_delta for unknown block', { index })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
block.text = stripLocalCommandTags(block.text)
|
||||||
|
if (!block.text) {
|
||||||
|
break
|
||||||
|
}
|
||||||
chunks.push({
|
chunks.push({
|
||||||
type: 'text-delta',
|
type: 'text-delta',
|
||||||
id: block.id,
|
id: block.id,
|
||||||
|
|||||||
@@ -48,6 +48,16 @@ import type {
|
|||||||
} from '../renderer/src/types/plugin'
|
} from '../renderer/src/types/plugin'
|
||||||
import type { ActionItem } from '../renderer/src/types/selectionTypes'
|
import type { ActionItem } from '../renderer/src/types/selectionTypes'
|
||||||
|
|
||||||
|
type DirectoryListOptions = {
|
||||||
|
recursive?: boolean
|
||||||
|
maxDepth?: number
|
||||||
|
includeHidden?: boolean
|
||||||
|
includeFiles?: boolean
|
||||||
|
includeDirectories?: boolean
|
||||||
|
maxEntries?: number
|
||||||
|
searchPattern?: string
|
||||||
|
}
|
||||||
|
|
||||||
export function tracedInvoke(channel: string, spanContext: SpanContext | undefined, ...args: any[]) {
|
export function tracedInvoke(channel: string, spanContext: SpanContext | undefined, ...args: any[]) {
|
||||||
if (spanContext) {
|
if (spanContext) {
|
||||||
const data = { type: 'trace', context: spanContext }
|
const data = { type: 'trace', context: spanContext }
|
||||||
@@ -201,6 +211,8 @@ const api = {
|
|||||||
openFileWithRelativePath: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_OpenWithRelativePath, file),
|
openFileWithRelativePath: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_OpenWithRelativePath, file),
|
||||||
isTextFile: (filePath: string): Promise<boolean> => ipcRenderer.invoke(IpcChannel.File_IsTextFile, filePath),
|
isTextFile: (filePath: string): Promise<boolean> => ipcRenderer.invoke(IpcChannel.File_IsTextFile, filePath),
|
||||||
getDirectoryStructure: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_GetDirectoryStructure, dirPath),
|
getDirectoryStructure: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_GetDirectoryStructure, dirPath),
|
||||||
|
listDirectory: (dirPath: string, options?: DirectoryListOptions) =>
|
||||||
|
ipcRenderer.invoke(IpcChannel.File_ListDirectory, dirPath, options),
|
||||||
checkFileName: (dirPath: string, fileName: string, isFile: boolean) =>
|
checkFileName: (dirPath: string, fileName: string, isFile: boolean) =>
|
||||||
ipcRenderer.invoke(IpcChannel.File_CheckFileName, dirPath, fileName, isFile),
|
ipcRenderer.invoke(IpcChannel.File_CheckFileName, dirPath, fileName, isFile),
|
||||||
validateNotesDirectory: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_ValidateNotesDirectory, dirPath),
|
validateNotesDirectory: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_ValidateNotesDirectory, dirPath),
|
||||||
|
|||||||
@@ -30,18 +30,22 @@ export class AiSdkToChunkAdapter {
|
|||||||
private onSessionUpdate?: (sessionId: string) => void
|
private onSessionUpdate?: (sessionId: string) => void
|
||||||
private responseStartTimestamp: number | null = null
|
private responseStartTimestamp: number | null = null
|
||||||
private firstTokenTimestamp: number | null = null
|
private firstTokenTimestamp: number | null = null
|
||||||
|
private hasTextContent = false
|
||||||
|
private getSessionWasCleared?: () => boolean
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private onChunk: (chunk: Chunk) => void,
|
private onChunk: (chunk: Chunk) => void,
|
||||||
mcpTools: MCPTool[] = [],
|
mcpTools: MCPTool[] = [],
|
||||||
accumulate?: boolean,
|
accumulate?: boolean,
|
||||||
enableWebSearch?: boolean,
|
enableWebSearch?: boolean,
|
||||||
onSessionUpdate?: (sessionId: string) => void
|
onSessionUpdate?: (sessionId: string) => void,
|
||||||
|
getSessionWasCleared?: () => boolean
|
||||||
) {
|
) {
|
||||||
this.toolCallHandler = new ToolCallChunkHandler(onChunk, mcpTools)
|
this.toolCallHandler = new ToolCallChunkHandler(onChunk, mcpTools)
|
||||||
this.accumulate = accumulate
|
this.accumulate = accumulate
|
||||||
this.enableWebSearch = enableWebSearch || false
|
this.enableWebSearch = enableWebSearch || false
|
||||||
this.onSessionUpdate = onSessionUpdate
|
this.onSessionUpdate = onSessionUpdate
|
||||||
|
this.getSessionWasCleared = getSessionWasCleared
|
||||||
}
|
}
|
||||||
|
|
||||||
private markFirstTokenIfNeeded() {
|
private markFirstTokenIfNeeded() {
|
||||||
@@ -84,8 +88,9 @@ export class AiSdkToChunkAdapter {
|
|||||||
}
|
}
|
||||||
this.resetTimingState()
|
this.resetTimingState()
|
||||||
this.responseStartTimestamp = Date.now()
|
this.responseStartTimestamp = Date.now()
|
||||||
// Reset link converter state at the start of stream
|
// Reset state at the start of stream
|
||||||
this.isFirstChunk = true
|
this.isFirstChunk = true
|
||||||
|
this.hasTextContent = false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -129,6 +134,8 @@ export class AiSdkToChunkAdapter {
|
|||||||
const agentRawMessage = chunk.rawValue as ClaudeCodeRawValue
|
const agentRawMessage = chunk.rawValue as ClaudeCodeRawValue
|
||||||
if (agentRawMessage.type === 'init' && agentRawMessage.session_id) {
|
if (agentRawMessage.type === 'init' && agentRawMessage.session_id) {
|
||||||
this.onSessionUpdate?.(agentRawMessage.session_id)
|
this.onSessionUpdate?.(agentRawMessage.session_id)
|
||||||
|
} else if (agentRawMessage.type === 'compact' && agentRawMessage.session_id) {
|
||||||
|
this.onSessionUpdate?.(agentRawMessage.session_id)
|
||||||
}
|
}
|
||||||
this.onChunk({
|
this.onChunk({
|
||||||
type: ChunkType.RAW,
|
type: ChunkType.RAW,
|
||||||
@@ -143,6 +150,7 @@ export class AiSdkToChunkAdapter {
|
|||||||
})
|
})
|
||||||
break
|
break
|
||||||
case 'text-delta': {
|
case 'text-delta': {
|
||||||
|
this.hasTextContent = true
|
||||||
const processedText = chunk.text || ''
|
const processedText = chunk.text || ''
|
||||||
let finalText: string
|
let finalText: string
|
||||||
|
|
||||||
@@ -301,6 +309,25 @@ export class AiSdkToChunkAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'finish': {
|
case 'finish': {
|
||||||
|
// Check if session was cleared (e.g., /clear command) and no text was output
|
||||||
|
const sessionCleared = this.getSessionWasCleared?.() ?? false
|
||||||
|
if (sessionCleared && !this.hasTextContent) {
|
||||||
|
// Inject a "context cleared" message for the user
|
||||||
|
const clearMessage = '✨ Context cleared. Starting fresh conversation.'
|
||||||
|
this.onChunk({
|
||||||
|
type: ChunkType.TEXT_START
|
||||||
|
})
|
||||||
|
this.onChunk({
|
||||||
|
type: ChunkType.TEXT_DELTA,
|
||||||
|
text: clearMessage
|
||||||
|
})
|
||||||
|
this.onChunk({
|
||||||
|
type: ChunkType.TEXT_COMPLETE,
|
||||||
|
text: clearMessage
|
||||||
|
})
|
||||||
|
final.text = clearMessage
|
||||||
|
}
|
||||||
|
|
||||||
const usage = {
|
const usage = {
|
||||||
completion_tokens: chunk.totalUsage?.outputTokens || 0,
|
completion_tokens: chunk.totalUsage?.outputTokens || 0,
|
||||||
prompt_tokens: chunk.totalUsage?.inputTokens || 0,
|
prompt_tokens: chunk.totalUsage?.inputTokens || 0,
|
||||||
|
|||||||
104
src/renderer/src/components/QuickPanel/defaultStrategies.ts
Normal file
104
src/renderer/src/components/QuickPanel/defaultStrategies.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import * as tinyPinyin from 'tiny-pinyin'
|
||||||
|
|
||||||
|
import type { QuickPanelFilterFn, QuickPanelListItem, QuickPanelSortFn } from './types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default filter function
|
||||||
|
* Implements standard filtering logic with pinyin support
|
||||||
|
*/
|
||||||
|
export const defaultFilterFn: QuickPanelFilterFn = (item, searchText, fuzzyRegex, pinyinCache) => {
|
||||||
|
if (!searchText) return true
|
||||||
|
|
||||||
|
let filterText = item.filterText || ''
|
||||||
|
if (typeof item.label === 'string') {
|
||||||
|
filterText += item.label
|
||||||
|
}
|
||||||
|
if (typeof item.description === 'string') {
|
||||||
|
filterText += item.description
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowerFilterText = filterText.toLowerCase()
|
||||||
|
const lowerSearchText = searchText.toLowerCase()
|
||||||
|
|
||||||
|
// Direct substring match
|
||||||
|
if (lowerFilterText.includes(lowerSearchText)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pinyin fuzzy match for Chinese characters
|
||||||
|
if (tinyPinyin.isSupported() && /[\u4e00-\u9fa5]/.test(filterText)) {
|
||||||
|
try {
|
||||||
|
let pinyinText = pinyinCache.get(item)
|
||||||
|
if (!pinyinText) {
|
||||||
|
pinyinText = tinyPinyin.convertToPinyin(filterText, '', true).toLowerCase()
|
||||||
|
pinyinCache.set(item, pinyinText)
|
||||||
|
}
|
||||||
|
return fuzzyRegex.test(pinyinText)
|
||||||
|
} catch (error) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return fuzzyRegex.test(filterText.toLowerCase())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate match score for sorting
|
||||||
|
* Higher score = better match
|
||||||
|
*/
|
||||||
|
const calculateMatchScore = (item: QuickPanelListItem, searchText: string): number => {
|
||||||
|
let filterText = item.filterText || ''
|
||||||
|
if (typeof item.label === 'string') {
|
||||||
|
filterText += item.label
|
||||||
|
}
|
||||||
|
if (typeof item.description === 'string') {
|
||||||
|
filterText += item.description
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowerFilterText = filterText.toLowerCase()
|
||||||
|
const lowerSearchText = searchText.toLowerCase()
|
||||||
|
|
||||||
|
// Exact match (highest priority)
|
||||||
|
if (lowerFilterText === lowerSearchText) {
|
||||||
|
return 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label exact match (very high priority)
|
||||||
|
if (typeof item.label === 'string' && item.label.toLowerCase() === lowerSearchText) {
|
||||||
|
return 900
|
||||||
|
}
|
||||||
|
|
||||||
|
// Starts with search text (high priority)
|
||||||
|
if (lowerFilterText.startsWith(lowerSearchText)) {
|
||||||
|
return 800
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label starts with search text
|
||||||
|
if (typeof item.label === 'string' && item.label.toLowerCase().startsWith(lowerSearchText)) {
|
||||||
|
return 700
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contains search text (medium priority)
|
||||||
|
if (lowerFilterText.includes(lowerSearchText)) {
|
||||||
|
// Earlier position = higher score
|
||||||
|
const position = lowerFilterText.indexOf(lowerSearchText)
|
||||||
|
return 600 - position
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pinyin fuzzy match (lower priority)
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default sort function
|
||||||
|
* Sorts items by match score in descending order
|
||||||
|
*/
|
||||||
|
export const defaultSortFn: QuickPanelSortFn = (items, searchText) => {
|
||||||
|
if (!searchText) return items
|
||||||
|
|
||||||
|
return [...items].sort((a, b) => {
|
||||||
|
const scoreA = calculateMatchScore(a, searchText)
|
||||||
|
const scoreB = calculateMatchScore(b, searchText)
|
||||||
|
return scoreB - scoreA
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export * from './defaultStrategies'
|
||||||
export * from './hook'
|
export * from './hook'
|
||||||
export * from './provider'
|
export * from './provider'
|
||||||
export * from './types'
|
export * from './types'
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import type {
|
|||||||
QuickPanelCallBackOptions,
|
QuickPanelCallBackOptions,
|
||||||
QuickPanelCloseAction,
|
QuickPanelCloseAction,
|
||||||
QuickPanelContextType,
|
QuickPanelContextType,
|
||||||
|
QuickPanelFilterFn,
|
||||||
QuickPanelListItem,
|
QuickPanelListItem,
|
||||||
QuickPanelOpenOptions,
|
QuickPanelOpenOptions,
|
||||||
|
QuickPanelSortFn,
|
||||||
QuickPanelTriggerInfo
|
QuickPanelTriggerInfo
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
const QuickPanelContext = createContext<QuickPanelContextType | null>(null)
|
const QuickPanelContext = createContext<QuickPanelContextType | null>(null)
|
||||||
|
|
||||||
export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children }) => {
|
export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children }) => {
|
||||||
@@ -17,19 +18,39 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
|||||||
|
|
||||||
const [list, setList] = useState<QuickPanelListItem[]>([])
|
const [list, setList] = useState<QuickPanelListItem[]>([])
|
||||||
const [title, setTitle] = useState<string | undefined>()
|
const [title, setTitle] = useState<string | undefined>()
|
||||||
const [defaultIndex, setDefaultIndex] = useState<number>(0)
|
const [defaultIndex, setDefaultIndex] = useState<number>(-1)
|
||||||
const [pageSize, setPageSize] = useState<number>(7)
|
const [pageSize, setPageSize] = useState<number>(7)
|
||||||
const [multiple, setMultiple] = useState<boolean>(false)
|
const [multiple, setMultiple] = useState<boolean>(false)
|
||||||
|
const [manageListExternally, setManageListExternally] = useState<boolean>(false)
|
||||||
const [triggerInfo, setTriggerInfo] = useState<QuickPanelTriggerInfo | undefined>()
|
const [triggerInfo, setTriggerInfo] = useState<QuickPanelTriggerInfo | undefined>()
|
||||||
|
const [filterFn, setFilterFn] = useState<QuickPanelFilterFn | undefined>()
|
||||||
|
const [sortFn, setSortFn] = useState<QuickPanelSortFn | undefined>()
|
||||||
const [onClose, setOnClose] = useState<((Options: Partial<QuickPanelCallBackOptions>) => void) | undefined>()
|
const [onClose, setOnClose] = useState<((Options: Partial<QuickPanelCallBackOptions>) => void) | undefined>()
|
||||||
const [beforeAction, setBeforeAction] = useState<((Options: QuickPanelCallBackOptions) => void) | undefined>()
|
const [beforeAction, setBeforeAction] = useState<((Options: QuickPanelCallBackOptions) => void) | undefined>()
|
||||||
const [afterAction, setAfterAction] = useState<((Options: QuickPanelCallBackOptions) => void) | undefined>()
|
const [afterAction, setAfterAction] = useState<((Options: QuickPanelCallBackOptions) => void) | undefined>()
|
||||||
|
const [onSearchChange, setOnSearchChange] = useState<((searchText: string) => void) | undefined>()
|
||||||
|
const [lastCloseAction, setLastCloseAction] = useState<QuickPanelCloseAction | undefined>(undefined)
|
||||||
|
|
||||||
const clearTimer = useRef<NodeJS.Timeout | null>(null)
|
const clearTimer = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
|
||||||
// 添加更新item选中状态的方法
|
// 添加更新item选中状态的方法
|
||||||
const updateItemSelection = useCallback((targetItem: QuickPanelListItem, isSelected: boolean) => {
|
const updateItemSelection = useCallback((targetItem: QuickPanelListItem, isSelected: boolean) => {
|
||||||
setList((prevList) => prevList.map((item) => (item === targetItem ? { ...item, isSelected } : item)))
|
setList((prevList) => {
|
||||||
|
// 先尝试引用匹配(快速路径)
|
||||||
|
const refIndex = prevList.findIndex((item) => item === targetItem)
|
||||||
|
if (refIndex !== -1) {
|
||||||
|
return prevList.map((item, idx) => (idx === refIndex ? { ...item, isSelected } : item))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果引用匹配失败,使用内容匹配(兜底方案)
|
||||||
|
// 通过 label 和 filterText 来识别同一个item
|
||||||
|
return prevList.map((item) => {
|
||||||
|
const isSameItem =
|
||||||
|
(item.label === targetItem.label || item.filterText === targetItem.filterText) &&
|
||||||
|
(!targetItem.filterText || item.filterText === targetItem.filterText)
|
||||||
|
return isSameItem ? { ...item, isSelected } : item
|
||||||
|
})
|
||||||
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// 添加更新整个列表的方法
|
// 添加更新整个列表的方法
|
||||||
@@ -43,17 +64,23 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
|||||||
clearTimer.current = null
|
clearTimer.current = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setLastCloseAction(undefined)
|
||||||
setTitle(options.title)
|
setTitle(options.title)
|
||||||
setList(options.list)
|
setList(options.list)
|
||||||
setDefaultIndex(options.defaultIndex ?? 0)
|
const nextDefaultIndex = typeof options.defaultIndex === 'number' ? Math.max(-1, options.defaultIndex) : -1
|
||||||
|
setDefaultIndex(nextDefaultIndex)
|
||||||
setPageSize(options.pageSize ?? 7)
|
setPageSize(options.pageSize ?? 7)
|
||||||
setMultiple(options.multiple ?? false)
|
setMultiple(options.multiple ?? false)
|
||||||
|
setManageListExternally(options.manageListExternally ?? false)
|
||||||
setSymbol(options.symbol)
|
setSymbol(options.symbol)
|
||||||
setTriggerInfo(options.triggerInfo)
|
setTriggerInfo(options.triggerInfo)
|
||||||
|
|
||||||
setOnClose(() => options.onClose)
|
setOnClose(() => options.onClose)
|
||||||
setBeforeAction(() => options.beforeAction)
|
setBeforeAction(() => options.beforeAction)
|
||||||
setAfterAction(() => options.afterAction)
|
setAfterAction(() => options.afterAction)
|
||||||
|
setOnSearchChange(() => options.onSearchChange)
|
||||||
|
setFilterFn(() => options.filterFn)
|
||||||
|
setSortFn(() => options.sortFn)
|
||||||
|
|
||||||
setIsVisible(true)
|
setIsVisible(true)
|
||||||
}, [])
|
}, [])
|
||||||
@@ -61,6 +88,8 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
|||||||
const close = useCallback(
|
const close = useCallback(
|
||||||
(action?: QuickPanelCloseAction, searchText?: string) => {
|
(action?: QuickPanelCloseAction, searchText?: string) => {
|
||||||
setIsVisible(false)
|
setIsVisible(false)
|
||||||
|
setManageListExternally(false)
|
||||||
|
setLastCloseAction(action)
|
||||||
onClose?.({ action, searchText, item: {} as QuickPanelListItem, context: this })
|
onClose?.({ action, searchText, item: {} as QuickPanelListItem, context: this })
|
||||||
|
|
||||||
clearTimer.current = setTimeout(() => {
|
clearTimer.current = setTimeout(() => {
|
||||||
@@ -68,9 +97,13 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
|||||||
setOnClose(undefined)
|
setOnClose(undefined)
|
||||||
setBeforeAction(undefined)
|
setBeforeAction(undefined)
|
||||||
setAfterAction(undefined)
|
setAfterAction(undefined)
|
||||||
|
setOnSearchChange(undefined)
|
||||||
|
setFilterFn(undefined)
|
||||||
|
setSortFn(undefined)
|
||||||
setTitle(undefined)
|
setTitle(undefined)
|
||||||
setSymbol('')
|
setSymbol('')
|
||||||
setTriggerInfo(undefined)
|
setTriggerInfo(undefined)
|
||||||
|
setManageListExternally(false)
|
||||||
}, 200)
|
}, 200)
|
||||||
},
|
},
|
||||||
[onClose]
|
[onClose]
|
||||||
@@ -100,10 +133,15 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
|||||||
defaultIndex,
|
defaultIndex,
|
||||||
pageSize,
|
pageSize,
|
||||||
multiple,
|
multiple,
|
||||||
|
manageListExternally,
|
||||||
triggerInfo,
|
triggerInfo,
|
||||||
|
lastCloseAction,
|
||||||
|
filterFn,
|
||||||
|
sortFn,
|
||||||
onClose,
|
onClose,
|
||||||
beforeAction,
|
beforeAction,
|
||||||
afterAction
|
afterAction,
|
||||||
|
onSearchChange
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
open,
|
open,
|
||||||
@@ -117,10 +155,15 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
|||||||
defaultIndex,
|
defaultIndex,
|
||||||
pageSize,
|
pageSize,
|
||||||
multiple,
|
multiple,
|
||||||
|
manageListExternally,
|
||||||
triggerInfo,
|
triggerInfo,
|
||||||
|
lastCloseAction,
|
||||||
|
filterFn,
|
||||||
|
sortFn,
|
||||||
onClose,
|
onClose,
|
||||||
beforeAction,
|
beforeAction,
|
||||||
afterAction
|
afterAction,
|
||||||
|
onSearchChange
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ export enum QuickPanelReservedSymbol {
|
|||||||
WebSearch = '?',
|
WebSearch = '?',
|
||||||
Mcp = 'mcp',
|
Mcp = 'mcp',
|
||||||
McpPrompt = 'mcp-prompt',
|
McpPrompt = 'mcp-prompt',
|
||||||
McpResource = 'mcp-resource'
|
McpResource = 'mcp-resource',
|
||||||
|
SlashCommands = 'slash-commands'
|
||||||
}
|
}
|
||||||
|
|
||||||
export type QuickPanelCloseAction = 'enter' | 'click' | 'esc' | 'outsideclick' | 'enter_empty' | string | undefined
|
export type QuickPanelCloseAction = 'enter' | 'click' | 'esc' | 'outsideclick' | 'enter_empty' | string | undefined
|
||||||
@@ -27,6 +28,29 @@ export type QuickPanelCallBackOptions = {
|
|||||||
searchText?: string
|
searchText?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter function type
|
||||||
|
* @param item - The item to check
|
||||||
|
* @param searchText - The search text (without leading symbol)
|
||||||
|
* @param fuzzyRegex - Fuzzy matching regex
|
||||||
|
* @param pinyinCache - Cache for pinyin conversions
|
||||||
|
* @returns true if item matches the search
|
||||||
|
*/
|
||||||
|
export type QuickPanelFilterFn = (
|
||||||
|
item: QuickPanelListItem,
|
||||||
|
searchText: string,
|
||||||
|
fuzzyRegex: RegExp,
|
||||||
|
pinyinCache: WeakMap<QuickPanelListItem, string>
|
||||||
|
) => boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort function type
|
||||||
|
* @param items - The filtered items to sort
|
||||||
|
* @param searchText - The search text (without leading symbol)
|
||||||
|
* @returns sorted items
|
||||||
|
*/
|
||||||
|
export type QuickPanelSortFn = (items: QuickPanelListItem[], searchText: string) => QuickPanelListItem[]
|
||||||
|
|
||||||
export type QuickPanelOpenOptions = {
|
export type QuickPanelOpenOptions = {
|
||||||
/** 显示在底部左边,类似于Placeholder */
|
/** 显示在底部左边,类似于Placeholder */
|
||||||
title?: string
|
title?: string
|
||||||
@@ -48,6 +72,14 @@ export type QuickPanelOpenOptions = {
|
|||||||
beforeAction?: (options: QuickPanelCallBackOptions) => void
|
beforeAction?: (options: QuickPanelCallBackOptions) => void
|
||||||
afterAction?: (options: QuickPanelCallBackOptions) => void
|
afterAction?: (options: QuickPanelCallBackOptions) => void
|
||||||
onClose?: (options: QuickPanelCallBackOptions) => void
|
onClose?: (options: QuickPanelCallBackOptions) => void
|
||||||
|
/** Callback when search text changes (called with debounced search text) */
|
||||||
|
onSearchChange?: (searchText: string) => void
|
||||||
|
/** Tool manages list + collapse behavior externally (skip filtering/auto-close) */
|
||||||
|
manageListExternally?: boolean
|
||||||
|
/** Custom filter function for items (follows open-closed principle) */
|
||||||
|
filterFn?: QuickPanelFilterFn
|
||||||
|
/** Custom sort function for filtered items (follows open-closed principle) */
|
||||||
|
sortFn?: QuickPanelSortFn
|
||||||
}
|
}
|
||||||
|
|
||||||
export type QuickPanelListItem = {
|
export type QuickPanelListItem = {
|
||||||
@@ -88,10 +120,15 @@ export interface QuickPanelContextType {
|
|||||||
readonly pageSize: number
|
readonly pageSize: number
|
||||||
readonly multiple: boolean
|
readonly multiple: boolean
|
||||||
readonly triggerInfo?: QuickPanelTriggerInfo
|
readonly triggerInfo?: QuickPanelTriggerInfo
|
||||||
|
readonly manageListExternally?: boolean
|
||||||
|
readonly lastCloseAction?: QuickPanelCloseAction
|
||||||
|
readonly filterFn?: QuickPanelFilterFn
|
||||||
|
readonly sortFn?: QuickPanelSortFn
|
||||||
|
|
||||||
readonly onClose?: (Options: QuickPanelCallBackOptions) => void
|
readonly onClose?: (Options: QuickPanelCallBackOptions) => void
|
||||||
readonly beforeAction?: (Options: QuickPanelCallBackOptions) => void
|
readonly beforeAction?: (Options: QuickPanelCallBackOptions) => void
|
||||||
readonly afterAction?: (Options: QuickPanelCallBackOptions) => void
|
readonly afterAction?: (Options: QuickPanelCallBackOptions) => void
|
||||||
|
readonly onSearchChange?: (searchText: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export type QuickPanelScrollTrigger = 'initial' | 'keyboard' | 'none'
|
export type QuickPanelScrollTrigger = 'initial' | 'keyboard' | 'none'
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import { debounce } from 'lodash'
|
|||||||
import { Check } from 'lucide-react'
|
import { Check } from 'lucide-react'
|
||||||
import React, { use, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
import React, { use, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
import * as tinyPinyin from 'tiny-pinyin'
|
|
||||||
|
|
||||||
|
import { defaultFilterFn, defaultSortFn } from './defaultStrategies'
|
||||||
import { QuickPanelContext } from './provider'
|
import { QuickPanelContext } from './provider'
|
||||||
import type {
|
import type {
|
||||||
QuickPanelCallBackOptions,
|
QuickPanelCallBackOptions,
|
||||||
@@ -62,21 +62,50 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
|||||||
|
|
||||||
const [_searchText, setSearchText] = useState('')
|
const [_searchText, setSearchText] = useState('')
|
||||||
const searchText = useDeferredValue(_searchText)
|
const searchText = useDeferredValue(_searchText)
|
||||||
|
const setSearchTextDebounced = useMemo(() => debounce((val: string) => setSearchText(val), 50), [])
|
||||||
|
|
||||||
const searchTextRef = useRef('')
|
const searchTextRef = useRef('')
|
||||||
|
|
||||||
// 缓存:按 item 缓存拼音文本,避免重复转换
|
// 缓存:按 item 缓存拼音文本,避免重复转换
|
||||||
const pinyinCacheRef = useRef<WeakMap<QuickPanelListItem, string>>(new WeakMap())
|
const pinyinCacheRef = useRef<WeakMap<QuickPanelListItem, string>>(new WeakMap())
|
||||||
|
|
||||||
// 轻量防抖:减少高频输入时的过滤调用
|
|
||||||
const setSearchTextDebounced = useMemo(() => debounce((val: string) => setSearchText(val), 50), [])
|
|
||||||
|
|
||||||
// 跟踪上一次的搜索文本和符号,用于判断是否需要重置index
|
// 跟踪上一次的搜索文本和符号,用于判断是否需要重置index
|
||||||
const prevSearchTextRef = useRef('')
|
const prevSearchTextRef = useRef('')
|
||||||
const prevSymbolRef = useRef('')
|
const prevSymbolRef = useRef('')
|
||||||
const { setTimeoutTimer } = useTimer()
|
const { setTimeoutTimer } = useTimer()
|
||||||
|
|
||||||
|
// Use injected filter and sort functions, or fall back to defaults
|
||||||
|
const filterFn = ctx.filterFn || defaultFilterFn
|
||||||
|
const sortFn = ctx.sortFn || defaultSortFn
|
||||||
// 处理搜索,过滤列表(始终保留 alwaysVisible 项在顶部)
|
// 处理搜索,过滤列表(始终保留 alwaysVisible 项在顶部)
|
||||||
const list = useMemo(() => {
|
const list = useMemo(() => {
|
||||||
if (!ctx.isVisible && !ctx.symbol) return []
|
if (!ctx.isVisible && !ctx.symbol) return []
|
||||||
|
|
||||||
|
const baseList = (ctx.list || []).filter((item) => !item.hidden)
|
||||||
|
|
||||||
|
if (ctx.manageListExternally) {
|
||||||
|
const combinedLength = baseList.length
|
||||||
|
const isSymbolChanged = prevSymbolRef.current !== ctx.symbol
|
||||||
|
if (isSymbolChanged) {
|
||||||
|
const maxIndex = combinedLength > 0 ? combinedLength - 1 : -1
|
||||||
|
const desiredIndex =
|
||||||
|
typeof ctx.defaultIndex === 'number' ? Math.min(Math.max(ctx.defaultIndex, -1), maxIndex) : -1
|
||||||
|
setIndex(desiredIndex)
|
||||||
|
} else {
|
||||||
|
setIndex((prevIndex) => {
|
||||||
|
if (prevIndex >= combinedLength) {
|
||||||
|
return combinedLength > 0 ? combinedLength - 1 : -1
|
||||||
|
}
|
||||||
|
return prevIndex
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
prevSearchTextRef.current = ''
|
||||||
|
prevSymbolRef.current = ctx.symbol
|
||||||
|
|
||||||
|
return baseList
|
||||||
|
}
|
||||||
|
|
||||||
const _searchText = searchText.replace(/^[/@]/, '')
|
const _searchText = searchText.replace(/^[/@]/, '')
|
||||||
const lowerSearchText = _searchText.toLowerCase()
|
const lowerSearchText = _searchText.toLowerCase()
|
||||||
const fuzzyPattern = lowerSearchText
|
const fuzzyPattern = lowerSearchText
|
||||||
@@ -86,52 +115,35 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
|||||||
const fuzzyRegex = new RegExp(fuzzyPattern, 'ig')
|
const fuzzyRegex = new RegExp(fuzzyPattern, 'ig')
|
||||||
|
|
||||||
// 拆分:固定显示项(不参与过滤)与普通项
|
// 拆分:固定显示项(不参与过滤)与普通项
|
||||||
const pinnedItems = (ctx.list || []).filter((item) => item.alwaysVisible)
|
const pinnedItems = baseList.filter((item) => item.alwaysVisible)
|
||||||
const normalItems = (ctx.list || []).filter((item) => !item.alwaysVisible)
|
const normalItems = baseList.filter((item) => !item.alwaysVisible)
|
||||||
|
|
||||||
|
// Filter normal items using injected filter function
|
||||||
const filteredNormalItems = normalItems.filter((item) => {
|
const filteredNormalItems = normalItems.filter((item) => {
|
||||||
if (!_searchText) return true
|
return filterFn(item, _searchText, fuzzyRegex, pinyinCacheRef.current)
|
||||||
|
|
||||||
let filterText = item.filterText || ''
|
|
||||||
if (typeof item.label === 'string') {
|
|
||||||
filterText += item.label
|
|
||||||
}
|
|
||||||
if (typeof item.description === 'string') {
|
|
||||||
filterText += item.description
|
|
||||||
}
|
|
||||||
|
|
||||||
const lowerFilterText = filterText.toLowerCase()
|
|
||||||
|
|
||||||
if (lowerFilterText.includes(lowerSearchText)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tinyPinyin.isSupported() && /[\u4e00-\u9fa5]/.test(filterText)) {
|
|
||||||
try {
|
|
||||||
let pinyinText = pinyinCacheRef.current.get(item)
|
|
||||||
if (!pinyinText) {
|
|
||||||
pinyinText = tinyPinyin.convertToPinyin(filterText, '', true).toLowerCase()
|
|
||||||
pinyinCacheRef.current.set(item, pinyinText)
|
|
||||||
}
|
|
||||||
return fuzzyRegex.test(pinyinText)
|
|
||||||
} catch (error) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return fuzzyRegex.test(filterText.toLowerCase())
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Sort filtered items using injected sort function
|
||||||
|
const sortedNormalItems = sortFn(filteredNormalItems, _searchText)
|
||||||
|
|
||||||
// 只有在搜索文本变化或面板符号变化时才重置index
|
// 只有在搜索文本变化或面板符号变化时才重置index
|
||||||
const isSearchChanged = prevSearchTextRef.current !== searchText
|
const isSearchChanged = prevSearchTextRef.current !== searchText
|
||||||
const isSymbolChanged = prevSymbolRef.current !== ctx.symbol
|
const isSymbolChanged = prevSymbolRef.current !== ctx.symbol
|
||||||
|
|
||||||
if (isSearchChanged || isSymbolChanged) {
|
if (isSearchChanged || isSymbolChanged) {
|
||||||
setIndex(-1) // 不默认高亮任何项,让用户主动选择
|
const combinedLength = pinnedItems.length + sortedNormalItems.length
|
||||||
|
if (isSymbolChanged) {
|
||||||
|
const maxIndex = combinedLength > 0 ? combinedLength - 1 : -1
|
||||||
|
const desiredIndex =
|
||||||
|
typeof ctx.defaultIndex === 'number' ? Math.min(Math.max(ctx.defaultIndex, -1), maxIndex) : -1
|
||||||
|
setIndex(desiredIndex)
|
||||||
|
} else {
|
||||||
|
setIndex(-1) // 搜索文本变化时不默认高亮
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// 如果当前index超出范围,调整到有效范围内
|
// 如果当前index超出范围,调整到有效范围内
|
||||||
setIndex((prevIndex) => {
|
setIndex((prevIndex) => {
|
||||||
const combinedLength = pinnedItems.length + filteredNormalItems.length
|
const combinedLength = pinnedItems.length + sortedNormalItems.length
|
||||||
if (prevIndex >= combinedLength) {
|
if (prevIndex >= combinedLength) {
|
||||||
return combinedLength > 0 ? combinedLength - 1 : -1
|
return combinedLength > 0 ? combinedLength - 1 : -1
|
||||||
}
|
}
|
||||||
@@ -142,10 +154,9 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
|||||||
prevSearchTextRef.current = searchText
|
prevSearchTextRef.current = searchText
|
||||||
prevSymbolRef.current = ctx.symbol
|
prevSymbolRef.current = ctx.symbol
|
||||||
|
|
||||||
// 固定项置顶 + 过滤后的普通项
|
// 固定项置顶 + 排序后的普通项
|
||||||
const pinnedFiltered = [...pinnedItems, ...filteredNormalItems]
|
return [...pinnedItems, ...sortedNormalItems]
|
||||||
return pinnedFiltered.filter((item) => !item.hidden)
|
}, [ctx.isVisible, ctx.symbol, ctx.manageListExternally, ctx.list, ctx.defaultIndex, searchText, filterFn, sortFn])
|
||||||
}, [ctx.isVisible, ctx.symbol, ctx.list, searchText])
|
|
||||||
|
|
||||||
const canForwardAndBackward = useMemo(() => {
|
const canForwardAndBackward = useMemo(() => {
|
||||||
return list.some((item) => item.isMenu) || historyPanel.length > 0
|
return list.some((item) => item.isMenu) || historyPanel.length > 0
|
||||||
@@ -179,19 +190,64 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
|||||||
|
|
||||||
if (deleteStart >= deleteEnd) return
|
if (deleteStart >= deleteEnd) return
|
||||||
|
|
||||||
// 删除文本
|
const activeSearchText = searchTextRef.current ?? ''
|
||||||
const newText = textArea.value.slice(0, deleteStart) + textArea.value.slice(deleteEnd)
|
|
||||||
setInputText(newText)
|
|
||||||
|
|
||||||
// 设置光标位置
|
setInputText((currentText) => {
|
||||||
setTimeoutTimer(
|
const safeText = currentText ?? ''
|
||||||
'quickpanel_focus',
|
const expectedSegment = includeSymbol ? symbolSegment : symbolSegment.slice(1)
|
||||||
() => {
|
const typedSearch = activeSearchText
|
||||||
textArea.focus()
|
const normalizedTyped = includeSymbol
|
||||||
textArea.setSelectionRange(deleteStart, deleteStart)
|
? typedSearch
|
||||||
},
|
: typedSearch.startsWith(symbolSegment[0] ?? '')
|
||||||
0
|
? typedSearch.slice(1)
|
||||||
)
|
: typedSearch
|
||||||
|
|
||||||
|
if (normalizedTyped && expectedSegment !== normalizedTyped) {
|
||||||
|
return safeText
|
||||||
|
}
|
||||||
|
|
||||||
|
const segmentStart = includeSymbol ? symbolStart : symbolStart + 1
|
||||||
|
const segmentEnd = segmentStart + expectedSegment.length
|
||||||
|
|
||||||
|
if (segmentStart < 0 || segmentStart > safeText.length) {
|
||||||
|
return safeText
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segmentEnd > safeText.length) {
|
||||||
|
return safeText
|
||||||
|
}
|
||||||
|
|
||||||
|
const actualSegment = safeText.slice(segmentStart, segmentEnd)
|
||||||
|
if (actualSegment !== expectedSegment) {
|
||||||
|
return safeText
|
||||||
|
}
|
||||||
|
|
||||||
|
const clampedDeleteStart = Math.max(0, Math.min(deleteStart, safeText.length))
|
||||||
|
const clampedDeleteEnd = Math.max(clampedDeleteStart, Math.min(deleteEnd, safeText.length))
|
||||||
|
|
||||||
|
if (clampedDeleteStart >= clampedDeleteEnd) {
|
||||||
|
return safeText
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedText = safeText.slice(0, clampedDeleteStart) + safeText.slice(clampedDeleteEnd)
|
||||||
|
|
||||||
|
if (updatedText === safeText) {
|
||||||
|
return safeText
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeoutTimer(
|
||||||
|
'quickpanel_focus',
|
||||||
|
() => {
|
||||||
|
const textareaEl = document.querySelector('.inputbar textarea') as HTMLTextAreaElement | null
|
||||||
|
if (!textareaEl) return
|
||||||
|
textareaEl.focus()
|
||||||
|
textareaEl.setSelectionRange(clampedDeleteStart, clampedDeleteStart)
|
||||||
|
},
|
||||||
|
0
|
||||||
|
)
|
||||||
|
|
||||||
|
return updatedText
|
||||||
|
})
|
||||||
|
|
||||||
setSearchText('')
|
setSearchText('')
|
||||||
},
|
},
|
||||||
@@ -211,11 +267,21 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
|||||||
if (textArea) {
|
if (textArea) {
|
||||||
setInputText(textArea.value)
|
setInputText(textArea.value)
|
||||||
}
|
}
|
||||||
} else if (action && !['outsideclick', 'esc', 'enter_empty', 'no_result'].includes(action)) {
|
} else if (
|
||||||
clearSearchText(true)
|
action &&
|
||||||
|
!['outsideclick', 'esc', 'enter_empty', 'no_result'].includes(action) &&
|
||||||
|
ctx.triggerInfo?.type === 'input'
|
||||||
|
) {
|
||||||
|
setTimeoutTimer(
|
||||||
|
'quickpanel_deferred_clear',
|
||||||
|
() => {
|
||||||
|
clearSearchText(true)
|
||||||
|
},
|
||||||
|
0
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[ctx, clearSearchText, setInputText, searchText]
|
[ctx, clearSearchText, setInputText, searchText, setTimeoutTimer]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleItemAction = useCallback(
|
const handleItemAction = useCallback(
|
||||||
@@ -285,12 +351,86 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
|||||||
searchTextRef.current = searchText
|
searchTextRef.current = searchText
|
||||||
}, [searchText])
|
}, [searchText])
|
||||||
|
|
||||||
|
// Track onSearchChange callback and search state for debouncing
|
||||||
|
const prevSearchCallbackTextRef = useRef('')
|
||||||
|
const isFirstSearchRef = useRef(true)
|
||||||
|
const searchCallbackTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
const onSearchChangeRef = useRef(ctx.onSearchChange)
|
||||||
|
|
||||||
|
// Keep onSearchChange ref up to date
|
||||||
|
useEffect(() => {
|
||||||
|
onSearchChangeRef.current = ctx.onSearchChange
|
||||||
|
}, [ctx.onSearchChange])
|
||||||
|
|
||||||
|
// Reset search history when panel closes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ctx.isVisible) {
|
||||||
|
prevSearchCallbackTextRef.current = ''
|
||||||
|
isFirstSearchRef.current = true
|
||||||
|
if (searchCallbackTimerRef.current) {
|
||||||
|
clearTimeout(searchCallbackTimerRef.current)
|
||||||
|
searchCallbackTimerRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [ctx.isVisible])
|
||||||
|
|
||||||
|
// Trigger onSearchChange with debounce (called from handleInput)
|
||||||
|
const triggerSearchChange = useCallback((searchText: string) => {
|
||||||
|
if (!onSearchChangeRef.current) return
|
||||||
|
|
||||||
|
// Clean search text: remove leading symbol (/ or @) and trim
|
||||||
|
const cleanSearchText = searchText.replace(/^[/@]/, '').trim()
|
||||||
|
|
||||||
|
// Don't trigger if search text hasn't changed
|
||||||
|
if (cleanSearchText === prevSearchCallbackTextRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't trigger callback for empty search text
|
||||||
|
if (!cleanSearchText) {
|
||||||
|
prevSearchCallbackTextRef.current = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear previous timer
|
||||||
|
if (searchCallbackTimerRef.current) {
|
||||||
|
clearTimeout(searchCallbackTimerRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
// First search triggers immediately (0ms), subsequent searches have 300ms debounce
|
||||||
|
const delay = isFirstSearchRef.current ? 0 : 300
|
||||||
|
|
||||||
|
searchCallbackTimerRef.current = setTimeout(() => {
|
||||||
|
prevSearchCallbackTextRef.current = cleanSearchText
|
||||||
|
isFirstSearchRef.current = false
|
||||||
|
onSearchChangeRef.current?.(cleanSearchText)
|
||||||
|
searchCallbackTimerRef.current = null
|
||||||
|
}, delay)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Cleanup timer on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (searchCallbackTimerRef.current) {
|
||||||
|
clearTimeout(searchCallbackTimerRef.current)
|
||||||
|
searchCallbackTimerRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
// 获取当前输入的搜索词
|
// 获取当前输入的搜索词
|
||||||
const isComposing = useRef(false)
|
const isComposing = useRef(false)
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
setSearchTextDebounced.cancel()
|
||||||
|
}
|
||||||
|
}, [setSearchTextDebounced])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ctx.isVisible) return
|
if (!ctx.isVisible) return
|
||||||
|
|
||||||
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement
|
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement
|
||||||
|
if (!textArea) return
|
||||||
|
|
||||||
const handleInput = (e: Event) => {
|
const handleInput = (e: Event) => {
|
||||||
if (isComposing.current) return
|
if (isComposing.current) return
|
||||||
@@ -305,6 +445,8 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
|||||||
if (lastSymbolIndex !== -1) {
|
if (lastSymbolIndex !== -1) {
|
||||||
const newSearchText = textBeforeCursor.slice(lastSymbolIndex)
|
const newSearchText = textBeforeCursor.slice(lastSymbolIndex)
|
||||||
setSearchTextDebounced(newSearchText)
|
setSearchTextDebounced(newSearchText)
|
||||||
|
// Trigger server-side search callback immediately (with its own debounce)
|
||||||
|
triggerSearchChange(newSearchText)
|
||||||
} else {
|
} else {
|
||||||
// 使用本地 handleClose,确保在删除触发符时同步受控输入值
|
// 使用本地 handleClose,确保在删除触发符时同步受控输入值
|
||||||
handleClose('delete-symbol')
|
handleClose('delete-symbol')
|
||||||
@@ -328,16 +470,17 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
|||||||
textArea.removeEventListener('input', handleInput)
|
textArea.removeEventListener('input', handleInput)
|
||||||
textArea.removeEventListener('compositionupdate', handleCompositionUpdate)
|
textArea.removeEventListener('compositionupdate', handleCompositionUpdate)
|
||||||
textArea.removeEventListener('compositionend', handleCompositionEnd)
|
textArea.removeEventListener('compositionend', handleCompositionEnd)
|
||||||
setSearchTextDebounced.cancel()
|
|
||||||
setTimeoutTimer(
|
|
||||||
'quickpanel_clear_search',
|
|
||||||
() => {
|
|
||||||
setSearchText('')
|
|
||||||
},
|
|
||||||
200
|
|
||||||
) // 等待面板关闭动画结束后,再清空搜索词
|
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [ctx.isVisible, ctx.symbol, handleClose, setSearchTextDebounced, triggerSearchChange])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (ctx.isVisible) return
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setSearchText('')
|
||||||
|
}, 200)
|
||||||
|
|
||||||
|
return () => clearTimeout(timer)
|
||||||
}, [ctx.isVisible])
|
}, [ctx.isVisible])
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -545,19 +688,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
|||||||
const hasSearchText = useMemo(() => searchText.replace(/^[/@]/, '').length > 0, [searchText])
|
const hasSearchText = useMemo(() => searchText.replace(/^[/@]/, '').length > 0, [searchText])
|
||||||
// 折叠仅依据“非固定项”的匹配数;仅剩固定项(如“清除”)时仍视为无匹配,保持折叠
|
// 折叠仅依据“非固定项”的匹配数;仅剩固定项(如“清除”)时仍视为无匹配,保持折叠
|
||||||
const visibleNonPinnedCount = useMemo(() => list.filter((i) => !i.alwaysVisible).length, [list])
|
const visibleNonPinnedCount = useMemo(() => list.filter((i) => !i.alwaysVisible).length, [list])
|
||||||
const collapsed = hasSearchText && visibleNonPinnedCount === 0
|
const collapsed = !ctx.manageListExternally && hasSearchText && visibleNonPinnedCount === 0
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!ctx.isVisible) return
|
|
||||||
if (!collapsed) return
|
|
||||||
if (ctx.triggerInfo?.type !== 'input') return
|
|
||||||
if (ctx.multiple) return
|
|
||||||
|
|
||||||
const trimmedSearch = searchText.replace(/^[/@]/, '').trim()
|
|
||||||
if (!trimmedSearch) return
|
|
||||||
|
|
||||||
handleClose('no_result')
|
|
||||||
}, [collapsed, ctx.isVisible, ctx.triggerInfo, ctx.multiple, handleClose, searchText])
|
|
||||||
|
|
||||||
const estimateSize = useCallback(() => ITEM_HEIGHT, [])
|
const estimateSize = useCallback(() => ITEM_HEIGHT, [])
|
||||||
|
|
||||||
@@ -616,7 +747,9 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
|||||||
return prev ? prev : true
|
return prev ? prev : true
|
||||||
})
|
})
|
||||||
}>
|
}>
|
||||||
{!collapsed && (
|
{collapsed ? (
|
||||||
|
<QuickPanelEmpty>{t('settings.quickPanel.noResult', 'No results')}</QuickPanelEmpty>
|
||||||
|
) : (
|
||||||
<DynamicVirtualList
|
<DynamicVirtualList
|
||||||
ref={listRef}
|
ref={listRef}
|
||||||
list={list}
|
list={list}
|
||||||
@@ -726,6 +859,13 @@ const QuickPanelBody = styled.div`
|
|||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
const QuickPanelEmpty = styled.div`
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
font-size: 13px;
|
||||||
|
`
|
||||||
|
|
||||||
const QuickPanelFooter = styled.div`
|
const QuickPanelFooter = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { loggerService } from '@logger'
|
||||||
import { useAgent } from '@renderer/hooks/agents/useAgent'
|
import { useAgent } from '@renderer/hooks/agents/useAgent'
|
||||||
import { useSessions } from '@renderer/hooks/agents/useSessions'
|
import { useSessions } from '@renderer/hooks/agents/useSessions'
|
||||||
import { useAppDispatch } from '@renderer/store'
|
import { useAppDispatch } from '@renderer/store'
|
||||||
@@ -6,6 +7,8 @@ import type { CreateSessionForm } from '@renderer/types'
|
|||||||
import { useCallback, useState } from 'react'
|
import { useCallback, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('useCreateDefaultSession')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a stable callback that creates a default agent session and updates UI state.
|
* Returns a stable callback that creates a default agent session and updates UI state.
|
||||||
*/
|
*/
|
||||||
@@ -37,6 +40,9 @@ export const useCreateDefaultSession = (agentId: string | null) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return created
|
return created
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error creating default session:', error as Error)
|
||||||
|
return null
|
||||||
} finally {
|
} finally {
|
||||||
setCreatingSession(false)
|
setCreatingSession(false)
|
||||||
}
|
}
|
||||||
|
|||||||
63
src/renderer/src/hooks/useInputText.ts
Normal file
63
src/renderer/src/hooks/useInputText.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { useCallback, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
export interface UseInputTextOptions {
|
||||||
|
initialValue?: string
|
||||||
|
onChange?: (text: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseInputTextReturn {
|
||||||
|
text: string
|
||||||
|
setText: (text: string | ((prev: string) => string)) => void
|
||||||
|
prevText: string
|
||||||
|
isEmpty: boolean
|
||||||
|
clear: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理文本输入状态的通用 Hook
|
||||||
|
*
|
||||||
|
* 提供文本状态管理、历史追踪和便捷方法
|
||||||
|
*
|
||||||
|
* @param options - 配置选项
|
||||||
|
* @param options.initialValue - 初始文本值
|
||||||
|
* @param options.onChange - 文本变化回调
|
||||||
|
* @returns 文本状态和操作方法
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const { text, setText, isEmpty, clear } = useInputText({
|
||||||
|
* initialValue: '',
|
||||||
|
* onChange: (text) => console.log('Text changed:', text)
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* <input value={text} onChange={(e) => setText(e.target.value)} />
|
||||||
|
* <button disabled={isEmpty}>Send</button>
|
||||||
|
* <button onClick={clear}>Clear</button>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useInputText(options: UseInputTextOptions = {}): UseInputTextReturn {
|
||||||
|
const [text, setText] = useState(options.initialValue ?? '')
|
||||||
|
const prevTextRef = useRef(text)
|
||||||
|
|
||||||
|
const handleSetText = useCallback(
|
||||||
|
(value: string | ((prev: string) => string)) => {
|
||||||
|
const newText = typeof value === 'function' ? value(text) : value
|
||||||
|
prevTextRef.current = text
|
||||||
|
setText(newText)
|
||||||
|
options.onChange?.(newText)
|
||||||
|
},
|
||||||
|
[text, options]
|
||||||
|
)
|
||||||
|
|
||||||
|
const clear = useCallback(() => {
|
||||||
|
handleSetText('')
|
||||||
|
}, [handleSetText])
|
||||||
|
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
setText: handleSetText,
|
||||||
|
prevText: prevTextRef.current,
|
||||||
|
isEmpty: text.trim().length === 0,
|
||||||
|
clear
|
||||||
|
}
|
||||||
|
}
|
||||||
94
src/renderer/src/hooks/useKeyboardHandler.ts
Normal file
94
src/renderer/src/hooks/useKeyboardHandler.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { useCallback, useRef } from 'react'
|
||||||
|
|
||||||
|
export interface KeyboardHandlerCallbacks {
|
||||||
|
onSend?: () => void
|
||||||
|
onEscape?: () => void
|
||||||
|
onTab?: () => void
|
||||||
|
onCustom?: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyboardHandlerOptions {
|
||||||
|
sendShortcut?: 'Enter' | 'Ctrl+Enter' | 'Cmd+Enter' | 'Shift+Enter'
|
||||||
|
enableTabNavigation?: boolean
|
||||||
|
enableEscape?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用键盘事件处理 Hook
|
||||||
|
*
|
||||||
|
* 提供常见的键盘快捷键处理(发送、取消、Tab 导航等)
|
||||||
|
*
|
||||||
|
* @param callbacks - 键盘事件回调函数
|
||||||
|
* @param callbacks.onSend - 发送消息回调(根据 sendShortcut 触发)
|
||||||
|
* @param callbacks.onEscape - Escape 键回调
|
||||||
|
* @param callbacks.onTab - Tab 键回调
|
||||||
|
* @param callbacks.onCustom - 自定义键盘处理回调
|
||||||
|
* @param options - 配置选项
|
||||||
|
* @param options.sendShortcut - 发送快捷键类型(默认 'Enter')
|
||||||
|
* @param options.enableTabNavigation - 是否启用 Tab 导航(默认 false)
|
||||||
|
* @param options.enableEscape - 是否启用 Escape 键处理(默认 false)
|
||||||
|
* @returns 键盘事件处理函数
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const handleKeyDown = useKeyboardHandler(
|
||||||
|
* {
|
||||||
|
* onSend: () => sendMessage(),
|
||||||
|
* onEscape: () => closeModal(),
|
||||||
|
* onTab: () => navigateToNextField()
|
||||||
|
* },
|
||||||
|
* {
|
||||||
|
* sendShortcut: 'Ctrl+Enter',
|
||||||
|
* enableTabNavigation: true,
|
||||||
|
* enableEscape: true
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* <textarea onKeyDown={handleKeyDown} />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useKeyboardHandler(callbacks: KeyboardHandlerCallbacks, options: KeyboardHandlerOptions = {}) {
|
||||||
|
const callbacksRef = useRef(callbacks)
|
||||||
|
callbacksRef.current = callbacks
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
const { sendShortcut = 'Enter', enableTabNavigation = false, enableEscape = false } = options
|
||||||
|
|
||||||
|
// Tab 导航
|
||||||
|
if (enableTabNavigation && event.key === 'Tab') {
|
||||||
|
event.preventDefault()
|
||||||
|
callbacksRef.current.onTab?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape 键
|
||||||
|
if (enableEscape && event.key === 'Escape') {
|
||||||
|
event.stopPropagation()
|
||||||
|
callbacksRef.current.onEscape?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter 键处理
|
||||||
|
if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
|
||||||
|
const isSendPressed =
|
||||||
|
(sendShortcut === 'Enter' && !event.shiftKey && !event.ctrlKey && !event.metaKey) ||
|
||||||
|
(sendShortcut === 'Ctrl+Enter' && event.ctrlKey) ||
|
||||||
|
(sendShortcut === 'Cmd+Enter' && event.metaKey) ||
|
||||||
|
(sendShortcut === 'Shift+Enter' && event.shiftKey)
|
||||||
|
|
||||||
|
if (isSendPressed) {
|
||||||
|
event.preventDefault()
|
||||||
|
callbacksRef.current.onSend?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自定义处理器
|
||||||
|
callbacksRef.current.onCustom?.(event)
|
||||||
|
},
|
||||||
|
[options]
|
||||||
|
)
|
||||||
|
|
||||||
|
return handleKeyDown
|
||||||
|
}
|
||||||
125
src/renderer/src/hooks/useTextareaResize.ts
Normal file
125
src/renderer/src/hooks/useTextareaResize.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import type { TextAreaRef } from 'antd/es/input/TextArea'
|
||||||
|
import { useCallback, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
export interface UseTextareaResizeOptions {
|
||||||
|
maxHeight?: number
|
||||||
|
minHeight?: number
|
||||||
|
autoResize?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseTextareaResizeReturn {
|
||||||
|
textareaRef: React.RefObject<TextAreaRef | null>
|
||||||
|
resize: (force?: boolean) => void
|
||||||
|
focus: () => void
|
||||||
|
customHeight: number | undefined
|
||||||
|
setCustomHeight: (height: number | undefined) => void
|
||||||
|
setExpanded: (expanded: boolean, expandedHeight?: number) => void
|
||||||
|
isExpanded: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理 Textarea 自动调整大小的通用 Hook
|
||||||
|
*
|
||||||
|
* 支持自动调整高度、手动展开/收起、自定义高度限制
|
||||||
|
*
|
||||||
|
* @param options - 配置选项
|
||||||
|
* @param options.maxHeight - 最大高度限制(默认 400px)
|
||||||
|
* @param options.minHeight - 最小高度限制(默认 30px)
|
||||||
|
* @param options.autoResize - 是否自动调整大小(默认 true)
|
||||||
|
* @returns Textarea ref 和调整方法
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const { textareaRef, resize, setExpanded, isExpanded, customHeight } = useTextareaResize({
|
||||||
|
* maxHeight: 400,
|
||||||
|
* minHeight: 30
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* useEffect(() => {
|
||||||
|
* resize() // 在内容变化后调用
|
||||||
|
* }, [text])
|
||||||
|
*
|
||||||
|
* <TextArea
|
||||||
|
* ref={textareaRef}
|
||||||
|
* style={{ height: customHeight }}
|
||||||
|
* autoSize={customHeight ? false : { minRows: 2, maxRows: 20 }}
|
||||||
|
* />
|
||||||
|
* <button onClick={() => setExpanded(!isExpanded)}>Toggle Expand</button>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useTextareaResize(options: UseTextareaResizeOptions = {}): UseTextareaResizeReturn {
|
||||||
|
const { maxHeight = 400, minHeight = 30, autoResize = true } = options
|
||||||
|
|
||||||
|
const textareaRef = useRef<TextAreaRef>(null)
|
||||||
|
const [customHeight, setCustomHeight] = useState<number>()
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false)
|
||||||
|
|
||||||
|
const resize = useCallback(
|
||||||
|
(force = false) => {
|
||||||
|
if (!autoResize && !force) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const textArea = textareaRef.current?.resizableTextArea?.textArea
|
||||||
|
if (!textArea) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果设置了自定义高度且不是强制调整,则跳过
|
||||||
|
if (customHeight !== undefined && !force) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
textArea.style.height = 'auto'
|
||||||
|
if (textArea.scrollHeight) {
|
||||||
|
const newHeight = Math.max(minHeight, Math.min(textArea.scrollHeight, maxHeight))
|
||||||
|
textArea.style.height = `${newHeight}px`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[autoResize, customHeight, maxHeight, minHeight]
|
||||||
|
)
|
||||||
|
|
||||||
|
const focus = useCallback(() => {
|
||||||
|
textareaRef.current?.focus()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setExpanded = useCallback(
|
||||||
|
(expanded: boolean, expandedHeight = 0.7 * window.innerHeight) => {
|
||||||
|
const textArea = textareaRef.current?.resizableTextArea?.textArea
|
||||||
|
if (!textArea) {
|
||||||
|
setIsExpanded(expanded)
|
||||||
|
setCustomHeight(expanded ? expandedHeight : undefined)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expanded) {
|
||||||
|
const viewportHeight = window.innerHeight || expandedHeight
|
||||||
|
const desiredHeight = Math.max(minHeight, Math.min(expandedHeight, viewportHeight * 0.9))
|
||||||
|
textArea.style.height = `${desiredHeight}px`
|
||||||
|
setCustomHeight(desiredHeight)
|
||||||
|
setIsExpanded(true)
|
||||||
|
} else {
|
||||||
|
textArea.style.height = 'auto'
|
||||||
|
setCustomHeight(undefined)
|
||||||
|
setIsExpanded(false)
|
||||||
|
// 收起后重新计算高度
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const contentHeight = textArea.scrollHeight
|
||||||
|
const nextHeight = Math.max(minHeight, Math.min(contentHeight, maxHeight))
|
||||||
|
textArea.style.height = `${nextHeight}px`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[maxHeight, minHeight]
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
textareaRef,
|
||||||
|
resize,
|
||||||
|
focus,
|
||||||
|
customHeight,
|
||||||
|
setCustomHeight,
|
||||||
|
setExpanded,
|
||||||
|
isExpanded
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -631,6 +631,15 @@
|
|||||||
"view_full_content": "View Full Content"
|
"view_full_content": "View Full Content"
|
||||||
},
|
},
|
||||||
"input": {
|
"input": {
|
||||||
|
"activity_directory": {
|
||||||
|
"description": "Select file from activity directory",
|
||||||
|
"loading": "Loading Files...",
|
||||||
|
"no_file_found": {
|
||||||
|
"description": "No files available in accessible directories",
|
||||||
|
"label": "No File Found"
|
||||||
|
},
|
||||||
|
"title": "Activity Directory"
|
||||||
|
},
|
||||||
"auto_resize": "Auto resize height",
|
"auto_resize": "Auto resize height",
|
||||||
"clear": {
|
"clear": {
|
||||||
"content": "Do you want to clear all messages of the current topic?",
|
"content": "Do you want to clear all messages of the current topic?",
|
||||||
@@ -654,6 +663,7 @@
|
|||||||
"new": {
|
"new": {
|
||||||
"context": "Clear Context {{Command}}"
|
"context": "Clear Context {{Command}}"
|
||||||
},
|
},
|
||||||
|
"new_session": "New Session {{Command}}",
|
||||||
"new_topic": "New Topic {{Command}}",
|
"new_topic": "New Topic {{Command}}",
|
||||||
"paste_text_file_confirm": "Paste into input bar?",
|
"paste_text_file_confirm": "Paste into input bar?",
|
||||||
"pause": "Pause",
|
"pause": "Pause",
|
||||||
@@ -661,6 +671,10 @@
|
|||||||
"placeholder_without_triggers": "Type your message here, press {{key}} to send",
|
"placeholder_without_triggers": "Type your message here, press {{key}} to send",
|
||||||
"send": "Send",
|
"send": "Send",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
|
"slash_commands": {
|
||||||
|
"description": "Agent session slash commands",
|
||||||
|
"title": "Slash Commands"
|
||||||
|
},
|
||||||
"thinking": {
|
"thinking": {
|
||||||
"budget_exceeds_max": "Thinking budget exceeds the maximum token number",
|
"budget_exceeds_max": "Thinking budget exceeds the maximum token number",
|
||||||
"label": "Thinking",
|
"label": "Thinking",
|
||||||
@@ -1771,6 +1785,9 @@
|
|||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"code_style": "Code style",
|
"code_style": "Code style",
|
||||||
|
"compact": {
|
||||||
|
"title": "Conversation Compacted"
|
||||||
|
},
|
||||||
"delete": {
|
"delete": {
|
||||||
"content": "Are you sure you want to delete this message?",
|
"content": "Are you sure you want to delete this message?",
|
||||||
"title": "Delete Message"
|
"title": "Delete Message"
|
||||||
@@ -4459,6 +4476,7 @@
|
|||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
"forward": "Forward",
|
"forward": "Forward",
|
||||||
"multiple": "Multiple Select",
|
"multiple": "Multiple Select",
|
||||||
|
"noResult": "No results found",
|
||||||
"page": "Page",
|
"page": "Page",
|
||||||
"select": "Select",
|
"select": "Select",
|
||||||
"title": "Quick Menu"
|
"title": "Quick Menu"
|
||||||
|
|||||||
@@ -631,6 +631,15 @@
|
|||||||
"view_full_content": "查看完整内容"
|
"view_full_content": "查看完整内容"
|
||||||
},
|
},
|
||||||
"input": {
|
"input": {
|
||||||
|
"activity_directory": {
|
||||||
|
"description": "从活动目录中选择文件",
|
||||||
|
"loading": "正在加载文件...",
|
||||||
|
"no_file_found": {
|
||||||
|
"description": "可访问目录中没有可用文件",
|
||||||
|
"label": "未找到文件"
|
||||||
|
},
|
||||||
|
"title": "活动目录"
|
||||||
|
},
|
||||||
"auto_resize": "自动调整高度",
|
"auto_resize": "自动调整高度",
|
||||||
"clear": {
|
"clear": {
|
||||||
"content": "确定要清除当前会话所有消息吗?",
|
"content": "确定要清除当前会话所有消息吗?",
|
||||||
@@ -654,6 +663,7 @@
|
|||||||
"new": {
|
"new": {
|
||||||
"context": "清除上下文 {{Command}}"
|
"context": "清除上下文 {{Command}}"
|
||||||
},
|
},
|
||||||
|
"new_session": "新会话 {{Command}}",
|
||||||
"new_topic": "新话题 {{Command}}",
|
"new_topic": "新话题 {{Command}}",
|
||||||
"paste_text_file_confirm": "粘贴到输入框?",
|
"paste_text_file_confirm": "粘贴到输入框?",
|
||||||
"pause": "暂停",
|
"pause": "暂停",
|
||||||
@@ -661,6 +671,10 @@
|
|||||||
"placeholder_without_triggers": "在这里输入消息,按 {{key}} 发送",
|
"placeholder_without_triggers": "在这里输入消息,按 {{key}} 发送",
|
||||||
"send": "发送",
|
"send": "发送",
|
||||||
"settings": "设置",
|
"settings": "设置",
|
||||||
|
"slash_commands": {
|
||||||
|
"description": "代理会话斜杠命令",
|
||||||
|
"title": "斜杠命令"
|
||||||
|
},
|
||||||
"thinking": {
|
"thinking": {
|
||||||
"budget_exceeds_max": "思考预算超过最大 Token 数",
|
"budget_exceeds_max": "思考预算超过最大 Token 数",
|
||||||
"label": "思考",
|
"label": "思考",
|
||||||
@@ -1771,6 +1785,9 @@
|
|||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"code_style": "代码风格",
|
"code_style": "代码风格",
|
||||||
|
"compact": {
|
||||||
|
"title": "对话已压缩"
|
||||||
|
},
|
||||||
"delete": {
|
"delete": {
|
||||||
"content": "确定要删除此消息吗?",
|
"content": "确定要删除此消息吗?",
|
||||||
"title": "删除消息"
|
"title": "删除消息"
|
||||||
@@ -4459,6 +4476,7 @@
|
|||||||
"confirm": "确认",
|
"confirm": "确认",
|
||||||
"forward": "前进",
|
"forward": "前进",
|
||||||
"multiple": "多选",
|
"multiple": "多选",
|
||||||
|
"noResult": "[to be translated]:No results found",
|
||||||
"page": "翻页",
|
"page": "翻页",
|
||||||
"select": "选择",
|
"select": "选择",
|
||||||
"title": "快捷菜单"
|
"title": "快捷菜单"
|
||||||
|
|||||||
@@ -631,6 +631,15 @@
|
|||||||
"view_full_content": "查看完整內容"
|
"view_full_content": "查看完整內容"
|
||||||
},
|
},
|
||||||
"input": {
|
"input": {
|
||||||
|
"activity_directory": {
|
||||||
|
"description": "從活動目錄中選擇檔案",
|
||||||
|
"loading": "載入檔案中...",
|
||||||
|
"no_file_found": {
|
||||||
|
"description": "可存取的目錄中沒有檔案",
|
||||||
|
"label": "找不到檔案"
|
||||||
|
},
|
||||||
|
"title": "活動目錄"
|
||||||
|
},
|
||||||
"auto_resize": "自動調整高度",
|
"auto_resize": "自動調整高度",
|
||||||
"clear": {
|
"clear": {
|
||||||
"content": "您想要清除目前話題的所有訊息嗎?",
|
"content": "您想要清除目前話題的所有訊息嗎?",
|
||||||
@@ -654,6 +663,7 @@
|
|||||||
"new": {
|
"new": {
|
||||||
"context": "清除上下文 {{Command}}"
|
"context": "清除上下文 {{Command}}"
|
||||||
},
|
},
|
||||||
|
"new_session": "新工作階段 {{Command}}",
|
||||||
"new_topic": "新話題 {{Command}}",
|
"new_topic": "新話題 {{Command}}",
|
||||||
"paste_text_file_confirm": "貼到輸入框?",
|
"paste_text_file_confirm": "貼到輸入框?",
|
||||||
"pause": "暫停",
|
"pause": "暫停",
|
||||||
@@ -661,6 +671,10 @@
|
|||||||
"placeholder_without_triggers": "在此輸入您的訊息,按 {{key}} 傳送",
|
"placeholder_without_triggers": "在此輸入您的訊息,按 {{key}} 傳送",
|
||||||
"send": "傳送",
|
"send": "傳送",
|
||||||
"settings": "設定",
|
"settings": "設定",
|
||||||
|
"slash_commands": {
|
||||||
|
"description": "代理會話斜線命令",
|
||||||
|
"title": "斜線指令"
|
||||||
|
},
|
||||||
"thinking": {
|
"thinking": {
|
||||||
"budget_exceeds_max": "思考預算超過最大 Token 數",
|
"budget_exceeds_max": "思考預算超過最大 Token 數",
|
||||||
"label": "思考",
|
"label": "思考",
|
||||||
@@ -1771,6 +1785,9 @@
|
|||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"code_style": "程式碼風格",
|
"code_style": "程式碼風格",
|
||||||
|
"compact": {
|
||||||
|
"title": "對話已壓縮"
|
||||||
|
},
|
||||||
"delete": {
|
"delete": {
|
||||||
"content": "確定要刪除此訊息嗎?",
|
"content": "確定要刪除此訊息嗎?",
|
||||||
"title": "刪除訊息"
|
"title": "刪除訊息"
|
||||||
@@ -4459,6 +4476,7 @@
|
|||||||
"confirm": "確認",
|
"confirm": "確認",
|
||||||
"forward": "前進",
|
"forward": "前進",
|
||||||
"multiple": "多選",
|
"multiple": "多選",
|
||||||
|
"noResult": "[to be translated]:No results found",
|
||||||
"page": "翻頁",
|
"page": "翻頁",
|
||||||
"select": "選擇",
|
"select": "選擇",
|
||||||
"title": "快捷選單"
|
"title": "快捷選單"
|
||||||
|
|||||||
@@ -631,6 +631,15 @@
|
|||||||
"view_full_content": "Vollständigen Inhalt anzeigen"
|
"view_full_content": "Vollständigen Inhalt anzeigen"
|
||||||
},
|
},
|
||||||
"input": {
|
"input": {
|
||||||
|
"activity_directory": {
|
||||||
|
"description": "Datei aus dem Aktivitätsverzeichnis auswählen",
|
||||||
|
"loading": "Dateien werden geladen...",
|
||||||
|
"no_file_found": {
|
||||||
|
"description": "Keine Dateien in zugänglichen Verzeichnissen verfügbar",
|
||||||
|
"label": "Keine Datei gefunden"
|
||||||
|
},
|
||||||
|
"title": "Aktivitätsverzeichnis"
|
||||||
|
},
|
||||||
"auto_resize": "Höhe automatisch anpassen",
|
"auto_resize": "Höhe automatisch anpassen",
|
||||||
"clear": {
|
"clear": {
|
||||||
"content": "Möchten Sie wirklich alle Nachrichten der aktuellen Sitzung löschen?",
|
"content": "Möchten Sie wirklich alle Nachrichten der aktuellen Sitzung löschen?",
|
||||||
@@ -654,6 +663,7 @@
|
|||||||
"new": {
|
"new": {
|
||||||
"context": "Kontext löschen {{Command}}"
|
"context": "Kontext löschen {{Command}}"
|
||||||
},
|
},
|
||||||
|
"new_session": "Neue Sitzung {{Command}}",
|
||||||
"new_topic": "Neues Thema {{Command}}",
|
"new_topic": "Neues Thema {{Command}}",
|
||||||
"paste_text_file_confirm": "In Eingabefeld einfügen?",
|
"paste_text_file_confirm": "In Eingabefeld einfügen?",
|
||||||
"pause": "Pause",
|
"pause": "Pause",
|
||||||
@@ -661,6 +671,10 @@
|
|||||||
"placeholder_without_triggers": "Geben Sie hier eine Nachricht ein, drücken Sie {{key}} zum Senden",
|
"placeholder_without_triggers": "Geben Sie hier eine Nachricht ein, drücken Sie {{key}} zum Senden",
|
||||||
"send": "Senden",
|
"send": "Senden",
|
||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen",
|
||||||
|
"slash_commands": {
|
||||||
|
"description": "Agent-Session-Slash-Befehle",
|
||||||
|
"title": "Schrägstrich-Befehle"
|
||||||
|
},
|
||||||
"thinking": {
|
"thinking": {
|
||||||
"budget_exceeds_max": "Denkbudget übersteigt maximale Token-Anzahl",
|
"budget_exceeds_max": "Denkbudget übersteigt maximale Token-Anzahl",
|
||||||
"label": "Denken",
|
"label": "Denken",
|
||||||
@@ -1771,6 +1785,9 @@
|
|||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"code_style": "Code-Stil",
|
"code_style": "Code-Stil",
|
||||||
|
"compact": {
|
||||||
|
"title": "Gespräch komprimiert"
|
||||||
|
},
|
||||||
"delete": {
|
"delete": {
|
||||||
"content": "Möchten Sie diese Nachricht wirklich löschen?",
|
"content": "Möchten Sie diese Nachricht wirklich löschen?",
|
||||||
"title": "Nachricht löschen"
|
"title": "Nachricht löschen"
|
||||||
@@ -4459,6 +4476,7 @@
|
|||||||
"confirm": "Bestätigen",
|
"confirm": "Bestätigen",
|
||||||
"forward": "Vorwärts",
|
"forward": "Vorwärts",
|
||||||
"multiple": "Mehrfachauswahl",
|
"multiple": "Mehrfachauswahl",
|
||||||
|
"noResult": "[to be translated]:No results found",
|
||||||
"page": "Seite umblättern",
|
"page": "Seite umblättern",
|
||||||
"select": "Auswählen",
|
"select": "Auswählen",
|
||||||
"title": "Schnellmenü"
|
"title": "Schnellmenü"
|
||||||
|
|||||||
@@ -631,6 +631,15 @@
|
|||||||
"view_full_content": "Προβολή πλήρους περιεχομένου"
|
"view_full_content": "Προβολή πλήρους περιεχομένου"
|
||||||
},
|
},
|
||||||
"input": {
|
"input": {
|
||||||
|
"activity_directory": {
|
||||||
|
"description": "Επιλέξτε αρχείο από τον κατάλογο δραστηριότητας",
|
||||||
|
"loading": "Φόρτωση Αρχείων...",
|
||||||
|
"no_file_found": {
|
||||||
|
"description": "Δεν υπάρχουν διαθέσιμα αρχεία σε προσβάσιμους καταλόγους",
|
||||||
|
"label": "Δεν Βρέθηκε Αρχείο"
|
||||||
|
},
|
||||||
|
"title": "Κατάλογος Δραστηριοτήτων"
|
||||||
|
},
|
||||||
"auto_resize": "Αυτόματη μείωση ύψους",
|
"auto_resize": "Αυτόματη μείωση ύψους",
|
||||||
"clear": {
|
"clear": {
|
||||||
"content": "Είσαι σίγουρος ότι θέλεις να διαγράψεις όλα τα μηνύματα της τρέχουσας συζήτησης;",
|
"content": "Είσαι σίγουρος ότι θέλεις να διαγράψεις όλα τα μηνύματα της τρέχουσας συζήτησης;",
|
||||||
@@ -654,6 +663,7 @@
|
|||||||
"new": {
|
"new": {
|
||||||
"context": "Καθαρισμός ενδιάμεσων {{Command}}"
|
"context": "Καθαρισμός ενδιάμεσων {{Command}}"
|
||||||
},
|
},
|
||||||
|
"new_session": "Νέα Συνεδρία {{Command}}",
|
||||||
"new_topic": "Νέο θέμα {{Command}}",
|
"new_topic": "Νέο θέμα {{Command}}",
|
||||||
"paste_text_file_confirm": "Επικόλληση στο πεδίο εισαγωγής;",
|
"paste_text_file_confirm": "Επικόλληση στο πεδίο εισαγωγής;",
|
||||||
"pause": "Παύση",
|
"pause": "Παύση",
|
||||||
@@ -661,6 +671,10 @@
|
|||||||
"placeholder_without_triggers": "Γράψτε το μήνυμά σας εδώ, πατήστε {{key}} για αποστολή",
|
"placeholder_without_triggers": "Γράψτε το μήνυμά σας εδώ, πατήστε {{key}} για αποστολή",
|
||||||
"send": "Αποστολή",
|
"send": "Αποστολή",
|
||||||
"settings": "Ρυθμίσεις",
|
"settings": "Ρυθμίσεις",
|
||||||
|
"slash_commands": {
|
||||||
|
"description": "Εντολές κάθετης γραμμής για συνεδρία πράκτορα",
|
||||||
|
"title": "Εντολές Κάθετης Γραμμής"
|
||||||
|
},
|
||||||
"thinking": {
|
"thinking": {
|
||||||
"budget_exceeds_max": "Ο προϋπολογισμός σκέψης υπερβαίνει τον μέγιστο αριθμό token",
|
"budget_exceeds_max": "Ο προϋπολογισμός σκέψης υπερβαίνει τον μέγιστο αριθμό token",
|
||||||
"label": "Σκέψη",
|
"label": "Σκέψη",
|
||||||
@@ -1771,6 +1785,9 @@
|
|||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"code_style": "Στυλ κώδικα",
|
"code_style": "Στυλ κώδικα",
|
||||||
|
"compact": {
|
||||||
|
"title": "Συνομιλία Συμπυκνωμένη"
|
||||||
|
},
|
||||||
"delete": {
|
"delete": {
|
||||||
"content": "Θέλετε να διαγράψετε αυτό το μήνυμα;",
|
"content": "Θέλετε να διαγράψετε αυτό το μήνυμα;",
|
||||||
"title": "Διαγραφή μηνύματος"
|
"title": "Διαγραφή μηνύματος"
|
||||||
@@ -4459,6 +4476,7 @@
|
|||||||
"confirm": "Επιβεβαίωση",
|
"confirm": "Επιβεβαίωση",
|
||||||
"forward": "Μπρος",
|
"forward": "Μπρος",
|
||||||
"multiple": "Πολλαπλή επιλογή",
|
"multiple": "Πολλαπλή επιλογή",
|
||||||
|
"noResult": "[to be translated]:No results found",
|
||||||
"page": "Σελίδα",
|
"page": "Σελίδα",
|
||||||
"select": "Επιλογή",
|
"select": "Επιλογή",
|
||||||
"title": "Γρήγορη Πρόσβαση"
|
"title": "Γρήγορη Πρόσβαση"
|
||||||
|
|||||||
@@ -631,6 +631,15 @@
|
|||||||
"view_full_content": "Ver contenido completo"
|
"view_full_content": "Ver contenido completo"
|
||||||
},
|
},
|
||||||
"input": {
|
"input": {
|
||||||
|
"activity_directory": {
|
||||||
|
"description": "Seleccionar archivo del directorio de actividad",
|
||||||
|
"loading": "Cargando archivos...",
|
||||||
|
"no_file_found": {
|
||||||
|
"description": "No hay archivos disponibles en los directorios accesibles",
|
||||||
|
"label": "No se encontró ningún archivo"
|
||||||
|
},
|
||||||
|
"title": "Directorio de Actividades"
|
||||||
|
},
|
||||||
"auto_resize": "Ajuste automático de altura",
|
"auto_resize": "Ajuste automático de altura",
|
||||||
"clear": {
|
"clear": {
|
||||||
"content": "¿Estás seguro de que quieres eliminar todos los mensajes de la sesión actual?",
|
"content": "¿Estás seguro de que quieres eliminar todos los mensajes de la sesión actual?",
|
||||||
@@ -654,6 +663,7 @@
|
|||||||
"new": {
|
"new": {
|
||||||
"context": "Limpiar contexto {{Command}}"
|
"context": "Limpiar contexto {{Command}}"
|
||||||
},
|
},
|
||||||
|
"new_session": "Nueva Sesión {{Command}}",
|
||||||
"new_topic": "Nuevo tema {{Command}}",
|
"new_topic": "Nuevo tema {{Command}}",
|
||||||
"paste_text_file_confirm": "¿Pegar en el cuadro de entrada?",
|
"paste_text_file_confirm": "¿Pegar en el cuadro de entrada?",
|
||||||
"pause": "Pausar",
|
"pause": "Pausar",
|
||||||
@@ -661,6 +671,10 @@
|
|||||||
"placeholder_without_triggers": "Escribe tu mensaje aquí, presiona {{key}} para enviar",
|
"placeholder_without_triggers": "Escribe tu mensaje aquí, presiona {{key}} para enviar",
|
||||||
"send": "Enviar",
|
"send": "Enviar",
|
||||||
"settings": "Configuración",
|
"settings": "Configuración",
|
||||||
|
"slash_commands": {
|
||||||
|
"description": "Comandos de sesión de agente con barra",
|
||||||
|
"title": "Comandos de barra"
|
||||||
|
},
|
||||||
"thinking": {
|
"thinking": {
|
||||||
"budget_exceeds_max": "El presupuesto de pensamiento excede el número máximo de tokens",
|
"budget_exceeds_max": "El presupuesto de pensamiento excede el número máximo de tokens",
|
||||||
"label": "Pensando",
|
"label": "Pensando",
|
||||||
@@ -1771,6 +1785,9 @@
|
|||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"code_style": "Estilo de código",
|
"code_style": "Estilo de código",
|
||||||
|
"compact": {
|
||||||
|
"title": "Conversación Compactada"
|
||||||
|
},
|
||||||
"delete": {
|
"delete": {
|
||||||
"content": "¿Está seguro de querer eliminar este mensaje?",
|
"content": "¿Está seguro de querer eliminar este mensaje?",
|
||||||
"title": "Eliminar mensaje"
|
"title": "Eliminar mensaje"
|
||||||
@@ -4459,6 +4476,7 @@
|
|||||||
"confirm": "Confirmar",
|
"confirm": "Confirmar",
|
||||||
"forward": "Adelante",
|
"forward": "Adelante",
|
||||||
"multiple": "Selección múltiple",
|
"multiple": "Selección múltiple",
|
||||||
|
"noResult": "[to be translated]:No results found",
|
||||||
"page": "Página",
|
"page": "Página",
|
||||||
"select": "Seleccionar",
|
"select": "Seleccionar",
|
||||||
"title": "Menú de acceso rápido"
|
"title": "Menú de acceso rápido"
|
||||||
|
|||||||
@@ -631,6 +631,15 @@
|
|||||||
"view_full_content": "Voir le contenu complet"
|
"view_full_content": "Voir le contenu complet"
|
||||||
},
|
},
|
||||||
"input": {
|
"input": {
|
||||||
|
"activity_directory": {
|
||||||
|
"description": "Sélectionner le fichier dans le répertoire d'activité",
|
||||||
|
"loading": "Chargement des fichiers...",
|
||||||
|
"no_file_found": {
|
||||||
|
"description": "Aucun fichier disponible dans les répertoires accessibles",
|
||||||
|
"label": "Aucun fichier trouvé"
|
||||||
|
},
|
||||||
|
"title": "Répertoire d'activités"
|
||||||
|
},
|
||||||
"auto_resize": "Ajustement automatique de la hauteur",
|
"auto_resize": "Ajustement automatique de la hauteur",
|
||||||
"clear": {
|
"clear": {
|
||||||
"content": "Êtes-vous sûr de vouloir effacer tous les messages de la conversation actuelle ?",
|
"content": "Êtes-vous sûr de vouloir effacer tous les messages de la conversation actuelle ?",
|
||||||
@@ -654,6 +663,7 @@
|
|||||||
"new": {
|
"new": {
|
||||||
"context": "Effacer le contexte {{Command}}"
|
"context": "Effacer le contexte {{Command}}"
|
||||||
},
|
},
|
||||||
|
"new_session": "Nouvelle Session {{Command}}",
|
||||||
"new_topic": "Nouveau sujet {{Command}}",
|
"new_topic": "Nouveau sujet {{Command}}",
|
||||||
"paste_text_file_confirm": "Coller dans la zone de saisie ?",
|
"paste_text_file_confirm": "Coller dans la zone de saisie ?",
|
||||||
"pause": "Pause",
|
"pause": "Pause",
|
||||||
@@ -661,6 +671,10 @@
|
|||||||
"placeholder_without_triggers": "Tapez votre message ici, appuyez sur {{key}} pour envoyer",
|
"placeholder_without_triggers": "Tapez votre message ici, appuyez sur {{key}} pour envoyer",
|
||||||
"send": "Envoyer",
|
"send": "Envoyer",
|
||||||
"settings": "Paramètres",
|
"settings": "Paramètres",
|
||||||
|
"slash_commands": {
|
||||||
|
"description": "Commandes slash de session d'agent",
|
||||||
|
"title": "Commandes Slash"
|
||||||
|
},
|
||||||
"thinking": {
|
"thinking": {
|
||||||
"budget_exceeds_max": "Le budget de réflexion dépasse le nombre maximum de tokens",
|
"budget_exceeds_max": "Le budget de réflexion dépasse le nombre maximum de tokens",
|
||||||
"label": "Pensée",
|
"label": "Pensée",
|
||||||
@@ -1771,6 +1785,9 @@
|
|||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"code_style": "Style de code",
|
"code_style": "Style de code",
|
||||||
|
"compact": {
|
||||||
|
"title": "Conversation Compactée"
|
||||||
|
},
|
||||||
"delete": {
|
"delete": {
|
||||||
"content": "Êtes-vous sûr de vouloir supprimer ce message?",
|
"content": "Êtes-vous sûr de vouloir supprimer ce message?",
|
||||||
"title": "Supprimer le message"
|
"title": "Supprimer le message"
|
||||||
@@ -4459,6 +4476,7 @@
|
|||||||
"confirm": "Подтвердить",
|
"confirm": "Подтвердить",
|
||||||
"forward": "Вперед",
|
"forward": "Вперед",
|
||||||
"multiple": "Множественный выбор",
|
"multiple": "Множественный выбор",
|
||||||
|
"noResult": "[to be translated]:No results found",
|
||||||
"page": "Перелистнуть страницу",
|
"page": "Перелистнуть страницу",
|
||||||
"select": "Выбрать",
|
"select": "Выбрать",
|
||||||
"title": "Быстрое меню"
|
"title": "Быстрое меню"
|
||||||
|
|||||||
@@ -631,6 +631,15 @@
|
|||||||
"view_full_content": "完全な内容を表示"
|
"view_full_content": "完全な内容を表示"
|
||||||
},
|
},
|
||||||
"input": {
|
"input": {
|
||||||
|
"activity_directory": {
|
||||||
|
"description": "アクティビティディレクトリからファイルを選択",
|
||||||
|
"loading": "ファイルを読み込んでいます...",
|
||||||
|
"no_file_found": {
|
||||||
|
"description": "アクセス可能なディレクトリに利用可能なファイルがありません",
|
||||||
|
"label": "ファイルが見つかりません"
|
||||||
|
},
|
||||||
|
"title": "アクティビティディレクトリ"
|
||||||
|
},
|
||||||
"auto_resize": "高さを自動調整",
|
"auto_resize": "高さを自動調整",
|
||||||
"clear": {
|
"clear": {
|
||||||
"content": "現在のトピックのすべてのメッセージをクリアしますか?",
|
"content": "現在のトピックのすべてのメッセージをクリアしますか?",
|
||||||
@@ -654,6 +663,7 @@
|
|||||||
"new": {
|
"new": {
|
||||||
"context": "コンテキストをクリア {{Command}}"
|
"context": "コンテキストをクリア {{Command}}"
|
||||||
},
|
},
|
||||||
|
"new_session": "新しいセッション {{Command}}",
|
||||||
"new_topic": "新しいトピック {{Command}}",
|
"new_topic": "新しいトピック {{Command}}",
|
||||||
"paste_text_file_confirm": "入力欄に貼り付けますか?",
|
"paste_text_file_confirm": "入力欄に貼り付けますか?",
|
||||||
"pause": "一時停止",
|
"pause": "一時停止",
|
||||||
@@ -661,6 +671,10 @@
|
|||||||
"placeholder_without_triggers": "ここにメッセージを入力し、{{key}} を押して送信...",
|
"placeholder_without_triggers": "ここにメッセージを入力し、{{key}} を押して送信...",
|
||||||
"send": "送信",
|
"send": "送信",
|
||||||
"settings": "設定",
|
"settings": "設定",
|
||||||
|
"slash_commands": {
|
||||||
|
"description": "エージェントセッションスラッシュコマンド",
|
||||||
|
"title": "スラッシュコマンド"
|
||||||
|
},
|
||||||
"thinking": {
|
"thinking": {
|
||||||
"budget_exceeds_max": "思考予算が最大トークン数を超えました",
|
"budget_exceeds_max": "思考予算が最大トークン数を超えました",
|
||||||
"label": "思考",
|
"label": "思考",
|
||||||
@@ -1771,6 +1785,9 @@
|
|||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"code_style": "コードスタイル",
|
"code_style": "コードスタイル",
|
||||||
|
"compact": {
|
||||||
|
"title": "会話圧縮"
|
||||||
|
},
|
||||||
"delete": {
|
"delete": {
|
||||||
"content": "このメッセージを削除してもよろしいですか?",
|
"content": "このメッセージを削除してもよろしいですか?",
|
||||||
"title": "メッセージを削除"
|
"title": "メッセージを削除"
|
||||||
@@ -4459,6 +4476,7 @@
|
|||||||
"confirm": "確認",
|
"confirm": "確認",
|
||||||
"forward": "進む",
|
"forward": "進む",
|
||||||
"multiple": "複数選択",
|
"multiple": "複数選択",
|
||||||
|
"noResult": "[to be translated]:No results found",
|
||||||
"page": "ページ",
|
"page": "ページ",
|
||||||
"select": "選択",
|
"select": "選択",
|
||||||
"title": "クイックメニュー"
|
"title": "クイックメニュー"
|
||||||
|
|||||||
@@ -631,6 +631,15 @@
|
|||||||
"view_full_content": "Ver conteúdo completo"
|
"view_full_content": "Ver conteúdo completo"
|
||||||
},
|
},
|
||||||
"input": {
|
"input": {
|
||||||
|
"activity_directory": {
|
||||||
|
"description": "Selecionar arquivo do diretório de atividades",
|
||||||
|
"loading": "Carregando Arquivos...",
|
||||||
|
"no_file_found": {
|
||||||
|
"description": "Nenhum arquivo disponível em diretórios acessíveis",
|
||||||
|
"label": "Nenhum Arquivo Encontrado"
|
||||||
|
},
|
||||||
|
"title": "Diretório de Atividades"
|
||||||
|
},
|
||||||
"auto_resize": "Ajuste automático de altura",
|
"auto_resize": "Ajuste automático de altura",
|
||||||
"clear": {
|
"clear": {
|
||||||
"content": "Tem certeza de que deseja limpar todas as mensagens da sessão atual?",
|
"content": "Tem certeza de que deseja limpar todas as mensagens da sessão atual?",
|
||||||
@@ -654,6 +663,7 @@
|
|||||||
"new": {
|
"new": {
|
||||||
"context": "Limpar contexto {{Command}}"
|
"context": "Limpar contexto {{Command}}"
|
||||||
},
|
},
|
||||||
|
"new_session": "Nova Sessão {{Command}}",
|
||||||
"new_topic": "Novo tópico {{Command}}",
|
"new_topic": "Novo tópico {{Command}}",
|
||||||
"paste_text_file_confirm": "Colar na caixa de entrada?",
|
"paste_text_file_confirm": "Colar na caixa de entrada?",
|
||||||
"pause": "Pausar",
|
"pause": "Pausar",
|
||||||
@@ -661,6 +671,10 @@
|
|||||||
"placeholder_without_triggers": "Escreve a tua mensagem aqui, pressiona {{key}} para enviar",
|
"placeholder_without_triggers": "Escreve a tua mensagem aqui, pressiona {{key}} para enviar",
|
||||||
"send": "Enviar",
|
"send": "Enviar",
|
||||||
"settings": "Configurações",
|
"settings": "Configurações",
|
||||||
|
"slash_commands": {
|
||||||
|
"description": "Comandos de barra da sessão do agente",
|
||||||
|
"title": "Comandos de Barra"
|
||||||
|
},
|
||||||
"thinking": {
|
"thinking": {
|
||||||
"budget_exceeds_max": "Orçamento de pensamento excede o número máximo de tokens",
|
"budget_exceeds_max": "Orçamento de pensamento excede o número máximo de tokens",
|
||||||
"label": "Pensando",
|
"label": "Pensando",
|
||||||
@@ -1771,6 +1785,9 @@
|
|||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"code_style": "Estilo de código",
|
"code_style": "Estilo de código",
|
||||||
|
"compact": {
|
||||||
|
"title": "Conversa Compactada"
|
||||||
|
},
|
||||||
"delete": {
|
"delete": {
|
||||||
"content": "Tem certeza de que deseja excluir esta mensagem?",
|
"content": "Tem certeza de que deseja excluir esta mensagem?",
|
||||||
"title": "Excluir mensagem"
|
"title": "Excluir mensagem"
|
||||||
@@ -4459,6 +4476,7 @@
|
|||||||
"confirm": "Confirmar",
|
"confirm": "Confirmar",
|
||||||
"forward": "Avançar",
|
"forward": "Avançar",
|
||||||
"multiple": "Múltipla Seleção",
|
"multiple": "Múltipla Seleção",
|
||||||
|
"noResult": "[to be translated]:No results found",
|
||||||
"page": "Página",
|
"page": "Página",
|
||||||
"select": "Selecionar",
|
"select": "Selecionar",
|
||||||
"title": "Menu de Atalho"
|
"title": "Menu de Atalho"
|
||||||
|
|||||||
@@ -631,6 +631,15 @@
|
|||||||
"view_full_content": "Показать полное содержимое"
|
"view_full_content": "Показать полное содержимое"
|
||||||
},
|
},
|
||||||
"input": {
|
"input": {
|
||||||
|
"activity_directory": {
|
||||||
|
"description": "Выбрать файл из каталога активности",
|
||||||
|
"loading": "Загрузка файлов...",
|
||||||
|
"no_file_found": {
|
||||||
|
"description": "Нет доступных файлов в доступных каталогах",
|
||||||
|
"label": "Файл не найден"
|
||||||
|
},
|
||||||
|
"title": "Каталог активностей"
|
||||||
|
},
|
||||||
"auto_resize": "Автоматическая высота",
|
"auto_resize": "Автоматическая высота",
|
||||||
"clear": {
|
"clear": {
|
||||||
"content": "Хотите очистить все сообщения текущего топика?",
|
"content": "Хотите очистить все сообщения текущего топика?",
|
||||||
@@ -654,6 +663,7 @@
|
|||||||
"new": {
|
"new": {
|
||||||
"context": "Очистить контекст {{Command}}"
|
"context": "Очистить контекст {{Command}}"
|
||||||
},
|
},
|
||||||
|
"new_session": "Новая сессия {{Команда}}",
|
||||||
"new_topic": "Новый топик {{Command}}",
|
"new_topic": "Новый топик {{Command}}",
|
||||||
"paste_text_file_confirm": "Вставить в поле ввода?",
|
"paste_text_file_confirm": "Вставить в поле ввода?",
|
||||||
"pause": "Остановить",
|
"pause": "Остановить",
|
||||||
@@ -661,6 +671,10 @@
|
|||||||
"placeholder_without_triggers": "Напишите сообщение здесь, нажмите {{key}} для отправки",
|
"placeholder_without_triggers": "Напишите сообщение здесь, нажмите {{key}} для отправки",
|
||||||
"send": "Отправить",
|
"send": "Отправить",
|
||||||
"settings": "Настройки",
|
"settings": "Настройки",
|
||||||
|
"slash_commands": {
|
||||||
|
"description": "Слэш-команды сеанса агента",
|
||||||
|
"title": "Слэш-команды"
|
||||||
|
},
|
||||||
"thinking": {
|
"thinking": {
|
||||||
"budget_exceeds_max": "Бюджет размышления превышает максимальное количество токенов",
|
"budget_exceeds_max": "Бюджет размышления превышает максимальное количество токенов",
|
||||||
"label": "Мыслим",
|
"label": "Мыслим",
|
||||||
@@ -1771,6 +1785,9 @@
|
|||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"code_style": "Стиль кода",
|
"code_style": "Стиль кода",
|
||||||
|
"compact": {
|
||||||
|
"title": "Сжатый разговор"
|
||||||
|
},
|
||||||
"delete": {
|
"delete": {
|
||||||
"content": "Вы уверены, что хотите удалить это сообщение?",
|
"content": "Вы уверены, что хотите удалить это сообщение?",
|
||||||
"title": "Удалить сообщение"
|
"title": "Удалить сообщение"
|
||||||
@@ -4459,6 +4476,7 @@
|
|||||||
"confirm": "Подтвердить",
|
"confirm": "Подтвердить",
|
||||||
"forward": "Вперед",
|
"forward": "Вперед",
|
||||||
"multiple": "Множественный выбор",
|
"multiple": "Множественный выбор",
|
||||||
|
"noResult": "[to be translated]:No results found",
|
||||||
"page": "Страница",
|
"page": "Страница",
|
||||||
"select": "Выбрать",
|
"select": "Выбрать",
|
||||||
"title": "Быстрое меню"
|
"title": "Быстрое меню"
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { Alert, Flex } from 'antd'
|
|||||||
import { debounce } from 'lodash'
|
import { debounce } from 'lodash'
|
||||||
import { AnimatePresence, motion } from 'motion/react'
|
import { AnimatePresence, motion } from 'motion/react'
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
import React, { useCallback, useMemo, useState } from 'react'
|
import React, { useCallback, useState } from 'react'
|
||||||
import { useHotkeys } from 'react-hotkeys-hook'
|
import { useHotkeys } from 'react-hotkeys-hook'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
@@ -161,29 +161,6 @@ const Chat: FC<Props> = (props) => {
|
|||||||
|
|
||||||
const mainHeight = isTopNavbar ? 'calc(100vh - var(--navbar-height) - 6px)' : 'calc(100vh - var(--navbar-height))'
|
const mainHeight = isTopNavbar ? 'calc(100vh - var(--navbar-height) - 6px)' : 'calc(100vh - var(--navbar-height))'
|
||||||
|
|
||||||
const SessionMessages = useMemo(() => {
|
|
||||||
if (activeAgentId === null) {
|
|
||||||
return () => <div> Active Agent ID is invalid.</div>
|
|
||||||
}
|
|
||||||
if (!activeSessionId) {
|
|
||||||
return () => <div> Active Session ID is invalid.</div>
|
|
||||||
}
|
|
||||||
if (!apiServer.enabled) {
|
|
||||||
return () => <Alert type="warning" message={t('agent.warning.enable_server')} style={{ margin: '5px 16px' }} />
|
|
||||||
}
|
|
||||||
return () => <AgentSessionMessages agentId={activeAgentId} sessionId={activeSessionId} />
|
|
||||||
}, [activeAgentId, activeSessionId, apiServer.enabled, t])
|
|
||||||
|
|
||||||
const SessionInputBar = useMemo(() => {
|
|
||||||
if (activeAgentId === null) {
|
|
||||||
return () => <div> Active Agent ID is invalid.</div>
|
|
||||||
}
|
|
||||||
if (!activeSessionId) {
|
|
||||||
return () => <div> Active Session ID is invalid.</div>
|
|
||||||
}
|
|
||||||
return () => <AgentSessionInputbar agentId={activeAgentId} sessionId={activeSessionId} />
|
|
||||||
}, [activeAgentId, activeSessionId])
|
|
||||||
|
|
||||||
// TODO: more info
|
// TODO: more info
|
||||||
const AgentInvalid = useCallback(() => {
|
const AgentInvalid = useCallback(() => {
|
||||||
return <Alert type="warning" message="Select an agent" style={{ margin: '5px 16px' }} />
|
return <Alert type="warning" message="Select an agent" style={{ margin: '5px 16px' }} />
|
||||||
@@ -250,8 +227,12 @@ const Chat: FC<Props> = (props) => {
|
|||||||
{activeTopicOrSession === 'session' && activeAgentId && !activeSessionId && <SessionInvalid />}
|
{activeTopicOrSession === 'session' && activeAgentId && !activeSessionId && <SessionInvalid />}
|
||||||
{activeTopicOrSession === 'session' && activeAgentId && activeSessionId && (
|
{activeTopicOrSession === 'session' && activeAgentId && activeSessionId && (
|
||||||
<>
|
<>
|
||||||
<SessionMessages />
|
{!apiServer.enabled ? (
|
||||||
<SessionInputBar />
|
<Alert type="warning" message={t('agent.warning.enable_server')} style={{ margin: '5px 16px' }} />
|
||||||
|
) : (
|
||||||
|
<AgentSessionMessages agentId={activeAgentId} sessionId={activeSessionId} />
|
||||||
|
)}
|
||||||
|
<AgentSessionInputbar agentId={activeAgentId} sessionId={activeSessionId} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{isMultiSelectMode && <MultiSelectActionPopup topic={props.activeTopic} />}
|
{isMultiSelectMode && <MultiSelectActionPopup topic={props.activeTopic} />}
|
||||||
|
|||||||
@@ -1,63 +1,201 @@
|
|||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
import type { QuickPanelTriggerInfo } from '@renderer/components/QuickPanel'
|
||||||
import { QuickPanelView } from '@renderer/components/QuickPanel'
|
import { QuickPanelReservedSymbol, useQuickPanel } from '@renderer/components/QuickPanel'
|
||||||
import { useCreateDefaultSession } from '@renderer/hooks/agents/useCreateDefaultSession'
|
import { isGenerateImageModel, isVisionModel } from '@renderer/config/models'
|
||||||
import { useSession } from '@renderer/hooks/agents/useSession'
|
import { useSession } from '@renderer/hooks/agents/useSession'
|
||||||
|
import { useInputText } from '@renderer/hooks/useInputText'
|
||||||
import { selectNewTopicLoading } from '@renderer/hooks/useMessageOperations'
|
import { selectNewTopicLoading } from '@renderer/hooks/useMessageOperations'
|
||||||
import { getModel } from '@renderer/hooks/useModel'
|
import { getModel } from '@renderer/hooks/useModel'
|
||||||
import { useSettings } from '@renderer/hooks/useSettings'
|
import { useSettings } from '@renderer/hooks/useSettings'
|
||||||
import { useShortcutDisplay } from '@renderer/hooks/useShortcuts'
|
import { useTextareaResize } from '@renderer/hooks/useTextareaResize'
|
||||||
import { useTimer } from '@renderer/hooks/useTimer'
|
import { useTimer } from '@renderer/hooks/useTimer'
|
||||||
import PasteService from '@renderer/services/PasteService'
|
|
||||||
import { pauseTrace } from '@renderer/services/SpanManagerService'
|
import { pauseTrace } from '@renderer/services/SpanManagerService'
|
||||||
import { estimateUserPromptUsage } from '@renderer/services/TokenService'
|
import { estimateUserPromptUsage } from '@renderer/services/TokenService'
|
||||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||||
import { newMessagesActions, selectMessagesForTopic } from '@renderer/store/newMessage'
|
import { newMessagesActions, selectMessagesForTopic } from '@renderer/store/newMessage'
|
||||||
import { sendMessage as dispatchSendMessage } from '@renderer/store/thunk/messageThunk'
|
import { sendMessage as dispatchSendMessage } from '@renderer/store/thunk/messageThunk'
|
||||||
import type { Assistant, Message, Model, Topic } from '@renderer/types'
|
import type { Assistant, Message, Model, Topic } from '@renderer/types'
|
||||||
|
import type { FileType } from '@renderer/types'
|
||||||
import type { MessageBlock } from '@renderer/types/newMessage'
|
import type { MessageBlock } from '@renderer/types/newMessage'
|
||||||
import { MessageBlockStatus } from '@renderer/types/newMessage'
|
import { MessageBlockStatus } from '@renderer/types/newMessage'
|
||||||
import { classNames } from '@renderer/utils'
|
|
||||||
import { abortCompletion } from '@renderer/utils/abortController'
|
import { abortCompletion } from '@renderer/utils/abortController'
|
||||||
import { buildAgentSessionTopicId } from '@renderer/utils/agentSession'
|
import { buildAgentSessionTopicId } from '@renderer/utils/agentSession'
|
||||||
import { getSendMessageShortcutLabel, isSendMessageKeyPressed } from '@renderer/utils/input'
|
import { getSendMessageShortcutLabel } from '@renderer/utils/input'
|
||||||
import { createMainTextBlock, createMessage } from '@renderer/utils/messageUtils/create'
|
import { createMainTextBlock, createMessage } from '@renderer/utils/messageUtils/create'
|
||||||
import { Tooltip } from 'antd'
|
import { documentExts, imageExts, textExts } from '@shared/config/constant'
|
||||||
import type { TextAreaRef } from 'antd/es/input/TextArea'
|
import type { FC } from 'react'
|
||||||
import TextArea from 'antd/es/input/TextArea'
|
import React, { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||||
import { isEmpty } from 'lodash'
|
|
||||||
import { CirclePause, MessageSquareDiff } from 'lucide-react'
|
|
||||||
import type { CSSProperties, FC } from 'react'
|
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
import { v4 as uuid } from 'uuid'
|
import { v4 as uuid } from 'uuid'
|
||||||
|
|
||||||
import NarrowLayout from '../Messages/NarrowLayout'
|
import { InputbarCore } from './components/InputbarCore'
|
||||||
import SendMessageButton from './SendMessageButton'
|
import {
|
||||||
|
InputbarToolsProvider,
|
||||||
|
useInputbarToolsDispatch,
|
||||||
|
useInputbarToolsInternalDispatch,
|
||||||
|
useInputbarToolsState
|
||||||
|
} from './context/InputbarToolsProvider'
|
||||||
|
import InputbarTools from './InputbarTools'
|
||||||
|
import { getInputbarConfig } from './registry'
|
||||||
|
import { TopicType } from './types'
|
||||||
|
|
||||||
const logger = loggerService.withContext('Inputbar')
|
const logger = loggerService.withContext('AgentSessionInputbar')
|
||||||
|
const agentSessionDraftCache = new Map<string, string>()
|
||||||
|
|
||||||
|
const readDraftFromCache = (key: string): string => {
|
||||||
|
return agentSessionDraftCache.get(key) ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const writeDraftToCache = (key: string, value: string) => {
|
||||||
|
if (!value) {
|
||||||
|
agentSessionDraftCache.delete(key)
|
||||||
|
} else {
|
||||||
|
agentSessionDraftCache.set(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
agentId: string
|
agentId: string
|
||||||
sessionId: string
|
sessionId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const _text = ''
|
|
||||||
|
|
||||||
const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
|
const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
|
||||||
const [text, setText] = useState(_text)
|
|
||||||
const [inputFocus, setInputFocus] = useState(false)
|
|
||||||
const { session } = useSession(agentId, sessionId)
|
const { session } = useSession(agentId, sessionId)
|
||||||
const { apiServer } = useSettings()
|
// FIXME: 不应该使用ref将action传到context提供给tool,权宜之计
|
||||||
const { createDefaultSession, creatingSession } = useCreateDefaultSession(agentId)
|
const actionsRef = useRef({
|
||||||
const newTopicShortcut = useShortcutDisplay('new_topic')
|
resizeTextArea: () => {},
|
||||||
|
// oxlint-disable-next-line no-unused-vars
|
||||||
|
onTextChange: (_updater: React.SetStateAction<string> | ((prev: string) => string)) => {},
|
||||||
|
toggleExpanded: () => {}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create assistant stub with session data
|
||||||
|
const assistantStub = useMemo<Assistant | null>(() => {
|
||||||
|
if (!session) return null
|
||||||
|
|
||||||
|
// Extract model info
|
||||||
|
const [providerId, actualModelId] = session.model?.split(':') ?? [undefined, undefined]
|
||||||
|
const actualModel = actualModelId ? getModel(actualModelId, providerId) : undefined
|
||||||
|
|
||||||
|
const model: Model | undefined = actualModel
|
||||||
|
? {
|
||||||
|
id: actualModel.id,
|
||||||
|
name: actualModel.name,
|
||||||
|
provider: actualModel.provider,
|
||||||
|
group: actualModel.group
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: session.agent_id ?? agentId,
|
||||||
|
name: session.name ?? 'Agent Session',
|
||||||
|
prompt: session.instructions ?? '',
|
||||||
|
topics: [] as Topic[],
|
||||||
|
type: 'agent-session',
|
||||||
|
model,
|
||||||
|
defaultModel: model,
|
||||||
|
tags: [],
|
||||||
|
enableWebSearch: false
|
||||||
|
} as Assistant
|
||||||
|
}, [session, agentId])
|
||||||
|
|
||||||
|
// Prepare session data for tools
|
||||||
|
const sessionData = useMemo(() => {
|
||||||
|
if (!session) return undefined
|
||||||
|
return {
|
||||||
|
agentId,
|
||||||
|
sessionId,
|
||||||
|
slashCommands: session.slash_commands,
|
||||||
|
tools: session.tools,
|
||||||
|
accessiblePaths: session.accessible_paths ?? []
|
||||||
|
}
|
||||||
|
}, [session, agentId, sessionId])
|
||||||
|
|
||||||
|
const initialState = useMemo(
|
||||||
|
() => ({
|
||||||
|
mentionedModels: [],
|
||||||
|
selectedKnowledgeBases: [],
|
||||||
|
files: [] as FileType[],
|
||||||
|
isExpanded: false
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!assistantStub) {
|
||||||
|
return null // Wait for session to load
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InputbarToolsProvider
|
||||||
|
initialState={initialState}
|
||||||
|
actions={{
|
||||||
|
resizeTextArea: () => actionsRef.current.resizeTextArea(),
|
||||||
|
onTextChange: (updater) => actionsRef.current.onTextChange(updater),
|
||||||
|
// Agent Session specific actions
|
||||||
|
addNewTopic: () => {},
|
||||||
|
clearTopic: () => {},
|
||||||
|
onNewContext: () => {},
|
||||||
|
toggleExpanded: () => actionsRef.current.toggleExpanded()
|
||||||
|
}}>
|
||||||
|
<AgentSessionInputbarInner
|
||||||
|
assistant={assistantStub}
|
||||||
|
agentId={agentId}
|
||||||
|
sessionId={sessionId}
|
||||||
|
sessionData={sessionData}
|
||||||
|
actionsRef={actionsRef}
|
||||||
|
/>
|
||||||
|
</InputbarToolsProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InnerProps {
|
||||||
|
assistant: Assistant
|
||||||
|
agentId: string
|
||||||
|
sessionId: string
|
||||||
|
sessionData?: {
|
||||||
|
agentId?: string
|
||||||
|
sessionId?: string
|
||||||
|
slashCommands?: Array<{ command: string; description?: string }>
|
||||||
|
tools?: Array<{ id: string; name: string; type: string; description?: string }>
|
||||||
|
}
|
||||||
|
actionsRef: React.MutableRefObject<{
|
||||||
|
resizeTextArea: () => void
|
||||||
|
onTextChange: (updater: React.SetStateAction<string> | ((prev: string) => string)) => void
|
||||||
|
toggleExpanded: (nextState?: boolean) => void
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
const AgentSessionInputbarInner: FC<InnerProps> = ({ assistant, agentId, sessionId, sessionData, actionsRef }) => {
|
||||||
|
const scope = TopicType.Session
|
||||||
|
const config = getInputbarConfig(scope)
|
||||||
|
|
||||||
|
// Use shared hooks for text and textarea management
|
||||||
|
const initialDraft = useMemo(() => readDraftFromCache(agentId), [agentId])
|
||||||
|
const persistDraft = useCallback((next: string) => writeDraftToCache(agentId, next), [agentId])
|
||||||
|
const {
|
||||||
|
text,
|
||||||
|
setText,
|
||||||
|
isEmpty: inputEmpty
|
||||||
|
} = useInputText({
|
||||||
|
initialValue: initialDraft,
|
||||||
|
onChange: persistDraft
|
||||||
|
})
|
||||||
|
const {
|
||||||
|
textareaRef,
|
||||||
|
resize: resizeTextArea,
|
||||||
|
focus: focusTextarea,
|
||||||
|
setExpanded,
|
||||||
|
isExpanded: textareaIsExpanded
|
||||||
|
} = useTextareaResize({ maxHeight: 400, minHeight: 30 })
|
||||||
|
const { sendMessageShortcut, apiServer } = useSettings()
|
||||||
|
|
||||||
const { sendMessageShortcut, fontSize, enableSpellCheck } = useSettings()
|
|
||||||
const textareaRef = useRef<TextAreaRef>(null)
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const quickPanel = useQuickPanel()
|
||||||
|
|
||||||
const containerRef = useRef(null)
|
const { files } = useInputbarToolsState()
|
||||||
|
const { toolsRegistry, setIsExpanded } = useInputbarToolsDispatch()
|
||||||
|
const { setCouldAddImageFile } = useInputbarToolsInternalDispatch()
|
||||||
|
|
||||||
const { setTimeoutTimer } = useTimer()
|
const { setTimeoutTimer } = useTimer()
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
@@ -65,12 +203,152 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
|
|||||||
const topicMessages = useAppSelector((state) => selectMessagesForTopic(state, sessionTopicId))
|
const topicMessages = useAppSelector((state) => selectMessagesForTopic(state, sessionTopicId))
|
||||||
const loading = useAppSelector((state) => selectNewTopicLoading(state, sessionTopicId))
|
const loading = useAppSelector((state) => selectNewTopicLoading(state, sessionTopicId))
|
||||||
|
|
||||||
const focusTextarea = useCallback(() => {
|
// Calculate vision and image generation support
|
||||||
textareaRef.current?.focus()
|
const isVisionAssistant = useMemo(() => (assistant.model ? isVisionModel(assistant.model) : false), [assistant.model])
|
||||||
}, [])
|
const isGenerateImageAssistant = useMemo(
|
||||||
|
() => (assistant.model ? isGenerateImageModel(assistant.model) : false),
|
||||||
|
[assistant.model]
|
||||||
|
)
|
||||||
|
|
||||||
const inputEmpty = isEmpty(text)
|
// Agent sessions don't support model mentions yet, so we only check the assistant's model
|
||||||
const sendDisabled = inputEmpty || !apiServer.enabled
|
const canAddImageFile = useMemo(() => {
|
||||||
|
return isVisionAssistant || isGenerateImageAssistant
|
||||||
|
}, [isVisionAssistant, isGenerateImageAssistant])
|
||||||
|
|
||||||
|
const canAddTextFile = useMemo(() => {
|
||||||
|
return isVisionAssistant || (!isVisionAssistant && !isGenerateImageAssistant)
|
||||||
|
}, [isVisionAssistant, isGenerateImageAssistant])
|
||||||
|
|
||||||
|
// Update the couldAddImageFile state when the model changes
|
||||||
|
useEffect(() => {
|
||||||
|
setCouldAddImageFile(canAddImageFile)
|
||||||
|
}, [canAddImageFile, setCouldAddImageFile])
|
||||||
|
|
||||||
|
const syncExpandedState = useCallback(
|
||||||
|
(expanded: boolean) => {
|
||||||
|
setExpanded(expanded)
|
||||||
|
setIsExpanded(expanded)
|
||||||
|
},
|
||||||
|
[setExpanded, setIsExpanded]
|
||||||
|
)
|
||||||
|
const handleToggleExpanded = useCallback(
|
||||||
|
(nextState?: boolean) => {
|
||||||
|
const target = typeof nextState === 'boolean' ? nextState : !textareaIsExpanded
|
||||||
|
syncExpandedState(target)
|
||||||
|
focusTextarea()
|
||||||
|
},
|
||||||
|
[focusTextarea, syncExpandedState, textareaIsExpanded]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Update actionsRef for InputbarTools
|
||||||
|
useEffect(() => {
|
||||||
|
actionsRef.current = {
|
||||||
|
resizeTextArea,
|
||||||
|
onTextChange: setText,
|
||||||
|
toggleExpanded: handleToggleExpanded
|
||||||
|
}
|
||||||
|
}, [resizeTextArea, setText, actionsRef, handleToggleExpanded])
|
||||||
|
|
||||||
|
const rootTriggerHandlerRef = useRef<((payload?: unknown) => void) | undefined>(undefined)
|
||||||
|
|
||||||
|
// Update handler logic when dependencies change
|
||||||
|
// For Agent Session, we directly trigger SlashCommands panel instead of Root menu
|
||||||
|
useEffect(() => {
|
||||||
|
rootTriggerHandlerRef.current = (payload) => {
|
||||||
|
const slashCommands = sessionData?.slashCommands || []
|
||||||
|
const triggerInfo = (payload ?? {}) as QuickPanelTriggerInfo
|
||||||
|
|
||||||
|
if (slashCommands.length === 0) {
|
||||||
|
quickPanel.open({
|
||||||
|
title: t('chat.input.slash_commands.title'),
|
||||||
|
symbol: QuickPanelReservedSymbol.SlashCommands,
|
||||||
|
triggerInfo,
|
||||||
|
list: [
|
||||||
|
{
|
||||||
|
label: t('chat.input.slash_commands.empty', 'No slash commands available'),
|
||||||
|
description: '',
|
||||||
|
icon: null,
|
||||||
|
disabled: true,
|
||||||
|
action: () => {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
quickPanel.open({
|
||||||
|
title: t('chat.input.slash_commands.title'),
|
||||||
|
symbol: QuickPanelReservedSymbol.SlashCommands,
|
||||||
|
triggerInfo,
|
||||||
|
list: slashCommands.map((cmd) => ({
|
||||||
|
label: cmd.command,
|
||||||
|
description: cmd.description || '',
|
||||||
|
icon: null,
|
||||||
|
filterText: `${cmd.command} ${cmd.description || ''}`,
|
||||||
|
action: () => {
|
||||||
|
// Insert command into textarea
|
||||||
|
setText((prev: string) => {
|
||||||
|
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement | null
|
||||||
|
if (!textArea) {
|
||||||
|
return prev + ' ' + cmd.command
|
||||||
|
}
|
||||||
|
|
||||||
|
const cursorPosition = textArea.selectionStart || 0
|
||||||
|
const textBeforeCursor = prev.slice(0, cursorPosition)
|
||||||
|
const lastSlashIndex = textBeforeCursor.lastIndexOf('/')
|
||||||
|
|
||||||
|
if (lastSlashIndex !== -1 && cursorPosition > lastSlashIndex) {
|
||||||
|
// Replace from '/' to cursor with command
|
||||||
|
const newText = prev.slice(0, lastSlashIndex) + cmd.command + ' ' + prev.slice(cursorPosition)
|
||||||
|
const newCursorPos = lastSlashIndex + cmd.command.length + 1
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (textArea) {
|
||||||
|
textArea.focus()
|
||||||
|
textArea.setSelectionRange(newCursorPos, newCursorPos)
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
return newText
|
||||||
|
}
|
||||||
|
|
||||||
|
// No '/' found, just insert at cursor
|
||||||
|
const newText = prev.slice(0, cursorPosition) + cmd.command + ' ' + prev.slice(cursorPosition)
|
||||||
|
const newCursorPos = cursorPosition + cmd.command.length + 1
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (textArea) {
|
||||||
|
textArea.focus()
|
||||||
|
textArea.setSelectionRange(newCursorPos, newCursorPos)
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
return newText
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [sessionData, quickPanel, t, setText])
|
||||||
|
|
||||||
|
// Register the trigger handler (only once)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!config.enableQuickPanel) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const disposeRootTrigger = toolsRegistry.registerTrigger(
|
||||||
|
'agent-session-root',
|
||||||
|
QuickPanelReservedSymbol.Root,
|
||||||
|
(payload) => rootTriggerHandlerRef.current?.(payload)
|
||||||
|
)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposeRootTrigger()
|
||||||
|
}
|
||||||
|
}, [config.enableQuickPanel, toolsRegistry])
|
||||||
|
|
||||||
|
const sendDisabled = (inputEmpty && files.length === 0) || !apiServer.enabled
|
||||||
|
|
||||||
const streamingAskIds = useMemo(() => {
|
const streamingAskIds = useMemo(() => {
|
||||||
if (!topicMessages) {
|
if (!topicMessages) {
|
||||||
@@ -93,64 +371,6 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
|
|||||||
}, [topicMessages])
|
}, [topicMessages])
|
||||||
|
|
||||||
const canAbort = loading && streamingAskIds.length > 0
|
const canAbort = loading && streamingAskIds.length > 0
|
||||||
const createSessionDisabled = creatingSession || !apiServer.enabled
|
|
||||||
|
|
||||||
const handleCreateSession = useCallback(async () => {
|
|
||||||
if (createSessionDisabled) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const created = await createDefaultSession()
|
|
||||||
if (created) {
|
|
||||||
focusTextarea()
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn('Failed to create agent session via toolbar:', error as Error)
|
|
||||||
}
|
|
||||||
}, [createDefaultSession, createSessionDisabled, focusTextarea])
|
|
||||||
|
|
||||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
||||||
//to check if the SendMessage key is pressed
|
|
||||||
//other keys should be ignored
|
|
||||||
const isEnterPressed = event.key === 'Enter' && !event.nativeEvent.isComposing
|
|
||||||
if (isEnterPressed) {
|
|
||||||
// 1) 优先判断是否为“发送”(当前仅支持纯 Enter 发送;其余 Enter 组合键均换行)
|
|
||||||
if (isSendMessageKeyPressed(event, sendMessageShortcut)) {
|
|
||||||
sendMessage()
|
|
||||||
return event.preventDefault()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) 不再基于 quickPanel.isVisible 主动拦截。
|
|
||||||
// 纯 Enter 的处理权交由 QuickPanel 的全局捕获(其只在纯 Enter 时拦截),
|
|
||||||
// 其它带修饰键的 Enter 则由输入框处理为换行。
|
|
||||||
|
|
||||||
if (event.shiftKey) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault()
|
|
||||||
const textArea = textareaRef.current?.resizableTextArea?.textArea
|
|
||||||
if (textArea) {
|
|
||||||
const start = textArea.selectionStart
|
|
||||||
const end = textArea.selectionEnd
|
|
||||||
const text = textArea.value
|
|
||||||
const newText = text.substring(0, start) + '\n' + text.substring(end)
|
|
||||||
|
|
||||||
// update text by setState, not directly modify textarea.value
|
|
||||||
setText(newText)
|
|
||||||
|
|
||||||
// set cursor position in the next render cycle
|
|
||||||
setTimeoutTimer(
|
|
||||||
'handleKeyDown',
|
|
||||||
() => {
|
|
||||||
textArea.selectionStart = textArea.selectionEnd = start + 1
|
|
||||||
},
|
|
||||||
0
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const abortAgentSession = useCallback(async () => {
|
const abortAgentSession = useCallback(async () => {
|
||||||
if (!streamingAskIds.length) {
|
if (!streamingAskIds.length) {
|
||||||
@@ -180,79 +400,43 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const userMessageId = uuid()
|
const userMessageId = uuid()
|
||||||
const mainBlock = createMainTextBlock(userMessageId, text, {
|
|
||||||
|
// For agent sessions, append file paths to the text content instead of uploading files
|
||||||
|
let messageText = text
|
||||||
|
if (files.length > 0) {
|
||||||
|
const filePaths = files.map((file) => file.path).join('\n')
|
||||||
|
messageText = text ? `${text}\n\nAttached files:\n${filePaths}` : `Attached files:\n${filePaths}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainBlock = createMainTextBlock(userMessageId, messageText, {
|
||||||
status: MessageBlockStatus.SUCCESS
|
status: MessageBlockStatus.SUCCESS
|
||||||
})
|
})
|
||||||
const userMessageBlocks: MessageBlock[] = [mainBlock]
|
const userMessageBlocks: MessageBlock[] = [mainBlock]
|
||||||
|
|
||||||
// Extract the actual model ID from session.model (format: "provider:modelId")
|
|
||||||
const [providerId, actualModelId] = session?.model?.split(':') ?? [undefined, undefined]
|
|
||||||
|
|
||||||
// Try to find the actual model from providers
|
|
||||||
const actualModel = actualModelId ? getModel(actualModelId, providerId) : undefined
|
|
||||||
|
|
||||||
const model: Model | undefined = actualModel
|
|
||||||
? {
|
|
||||||
id: actualModel.id,
|
|
||||||
name: actualModel.name, // Use actual model name if found
|
|
||||||
provider: actualModel.provider,
|
|
||||||
group: actualModel.group
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
// Calculate token usage for the user message
|
// Calculate token usage for the user message
|
||||||
const usage = await estimateUserPromptUsage({ content: text })
|
const usage = await estimateUserPromptUsage({ content: text })
|
||||||
|
|
||||||
const userMessage: Message = createMessage('user', sessionTopicId, agentId, {
|
const userMessage: Message = createMessage('user', sessionTopicId, agentId, {
|
||||||
id: userMessageId,
|
id: userMessageId,
|
||||||
blocks: userMessageBlocks.map((block) => block?.id),
|
blocks: userMessageBlocks.map((block) => block?.id),
|
||||||
model,
|
model: assistant.model,
|
||||||
modelId: model?.id,
|
modelId: assistant.model?.id,
|
||||||
usage
|
usage
|
||||||
})
|
})
|
||||||
|
|
||||||
const assistantStub: Assistant = {
|
|
||||||
id: session?.agent_id ?? agentId,
|
|
||||||
name: session?.name ?? 'Agent Session',
|
|
||||||
prompt: session?.instructions ?? '',
|
|
||||||
topics: [] as Topic[],
|
|
||||||
type: 'agent-session',
|
|
||||||
model,
|
|
||||||
defaultModel: model,
|
|
||||||
tags: [],
|
|
||||||
enableWebSearch: false
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(
|
dispatch(
|
||||||
dispatchSendMessage(userMessage, userMessageBlocks, assistantStub, sessionTopicId, {
|
dispatchSendMessage(userMessage, userMessageBlocks, assistant, sessionTopicId, {
|
||||||
agentId,
|
agentId,
|
||||||
sessionId
|
sessionId
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
setText('')
|
setText('')
|
||||||
setTimeoutTimer('sendMessage_1', () => setText(''), 500)
|
setTimeoutTimer('agentSession_sendMessage', () => setText(''), 500)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('Failed to send message:', error as Error)
|
logger.warn('Failed to send message:', error as Error)
|
||||||
}
|
}
|
||||||
}, [
|
}, [sendDisabled, agentId, dispatch, assistant, sessionId, sessionTopicId, setText, setTimeoutTimer, text, files])
|
||||||
session?.model,
|
|
||||||
agentId,
|
|
||||||
dispatch,
|
|
||||||
sendDisabled,
|
|
||||||
session?.agent_id,
|
|
||||||
session?.instructions,
|
|
||||||
session?.name,
|
|
||||||
sessionId,
|
|
||||||
sessionTopicId,
|
|
||||||
setTimeoutTimer,
|
|
||||||
text
|
|
||||||
])
|
|
||||||
|
|
||||||
const onChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
||||||
const newText = e.target.value
|
|
||||||
setText(newText)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!document.querySelector('.topview-fullscreen-container')) {
|
if (!document.querySelector('.topview-fullscreen-container')) {
|
||||||
@@ -260,137 +444,57 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
|
|||||||
}
|
}
|
||||||
}, [focusTextarea])
|
}, [focusTextarea])
|
||||||
|
|
||||||
useEffect(() => {
|
const supportedExts = useMemo(() => {
|
||||||
const onFocus = () => {
|
if (canAddImageFile && canAddTextFile) {
|
||||||
if (document.activeElement?.closest('.ant-modal')) {
|
return [...imageExts, ...documentExts, ...textExts]
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastFocusedComponent = PasteService.getLastFocusedComponent()
|
|
||||||
|
|
||||||
if (!lastFocusedComponent || lastFocusedComponent === 'inputbar') {
|
|
||||||
focusTextarea()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
window.addEventListener('focus', onFocus)
|
|
||||||
return () => window.removeEventListener('focus', onFocus)
|
if (canAddImageFile) {
|
||||||
}, [focusTextarea])
|
return [...imageExts]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canAddTextFile) {
|
||||||
|
return [...documentExts, ...textExts]
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}, [canAddImageFile, canAddTextFile])
|
||||||
|
|
||||||
|
const leftToolbar = useMemo(
|
||||||
|
() => (
|
||||||
|
<ToolbarGroup>
|
||||||
|
{config.showTools && <InputbarTools scope={scope} assistantId={assistant.id} session={sessionData} />}
|
||||||
|
</ToolbarGroup>
|
||||||
|
),
|
||||||
|
[config.showTools, scope, assistant.id, sessionData]
|
||||||
|
)
|
||||||
|
const placeholderText = useMemo(
|
||||||
|
() =>
|
||||||
|
t('chat.input.placeholder', {
|
||||||
|
key: getSendMessageShortcutLabel(sendMessageShortcut)
|
||||||
|
}),
|
||||||
|
[sendMessageShortcut, t]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NarrowLayout style={{ width: '100%' }}>
|
<InputbarCore
|
||||||
<Container className="inputbar">
|
scope={TopicType.Session}
|
||||||
<QuickPanelView setInputText={setText} />
|
text={text}
|
||||||
<InputBarContainer
|
onTextChange={setText}
|
||||||
id="inputbar"
|
textareaRef={textareaRef}
|
||||||
className={classNames('inputbar-container', inputFocus && 'focus')}
|
resizeTextArea={resizeTextArea}
|
||||||
ref={containerRef}>
|
focusTextarea={focusTextarea}
|
||||||
<Textarea
|
placeholder={placeholderText}
|
||||||
value={text}
|
supportedExts={supportedExts}
|
||||||
onChange={onChange}
|
onPause={abortAgentSession}
|
||||||
onKeyDown={handleKeyDown}
|
isLoading={canAbort}
|
||||||
placeholder={t('chat.input.placeholder_without_triggers', {
|
handleSendMessage={sendMessage}
|
||||||
key: getSendMessageShortcutLabel(sendMessageShortcut)
|
leftToolbar={leftToolbar}
|
||||||
})}
|
forceEnableQuickPanelTriggers
|
||||||
autoFocus
|
/>
|
||||||
variant="borderless"
|
|
||||||
spellCheck={enableSpellCheck}
|
|
||||||
rows={2}
|
|
||||||
autoSize={{ minRows: 2, maxRows: 20 }}
|
|
||||||
ref={textareaRef}
|
|
||||||
style={{
|
|
||||||
fontSize,
|
|
||||||
minHeight: '30px'
|
|
||||||
}}
|
|
||||||
styles={{ textarea: TextareaStyle }}
|
|
||||||
onFocus={(e: React.FocusEvent<HTMLTextAreaElement>) => {
|
|
||||||
setInputFocus(true)
|
|
||||||
// 记录当前聚焦的组件
|
|
||||||
PasteService.setLastFocusedComponent('inputbar')
|
|
||||||
if (e.target.value.length === 0) {
|
|
||||||
e.target.setSelectionRange(0, 0)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onBlur={() => setInputFocus(false)}
|
|
||||||
/>
|
|
||||||
<Toolbar>
|
|
||||||
<ToolbarGroup>
|
|
||||||
<Tooltip placement="top" title={t('chat.input.new_topic', { Command: newTopicShortcut })}>
|
|
||||||
<ActionIconButton
|
|
||||||
onClick={handleCreateSession}
|
|
||||||
disabled={createSessionDisabled}
|
|
||||||
loading={creatingSession}>
|
|
||||||
<MessageSquareDiff size={19} />
|
|
||||||
</ActionIconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</ToolbarGroup>
|
|
||||||
<ToolbarGroup>
|
|
||||||
<SendMessageButton sendMessage={sendMessage} disabled={sendDisabled} />
|
|
||||||
{canAbort && (
|
|
||||||
<Tooltip placement="top" title={t('chat.input.pause')}>
|
|
||||||
<ActionIconButton onClick={abortAgentSession} style={{ marginRight: -2 }}>
|
|
||||||
<CirclePause size={20} color="var(--color-error)" />
|
|
||||||
</ActionIconButton>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</ToolbarGroup>
|
|
||||||
</Toolbar>
|
|
||||||
</InputBarContainer>
|
|
||||||
</Container>
|
|
||||||
</NarrowLayout>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add these styled components at the bottom
|
|
||||||
|
|
||||||
const Container = styled.div`
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
padding: 0 18px 18px 18px;
|
|
||||||
[navbar-position='top'] & {
|
|
||||||
padding: 0 18px 10px 18px;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
const InputBarContainer = styled.div`
|
|
||||||
border: 0.5px solid var(--color-border);
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
position: relative;
|
|
||||||
border-radius: 17px;
|
|
||||||
padding-top: 8px; // 为拖动手柄留出空间
|
|
||||||
background-color: var(--color-background-opacity);
|
|
||||||
|
|
||||||
&.file-dragging {
|
|
||||||
border: 2px dashed #2ecc71;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background-color: rgba(46, 204, 113, 0.03);
|
|
||||||
border-radius: 14px;
|
|
||||||
z-index: 5;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
const Toolbar = styled.div`
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 5px 8px;
|
|
||||||
height: 40px;
|
|
||||||
gap: 16px;
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
flex-shrink: 0;
|
|
||||||
`
|
|
||||||
|
|
||||||
const ToolbarGroup = styled.div`
|
const ToolbarGroup = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@@ -398,26 +502,4 @@ const ToolbarGroup = styled.div`
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
`
|
`
|
||||||
|
|
||||||
const TextareaStyle: CSSProperties = {
|
|
||||||
paddingLeft: 0,
|
|
||||||
padding: '6px 15px 0px' // 减小顶部padding
|
|
||||||
}
|
|
||||||
|
|
||||||
const Textarea = styled(TextArea)`
|
|
||||||
padding: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
display: flex;
|
|
||||||
resize: none !important;
|
|
||||||
overflow: auto;
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
transition: none !important;
|
|
||||||
&.ant-input {
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
&::-webkit-scrollbar {
|
|
||||||
width: 3px;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
export default AgentSessionInputbar
|
export default AgentSessionInputbar
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,205 +1,241 @@
|
|||||||
|
import '@renderer/pages/home/Inputbar/tools'
|
||||||
|
|
||||||
import type { DropResult } from '@hello-pangea/dnd'
|
import type { DropResult } from '@hello-pangea/dnd'
|
||||||
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd'
|
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd'
|
||||||
import { loggerService } from '@logger'
|
|
||||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||||
import { MdiLightbulbOn } from '@renderer/components/Icons'
|
import type { QuickPanelListItem, QuickPanelReservedSymbol } from '@renderer/components/QuickPanel'
|
||||||
import type { QuickPanelListItem } from '@renderer/components/QuickPanel'
|
import { useQuickPanel } from '@renderer/components/QuickPanel'
|
||||||
import {
|
|
||||||
isAnthropicModel,
|
|
||||||
isGeminiModel,
|
|
||||||
isGenerateImageModel,
|
|
||||||
isMandatoryWebSearchModel,
|
|
||||||
isSupportedReasoningEffortModel,
|
|
||||||
isSupportedThinkingTokenModel,
|
|
||||||
isVisionModel
|
|
||||||
} from '@renderer/config/models'
|
|
||||||
import { isSupportUrlContextProvider } from '@renderer/config/providers'
|
|
||||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||||
import { useShortcutDisplay } from '@renderer/hooks/useShortcuts'
|
import { useInputbarTools } from '@renderer/pages/home/Inputbar/context/InputbarToolsProvider'
|
||||||
import { useSidebarIconShow } from '@renderer/hooks/useSidebarIcon'
|
import type {
|
||||||
import { getProviderByModel } from '@renderer/services/AssistantService'
|
InputbarScope,
|
||||||
import { getModelUniqId } from '@renderer/services/ModelService'
|
ToolActionKey,
|
||||||
|
ToolActionMap,
|
||||||
|
ToolDefinition,
|
||||||
|
ToolOrderConfig,
|
||||||
|
ToolQuickPanelApi,
|
||||||
|
ToolRenderContext,
|
||||||
|
ToolStateKey,
|
||||||
|
ToolStateMap
|
||||||
|
} from '@renderer/pages/home/Inputbar/types'
|
||||||
|
import { getToolsForScope } from '@renderer/pages/home/Inputbar/types'
|
||||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||||
import { setIsCollapsed, setToolOrder } from '@renderer/store/inputTools'
|
import { selectToolOrderForScope, setIsCollapsed, setToolOrder } from '@renderer/store/inputTools'
|
||||||
import type { FileType, KnowledgeBase, Model } from '@renderer/types'
|
|
||||||
import { FileTypes } from '@renderer/types'
|
|
||||||
import type { InputBarToolType } from '@renderer/types/chat'
|
import type { InputBarToolType } from '@renderer/types/chat'
|
||||||
import { classNames } from '@renderer/utils'
|
import { classNames } from '@renderer/utils'
|
||||||
import { isPromptToolUse, isSupportedToolUse } from '@renderer/utils/mcp-tools'
|
import { Divider, Dropdown } from 'antd'
|
||||||
import { Divider, Dropdown, Tooltip } from 'antd'
|
|
||||||
import type { ItemType } from 'antd/es/menu/interface'
|
import type { ItemType } from 'antd/es/menu/interface'
|
||||||
import {
|
import { Check, CircleChevronRight } from 'lucide-react'
|
||||||
AtSign,
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
Check,
|
|
||||||
CircleChevronRight,
|
|
||||||
FileSearch,
|
|
||||||
Globe,
|
|
||||||
Hammer,
|
|
||||||
Languages,
|
|
||||||
Link,
|
|
||||||
Maximize,
|
|
||||||
MessageSquareDiff,
|
|
||||||
Minimize,
|
|
||||||
PaintbrushVertical,
|
|
||||||
Paperclip,
|
|
||||||
Zap
|
|
||||||
} from 'lucide-react'
|
|
||||||
import type { Dispatch, ReactNode, SetStateAction } from 'react'
|
|
||||||
import { useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
import type { AttachmentButtonRef } from './AttachmentButton'
|
export interface InputbarToolsNewProps {
|
||||||
import AttachmentButton from './AttachmentButton'
|
scope: InputbarScope
|
||||||
import GenerateImageButton from './GenerateImageButton'
|
|
||||||
import type { KnowledgeBaseButtonRef } from './KnowledgeBaseButton'
|
|
||||||
import KnowledgeBaseButton from './KnowledgeBaseButton'
|
|
||||||
import type { MCPToolsButtonRef } from './MCPToolsButton'
|
|
||||||
import MCPToolsButton from './MCPToolsButton'
|
|
||||||
import type { MentionModelsButtonRef } from './MentionModelsButton'
|
|
||||||
import MentionModelsButton from './MentionModelsButton'
|
|
||||||
import NewContextButton from './NewContextButton'
|
|
||||||
import type { QuickPhrasesButtonRef } from './QuickPhrasesButton'
|
|
||||||
import QuickPhrasesButton from './QuickPhrasesButton'
|
|
||||||
import type { ThinkingButtonRef } from './ThinkingButton'
|
|
||||||
import ThinkingButton from './ThinkingButton'
|
|
||||||
import type { UrlContextButtonRef } from './UrlContextbutton'
|
|
||||||
import UrlContextButton from './UrlContextbutton'
|
|
||||||
import type { WebSearchButtonRef } from './WebSearchButton'
|
|
||||||
import WebSearchButton from './WebSearchButton'
|
|
||||||
|
|
||||||
const logger = loggerService.withContext('InputbarTools')
|
|
||||||
|
|
||||||
export interface InputbarToolsRef {
|
|
||||||
getQuickPanelMenu: (params: { text: string; translate: () => void }) => QuickPanelListItem[]
|
|
||||||
openMentionModelsPanel: (triggerInfo?: { type: 'input' | 'button'; position?: number; originalText?: string }) => void
|
|
||||||
openAttachmentQuickPanel: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InputbarToolsProps {
|
|
||||||
assistantId: string
|
assistantId: string
|
||||||
model: Model
|
// Session data for Agent Session scope (optional)
|
||||||
files: FileType[]
|
session?: {
|
||||||
setFiles: Dispatch<SetStateAction<FileType[]>>
|
agentId?: string
|
||||||
extensions: string[]
|
sessionId?: string
|
||||||
setText: Dispatch<SetStateAction<string>>
|
slashCommands?: Array<{ command: string; description?: string }>
|
||||||
resizeTextArea: () => void
|
tools?: Array<{ id: string; name: string; type: string; description?: string }>
|
||||||
selectedKnowledgeBases: KnowledgeBase[]
|
}
|
||||||
setSelectedKnowledgeBases: Dispatch<SetStateAction<KnowledgeBase[]>>
|
|
||||||
mentionedModels: Model[]
|
|
||||||
setMentionedModels: Dispatch<SetStateAction<Model[]>>
|
|
||||||
couldAddImageFile: boolean
|
|
||||||
isExpanded: boolean
|
|
||||||
onToggleExpanded: () => void
|
|
||||||
|
|
||||||
addNewTopic: () => void
|
|
||||||
clearTopic: () => void
|
|
||||||
onNewContext: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ToolButtonConfig {
|
interface ToolConfig {
|
||||||
key: InputBarToolType
|
key: InputBarToolType
|
||||||
component: ReactNode
|
label: string
|
||||||
condition?: boolean
|
tool: ToolDefinition
|
||||||
visible?: boolean
|
visible: boolean
|
||||||
label?: string
|
|
||||||
icon?: ReactNode
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DraggablePortal = ({ children, isDragging }) => {
|
const DraggablePortal = ({ children, isDragging }: { children: React.ReactNode; isDragging: boolean }) => {
|
||||||
return isDragging ? createPortal(children, document.body) : children
|
return isDragging ? createPortal(children, document.body) : children
|
||||||
}
|
}
|
||||||
|
|
||||||
const InputbarTools = ({
|
const InputbarTools = ({ scope, assistantId, session }: InputbarToolsNewProps) => {
|
||||||
ref,
|
|
||||||
assistantId,
|
|
||||||
model,
|
|
||||||
files,
|
|
||||||
setFiles,
|
|
||||||
setText,
|
|
||||||
resizeTextArea,
|
|
||||||
selectedKnowledgeBases,
|
|
||||||
setSelectedKnowledgeBases,
|
|
||||||
mentionedModels,
|
|
||||||
setMentionedModels,
|
|
||||||
couldAddImageFile,
|
|
||||||
isExpanded: isExpended,
|
|
||||||
onToggleExpanded: onToggleExpended,
|
|
||||||
addNewTopic,
|
|
||||||
clearTopic,
|
|
||||||
onNewContext,
|
|
||||||
extensions
|
|
||||||
}: InputbarToolsProps & { ref?: React.RefObject<InputbarToolsRef | null> }) => {
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
const { assistant, updateAssistant } = useAssistant(assistantId)
|
const { assistant, model } = useAssistant(assistantId)
|
||||||
|
const toolsContext = useInputbarTools()
|
||||||
|
const quickPanelContext = useQuickPanel()
|
||||||
|
const quickPanelApiCacheRef = useRef(new Map<string, ToolQuickPanelApi>())
|
||||||
|
|
||||||
const quickPhrasesButtonRef = useRef<QuickPhrasesButtonRef>(null)
|
const getQuickPanelApiForTool = useCallback(
|
||||||
const mentionModelsButtonRef = useRef<MentionModelsButtonRef>(null)
|
(toolKey: string): ToolQuickPanelApi => {
|
||||||
const knowledgeBaseButtonRef = useRef<KnowledgeBaseButtonRef>(null)
|
const cache = quickPanelApiCacheRef.current
|
||||||
const mcpToolsButtonRef = useRef<MCPToolsButtonRef>(null)
|
|
||||||
const attachmentButtonRef = useRef<AttachmentButtonRef>(null)
|
|
||||||
const webSearchButtonRef = useRef<WebSearchButtonRef | null>(null)
|
|
||||||
const thinkingButtonRef = useRef<ThinkingButtonRef | null>(null)
|
|
||||||
const urlContextButtonRef = useRef<UrlContextButtonRef | null>(null)
|
|
||||||
|
|
||||||
const toolOrder = useAppSelector((state) => state.inputTools.toolOrder)
|
if (!cache.has(toolKey)) {
|
||||||
const isCollapse = useAppSelector((state) => state.inputTools.isCollapsed)
|
cache.set(toolKey, {
|
||||||
|
registerRootMenu: (entries: QuickPanelListItem[]) =>
|
||||||
const [targetTool, setTargetTool] = useState<ToolButtonConfig | null>(null)
|
toolsContext.toolsRegistry.registerRootMenu(toolKey, entries),
|
||||||
|
registerTrigger: (symbol: QuickPanelReservedSymbol, handler: (payload?: unknown) => void) =>
|
||||||
const showThinkingButton = useMemo(
|
toolsContext.toolsRegistry.registerTrigger(toolKey, symbol, handler)
|
||||||
() => isSupportedThinkingTokenModel(model) || isSupportedReasoningEffortModel(model),
|
|
||||||
[model]
|
|
||||||
)
|
|
||||||
|
|
||||||
const showMcpServerButton = useMemo(() => isSupportedToolUse(assistant) || isPromptToolUse(assistant), [assistant])
|
|
||||||
|
|
||||||
const knowledgeSidebarEnabled = useSidebarIconShow('knowledge')
|
|
||||||
const showKnowledgeBaseButton = knowledgeSidebarEnabled && showMcpServerButton
|
|
||||||
|
|
||||||
const handleKnowledgeBaseSelect = useCallback(
|
|
||||||
(bases?: KnowledgeBase[]) => {
|
|
||||||
updateAssistant({ knowledge_bases: bases })
|
|
||||||
setSelectedKnowledgeBases(bases ?? [])
|
|
||||||
},
|
|
||||||
[setSelectedKnowledgeBases, updateAssistant]
|
|
||||||
)
|
|
||||||
|
|
||||||
// 仅允许在不含图片文件时mention非视觉模型
|
|
||||||
const couldMentionNotVisionModel = useMemo(() => {
|
|
||||||
return !files.some((file) => file.type === FileTypes.IMAGE)
|
|
||||||
}, [files])
|
|
||||||
|
|
||||||
const onMentionModel = useCallback(
|
|
||||||
(model: Model) => {
|
|
||||||
// 我想应该没有模型是只支持视觉而不支持文本的?
|
|
||||||
if (isVisionModel(model) || couldMentionNotVisionModel) {
|
|
||||||
setMentionedModels((prev) => {
|
|
||||||
const modelId = getModelUniqId(model)
|
|
||||||
const exists = prev.some((m) => getModelUniqId(m) === modelId)
|
|
||||||
return exists ? prev.filter((m) => getModelUniqId(m) !== modelId) : [...prev, model]
|
|
||||||
})
|
})
|
||||||
} else {
|
|
||||||
logger.error('Cannot add non-vision model when images are uploaded')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return cache.get(toolKey)!
|
||||||
},
|
},
|
||||||
[couldMentionNotVisionModel, setMentionedModels]
|
[toolsContext.toolsRegistry]
|
||||||
)
|
)
|
||||||
|
|
||||||
const onClearMentionModels = useCallback(() => setMentionedModels([]), [setMentionedModels])
|
const reduxToolOrder = useAppSelector((state) => selectToolOrderForScope(state, scope))
|
||||||
|
const isCollapse = useAppSelector((state) => state.inputTools.isCollapsed)
|
||||||
|
const [targetTool, setTargetTool] = useState<ToolConfig | null>(null)
|
||||||
|
|
||||||
const onEnableGenerateImage = useCallback(() => {
|
// Get tools for current scope
|
||||||
updateAssistant({ enableGenerateImage: !assistant.enableGenerateImage })
|
const availableTools = useMemo(() => {
|
||||||
}, [assistant.enableGenerateImage, updateAssistant])
|
return getToolsForScope(scope, { assistant, model, session })
|
||||||
|
}, [scope, assistant, model, session])
|
||||||
|
|
||||||
const newTopicShortcut = useShortcutDisplay('new_topic')
|
// Get tool order for current scope
|
||||||
const clearTopicShortcut = useShortcutDisplay('clear_topic')
|
const toolOrder = useMemo(() => {
|
||||||
|
return reduxToolOrder
|
||||||
|
}, [reduxToolOrder])
|
||||||
|
|
||||||
|
// Build render context for tools
|
||||||
|
const buildRenderContext = useCallback(
|
||||||
|
<S extends readonly ToolStateKey[], A extends readonly ToolActionKey[]>(
|
||||||
|
tool: ToolDefinition<S, A>
|
||||||
|
): ToolRenderContext<S, A> => {
|
||||||
|
const deps = tool.dependencies
|
||||||
|
// 为工具提供完整的 QuickPanel API(注册 + 控制面板)
|
||||||
|
const quickPanel = getQuickPanelApiForTool(tool.key)
|
||||||
|
|
||||||
|
const state = (deps?.state || ([] as unknown as S)).reduce(
|
||||||
|
(acc, key) => {
|
||||||
|
acc[key] = toolsContext[key]
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{} as Pick<ToolStateMap, S[number]>
|
||||||
|
)
|
||||||
|
|
||||||
|
const actions = (deps?.actions || ([] as unknown as A)).reduce(
|
||||||
|
(acc, key) => {
|
||||||
|
const actionValue = toolsContext[key]
|
||||||
|
if (actionValue) {
|
||||||
|
acc[key] = actionValue
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{} as Pick<ToolActionMap, A[number]>
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
scope,
|
||||||
|
assistant,
|
||||||
|
model,
|
||||||
|
session,
|
||||||
|
state,
|
||||||
|
actions,
|
||||||
|
quickPanel,
|
||||||
|
quickPanelController: quickPanelContext,
|
||||||
|
t
|
||||||
|
} as ToolRenderContext<S, A>
|
||||||
|
},
|
||||||
|
[assistant, model, quickPanelContext, scope, session, t, toolsContext, getQuickPanelApiForTool]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build tool metadata (without rendering)
|
||||||
|
// Tools with render: null are pure menu contributors and won't appear in UI
|
||||||
|
const toolMetadata = useMemo(() => {
|
||||||
|
return availableTools.map((tool) => ({
|
||||||
|
key: tool.key as InputBarToolType,
|
||||||
|
label: typeof tool.label === 'function' ? tool.label(t) : tool.label,
|
||||||
|
tool
|
||||||
|
}))
|
||||||
|
}, [availableTools, t])
|
||||||
|
|
||||||
|
// Declarative tools registration (for tools with quickPanel config)
|
||||||
|
// This handles pure menu contributors and trigger handlers
|
||||||
|
useEffect(() => {
|
||||||
|
const disposeCallbacks: Array<() => void> = []
|
||||||
|
|
||||||
|
for (const tool of availableTools) {
|
||||||
|
if (!tool.quickPanel) continue
|
||||||
|
|
||||||
|
const context = buildRenderContext(tool)
|
||||||
|
|
||||||
|
// Register root menu items (declarative)
|
||||||
|
if (tool.quickPanel.rootMenu) {
|
||||||
|
const menuItems = tool.quickPanel.rootMenu.createMenuItems(context)
|
||||||
|
const dispose = toolsContext.toolsRegistry.registerRootMenu(tool.key, menuItems)
|
||||||
|
disposeCallbacks.push(dispose)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register triggers (declarative)
|
||||||
|
if (tool.quickPanel.triggers) {
|
||||||
|
for (const triggerConfig of tool.quickPanel.triggers) {
|
||||||
|
const handler = triggerConfig.createHandler(context)
|
||||||
|
const dispose = toolsContext.toolsRegistry.registerTrigger(tool.key, triggerConfig.symbol, handler)
|
||||||
|
disposeCallbacks.push(dispose)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposeCallbacks.forEach((dispose) => dispose())
|
||||||
|
}
|
||||||
|
}, [availableTools, buildRenderContext, toolsContext.toolsRegistry])
|
||||||
|
|
||||||
|
// Filter visible tools (only those with render functions, not pure menu contributors)
|
||||||
|
const visibleTools = useMemo(() => {
|
||||||
|
// 1. Get explicitly visible tools from toolOrder
|
||||||
|
const explicitlyVisible = toolOrder.visible
|
||||||
|
.map((key) => {
|
||||||
|
const meta = toolMetadata.find((item) => item.key === key)
|
||||||
|
if (!meta || meta.tool.render === null) return null
|
||||||
|
return {
|
||||||
|
key: meta.key,
|
||||||
|
label: meta.label,
|
||||||
|
tool: meta.tool,
|
||||||
|
visible: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(Boolean) as ToolConfig[]
|
||||||
|
|
||||||
|
// 2. Find new tools not in toolOrder (auto-show new tools)
|
||||||
|
const knownToolKeys = new Set([...toolOrder.visible, ...toolOrder.hidden])
|
||||||
|
const newTools = toolMetadata
|
||||||
|
.filter((meta) => !knownToolKeys.has(meta.key) && meta.tool.render !== null)
|
||||||
|
.map((meta) => ({
|
||||||
|
key: meta.key,
|
||||||
|
label: meta.label,
|
||||||
|
tool: meta.tool,
|
||||||
|
visible: true
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 3. Merge: explicit order + new tools at end
|
||||||
|
return [...explicitlyVisible, ...newTools]
|
||||||
|
}, [toolMetadata, toolOrder.visible, toolOrder.hidden])
|
||||||
|
|
||||||
|
const hiddenTools = useMemo(() => {
|
||||||
|
return toolOrder.hidden
|
||||||
|
.map((key) => {
|
||||||
|
const meta = toolMetadata.find((item) => item.key === key)
|
||||||
|
if (!meta || meta.tool.render === null) return null // Filter out pure menu contributors
|
||||||
|
return {
|
||||||
|
key: meta.key,
|
||||||
|
label: meta.label,
|
||||||
|
tool: meta.tool,
|
||||||
|
visible: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(Boolean) as ToolConfig[]
|
||||||
|
}, [toolMetadata, toolOrder.hidden])
|
||||||
|
|
||||||
|
const showDivider = useMemo(() => {
|
||||||
|
return hiddenTools.length > 0 && visibleTools.length > 0
|
||||||
|
}, [hiddenTools, visibleTools])
|
||||||
|
|
||||||
|
const showCollapseButton = useMemo(() => {
|
||||||
|
return hiddenTools.length > 0
|
||||||
|
}, [hiddenTools])
|
||||||
|
|
||||||
const toggleToolVisibility = useCallback(
|
const toggleToolVisibility = useCallback(
|
||||||
(toolKey: InputBarToolType, isVisible: boolean | undefined) => {
|
(toolKey: InputBarToolType, isVisible: boolean | undefined) => {
|
||||||
const newToolOrder = {
|
const newToolOrder: ToolOrderConfig = {
|
||||||
visible: [...toolOrder.visible],
|
visible: [...toolOrder.visible],
|
||||||
hidden: [...toolOrder.hidden]
|
hidden: [...toolOrder.hidden]
|
||||||
}
|
}
|
||||||
@@ -212,129 +248,20 @@ const InputbarTools = ({
|
|||||||
newToolOrder.visible.push(toolKey)
|
newToolOrder.visible.push(toolKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch(setToolOrder(newToolOrder))
|
dispatch(setToolOrder({ scope, toolOrder: newToolOrder }))
|
||||||
setTargetTool(null)
|
setTargetTool(null)
|
||||||
},
|
},
|
||||||
[dispatch, toolOrder.hidden, toolOrder.visible]
|
[dispatch, scope, toolOrder]
|
||||||
)
|
)
|
||||||
|
|
||||||
const getQuickPanelMenuImpl = (params: { text: string; translate: () => void }): QuickPanelListItem[] => {
|
|
||||||
const { text, translate } = params
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: t('settings.quickPhrase.title'),
|
|
||||||
description: '',
|
|
||||||
icon: <Zap />,
|
|
||||||
isMenu: true,
|
|
||||||
action: () => {
|
|
||||||
quickPhrasesButtonRef.current?.openQuickPanel()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('assistants.settings.reasoning_effort.label'),
|
|
||||||
description: '',
|
|
||||||
icon: <MdiLightbulbOn />,
|
|
||||||
isMenu: true,
|
|
||||||
action: () => {
|
|
||||||
thinkingButtonRef.current?.openQuickPanel()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('assistants.presets.edit.model.select.title'),
|
|
||||||
description: '',
|
|
||||||
icon: <AtSign />,
|
|
||||||
isMenu: true,
|
|
||||||
action: () => {
|
|
||||||
mentionModelsButtonRef.current?.openQuickPanel()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('chat.input.knowledge_base'),
|
|
||||||
description: '',
|
|
||||||
icon: <FileSearch />,
|
|
||||||
isMenu: true,
|
|
||||||
disabled: files.length > 0,
|
|
||||||
hidden: !showKnowledgeBaseButton,
|
|
||||||
action: () => {
|
|
||||||
knowledgeBaseButtonRef.current?.openQuickPanel()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('settings.mcp.title'),
|
|
||||||
description: t('settings.mcp.not_support'),
|
|
||||||
icon: <Hammer />,
|
|
||||||
isMenu: true,
|
|
||||||
action: () => {
|
|
||||||
mcpToolsButtonRef.current?.openQuickPanel()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: `MCP ${t('settings.mcp.tabs.prompts')}`,
|
|
||||||
description: '',
|
|
||||||
icon: <Hammer />,
|
|
||||||
isMenu: true,
|
|
||||||
action: () => {
|
|
||||||
mcpToolsButtonRef.current?.openPromptList()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: `MCP ${t('settings.mcp.tabs.resources')}`,
|
|
||||||
description: '',
|
|
||||||
icon: <Hammer />,
|
|
||||||
isMenu: true,
|
|
||||||
action: () => {
|
|
||||||
mcpToolsButtonRef.current?.openResourcesList()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('chat.input.web_search.label'),
|
|
||||||
description: '',
|
|
||||||
icon: <Globe />,
|
|
||||||
isMenu: true,
|
|
||||||
action: () => {
|
|
||||||
webSearchButtonRef.current?.openQuickPanel()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('chat.input.url_context'),
|
|
||||||
description: '',
|
|
||||||
icon: <Link />,
|
|
||||||
isMenu: true,
|
|
||||||
action: () => {
|
|
||||||
urlContextButtonRef.current?.openQuickPanel()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: couldAddImageFile ? t('chat.input.upload.attachment') : t('chat.input.upload.document'),
|
|
||||||
description: '',
|
|
||||||
icon: <Paperclip />,
|
|
||||||
isMenu: true,
|
|
||||||
action: () => {
|
|
||||||
attachmentButtonRef.current?.openQuickPanel()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('translate.title'),
|
|
||||||
description: t('translate.menu.description'),
|
|
||||||
icon: <Languages />,
|
|
||||||
action: () => {
|
|
||||||
if (!text) return
|
|
||||||
translate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
] satisfies QuickPanelListItem[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDragEnd = (result: DropResult) => {
|
const handleDragEnd = (result: DropResult) => {
|
||||||
const { source, destination } = result
|
const { source, destination } = result
|
||||||
|
|
||||||
if (!destination) return
|
if (!destination) return
|
||||||
|
|
||||||
const sourceId = source.droppableId
|
const sourceId = source.droppableId
|
||||||
const destinationId = destination.droppableId
|
const destinationId = destination.droppableId
|
||||||
|
|
||||||
const newToolOrder = {
|
const newToolOrder: ToolOrderConfig = {
|
||||||
visible: [...toolOrder.visible],
|
visible: [...toolOrder.visible],
|
||||||
hidden: [...toolOrder.hidden]
|
hidden: [...toolOrder.hidden]
|
||||||
}
|
}
|
||||||
@@ -352,216 +279,9 @@ const InputbarTools = ({
|
|||||||
newToolOrder[destArray].splice(destination.index, 0, removed)
|
newToolOrder[destArray].splice(destination.index, 0, removed)
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch(setToolOrder(newToolOrder))
|
dispatch(setToolOrder({ scope, toolOrder: newToolOrder }))
|
||||||
}
|
}
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
|
||||||
getQuickPanelMenu: getQuickPanelMenuImpl,
|
|
||||||
openMentionModelsPanel: (triggerInfo) => mentionModelsButtonRef.current?.openQuickPanel(triggerInfo),
|
|
||||||
openAttachmentQuickPanel: () => attachmentButtonRef.current?.openQuickPanel()
|
|
||||||
}))
|
|
||||||
|
|
||||||
const toolButtons = useMemo<ToolButtonConfig[]>(() => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
key: 'new_topic',
|
|
||||||
label: t('chat.input.new_topic', { Command: '' }),
|
|
||||||
component: (
|
|
||||||
<Tooltip
|
|
||||||
placement="top"
|
|
||||||
title={t('chat.input.new_topic', { Command: newTopicShortcut })}
|
|
||||||
mouseLeaveDelay={0}
|
|
||||||
arrow>
|
|
||||||
<ActionIconButton onClick={addNewTopic}>
|
|
||||||
<MessageSquareDiff size={19} />
|
|
||||||
</ActionIconButton>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'attachment',
|
|
||||||
label: t('chat.input.upload.image_or_document'),
|
|
||||||
component: (
|
|
||||||
<AttachmentButton
|
|
||||||
ref={attachmentButtonRef}
|
|
||||||
couldAddImageFile={couldAddImageFile}
|
|
||||||
extensions={extensions}
|
|
||||||
files={files}
|
|
||||||
setFiles={setFiles}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'thinking',
|
|
||||||
label: t('chat.input.thinking.label'),
|
|
||||||
component: <ThinkingButton ref={thinkingButtonRef} model={model} assistantId={assistant.id} />,
|
|
||||||
condition: showThinkingButton
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'web_search',
|
|
||||||
label: t('chat.input.web_search.label'),
|
|
||||||
component: <WebSearchButton ref={webSearchButtonRef} assistantId={assistant.id} />,
|
|
||||||
condition: !isMandatoryWebSearchModel(model)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'url_context',
|
|
||||||
label: t('chat.input.url_context'),
|
|
||||||
component: <UrlContextButton ref={urlContextButtonRef} assistantId={assistant.id} />,
|
|
||||||
condition:
|
|
||||||
(isGeminiModel(model) || isAnthropicModel(model)) &&
|
|
||||||
(isSupportUrlContextProvider(getProviderByModel(model)) || model.endpoint_type === 'gemini')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'knowledge_base',
|
|
||||||
label: t('chat.input.knowledge_base'),
|
|
||||||
component: (
|
|
||||||
<KnowledgeBaseButton
|
|
||||||
ref={knowledgeBaseButtonRef}
|
|
||||||
selectedBases={selectedKnowledgeBases}
|
|
||||||
onSelect={handleKnowledgeBaseSelect}
|
|
||||||
disabled={files.length > 0}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
condition: showKnowledgeBaseButton
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'mcp_tools',
|
|
||||||
label: t('settings.mcp.title'),
|
|
||||||
component: (
|
|
||||||
<MCPToolsButton
|
|
||||||
assistantId={assistant.id}
|
|
||||||
ref={mcpToolsButtonRef}
|
|
||||||
setInputValue={setText}
|
|
||||||
resizeTextArea={resizeTextArea}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
condition: showMcpServerButton
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'generate_image',
|
|
||||||
label: t('chat.input.generate_image'),
|
|
||||||
component: (
|
|
||||||
<GenerateImageButton model={model} assistant={assistant} onEnableGenerateImage={onEnableGenerateImage} />
|
|
||||||
),
|
|
||||||
condition: isGenerateImageModel(model)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'mention_models',
|
|
||||||
label: t('assistants.presets.edit.model.select.title'),
|
|
||||||
component: (
|
|
||||||
<MentionModelsButton
|
|
||||||
ref={mentionModelsButtonRef}
|
|
||||||
mentionedModels={mentionedModels}
|
|
||||||
onMentionModel={onMentionModel}
|
|
||||||
onClearMentionModels={onClearMentionModels}
|
|
||||||
couldMentionNotVisionModel={couldMentionNotVisionModel}
|
|
||||||
files={files}
|
|
||||||
setText={setText}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'quick_phrases',
|
|
||||||
label: t('settings.quickPhrase.title'),
|
|
||||||
component: (
|
|
||||||
<QuickPhrasesButton
|
|
||||||
ref={quickPhrasesButtonRef}
|
|
||||||
setInputValue={setText}
|
|
||||||
resizeTextArea={resizeTextArea}
|
|
||||||
assistantId={assistant.id}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'clear_topic',
|
|
||||||
label: t('chat.input.clear.label', { Command: '' }),
|
|
||||||
component: (
|
|
||||||
<Tooltip
|
|
||||||
placement="top"
|
|
||||||
title={t('chat.input.clear.label', { Command: clearTopicShortcut })}
|
|
||||||
mouseLeaveDelay={0}
|
|
||||||
arrow>
|
|
||||||
<ActionIconButton onClick={clearTopic}>
|
|
||||||
<PaintbrushVertical size={18} />
|
|
||||||
</ActionIconButton>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'toggle_expand',
|
|
||||||
label: isExpended ? t('chat.input.collapse') : t('chat.input.expand'),
|
|
||||||
component: (
|
|
||||||
<Tooltip
|
|
||||||
placement="top"
|
|
||||||
title={isExpended ? t('chat.input.collapse') : t('chat.input.expand')}
|
|
||||||
mouseLeaveDelay={0}
|
|
||||||
arrow>
|
|
||||||
<ActionIconButton onClick={onToggleExpended}>
|
|
||||||
{isExpended ? <Minimize size={18} /> : <Maximize size={18} />}
|
|
||||||
</ActionIconButton>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'new_context',
|
|
||||||
label: t('chat.input.new.context', { Command: '' }),
|
|
||||||
component: <NewContextButton onNewContext={onNewContext} />
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}, [
|
|
||||||
addNewTopic,
|
|
||||||
assistant,
|
|
||||||
clearTopicShortcut,
|
|
||||||
clearTopic,
|
|
||||||
couldAddImageFile,
|
|
||||||
couldMentionNotVisionModel,
|
|
||||||
extensions,
|
|
||||||
files,
|
|
||||||
handleKnowledgeBaseSelect,
|
|
||||||
isExpended,
|
|
||||||
mentionedModels,
|
|
||||||
model,
|
|
||||||
newTopicShortcut,
|
|
||||||
onClearMentionModels,
|
|
||||||
onEnableGenerateImage,
|
|
||||||
onMentionModel,
|
|
||||||
onNewContext,
|
|
||||||
onToggleExpended,
|
|
||||||
resizeTextArea,
|
|
||||||
selectedKnowledgeBases,
|
|
||||||
setFiles,
|
|
||||||
setText,
|
|
||||||
showKnowledgeBaseButton,
|
|
||||||
showMcpServerButton,
|
|
||||||
showThinkingButton,
|
|
||||||
t
|
|
||||||
])
|
|
||||||
|
|
||||||
const visibleTools = useMemo(() => {
|
|
||||||
return toolOrder.visible.map((v) => ({
|
|
||||||
...toolButtons.find((tool) => tool.key === v),
|
|
||||||
visible: true
|
|
||||||
})) as ToolButtonConfig[]
|
|
||||||
}, [toolButtons, toolOrder])
|
|
||||||
|
|
||||||
const hiddenTools = useMemo(() => {
|
|
||||||
return toolOrder.hidden.map((v) => ({
|
|
||||||
...toolButtons.find((tool) => tool.key === v),
|
|
||||||
visible: false
|
|
||||||
})) as ToolButtonConfig[]
|
|
||||||
}, [toolButtons, toolOrder])
|
|
||||||
|
|
||||||
const showDivider = useMemo(() => {
|
|
||||||
return (
|
|
||||||
hiddenTools.filter((tool) => tool.condition ?? true).length > 0 &&
|
|
||||||
visibleTools.filter((tool) => tool.condition ?? true).length !== 0
|
|
||||||
)
|
|
||||||
}, [hiddenTools, visibleTools])
|
|
||||||
|
|
||||||
const showCollapseButton = useMemo(() => {
|
|
||||||
return hiddenTools.filter((tool) => tool.condition ?? true).length > 0
|
|
||||||
}, [hiddenTools])
|
|
||||||
|
|
||||||
const getMenuItems = useMemo(() => {
|
const getMenuItems = useMemo(() => {
|
||||||
const baseItems: ItemType[] = [...visibleTools, ...hiddenTools].map((tool) => ({
|
const baseItems: ItemType[] = [...visibleTools, ...hiddenTools].map((tool) => ({
|
||||||
label: tool.label,
|
label: tool.label,
|
||||||
@@ -571,87 +291,88 @@ const InputbarTools = ({
|
|||||||
{tool.visible ? <Check size={16} /> : undefined}
|
{tool.visible ? <Check size={16} /> : undefined}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
onClick: () => {
|
onClick: () => toggleToolVisibility(tool.key, tool.visible)
|
||||||
toggleToolVisibility(tool.key, tool.visible)
|
|
||||||
}
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
if (targetTool) {
|
if (targetTool) {
|
||||||
baseItems.push({
|
baseItems.push({ type: 'divider' })
|
||||||
type: 'divider'
|
|
||||||
})
|
|
||||||
baseItems.push({
|
baseItems.push({
|
||||||
label: `${targetTool.visible ? t('chat.input.tools.collapse_in') : t('chat.input.tools.collapse_out')} "${targetTool.label}"`,
|
label: `${targetTool.visible ? t('chat.input.tools.collapse_in') : t('chat.input.tools.collapse_out')} "${targetTool.label}"`,
|
||||||
key: 'selected_' + targetTool.key,
|
key: 'selected_' + targetTool.key,
|
||||||
icon: <div style={{ width: 20, height: 20 }}></div>,
|
icon: <div style={{ width: 20, height: 20 }}></div>,
|
||||||
onClick: () => {
|
onClick: () => toggleToolVisibility(targetTool.key, targetTool.visible)
|
||||||
toggleToolVisibility(targetTool.key, targetTool.visible)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return baseItems
|
return baseItems
|
||||||
}, [hiddenTools, t, targetTool, toggleToolVisibility, visibleTools])
|
}, [hiddenTools, t, targetTool, toggleToolVisibility, visibleTools])
|
||||||
|
|
||||||
|
const managerElements = useMemo(() => {
|
||||||
|
return availableTools
|
||||||
|
.map((tool) => {
|
||||||
|
if (!tool.quickPanelManager) return null
|
||||||
|
const Manager = tool.quickPanelManager
|
||||||
|
const context = buildRenderContext(tool)
|
||||||
|
return <Manager key={`${tool.key}-quick-panel-manager`} context={context} />
|
||||||
|
})
|
||||||
|
.filter((element): element is React.ReactElement => element !== null)
|
||||||
|
}, [availableTools, buildRenderContext])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dropdown menu={{ items: getMenuItems }} trigger={['contextMenu']}>
|
<>
|
||||||
<ToolsContainer
|
<Dropdown menu={{ items: getMenuItems }} trigger={['contextMenu']}>
|
||||||
onContextMenu={(e) => {
|
<ToolsContainer
|
||||||
const target = e.target as HTMLElement
|
onContextMenu={(e) => {
|
||||||
const isToolButton = target.closest('[data-key]')
|
const target = e.target as HTMLElement
|
||||||
if (!isToolButton) {
|
const isToolButton = target.closest('[data-key]')
|
||||||
setTargetTool(null)
|
if (!isToolButton) {
|
||||||
}
|
setTargetTool(null)
|
||||||
}}>
|
}
|
||||||
<DragDropContext onDragEnd={handleDragEnd}>
|
}}>
|
||||||
<Droppable droppableId="inputbar-tools-visible" direction="horizontal">
|
<DragDropContext onDragEnd={handleDragEnd}>
|
||||||
{(provided) => (
|
<Droppable droppableId="inputbar-tools-visible" direction="horizontal">
|
||||||
<VisibleTools ref={provided.innerRef} {...provided.droppableProps}>
|
{(provided) => (
|
||||||
{visibleTools.map(
|
<VisibleTools ref={provided.innerRef} {...provided.droppableProps}>
|
||||||
(tool, index) =>
|
{visibleTools.map((toolConfig, index) => {
|
||||||
(tool.condition ?? true) && (
|
const context = buildRenderContext(toolConfig.tool)
|
||||||
<Draggable key={tool.key} draggableId={tool.key} index={index}>
|
return (
|
||||||
|
<Draggable key={toolConfig.key} draggableId={toolConfig.key} index={index}>
|
||||||
{(provided, snapshot) => (
|
{(provided, snapshot) => (
|
||||||
<DraggablePortal isDragging={snapshot.isDragging}>
|
<DraggablePortal isDragging={snapshot.isDragging}>
|
||||||
<ToolWrapper
|
<ToolWrapper
|
||||||
data-key={tool.key}
|
data-key={toolConfig.key}
|
||||||
onContextMenu={() => setTargetTool(tool)}
|
onContextMenu={() => setTargetTool(toolConfig)}
|
||||||
ref={provided.innerRef}
|
ref={provided.innerRef}
|
||||||
{...provided.draggableProps}
|
{...provided.draggableProps}
|
||||||
{...provided.dragHandleProps}
|
{...provided.dragHandleProps}
|
||||||
style={{
|
style={provided.draggableProps.style}>
|
||||||
...provided.draggableProps.style
|
{toolConfig.tool.render?.(context)}
|
||||||
}}>
|
|
||||||
{tool.component}
|
|
||||||
</ToolWrapper>
|
</ToolWrapper>
|
||||||
</DraggablePortal>
|
</DraggablePortal>
|
||||||
)}
|
)}
|
||||||
</Draggable>
|
</Draggable>
|
||||||
)
|
)
|
||||||
)}
|
})}
|
||||||
|
{provided.placeholder}
|
||||||
|
</VisibleTools>
|
||||||
|
)}
|
||||||
|
</Droppable>
|
||||||
|
|
||||||
{provided.placeholder}
|
{showDivider && <Divider type="vertical" style={{ margin: '0 4px' }} />}
|
||||||
</VisibleTools>
|
|
||||||
)}
|
|
||||||
</Droppable>
|
|
||||||
|
|
||||||
{showDivider && <Divider type="vertical" style={{ margin: '0 4px' }} />}
|
<Droppable droppableId="inputbar-tools-hidden" direction="horizontal">
|
||||||
|
{(provided) => (
|
||||||
<Droppable droppableId="inputbar-tools-hidden" direction="horizontal">
|
<HiddenTools ref={provided.innerRef} {...provided.droppableProps}>
|
||||||
{(provided) => (
|
{hiddenTools.map((toolConfig, index) => {
|
||||||
<HiddenTools ref={provided.innerRef} {...provided.droppableProps}>
|
const context = buildRenderContext(toolConfig.tool)
|
||||||
{hiddenTools.map(
|
return (
|
||||||
(tool, index) =>
|
<Draggable key={toolConfig.key} draggableId={toolConfig.key} index={index}>
|
||||||
(tool.condition ?? true) && (
|
|
||||||
<Draggable key={tool.key} draggableId={tool.key} index={index}>
|
|
||||||
{(provided, snapshot) => (
|
{(provided, snapshot) => (
|
||||||
<DraggablePortal isDragging={snapshot.isDragging}>
|
<DraggablePortal isDragging={snapshot.isDragging}>
|
||||||
<ToolWrapper
|
<ToolWrapper
|
||||||
data-key={tool.key}
|
data-key={toolConfig.key}
|
||||||
className={classNames({
|
className={classNames({ 'is-collapsed': isCollapse })}
|
||||||
'is-collapsed': isCollapse
|
onContextMenu={() => setTargetTool(toolConfig)}
|
||||||
})}
|
|
||||||
onContextMenu={() => setTargetTool(tool)}
|
|
||||||
ref={provided.innerRef}
|
ref={provided.innerRef}
|
||||||
{...provided.draggableProps}
|
{...provided.draggableProps}
|
||||||
{...provided.dragHandleProps}
|
{...provided.dragHandleProps}
|
||||||
@@ -659,39 +380,35 @@ const InputbarTools = ({
|
|||||||
...provided.draggableProps.style,
|
...provided.draggableProps.style,
|
||||||
transitionDelay: `${index * 0.02}s`
|
transitionDelay: `${index * 0.02}s`
|
||||||
}}>
|
}}>
|
||||||
{tool.component}
|
{toolConfig.tool.render?.(context)}
|
||||||
</ToolWrapper>
|
</ToolWrapper>
|
||||||
</DraggablePortal>
|
</DraggablePortal>
|
||||||
)}
|
)}
|
||||||
</Draggable>
|
</Draggable>
|
||||||
)
|
)
|
||||||
)}
|
})}
|
||||||
{provided.placeholder}
|
{provided.placeholder}
|
||||||
</HiddenTools>
|
</HiddenTools>
|
||||||
)}
|
)}
|
||||||
</Droppable>
|
</Droppable>
|
||||||
</DragDropContext>
|
</DragDropContext>
|
||||||
|
|
||||||
{showCollapseButton && (
|
{showCollapseButton && (
|
||||||
<Tooltip
|
<ActionIconButton
|
||||||
placement="top"
|
onClick={() => dispatch(setIsCollapsed(!isCollapse))}
|
||||||
title={isCollapse ? t('chat.input.tools.expand') : t('chat.input.tools.collapse')}
|
title={isCollapse ? t('chat.input.tools.expand') : t('chat.input.tools.collapse')}>
|
||||||
arrow>
|
<CircleChevronRight size={18} style={{ transform: isCollapse ? 'scaleX(1)' : 'scaleX(-1)' }} />
|
||||||
<ActionIconButton onClick={() => dispatch(setIsCollapsed(!isCollapse))}>
|
|
||||||
<CircleChevronRight
|
|
||||||
size={18}
|
|
||||||
style={{
|
|
||||||
transform: isCollapse ? 'scaleX(1)' : 'scaleX(-1)'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ActionIconButton>
|
</ActionIconButton>
|
||||||
</Tooltip>
|
)}
|
||||||
)}
|
</ToolsContainer>
|
||||||
</ToolsContainer>
|
</Dropdown>
|
||||||
</Dropdown>
|
{managerElements}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
InputbarTools.displayName = 'InputbarTools'
|
||||||
|
|
||||||
const ToolsContainer = styled.div`
|
const ToolsContainer = styled.div`
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,318 +0,0 @@
|
|||||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
|
||||||
import ModelTagsWithLabel from '@renderer/components/ModelTagsWithLabel'
|
|
||||||
import { type QuickPanelListItem, QuickPanelReservedSymbol, useQuickPanel } from '@renderer/components/QuickPanel'
|
|
||||||
import { getModelLogo, isEmbeddingModel, isRerankModel, isVisionModel } from '@renderer/config/models'
|
|
||||||
import db from '@renderer/databases'
|
|
||||||
import { useProviders } from '@renderer/hooks/useProvider'
|
|
||||||
import { getModelUniqId } from '@renderer/services/ModelService'
|
|
||||||
import type { FileType, Model } from '@renderer/types'
|
|
||||||
import { getFancyProviderName } from '@renderer/utils'
|
|
||||||
import { Avatar, Tooltip } from 'antd'
|
|
||||||
import { useLiveQuery } from 'dexie-react-hooks'
|
|
||||||
import { first, sortBy } from 'lodash'
|
|
||||||
import { AtSign, CircleX, Plus } from 'lucide-react'
|
|
||||||
import type { FC } from 'react'
|
|
||||||
import { memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { useNavigate } from 'react-router'
|
|
||||||
import styled from 'styled-components'
|
|
||||||
|
|
||||||
export interface MentionModelsButtonRef {
|
|
||||||
openQuickPanel: (triggerInfo?: { type: 'input' | 'button'; position?: number; originalText?: string }) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
ref?: React.RefObject<MentionModelsButtonRef | null>
|
|
||||||
mentionedModels: Model[]
|
|
||||||
onMentionModel: (model: Model) => void
|
|
||||||
onClearMentionModels: () => void
|
|
||||||
couldMentionNotVisionModel: boolean
|
|
||||||
files: FileType[]
|
|
||||||
setText: React.Dispatch<React.SetStateAction<string>>
|
|
||||||
}
|
|
||||||
|
|
||||||
const MentionModelsButton: FC<Props> = ({
|
|
||||||
ref,
|
|
||||||
mentionedModels,
|
|
||||||
onMentionModel,
|
|
||||||
onClearMentionModels,
|
|
||||||
couldMentionNotVisionModel,
|
|
||||||
files,
|
|
||||||
setText
|
|
||||||
}) => {
|
|
||||||
const { providers } = useProviders()
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const quickPanel = useQuickPanel()
|
|
||||||
|
|
||||||
// 记录是否有模型被选择的动作发生
|
|
||||||
const hasModelActionRef = useRef<boolean>(false)
|
|
||||||
// 记录触发信息,用于清除操作
|
|
||||||
const triggerInfoRef = useRef<{ type: 'input' | 'button'; position?: number; originalText?: string } | undefined>(
|
|
||||||
undefined
|
|
||||||
)
|
|
||||||
|
|
||||||
// 基于光标 + 搜索词定位并删除最近一次触发的 @ 及搜索文本
|
|
||||||
const removeAtSymbolAndText = useCallback(
|
|
||||||
(currentText: string, caretPosition: number, searchText?: string, fallbackPosition?: number) => {
|
|
||||||
const safeCaret = Math.max(0, Math.min(caretPosition ?? 0, currentText.length))
|
|
||||||
|
|
||||||
// ESC/精确删除:优先按 pattern = "@" + searchText 从光标向左最近匹配
|
|
||||||
if (searchText !== undefined) {
|
|
||||||
const pattern = '@' + searchText
|
|
||||||
const fromIndex = Math.max(0, safeCaret - 1)
|
|
||||||
const start = currentText.lastIndexOf(pattern, fromIndex)
|
|
||||||
if (start !== -1) {
|
|
||||||
const end = start + pattern.length
|
|
||||||
return currentText.slice(0, start) + currentText.slice(end)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 兜底:使用打开时的 position 做校验后再删
|
|
||||||
if (typeof fallbackPosition === 'number' && currentText[fallbackPosition] === '@') {
|
|
||||||
const expected = pattern
|
|
||||||
const actual = currentText.slice(fallbackPosition, fallbackPosition + expected.length)
|
|
||||||
if (actual === expected) {
|
|
||||||
return currentText.slice(0, fallbackPosition) + currentText.slice(fallbackPosition + expected.length)
|
|
||||||
}
|
|
||||||
// 如果不完全匹配,安全起见仅删除单个 '@'
|
|
||||||
return currentText.slice(0, fallbackPosition) + currentText.slice(fallbackPosition + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 未找到匹配则不改动
|
|
||||||
return currentText
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清除按钮:未知搜索词,删除离光标最近的 '@' 及后续连续非空白(到空格/换行/结尾)
|
|
||||||
{
|
|
||||||
const fromIndex = Math.max(0, safeCaret - 1)
|
|
||||||
const start = currentText.lastIndexOf('@', fromIndex)
|
|
||||||
if (start === -1) {
|
|
||||||
// 兜底:使用打开时的 position(若存在),按空白边界删除
|
|
||||||
if (typeof fallbackPosition === 'number' && currentText[fallbackPosition] === '@') {
|
|
||||||
let endPos = fallbackPosition + 1
|
|
||||||
while (endPos < currentText.length && !/\s/.test(currentText[endPos])) {
|
|
||||||
endPos++
|
|
||||||
}
|
|
||||||
return currentText.slice(0, fallbackPosition) + currentText.slice(endPos)
|
|
||||||
}
|
|
||||||
return currentText
|
|
||||||
}
|
|
||||||
|
|
||||||
let endPos = start + 1
|
|
||||||
while (endPos < currentText.length && !/\s/.test(currentText[endPos])) {
|
|
||||||
endPos++
|
|
||||||
}
|
|
||||||
return currentText.slice(0, start) + currentText.slice(endPos)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
|
|
||||||
const pinnedModels = useLiveQuery(
|
|
||||||
async () => {
|
|
||||||
const setting = await db.settings.get('pinned:models')
|
|
||||||
return setting?.value || []
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
|
|
||||||
const modelItems = useMemo(() => {
|
|
||||||
const items: QuickPanelListItem[] = []
|
|
||||||
|
|
||||||
if (pinnedModels.length > 0) {
|
|
||||||
const pinnedItems = providers.flatMap((p) =>
|
|
||||||
p.models
|
|
||||||
.filter((m) => !isEmbeddingModel(m) && !isRerankModel(m))
|
|
||||||
.filter((m) => pinnedModels.includes(getModelUniqId(m)))
|
|
||||||
.filter((m) => couldMentionNotVisionModel || (!couldMentionNotVisionModel && isVisionModel(m)))
|
|
||||||
.map((m) => ({
|
|
||||||
label: (
|
|
||||||
<>
|
|
||||||
<ProviderName>{getFancyProviderName(p)}</ProviderName>
|
|
||||||
<span style={{ opacity: 0.8 }}> | {m.name}</span>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
description: <ModelTagsWithLabel model={m} showLabel={false} size={10} style={{ opacity: 0.8 }} />,
|
|
||||||
icon: (
|
|
||||||
<Avatar src={getModelLogo(m)} size={20}>
|
|
||||||
{first(m.name)}
|
|
||||||
</Avatar>
|
|
||||||
),
|
|
||||||
filterText: getFancyProviderName(p) + m.name,
|
|
||||||
action: () => {
|
|
||||||
hasModelActionRef.current = true // 标记有模型动作发生
|
|
||||||
onMentionModel(m)
|
|
||||||
},
|
|
||||||
isSelected: mentionedModels.some((selected) => getModelUniqId(selected) === getModelUniqId(m))
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
|
|
||||||
if (pinnedItems.length > 0) {
|
|
||||||
items.push(...sortBy(pinnedItems, ['label']))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
providers.forEach((p) => {
|
|
||||||
const providerModels = sortBy(
|
|
||||||
p.models
|
|
||||||
.filter((m) => !isEmbeddingModel(m) && !isRerankModel(m))
|
|
||||||
.filter((m) => !pinnedModels.includes(getModelUniqId(m)))
|
|
||||||
.filter((m) => couldMentionNotVisionModel || (!couldMentionNotVisionModel && isVisionModel(m))),
|
|
||||||
['group', 'name']
|
|
||||||
)
|
|
||||||
|
|
||||||
const providerModelItems = providerModels.map((m) => ({
|
|
||||||
label: (
|
|
||||||
<>
|
|
||||||
<ProviderName>{getFancyProviderName(p)}</ProviderName>
|
|
||||||
<span style={{ opacity: 0.8 }}> | {m.name}</span>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
description: <ModelTagsWithLabel model={m} showLabel={false} size={10} style={{ opacity: 0.8 }} />,
|
|
||||||
icon: (
|
|
||||||
<Avatar src={getModelLogo(m)} size={20}>
|
|
||||||
{first(m.name)}
|
|
||||||
</Avatar>
|
|
||||||
),
|
|
||||||
filterText: getFancyProviderName(p) + m.name,
|
|
||||||
action: () => {
|
|
||||||
hasModelActionRef.current = true // 标记有模型动作发生
|
|
||||||
onMentionModel(m)
|
|
||||||
},
|
|
||||||
isSelected: mentionedModels.some((selected) => getModelUniqId(selected) === getModelUniqId(m))
|
|
||||||
}))
|
|
||||||
|
|
||||||
if (providerModelItems.length > 0) {
|
|
||||||
items.push(...providerModelItems)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
items.push({
|
|
||||||
label: t('settings.models.add.add_model') + '...',
|
|
||||||
icon: <Plus />,
|
|
||||||
action: () => navigate('/settings/provider'),
|
|
||||||
isSelected: false
|
|
||||||
})
|
|
||||||
|
|
||||||
items.unshift({
|
|
||||||
label: t('settings.input.clear.all'),
|
|
||||||
description: t('settings.input.clear.models'),
|
|
||||||
icon: <CircleX />,
|
|
||||||
alwaysVisible: true,
|
|
||||||
isSelected: false,
|
|
||||||
action: ({ context: ctx }) => {
|
|
||||||
onClearMentionModels()
|
|
||||||
|
|
||||||
// 只有输入触发时才需要删除 @ 与搜索文本(未知搜索词,按光标就近删除)
|
|
||||||
if (triggerInfoRef.current?.type === 'input') {
|
|
||||||
setText((currentText) => {
|
|
||||||
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement | null
|
|
||||||
const caret = textArea ? (textArea.selectionStart ?? currentText.length) : currentText.length
|
|
||||||
return removeAtSymbolAndText(currentText, caret, undefined, triggerInfoRef.current?.position)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.close()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return items
|
|
||||||
}, [
|
|
||||||
pinnedModels,
|
|
||||||
providers,
|
|
||||||
t,
|
|
||||||
couldMentionNotVisionModel,
|
|
||||||
mentionedModels,
|
|
||||||
onMentionModel,
|
|
||||||
navigate,
|
|
||||||
onClearMentionModels,
|
|
||||||
setText,
|
|
||||||
removeAtSymbolAndText
|
|
||||||
])
|
|
||||||
|
|
||||||
const openQuickPanel = useCallback(
|
|
||||||
(triggerInfo?: { type: 'input' | 'button'; position?: number; originalText?: string }) => {
|
|
||||||
// 重置模型动作标记
|
|
||||||
hasModelActionRef.current = false
|
|
||||||
// 保存触发信息
|
|
||||||
triggerInfoRef.current = triggerInfo
|
|
||||||
|
|
||||||
quickPanel.open({
|
|
||||||
title: t('assistants.presets.edit.model.select.title'),
|
|
||||||
list: modelItems,
|
|
||||||
symbol: QuickPanelReservedSymbol.MentionModels,
|
|
||||||
multiple: true,
|
|
||||||
triggerInfo: triggerInfo || { type: 'button' },
|
|
||||||
afterAction({ item }) {
|
|
||||||
item.isSelected = !item.isSelected
|
|
||||||
},
|
|
||||||
onClose({ action, searchText, context: ctx }) {
|
|
||||||
// ESC关闭时的处理:删除 @ 和搜索文本
|
|
||||||
if (action === 'esc') {
|
|
||||||
// 只有在输入触发且有模型选择动作时才删除@字符和搜索文本
|
|
||||||
const triggerInfo = ctx?.triggerInfo ?? triggerInfoRef.current
|
|
||||||
if (hasModelActionRef.current && triggerInfo?.type === 'input' && triggerInfo?.position !== undefined) {
|
|
||||||
// 基于当前光标 + 搜索词精确定位并删除,position 仅作兜底
|
|
||||||
setText((currentText) => {
|
|
||||||
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement | null
|
|
||||||
const caret = textArea ? (textArea.selectionStart ?? currentText.length) : currentText.length
|
|
||||||
return removeAtSymbolAndText(currentText, caret, searchText || '', triggerInfo.position!)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Backspace删除@的情况(delete-symbol):
|
|
||||||
// @ 已经被Backspace自然删除,面板关闭,不需要额外操作
|
|
||||||
triggerInfoRef.current = undefined
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
[modelItems, quickPanel, t, setText, removeAtSymbolAndText]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleOpenQuickPanel = useCallback(() => {
|
|
||||||
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.MentionModels) {
|
|
||||||
quickPanel.close()
|
|
||||||
} else {
|
|
||||||
openQuickPanel({ type: 'button' })
|
|
||||||
}
|
|
||||||
}, [openQuickPanel, quickPanel])
|
|
||||||
|
|
||||||
const filesRef = useRef(files)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// 检查files是否变化
|
|
||||||
if (filesRef.current !== files) {
|
|
||||||
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.MentionModels) {
|
|
||||||
quickPanel.close()
|
|
||||||
}
|
|
||||||
filesRef.current = files
|
|
||||||
}
|
|
||||||
}, [files, quickPanel])
|
|
||||||
|
|
||||||
// 监听 mentionedModels 变化,动态更新已打开的 QuickPanel 列表状态
|
|
||||||
useEffect(() => {
|
|
||||||
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.MentionModels) {
|
|
||||||
// 直接使用重新计算的 modelItems,因为它已经包含了最新的 isSelected 状态
|
|
||||||
quickPanel.updateList(modelItems)
|
|
||||||
}
|
|
||||||
}, [mentionedModels, quickPanel, modelItems])
|
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
|
||||||
openQuickPanel
|
|
||||||
}))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip placement="top" title={t('assistants.presets.edit.model.select.title')} mouseLeaveDelay={0} arrow>
|
|
||||||
<ActionIconButton onClick={handleOpenQuickPanel} active={mentionedModels.length > 0}>
|
|
||||||
<AtSign size={18} />
|
|
||||||
</ActionIconButton>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ProviderName = styled.span`
|
|
||||||
font-weight: 500;
|
|
||||||
`
|
|
||||||
|
|
||||||
export default memo(MentionModelsButton)
|
|
||||||
803
src/renderer/src/pages/home/Inputbar/components/InputbarCore.tsx
Normal file
803
src/renderer/src/pages/home/Inputbar/components/InputbarCore.tsx
Normal file
@@ -0,0 +1,803 @@
|
|||||||
|
import { HolderOutlined } from '@ant-design/icons'
|
||||||
|
import { loggerService } from '@logger'
|
||||||
|
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||||
|
import type { QuickPanelTriggerInfo } from '@renderer/components/QuickPanel'
|
||||||
|
import { QuickPanelReservedSymbol, QuickPanelView, useQuickPanel } from '@renderer/components/QuickPanel'
|
||||||
|
import TranslateButton from '@renderer/components/TranslateButton'
|
||||||
|
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||||
|
import { useSettings } from '@renderer/hooks/useSettings'
|
||||||
|
import { useTimer } from '@renderer/hooks/useTimer'
|
||||||
|
import useTranslate from '@renderer/hooks/useTranslate'
|
||||||
|
import PasteService from '@renderer/services/PasteService'
|
||||||
|
import { translateText } from '@renderer/services/TranslateService'
|
||||||
|
import { useAppDispatch } from '@renderer/store'
|
||||||
|
import { setSearching } from '@renderer/store/runtime'
|
||||||
|
import type { FileType } from '@renderer/types'
|
||||||
|
import { classNames } from '@renderer/utils'
|
||||||
|
import { formatQuotedText } from '@renderer/utils/formats'
|
||||||
|
import { isSendMessageKeyPressed } from '@renderer/utils/input'
|
||||||
|
import { IpcChannel } from '@shared/IpcChannel'
|
||||||
|
import { Tooltip } from 'antd'
|
||||||
|
import TextArea from 'antd/es/input/TextArea'
|
||||||
|
import { CirclePause, Languages } from 'lucide-react'
|
||||||
|
import type { CSSProperties, FC } from 'react'
|
||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import styled from 'styled-components'
|
||||||
|
|
||||||
|
import NarrowLayout from '../../Messages/NarrowLayout'
|
||||||
|
import AttachmentPreview from '../AttachmentPreview'
|
||||||
|
import {
|
||||||
|
useInputbarToolsDispatch,
|
||||||
|
useInputbarToolsInternalDispatch,
|
||||||
|
useInputbarToolsState
|
||||||
|
} from '../context/InputbarToolsProvider'
|
||||||
|
import { useFileDragDrop } from '../hooks/useFileDragDrop'
|
||||||
|
import { usePasteHandler } from '../hooks/usePasteHandler'
|
||||||
|
import { getInputbarConfig } from '../registry'
|
||||||
|
import SendMessageButton from '../SendMessageButton'
|
||||||
|
import type { InputbarScope } from '../types'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('InputbarCore')
|
||||||
|
|
||||||
|
export interface InputbarCoreProps {
|
||||||
|
scope: InputbarScope
|
||||||
|
placeholder?: string
|
||||||
|
|
||||||
|
text: string
|
||||||
|
onTextChange: (text: string) => void
|
||||||
|
textareaRef: React.RefObject<any>
|
||||||
|
resizeTextArea: (force?: boolean) => void
|
||||||
|
focusTextarea: () => void
|
||||||
|
|
||||||
|
supportedExts: string[]
|
||||||
|
isLoading: boolean
|
||||||
|
|
||||||
|
onPause?: () => void
|
||||||
|
handleSendMessage: () => void
|
||||||
|
|
||||||
|
// Toolbar sections
|
||||||
|
leftToolbar?: React.ReactNode
|
||||||
|
rightToolbar?: React.ReactNode
|
||||||
|
|
||||||
|
// Preview sections (attachments, mentions, etc.)
|
||||||
|
topContent?: React.ReactNode
|
||||||
|
|
||||||
|
// Override the user preference for quick panel triggers
|
||||||
|
forceEnableQuickPanelTriggers?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const TextareaStyle: CSSProperties = {
|
||||||
|
paddingLeft: 0,
|
||||||
|
padding: '6px 15px 0px'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* InputbarCore - 核心输入栏组件
|
||||||
|
*
|
||||||
|
* 提供基础的文本输入、工具栏、拖拽等功能的 UI 框架
|
||||||
|
* 业务逻辑通过 props 注入,保持组件纯粹
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <InputbarCore
|
||||||
|
* text={text}
|
||||||
|
* onTextChange={(e) => setText(e.target.value)}
|
||||||
|
* textareaRef={textareaRef}
|
||||||
|
* textareaHeight={customHeight}
|
||||||
|
* onKeyDown={handleKeyDown}
|
||||||
|
* onPaste={handlePaste}
|
||||||
|
* topContent={<AttachmentPreview files={files} />}
|
||||||
|
* leftToolbar={<InputbarTools />}
|
||||||
|
* rightToolbar={<SendMessageButton />}
|
||||||
|
* quickPanel={<QuickPanelView />}
|
||||||
|
* fontSize={14}
|
||||||
|
* enableSpellCheck={true}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const InputbarCore: FC<InputbarCoreProps> = ({
|
||||||
|
scope,
|
||||||
|
placeholder,
|
||||||
|
text,
|
||||||
|
onTextChange,
|
||||||
|
textareaRef,
|
||||||
|
resizeTextArea,
|
||||||
|
focusTextarea,
|
||||||
|
supportedExts,
|
||||||
|
isLoading,
|
||||||
|
onPause,
|
||||||
|
handleSendMessage,
|
||||||
|
leftToolbar,
|
||||||
|
rightToolbar,
|
||||||
|
topContent,
|
||||||
|
forceEnableQuickPanelTriggers
|
||||||
|
}) => {
|
||||||
|
const config = useMemo(() => getInputbarConfig(scope), [scope])
|
||||||
|
const { files, isExpanded } = useInputbarToolsState()
|
||||||
|
const { setFiles, setIsExpanded, toolsRegistry, triggers } = useInputbarToolsDispatch()
|
||||||
|
const { setExtensions } = useInputbarToolsInternalDispatch()
|
||||||
|
const isEmpty = text.trim().length === 0
|
||||||
|
const [inputFocus, setInputFocus] = useState(false)
|
||||||
|
const {
|
||||||
|
targetLanguage,
|
||||||
|
sendMessageShortcut,
|
||||||
|
fontSize,
|
||||||
|
pasteLongTextAsFile,
|
||||||
|
pasteLongTextThreshold,
|
||||||
|
autoTranslateWithSpace,
|
||||||
|
enableQuickPanelTriggers,
|
||||||
|
enableSpellCheck
|
||||||
|
} = useSettings()
|
||||||
|
const quickPanelTriggersEnabled = forceEnableQuickPanelTriggers ?? enableQuickPanelTriggers
|
||||||
|
|
||||||
|
const [textareaHeight, setTextareaHeight] = useState<number>()
|
||||||
|
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [isTranslating, setIsTranslating] = useState(false)
|
||||||
|
const { getLanguageByLangcode } = useTranslate()
|
||||||
|
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
const [spaceClickCount, setSpaceClickCount] = useState(0)
|
||||||
|
const spaceClickTimer = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
const { searching } = useRuntime()
|
||||||
|
const startDragY = useRef<number>(0)
|
||||||
|
const startHeight = useRef<number>(0)
|
||||||
|
const { setTimeoutTimer } = useTimer()
|
||||||
|
|
||||||
|
// 全局 QuickPanel Hook (用于控制面板显示状态)
|
||||||
|
const quickPanel = useQuickPanel()
|
||||||
|
const quickPanelOpen = quickPanel.open
|
||||||
|
|
||||||
|
const textRef = useRef(text)
|
||||||
|
useEffect(() => {
|
||||||
|
textRef.current = text
|
||||||
|
}, [text])
|
||||||
|
|
||||||
|
const setText = useCallback<React.Dispatch<React.SetStateAction<string>>>(
|
||||||
|
(value) => {
|
||||||
|
if (typeof value === 'function') {
|
||||||
|
onTextChange(value(textRef.current))
|
||||||
|
} else {
|
||||||
|
onTextChange(value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onTextChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
const { handlePaste } = usePasteHandler(text, setText, {
|
||||||
|
supportedExts,
|
||||||
|
setFiles,
|
||||||
|
pasteLongTextAsFile,
|
||||||
|
pasteLongTextThreshold,
|
||||||
|
onResize: resizeTextArea,
|
||||||
|
t
|
||||||
|
})
|
||||||
|
|
||||||
|
const { handleDragEnter, handleDragLeave, handleDragOver, handleDrop, isDragging } = useFileDragDrop({
|
||||||
|
supportedExts,
|
||||||
|
setFiles,
|
||||||
|
onTextDropped: (droppedText) => setText((prev) => prev + droppedText),
|
||||||
|
enabled: config.enableDragDrop,
|
||||||
|
t
|
||||||
|
})
|
||||||
|
// 判断是否可以发送:文本不为空或有文件
|
||||||
|
const cannotSend = isEmpty && files.length === 0
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setExtensions(supportedExts)
|
||||||
|
}, [setExtensions, supportedExts])
|
||||||
|
|
||||||
|
const handleToggleExpanded = useCallback(
|
||||||
|
(nextState?: boolean) => {
|
||||||
|
const target = typeof nextState === 'boolean' ? nextState : !isExpanded
|
||||||
|
setIsExpanded(target)
|
||||||
|
focusTextarea()
|
||||||
|
},
|
||||||
|
[focusTextarea, setIsExpanded, isExpanded]
|
||||||
|
)
|
||||||
|
|
||||||
|
const translate = useCallback(async () => {
|
||||||
|
if (isTranslating) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsTranslating(true)
|
||||||
|
const translatedText = await translateText(text, getLanguageByLangcode(targetLanguage))
|
||||||
|
translatedText && setText(translatedText)
|
||||||
|
setTimeoutTimer('translate', () => resizeTextArea(), 0)
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Translation failed:', error as Error)
|
||||||
|
} finally {
|
||||||
|
setIsTranslating(false)
|
||||||
|
}
|
||||||
|
}, [getLanguageByLangcode, isTranslating, resizeTextArea, setText, setTimeoutTimer, targetLanguage, text])
|
||||||
|
|
||||||
|
const rootTriggerHandlerRef = useRef<((payload?: unknown) => void) | undefined>(undefined)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
rootTriggerHandlerRef.current = (payload) => {
|
||||||
|
const menuItems = triggers.getRootMenu()
|
||||||
|
|
||||||
|
if (text.trim()) {
|
||||||
|
menuItems.push({
|
||||||
|
label: t('translate.title'),
|
||||||
|
description: t('translate.menu.description'),
|
||||||
|
icon: <Languages size={16} />,
|
||||||
|
action: () => translate()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!menuItems.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggerInfo = (payload ?? {}) as QuickPanelTriggerInfo
|
||||||
|
quickPanelOpen({
|
||||||
|
title: t('settings.quickPanel.title'),
|
||||||
|
list: menuItems,
|
||||||
|
symbol: QuickPanelReservedSymbol.Root,
|
||||||
|
triggerInfo
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [triggers, quickPanelOpen, t, text, translate])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!config.enableQuickPanel) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const disposeRootTrigger = toolsRegistry.registerTrigger(
|
||||||
|
'inputbar-root',
|
||||||
|
QuickPanelReservedSymbol.Root,
|
||||||
|
(payload) => rootTriggerHandlerRef.current?.(payload)
|
||||||
|
)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposeRootTrigger()
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [config.enableQuickPanel])
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (event.key === 'Tab' && inputFocus) {
|
||||||
|
event.preventDefault()
|
||||||
|
const textArea = textareaRef.current?.resizableTextArea?.textArea
|
||||||
|
if (!textArea) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const cursorPosition = textArea.selectionStart
|
||||||
|
const selectionLength = textArea.selectionEnd - textArea.selectionStart
|
||||||
|
const text = textArea.value
|
||||||
|
|
||||||
|
let match = text.slice(cursorPosition + selectionLength).match(/\$\{[^}]+\}/)
|
||||||
|
let startIndex: number
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
match = text.match(/\$\{[^}]+\}/)
|
||||||
|
startIndex = match?.index ?? -1
|
||||||
|
} else {
|
||||||
|
startIndex = cursorPosition + selectionLength + match.index!
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startIndex !== -1) {
|
||||||
|
const endIndex = startIndex + match![0].length
|
||||||
|
textArea.setSelectionRange(startIndex, endIndex)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (autoTranslateWithSpace && event.key === ' ') {
|
||||||
|
setSpaceClickCount((prev) => prev + 1)
|
||||||
|
if (spaceClickTimer.current) {
|
||||||
|
clearTimeout(spaceClickTimer.current)
|
||||||
|
}
|
||||||
|
spaceClickTimer.current = setTimeout(() => {
|
||||||
|
setSpaceClickCount(0)
|
||||||
|
}, 200)
|
||||||
|
|
||||||
|
if (spaceClickCount === 2) {
|
||||||
|
logger.info('Triple space detected - trigger translation')
|
||||||
|
setSpaceClickCount(0)
|
||||||
|
translate()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isExpanded && event.key === 'Escape') {
|
||||||
|
event.stopPropagation()
|
||||||
|
handleToggleExpanded()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const isEnterPressed = event.key === 'Enter' && !event.nativeEvent.isComposing
|
||||||
|
if (isEnterPressed) {
|
||||||
|
if (isSendMessageKeyPressed(event, sendMessageShortcut)) {
|
||||||
|
handleSendMessage()
|
||||||
|
event.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.shiftKey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
const textArea = textareaRef.current?.resizableTextArea?.textArea
|
||||||
|
if (textArea) {
|
||||||
|
const start = textArea.selectionStart
|
||||||
|
const end = textArea.selectionEnd
|
||||||
|
const currentText = textArea.value
|
||||||
|
const newText = currentText.substring(0, start) + '\n' + currentText.substring(end)
|
||||||
|
|
||||||
|
setText(newText)
|
||||||
|
|
||||||
|
setTimeoutTimer(
|
||||||
|
'handleKeyDown',
|
||||||
|
() => {
|
||||||
|
textArea.selectionStart = textArea.selectionEnd = start + 1
|
||||||
|
},
|
||||||
|
0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Backspace' && text.length === 0 && files.length > 0) {
|
||||||
|
setFiles((prev) => prev.slice(0, -1))
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
inputFocus,
|
||||||
|
autoTranslateWithSpace,
|
||||||
|
isExpanded,
|
||||||
|
text.length,
|
||||||
|
files.length,
|
||||||
|
textareaRef,
|
||||||
|
spaceClickCount,
|
||||||
|
translate,
|
||||||
|
handleToggleExpanded,
|
||||||
|
sendMessageShortcut,
|
||||||
|
handleSendMessage,
|
||||||
|
setText,
|
||||||
|
setTimeoutTimer,
|
||||||
|
setFiles
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleTextareaChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
const newText = e.target.value
|
||||||
|
setText(newText)
|
||||||
|
|
||||||
|
const isDeletion = newText.length < textRef.current.length
|
||||||
|
|
||||||
|
const textArea = textareaRef.current?.resizableTextArea?.textArea
|
||||||
|
const cursorPosition = textArea?.selectionStart ?? newText.length
|
||||||
|
const lastSymbol = newText[cursorPosition - 1]
|
||||||
|
const previousChar = newText[cursorPosition - 2]
|
||||||
|
const isCursorAtTextStart = cursorPosition <= 1
|
||||||
|
const hasValidTriggerBoundary = previousChar === ' ' || isCursorAtTextStart
|
||||||
|
|
||||||
|
const openRootPanelAt = (position: number) => {
|
||||||
|
triggers.emit(QuickPanelReservedSymbol.Root, {
|
||||||
|
type: 'input',
|
||||||
|
position,
|
||||||
|
originalText: newText
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const openMentionPanelAt = (position: number) => {
|
||||||
|
triggers.emit(QuickPanelReservedSymbol.MentionModels, {
|
||||||
|
type: 'input',
|
||||||
|
position,
|
||||||
|
originalText: newText
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quickPanelTriggersEnabled && config.enableQuickPanel) {
|
||||||
|
const hasRootMenuItems = triggers.getRootMenu().length > 0
|
||||||
|
const textBeforeCursor = newText.slice(0, cursorPosition)
|
||||||
|
const lastRootIndex = textBeforeCursor.lastIndexOf(QuickPanelReservedSymbol.Root)
|
||||||
|
const lastMentionIndex = textBeforeCursor.lastIndexOf(QuickPanelReservedSymbol.MentionModels)
|
||||||
|
const lastTriggerIndex = Math.max(lastRootIndex, lastMentionIndex)
|
||||||
|
|
||||||
|
const allowResumeSearch =
|
||||||
|
!quickPanel.isVisible &&
|
||||||
|
(quickPanel.lastCloseAction === undefined || quickPanel.lastCloseAction === 'outsideclick')
|
||||||
|
|
||||||
|
if (!quickPanel.isVisible && lastTriggerIndex !== -1 && cursorPosition > lastTriggerIndex) {
|
||||||
|
const triggerChar = newText[lastTriggerIndex]
|
||||||
|
const boundaryChar = newText[lastTriggerIndex - 1] ?? ''
|
||||||
|
const hasBoundary = lastTriggerIndex === 0 || /\s/.test(boundaryChar)
|
||||||
|
const searchSegment = newText.slice(lastTriggerIndex + 1, cursorPosition)
|
||||||
|
const hasSearchContent = searchSegment.trim().length > 0
|
||||||
|
|
||||||
|
if (hasBoundary && (!hasSearchContent || isDeletion || allowResumeSearch)) {
|
||||||
|
if (triggerChar === QuickPanelReservedSymbol.Root && hasRootMenuItems) {
|
||||||
|
openRootPanelAt(lastTriggerIndex)
|
||||||
|
} else if (triggerChar === QuickPanelReservedSymbol.MentionModels) {
|
||||||
|
openMentionPanelAt(lastTriggerIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastSymbol === QuickPanelReservedSymbol.Root && hasValidTriggerBoundary && hasRootMenuItems) {
|
||||||
|
if (quickPanel.isVisible && quickPanel.symbol !== QuickPanelReservedSymbol.Root) {
|
||||||
|
quickPanel.close('switch-symbol')
|
||||||
|
}
|
||||||
|
if (!quickPanel.isVisible || quickPanel.symbol !== QuickPanelReservedSymbol.Root) {
|
||||||
|
openRootPanelAt(cursorPosition - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastSymbol === QuickPanelReservedSymbol.MentionModels && hasValidTriggerBoundary) {
|
||||||
|
if (quickPanel.isVisible && quickPanel.symbol !== QuickPanelReservedSymbol.MentionModels) {
|
||||||
|
quickPanel.close('switch-symbol')
|
||||||
|
}
|
||||||
|
if (!quickPanel.isVisible || quickPanel.symbol !== QuickPanelReservedSymbol.MentionModels) {
|
||||||
|
openMentionPanelAt(cursorPosition - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quickPanel.isVisible && quickPanel.triggerInfo?.type === 'input') {
|
||||||
|
const activeSymbol = quickPanel.symbol as QuickPanelReservedSymbol
|
||||||
|
const triggerPosition = quickPanel.triggerInfo.position ?? -1
|
||||||
|
const isTrackedSymbol =
|
||||||
|
activeSymbol === QuickPanelReservedSymbol.Root || activeSymbol === QuickPanelReservedSymbol.MentionModels
|
||||||
|
|
||||||
|
if (isTrackedSymbol && triggerPosition >= 0) {
|
||||||
|
// Check if cursor is before the trigger position (user deleted the symbol)
|
||||||
|
if (cursorPosition <= triggerPosition) {
|
||||||
|
quickPanel.close('delete-symbol')
|
||||||
|
} else {
|
||||||
|
// Check if the trigger symbol still exists at the expected position
|
||||||
|
const triggerChar = newText[triggerPosition]
|
||||||
|
if (triggerChar !== activeSymbol) {
|
||||||
|
quickPanel.close('delete-symbol')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setText, textareaRef, quickPanelTriggersEnabled, config.enableQuickPanel, quickPanel, triggers]
|
||||||
|
)
|
||||||
|
|
||||||
|
const onTranslated = useCallback(
|
||||||
|
(translatedText: string) => {
|
||||||
|
setText(translatedText)
|
||||||
|
setTimeoutTimer('onTranslated', () => resizeTextArea(), 0)
|
||||||
|
},
|
||||||
|
[resizeTextArea, setText, setTimeoutTimer]
|
||||||
|
)
|
||||||
|
|
||||||
|
const appendTxtContentToInput = useCallback(
|
||||||
|
async (file: FileType, event: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const targetPath = file.path
|
||||||
|
const content = await window.api.file.readExternal(targetPath, true)
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(content)
|
||||||
|
} catch (clipboardError) {
|
||||||
|
logger.warn('Failed to copy txt attachment content to clipboard:', clipboardError as Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
setText((prev) => {
|
||||||
|
if (!prev) {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
const needsSeparator = !prev.endsWith('\n')
|
||||||
|
return needsSeparator ? `${prev}\n${content}` : prev + content
|
||||||
|
})
|
||||||
|
|
||||||
|
setFiles((prev) => prev.filter((currentFile) => currentFile.id !== file.id))
|
||||||
|
|
||||||
|
setTimeoutTimer(
|
||||||
|
'appendTxtAttachment',
|
||||||
|
() => {
|
||||||
|
const textArea = textareaRef.current?.resizableTextArea?.textArea
|
||||||
|
if (textArea) {
|
||||||
|
const end = textArea.value.length
|
||||||
|
focusTextarea()
|
||||||
|
textArea.setSelectionRange(end, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
resizeTextArea(true)
|
||||||
|
},
|
||||||
|
0
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to append txt attachment content:', error as Error)
|
||||||
|
window.toast.error(t('chat.input.file_error'))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[focusTextarea, resizeTextArea, setFiles, setText, setTimeoutTimer, t, textareaRef]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleFocus = useCallback(() => {
|
||||||
|
setInputFocus(true)
|
||||||
|
dispatch(setSearching(false))
|
||||||
|
if (quickPanel.isVisible && quickPanel.triggerInfo?.type !== 'input') {
|
||||||
|
quickPanel.close()
|
||||||
|
}
|
||||||
|
PasteService.setLastFocusedComponent('inputbar')
|
||||||
|
}, [dispatch, quickPanel])
|
||||||
|
|
||||||
|
const handleDragStart = useCallback(
|
||||||
|
(event: React.MouseEvent) => {
|
||||||
|
if (!config.enableDragDrop) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
startDragY.current = event.clientY
|
||||||
|
startHeight.current = textareaRef.current?.resizableTextArea?.textArea?.offsetHeight || 0
|
||||||
|
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
const deltaY = startDragY.current - e.clientY
|
||||||
|
const newHeight = Math.max(40, Math.min(400, startHeight.current + deltaY))
|
||||||
|
setTextareaHeight(newHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove)
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMouseMove)
|
||||||
|
document.addEventListener('mouseup', handleMouseUp)
|
||||||
|
},
|
||||||
|
[config.enableDragDrop, setTextareaHeight, textareaRef]
|
||||||
|
)
|
||||||
|
|
||||||
|
const onQuote = useCallback(
|
||||||
|
(quoted: string) => {
|
||||||
|
const formatted = formatQuotedText(quoted)
|
||||||
|
setText((prevText) => {
|
||||||
|
const next = prevText ? `${prevText}\n${formatted}\n` : `${formatted}\n`
|
||||||
|
setTimeoutTimer('onQuote', () => resizeTextArea(), 0)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
focusTextarea()
|
||||||
|
},
|
||||||
|
[focusTextarea, resizeTextArea, setText, setTimeoutTimer]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const quoteListener = window.electron?.ipcRenderer.on(IpcChannel.App_QuoteToMain, (_, selectedText: string) =>
|
||||||
|
onQuote(selectedText)
|
||||||
|
)
|
||||||
|
return () => {
|
||||||
|
quoteListener?.()
|
||||||
|
}
|
||||||
|
}, [onQuote])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timerId = requestAnimationFrame(() => resizeTextArea())
|
||||||
|
return () => cancelAnimationFrame(timerId)
|
||||||
|
}, [resizeTextArea])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onFocus = () => {
|
||||||
|
if (document.activeElement?.closest('.ant-modal')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastFocusedComponent = PasteService.getLastFocusedComponent()
|
||||||
|
if (!lastFocusedComponent || lastFocusedComponent === 'inputbar') {
|
||||||
|
focusTextarea()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('focus', onFocus)
|
||||||
|
return () => window.removeEventListener('focus', onFocus)
|
||||||
|
}, [focusTextarea])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
PasteService.init()
|
||||||
|
|
||||||
|
PasteService.registerHandler('inputbar', handlePaste)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
PasteService.unregisterHandler('inputbar')
|
||||||
|
}
|
||||||
|
}, [handlePaste])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (spaceClickTimer.current) {
|
||||||
|
clearTimeout(spaceClickTimer.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const rightSectionExtras = useMemo(() => {
|
||||||
|
const extras: React.ReactNode[] = []
|
||||||
|
extras.push(<TranslateButton key="translate" text={text} onTranslated={onTranslated} isLoading={isTranslating} />)
|
||||||
|
extras.push(<SendMessageButton sendMessage={handleSendMessage} disabled={cannotSend || isLoading || searching} />)
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
extras.push(
|
||||||
|
<Tooltip key="pause" placement="top" title={t('chat.input.pause')} mouseLeaveDelay={0} arrow>
|
||||||
|
<ActionIconButton onClick={onPause} style={{ marginRight: -2 }}>
|
||||||
|
<CirclePause size={20} color="var(--color-error)" />
|
||||||
|
</ActionIconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{extras}</>
|
||||||
|
}, [text, onTranslated, isTranslating, handleSendMessage, cannotSend, isLoading, searching, t, onPause])
|
||||||
|
|
||||||
|
const quickPanelElement = config.enableQuickPanel ? <QuickPanelView setInputText={setText} /> : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NarrowLayout style={{ width: '100%' }}>
|
||||||
|
<Container
|
||||||
|
onDragEnter={handleDragEnter}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
className={classNames('inputbar')}>
|
||||||
|
{quickPanelElement}
|
||||||
|
<InputBarContainer
|
||||||
|
id="inputbar"
|
||||||
|
className={classNames('inputbar-container', isDragging && 'file-dragging', isExpanded && 'expanded')}>
|
||||||
|
<DragHandle onMouseDown={handleDragStart}>
|
||||||
|
<HolderOutlined style={{ fontSize: 12 }} />
|
||||||
|
</DragHandle>
|
||||||
|
{files.length > 0 && (
|
||||||
|
<AttachmentPreview files={files} setFiles={setFiles} onAttachmentContextMenu={appendTxtContentToInput} />
|
||||||
|
)}
|
||||||
|
{topContent}
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={text}
|
||||||
|
onChange={handleTextareaChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onPaste={(e) => handlePaste(e.nativeEvent)}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={() => setInputFocus(false)}
|
||||||
|
placeholder={isTranslating ? t('chat.input.translating') : placeholder}
|
||||||
|
autoFocus
|
||||||
|
variant="borderless"
|
||||||
|
spellCheck={enableSpellCheck}
|
||||||
|
rows={2}
|
||||||
|
autoSize={textareaHeight ? false : { minRows: 2, maxRows: 20 }}
|
||||||
|
styles={{ textarea: TextareaStyle }}
|
||||||
|
style={{
|
||||||
|
fontSize,
|
||||||
|
height: textareaHeight,
|
||||||
|
minHeight: '30px'
|
||||||
|
}}
|
||||||
|
disabled={isTranslating || searching}
|
||||||
|
onClick={() => {
|
||||||
|
searching && dispatch(setSearching(false))
|
||||||
|
quickPanel.close()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BottomBar>
|
||||||
|
<LeftSection>{leftToolbar}</LeftSection>
|
||||||
|
<RightSection>
|
||||||
|
{rightToolbar}
|
||||||
|
{rightSectionExtras}
|
||||||
|
</RightSection>
|
||||||
|
</BottomBar>
|
||||||
|
</InputBarContainer>
|
||||||
|
</Container>
|
||||||
|
</NarrowLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Styled Components
|
||||||
|
const DragHandle = styled.div`
|
||||||
|
position: absolute;
|
||||||
|
top: -3px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: row-resize;
|
||||||
|
color: var(--color-icon);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anticon {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const Container = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
padding: 0 18px 18px 18px;
|
||||||
|
[navbar-position='top'] & {
|
||||||
|
padding: 0 18px 10px 18px;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const InputBarContainer = styled.div`
|
||||||
|
border: 0.5px solid var(--color-border);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
position: relative;
|
||||||
|
border-radius: 17px;
|
||||||
|
padding-top: 8px;
|
||||||
|
background-color: var(--color-background-opacity);
|
||||||
|
|
||||||
|
&.file-dragging {
|
||||||
|
border: 2px dashed #2ecc71;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(46, 204, 113, 0.03);
|
||||||
|
border-radius: 14px;
|
||||||
|
z-index: 5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const Textarea = styled(TextArea)`
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
display: flex;
|
||||||
|
resize: none !important;
|
||||||
|
overflow: auto;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: none !important;
|
||||||
|
&.ant-input {
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 3px;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const BottomBar = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 5px 8px;
|
||||||
|
height: 40px;
|
||||||
|
gap: 16px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
flex-shrink: 0;
|
||||||
|
`
|
||||||
|
|
||||||
|
const LeftSection = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
`
|
||||||
|
|
||||||
|
const RightSection = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
`
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
import type { QuickPanelListItem, QuickPanelReservedSymbol } from '@renderer/components/QuickPanel'
|
||||||
|
import type { FileType, KnowledgeBase, Model } from '@renderer/types'
|
||||||
|
import { FileTypes } from '@renderer/types'
|
||||||
|
import React, { createContext, use, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
type QuickPanelTriggerHandler = (payload?: unknown) => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only state interface for Inputbar tools.
|
||||||
|
* Components subscribing to this state will re-render on changes.
|
||||||
|
*/
|
||||||
|
export interface InputbarToolsState {
|
||||||
|
/** Attached files */
|
||||||
|
files: FileType[]
|
||||||
|
/** Models mentioned in the input */
|
||||||
|
mentionedModels: Model[]
|
||||||
|
/** Selected knowledge base items */
|
||||||
|
selectedKnowledgeBases: KnowledgeBase[]
|
||||||
|
/** Whether the inputbar is expanded */
|
||||||
|
isExpanded: boolean
|
||||||
|
|
||||||
|
/** Whether image files can be added (derived state) */
|
||||||
|
couldAddImageFile: boolean
|
||||||
|
/** Whether non-vision models can be mentioned (derived state) */
|
||||||
|
couldMentionNotVisionModel: boolean
|
||||||
|
/** Supported file extensions (derived state) */
|
||||||
|
extensions: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tools registry API for tool buttons.
|
||||||
|
* Used to register menu items and triggers.
|
||||||
|
*/
|
||||||
|
export interface ToolsRegistryAPI {
|
||||||
|
/**
|
||||||
|
* Register a tool to the root menu (triggered by `/`).
|
||||||
|
* @param toolKey - Unique tool identifier
|
||||||
|
* @param entries - Menu items to register
|
||||||
|
* @returns Cleanup function to unregister
|
||||||
|
*/
|
||||||
|
registerRootMenu: (toolKey: string, entries: QuickPanelListItem[]) => () => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a trigger handler function.
|
||||||
|
* @param toolKey - Unique tool identifier
|
||||||
|
* @param symbol - Trigger symbol (e.g., @, #, /)
|
||||||
|
* @param handler - Handler function to execute on trigger
|
||||||
|
* @returns Cleanup function to unregister
|
||||||
|
*/
|
||||||
|
registerTrigger: (toolKey: string, symbol: QuickPanelReservedSymbol, handler: QuickPanelTriggerHandler) => () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers API for Inputbar component.
|
||||||
|
* Used to trigger panels and retrieve menu items.
|
||||||
|
*/
|
||||||
|
export interface TriggersAPI {
|
||||||
|
/**
|
||||||
|
* Emit a trigger for the specified symbol.
|
||||||
|
* @param symbol - Trigger symbol
|
||||||
|
* @param payload - Data to pass to trigger handlers
|
||||||
|
*/
|
||||||
|
emit: (symbol: QuickPanelReservedSymbol, payload?: unknown) => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all root menu items (merged from all registered tools).
|
||||||
|
* @returns Merged menu items list
|
||||||
|
*/
|
||||||
|
getRootMenu: () => QuickPanelListItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch interface containing all action functions.
|
||||||
|
* These functions have stable references and won't cause re-renders.
|
||||||
|
*/
|
||||||
|
export interface InputbarToolsDispatch {
|
||||||
|
/** State setters */
|
||||||
|
setFiles: React.Dispatch<React.SetStateAction<FileType[]>>
|
||||||
|
setMentionedModels: React.Dispatch<React.SetStateAction<Model[]>>
|
||||||
|
setSelectedKnowledgeBases: React.Dispatch<React.SetStateAction<KnowledgeBase[]>>
|
||||||
|
setIsExpanded: React.Dispatch<React.SetStateAction<boolean>>
|
||||||
|
|
||||||
|
/** Parent component actions */
|
||||||
|
resizeTextArea: () => void
|
||||||
|
addNewTopic: () => void
|
||||||
|
clearTopic: () => void
|
||||||
|
onNewContext: () => void
|
||||||
|
toggleExpanded: (nextState?: boolean) => void
|
||||||
|
|
||||||
|
/** Text manipulation (avoids putting text state in Context) */
|
||||||
|
onTextChange: (updater: string | ((prev: string) => string)) => void
|
||||||
|
|
||||||
|
/** Tools registry API (for tool buttons) */
|
||||||
|
toolsRegistry: ToolsRegistryAPI
|
||||||
|
|
||||||
|
/** Triggers API (for Inputbar component) */
|
||||||
|
triggers: TriggersAPI
|
||||||
|
}
|
||||||
|
|
||||||
|
const InputbarToolsStateContext = createContext<InputbarToolsState | undefined>(undefined)
|
||||||
|
const InputbarToolsDispatchContext = createContext<InputbarToolsDispatch | undefined>(undefined)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Inputbar Tools state (read-only).
|
||||||
|
* Components using this hook will re-render when state changes.
|
||||||
|
*/
|
||||||
|
export const useInputbarToolsState = (): InputbarToolsState => {
|
||||||
|
const context = use(InputbarToolsStateContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useInputbarToolsState must be used within InputbarToolsProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Inputbar Tools dispatch functions (stable references).
|
||||||
|
* Components using this hook won't re-render when state changes.
|
||||||
|
*/
|
||||||
|
export const useInputbarToolsDispatch = (): InputbarToolsDispatch => {
|
||||||
|
const context = use(InputbarToolsDispatchContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useInputbarToolsDispatch must be used within InputbarToolsProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combined type containing both state and dispatch.
|
||||||
|
* Used for type inference in tool buttons.
|
||||||
|
*/
|
||||||
|
export type InputbarToolsContextValue = InputbarToolsState & InputbarToolsDispatch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get both state and dispatch (convenience hook).
|
||||||
|
* Components using this hook will re-render when state changes.
|
||||||
|
*/
|
||||||
|
export const useInputbarTools = (): InputbarToolsContextValue => {
|
||||||
|
const state = useInputbarToolsState()
|
||||||
|
const dispatch = useInputbarToolsDispatch()
|
||||||
|
return { ...state, ...dispatch }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InputbarToolsProviderProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
initialState?: Partial<{
|
||||||
|
files: FileType[]
|
||||||
|
mentionedModels: Model[]
|
||||||
|
selectedKnowledgeBases: KnowledgeBase[]
|
||||||
|
isExpanded: boolean
|
||||||
|
couldAddImageFile: boolean
|
||||||
|
extensions: string[]
|
||||||
|
}>
|
||||||
|
actions: {
|
||||||
|
resizeTextArea: () => void
|
||||||
|
addNewTopic: () => void
|
||||||
|
clearTopic: () => void
|
||||||
|
onNewContext: () => void
|
||||||
|
onTextChange: (updater: string | ((prev: string) => string)) => void
|
||||||
|
toggleExpanded: (nextState?: boolean) => void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InputbarToolsProvider: React.FC<InputbarToolsProviderProps> = ({ children, initialState, actions }) => {
|
||||||
|
// Core state
|
||||||
|
const [files, setFiles] = useState<FileType[]>(initialState?.files || [])
|
||||||
|
const [mentionedModels, setMentionedModels] = useState<Model[]>(initialState?.mentionedModels || [])
|
||||||
|
const [selectedKnowledgeBases, setSelectedKnowledgeBases] = useState<KnowledgeBase[]>(
|
||||||
|
initialState?.selectedKnowledgeBases || []
|
||||||
|
)
|
||||||
|
const [isExpanded, setIsExpanded] = useState(initialState?.isExpanded || false)
|
||||||
|
|
||||||
|
// Derived state (internal management)
|
||||||
|
const [couldAddImageFile, setCouldAddImageFile] = useState(initialState?.couldAddImageFile || false)
|
||||||
|
const [extensions, setExtensions] = useState<string[]>(initialState?.extensions || [])
|
||||||
|
|
||||||
|
const couldMentionNotVisionModel = !files.some((file) => file.type === FileTypes.IMAGE)
|
||||||
|
|
||||||
|
// Quick Panel Registry (stored in refs to avoid re-renders)
|
||||||
|
const rootMenuRegistryRef = useRef(new Map<string, QuickPanelListItem[]>())
|
||||||
|
const triggerRegistryRef = useRef(new Map<QuickPanelReservedSymbol, Map<string, QuickPanelTriggerHandler>>())
|
||||||
|
|
||||||
|
// Quick Panel API (stable references)
|
||||||
|
const getQuickPanelRootMenu = useCallback(() => {
|
||||||
|
const allEntries: QuickPanelListItem[] = []
|
||||||
|
rootMenuRegistryRef.current.forEach((entries) => {
|
||||||
|
allEntries.push(...entries)
|
||||||
|
})
|
||||||
|
return allEntries
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const registerRootMenu = useCallback((toolKey: string, entries: QuickPanelListItem[]) => {
|
||||||
|
rootMenuRegistryRef.current.set(toolKey, entries)
|
||||||
|
return () => {
|
||||||
|
rootMenuRegistryRef.current.delete(toolKey)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const registerTrigger = useCallback(
|
||||||
|
(toolKey: string, symbol: QuickPanelReservedSymbol, handler: QuickPanelTriggerHandler) => {
|
||||||
|
if (!triggerRegistryRef.current.has(symbol)) {
|
||||||
|
triggerRegistryRef.current.set(symbol, new Map())
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlers = triggerRegistryRef.current.get(symbol)!
|
||||||
|
handlers.set(toolKey, handler)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const currentHandlers = triggerRegistryRef.current.get(symbol)
|
||||||
|
if (!currentHandlers) return
|
||||||
|
|
||||||
|
currentHandlers.delete(toolKey)
|
||||||
|
if (currentHandlers.size === 0) {
|
||||||
|
triggerRegistryRef.current.delete(symbol)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const emitTrigger = useCallback((symbol: QuickPanelReservedSymbol, payload?: unknown) => {
|
||||||
|
const handlers = triggerRegistryRef.current.get(symbol)
|
||||||
|
handlers?.forEach((handler) => {
|
||||||
|
handler?.(payload)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Stabilize parent actions (prevent dispatch context updates from parent action reference changes)
|
||||||
|
const actionsRef = useRef(actions)
|
||||||
|
useEffect(() => {
|
||||||
|
actionsRef.current = actions
|
||||||
|
}, [actions])
|
||||||
|
|
||||||
|
const stableActions = useMemo(
|
||||||
|
() => ({
|
||||||
|
resizeTextArea: () => actionsRef.current.resizeTextArea(),
|
||||||
|
addNewTopic: () => actionsRef.current.addNewTopic(),
|
||||||
|
clearTopic: () => actionsRef.current.clearTopic(),
|
||||||
|
onNewContext: () => actionsRef.current.onNewContext(),
|
||||||
|
onTextChange: (updater: string | ((prev: string) => string)) => actionsRef.current.onTextChange(updater),
|
||||||
|
toggleExpanded: (nextState?: boolean) => actionsRef.current.toggleExpanded(nextState)
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
// State Context Value (updates when state changes)
|
||||||
|
const stateValue = useMemo<InputbarToolsState>(
|
||||||
|
() => ({
|
||||||
|
files,
|
||||||
|
mentionedModels,
|
||||||
|
selectedKnowledgeBases,
|
||||||
|
isExpanded,
|
||||||
|
couldAddImageFile,
|
||||||
|
couldMentionNotVisionModel,
|
||||||
|
extensions
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
files,
|
||||||
|
mentionedModels,
|
||||||
|
selectedKnowledgeBases,
|
||||||
|
isExpanded,
|
||||||
|
couldAddImageFile,
|
||||||
|
couldMentionNotVisionModel,
|
||||||
|
extensions
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tools Registry API (stable references for tool buttons)
|
||||||
|
const toolsRegistryAPI = useMemo<ToolsRegistryAPI>(
|
||||||
|
() => ({
|
||||||
|
registerRootMenu,
|
||||||
|
registerTrigger
|
||||||
|
}),
|
||||||
|
[registerRootMenu, registerTrigger]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Triggers API (stable references for Inputbar component)
|
||||||
|
const triggersAPI = useMemo<TriggersAPI>(
|
||||||
|
() => ({
|
||||||
|
emit: emitTrigger,
|
||||||
|
getRootMenu: getQuickPanelRootMenu
|
||||||
|
}),
|
||||||
|
[emitTrigger, getQuickPanelRootMenu]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Dispatch Context Value (stable references)
|
||||||
|
const dispatchValue = useMemo<InputbarToolsDispatch>(
|
||||||
|
() => ({
|
||||||
|
// State setters (React guarantees stable references)
|
||||||
|
setFiles,
|
||||||
|
setMentionedModels,
|
||||||
|
setSelectedKnowledgeBases,
|
||||||
|
setIsExpanded,
|
||||||
|
|
||||||
|
// Stable actions
|
||||||
|
...stableActions,
|
||||||
|
|
||||||
|
// API objects
|
||||||
|
toolsRegistry: toolsRegistryAPI,
|
||||||
|
triggers: triggersAPI
|
||||||
|
}),
|
||||||
|
[stableActions, toolsRegistryAPI, triggersAPI]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Internal Dispatch (contains setCouldAddImageFile and setExtensions)
|
||||||
|
// These setters are exposed to Inputbar but not to tool buttons
|
||||||
|
// Using a separate internal context to avoid polluting the main dispatch context
|
||||||
|
const internalDispatchValue = useMemo(
|
||||||
|
() => ({
|
||||||
|
setCouldAddImageFile,
|
||||||
|
setExtensions
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InputbarToolsStateContext value={stateValue}>
|
||||||
|
<InputbarToolsDispatchContext value={dispatchValue}>
|
||||||
|
<InputbarToolsInternalDispatchContext value={internalDispatchValue}>
|
||||||
|
{children}
|
||||||
|
</InputbarToolsInternalDispatchContext>
|
||||||
|
</InputbarToolsDispatchContext>
|
||||||
|
</InputbarToolsStateContext>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal dispatch interface for Inputbar component only.
|
||||||
|
* Used to set derived state (couldAddImageFile, extensions).
|
||||||
|
*/
|
||||||
|
interface InputbarToolsInternalDispatch {
|
||||||
|
setCouldAddImageFile: React.Dispatch<React.SetStateAction<boolean>>
|
||||||
|
setExtensions: React.Dispatch<React.SetStateAction<string[]>>
|
||||||
|
}
|
||||||
|
|
||||||
|
const InputbarToolsInternalDispatchContext = createContext<InputbarToolsInternalDispatch | undefined>(undefined)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal hook for Inputbar component only.
|
||||||
|
* Used to set derived state (couldAddImageFile, extensions).
|
||||||
|
*/
|
||||||
|
export const useInputbarToolsInternalDispatch = (): InputbarToolsInternalDispatch => {
|
||||||
|
const context = use(InputbarToolsInternalDispatchContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useInputbarToolsInternalDispatch must be used within InputbarToolsProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { loggerService } from '@logger'
|
||||||
|
import { useDrag } from '@renderer/hooks/useDrag'
|
||||||
|
import type { FileType } from '@renderer/types'
|
||||||
|
import { filterSupportedFiles } from '@renderer/utils'
|
||||||
|
import { getFilesFromDropEvent, getTextFromDropEvent } from '@renderer/utils/input'
|
||||||
|
import type { TFunction } from 'i18next'
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('useFileDragDrop')
|
||||||
|
|
||||||
|
export interface UseFileDragDropOptions {
|
||||||
|
supportedExts: string[]
|
||||||
|
setFiles: (updater: (prevFiles: FileType[]) => FileType[]) => void
|
||||||
|
onTextDropped?: (text: string) => void
|
||||||
|
enabled?: boolean
|
||||||
|
t: TFunction
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inputbar 文件拖拽上传 Hook
|
||||||
|
*
|
||||||
|
* 处理文件拖拽、文本拖拽,支持文件类型过滤和错误提示
|
||||||
|
*
|
||||||
|
* @param options - 拖拽配置选项
|
||||||
|
* @returns 拖拽状态和事件处理函数
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const dragDrop = useFileDragDrop({
|
||||||
|
* supportedExts: ['.png', '.jpg', '.pdf'],
|
||||||
|
* setFiles: (updater) => setFiles(updater),
|
||||||
|
* onTextDropped: (text) => setText(text),
|
||||||
|
* enabled: true,
|
||||||
|
* t: useTranslation().t
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* <div
|
||||||
|
* onDragEnter={dragDrop.handleDragEnter}
|
||||||
|
* onDragLeave={dragDrop.handleDragLeave}
|
||||||
|
* onDragOver={dragDrop.handleDragOver}
|
||||||
|
* onDrop={dragDrop.handleDrop}
|
||||||
|
* className={dragDrop.isDragging ? 'dragging' : ''}
|
||||||
|
* >
|
||||||
|
* Drop files here
|
||||||
|
* </div>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useFileDragDrop(options: UseFileDragDropOptions) {
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
async (event: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
if (!options.enabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理文本拖拽
|
||||||
|
const droppedText = await getTextFromDropEvent(event)
|
||||||
|
if (droppedText) {
|
||||||
|
options.onTextDropped?.(droppedText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理文件拖拽
|
||||||
|
const droppedFiles = await getFilesFromDropEvent(event).catch((err) => {
|
||||||
|
logger.error('handleDrop:', err)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
if (droppedFiles) {
|
||||||
|
const supportedFiles = await filterSupportedFiles(droppedFiles, options.supportedExts)
|
||||||
|
if (supportedFiles.length > 0) {
|
||||||
|
options.setFiles((prevFiles) => [...prevFiles, ...supportedFiles])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有不支持的文件,显示提示
|
||||||
|
if (droppedFiles.length > 0 && supportedFiles.length !== droppedFiles.length) {
|
||||||
|
window.toast.info(
|
||||||
|
options.t('chat.input.file_not_supported_count', {
|
||||||
|
count: droppedFiles.length - supportedFiles.length
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[options]
|
||||||
|
)
|
||||||
|
|
||||||
|
const dragState = useDrag(handleDrop)
|
||||||
|
|
||||||
|
return {
|
||||||
|
isDragging: options.enabled ? dragState.isDragging : false,
|
||||||
|
setIsDragging: dragState.setIsDragging,
|
||||||
|
handleDragOver: options.enabled ? dragState.handleDragOver : undefined,
|
||||||
|
handleDragEnter: options.enabled ? dragState.handleDragEnter : undefined,
|
||||||
|
handleDragLeave: options.enabled ? dragState.handleDragLeave : undefined,
|
||||||
|
handleDrop: options.enabled ? dragState.handleDrop : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import PasteService from '@renderer/services/PasteService'
|
||||||
|
import type { FileMetadata } from '@renderer/types'
|
||||||
|
import type { TFunction } from 'i18next'
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
|
||||||
|
export interface UsePasteHandlerOptions {
|
||||||
|
supportedExts: string[]
|
||||||
|
pasteLongTextAsFile?: boolean
|
||||||
|
pasteLongTextThreshold?: number
|
||||||
|
setFiles: (updater: (prevFiles: FileMetadata[]) => FileMetadata[]) => void
|
||||||
|
onResize?: () => void
|
||||||
|
t: TFunction
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inputbar 专用粘贴处理 Hook
|
||||||
|
*
|
||||||
|
* 处理文件、长文本、图片等粘贴场景,集成 PasteService
|
||||||
|
*
|
||||||
|
* @param text - 当前文本内容
|
||||||
|
* @param setText - 设置文本的函数
|
||||||
|
* @param options - 粘贴处理配置
|
||||||
|
* @returns 粘贴事件处理函数
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const { handlePaste } = usePasteHandler(text, setText, {
|
||||||
|
* supportedExts: ['.png', '.jpg', '.pdf'],
|
||||||
|
* pasteLongTextAsFile: true,
|
||||||
|
* pasteLongTextThreshold: 5000,
|
||||||
|
* setFiles: (updater) => setFiles(updater),
|
||||||
|
* onResize: () => resize(),
|
||||||
|
* t: useTranslation().t
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* <textarea onPaste={handlePaste} />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function usePasteHandler(
|
||||||
|
text: string,
|
||||||
|
setText: (text: string | ((prev: string) => string)) => void,
|
||||||
|
options: UsePasteHandlerOptions
|
||||||
|
) {
|
||||||
|
const handlePaste = useCallback(
|
||||||
|
async (event: ClipboardEvent) => {
|
||||||
|
return await PasteService.handlePaste(
|
||||||
|
event,
|
||||||
|
options.supportedExts,
|
||||||
|
options.setFiles,
|
||||||
|
setText,
|
||||||
|
options.pasteLongTextAsFile ?? false,
|
||||||
|
options.pasteLongTextThreshold ?? 5000,
|
||||||
|
text,
|
||||||
|
options.onResize ?? (() => {}),
|
||||||
|
options.t
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[text, setText, options]
|
||||||
|
)
|
||||||
|
|
||||||
|
return { handlePaste }
|
||||||
|
}
|
||||||
53
src/renderer/src/pages/home/Inputbar/registry.ts
Normal file
53
src/renderer/src/pages/home/Inputbar/registry.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { TopicType } from '@renderer/types'
|
||||||
|
|
||||||
|
import type { InputbarScope, InputbarScopeConfig } from './types'
|
||||||
|
|
||||||
|
const DEFAULT_INPUTBAR_SCOPE: InputbarScope = TopicType.Chat
|
||||||
|
|
||||||
|
const inputbarRegistry = new Map<InputbarScope, InputbarScopeConfig>([
|
||||||
|
[
|
||||||
|
TopicType.Chat,
|
||||||
|
{
|
||||||
|
minRows: 1,
|
||||||
|
maxRows: 8,
|
||||||
|
showTokenCount: true,
|
||||||
|
showTools: true,
|
||||||
|
toolsCollapsible: true,
|
||||||
|
enableQuickPanel: true,
|
||||||
|
enableDragDrop: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
TopicType.Session,
|
||||||
|
{
|
||||||
|
placeholder: 'Type a message...',
|
||||||
|
minRows: 2,
|
||||||
|
maxRows: 20,
|
||||||
|
showTokenCount: false,
|
||||||
|
showTools: true,
|
||||||
|
toolsCollapsible: false,
|
||||||
|
enableQuickPanel: true,
|
||||||
|
enableDragDrop: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'mini-window',
|
||||||
|
{
|
||||||
|
minRows: 1,
|
||||||
|
maxRows: 3,
|
||||||
|
showTokenCount: false,
|
||||||
|
showTools: true,
|
||||||
|
toolsCollapsible: false,
|
||||||
|
enableQuickPanel: true,
|
||||||
|
enableDragDrop: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
])
|
||||||
|
|
||||||
|
export const registerInputbarConfig = (scope: InputbarScope, config: InputbarScopeConfig): void => {
|
||||||
|
inputbarRegistry.set(scope, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getInputbarConfig = (scope: InputbarScope): InputbarScopeConfig => {
|
||||||
|
return inputbarRegistry.get(scope) || inputbarRegistry.get(DEFAULT_INPUTBAR_SCOPE)!
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { defineTool, registerTool, TopicType } from '@renderer/pages/home/Inputbar/types'
|
||||||
|
import type React from 'react'
|
||||||
|
|
||||||
|
import ActivityDirectoryButton from './components/ActivityDirectoryButton'
|
||||||
|
import ActivityDirectoryQuickPanelManager from './components/ActivityDirectoryQuickPanelManager'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activity Directory Tool
|
||||||
|
*
|
||||||
|
* Allows users to search and select files from the agent's accessible directories.
|
||||||
|
* Uses @ trigger (same symbol as MentionModels, but different scope).
|
||||||
|
* Only visible in Agent Session (TopicType.Session).
|
||||||
|
*/
|
||||||
|
const activityDirectoryTool = defineTool({
|
||||||
|
key: 'activity_directory',
|
||||||
|
label: (t) => t('chat.input.activity_directory.title'),
|
||||||
|
visibleInScopes: [TopicType.Session],
|
||||||
|
|
||||||
|
dependencies: {
|
||||||
|
state: [] as const,
|
||||||
|
actions: ['onTextChange'] as const
|
||||||
|
},
|
||||||
|
|
||||||
|
render: function ActivityDirectoryToolRender(context) {
|
||||||
|
const { quickPanel, quickPanelController, actions, session } = context
|
||||||
|
const { onTextChange } = actions
|
||||||
|
|
||||||
|
// Get accessible paths from session data
|
||||||
|
const accessiblePaths = session?.accessiblePaths ?? []
|
||||||
|
|
||||||
|
// Only render if we have accessible paths
|
||||||
|
if (accessiblePaths.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActivityDirectoryButton
|
||||||
|
quickPanel={quickPanel}
|
||||||
|
quickPanelController={quickPanelController}
|
||||||
|
accessiblePaths={accessiblePaths}
|
||||||
|
setText={onTextChange as React.Dispatch<React.SetStateAction<string>>}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
quickPanelManager: ActivityDirectoryQuickPanelManager
|
||||||
|
})
|
||||||
|
|
||||||
|
registerTool(activityDirectoryTool)
|
||||||
|
|
||||||
|
export default activityDirectoryTool
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import AttachmentButton from '@renderer/pages/home/Inputbar/tools/components/AttachmentButton'
|
||||||
|
import { defineTool, registerTool, TopicType } from '@renderer/pages/home/Inputbar/types'
|
||||||
|
|
||||||
|
const attachmentTool = defineTool({
|
||||||
|
key: 'attachment',
|
||||||
|
label: (t) => t('chat.input.upload.image_or_document'),
|
||||||
|
|
||||||
|
visibleInScopes: [TopicType.Chat, TopicType.Session, 'mini-window'],
|
||||||
|
|
||||||
|
dependencies: {
|
||||||
|
state: ['files', 'couldAddImageFile', 'extensions'] as const,
|
||||||
|
actions: ['setFiles'] as const
|
||||||
|
},
|
||||||
|
|
||||||
|
render: (context) => {
|
||||||
|
const { state, actions, quickPanel } = context
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AttachmentButton
|
||||||
|
quickPanel={quickPanel}
|
||||||
|
couldAddImageFile={state.couldAddImageFile}
|
||||||
|
extensions={state.extensions}
|
||||||
|
files={state.files}
|
||||||
|
setFiles={actions.setFiles}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Register the tool
|
||||||
|
registerTool(attachmentTool)
|
||||||
|
|
||||||
|
export default attachmentTool
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||||
|
import { useShortcutDisplay } from '@renderer/hooks/useShortcuts'
|
||||||
|
import { defineTool, registerTool, TopicType } from '@renderer/pages/home/Inputbar/types'
|
||||||
|
import { Tooltip } from 'antd'
|
||||||
|
import { PaintbrushVertical } from 'lucide-react'
|
||||||
|
|
||||||
|
const clearTopicTool = defineTool({
|
||||||
|
key: 'clear_topic',
|
||||||
|
label: (t) => t('chat.input.clear.label', { Command: '' }),
|
||||||
|
visibleInScopes: [TopicType.Chat],
|
||||||
|
dependencies: {
|
||||||
|
actions: ['clearTopic'] as const
|
||||||
|
},
|
||||||
|
render: function ClearTopicRender(context) {
|
||||||
|
const { actions, t } = context
|
||||||
|
const clearTopicShortcut = useShortcutDisplay('clear_topic')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
placement="top"
|
||||||
|
title={t('chat.input.clear.label', { Command: clearTopicShortcut })}
|
||||||
|
mouseLeaveDelay={0}
|
||||||
|
arrow>
|
||||||
|
<ActionIconButton onClick={actions.clearTopic}>
|
||||||
|
<PaintbrushVertical size={18} />
|
||||||
|
</ActionIconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
registerTool(clearTopicTool)
|
||||||
|
|
||||||
|
export default clearTopicTool
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||||
|
import type { ToolQuickPanelApi, ToolQuickPanelController } from '@renderer/pages/home/Inputbar/types'
|
||||||
|
import { Tooltip } from 'antd'
|
||||||
|
import { FolderOpen } from 'lucide-react'
|
||||||
|
import type { FC } from 'react'
|
||||||
|
import type React from 'react'
|
||||||
|
import { memo } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
import { useActivityDirectoryPanel } from './useActivityDirectoryPanel'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
quickPanel: ToolQuickPanelApi
|
||||||
|
quickPanelController: ToolQuickPanelController
|
||||||
|
accessiblePaths: string[]
|
||||||
|
setText: React.Dispatch<React.SetStateAction<string>>
|
||||||
|
}
|
||||||
|
|
||||||
|
const ActivityDirectoryButton: FC<Props> = ({ quickPanel, quickPanelController, accessiblePaths, setText }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const { handleOpenQuickPanel } = useActivityDirectoryPanel(
|
||||||
|
{
|
||||||
|
quickPanel,
|
||||||
|
quickPanelController,
|
||||||
|
accessiblePaths,
|
||||||
|
setText
|
||||||
|
},
|
||||||
|
'button'
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip placement="top" title={t('chat.input.activity_directory.title')} mouseLeaveDelay={0} arrow>
|
||||||
|
<ActionIconButton onClick={handleOpenQuickPanel}>
|
||||||
|
<FolderOpen size={18} />
|
||||||
|
</ActionIconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(ActivityDirectoryButton)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { ToolActionKey, ToolRenderContext, ToolStateKey } from '@renderer/pages/home/Inputbar/types'
|
||||||
|
import type React from 'react'
|
||||||
|
|
||||||
|
import { useActivityDirectoryPanel } from './useActivityDirectoryPanel'
|
||||||
|
|
||||||
|
interface ManagerProps {
|
||||||
|
context: ToolRenderContext<readonly ToolStateKey[], readonly ToolActionKey[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
const ActivityDirectoryQuickPanelManager = ({ context }: ManagerProps) => {
|
||||||
|
const {
|
||||||
|
quickPanel,
|
||||||
|
quickPanelController,
|
||||||
|
actions: { onTextChange },
|
||||||
|
session
|
||||||
|
} = context
|
||||||
|
|
||||||
|
// Get accessible paths from session data
|
||||||
|
const accessiblePaths = session?.accessiblePaths ?? []
|
||||||
|
|
||||||
|
// Always call hooks unconditionally (React rules)
|
||||||
|
useActivityDirectoryPanel(
|
||||||
|
{
|
||||||
|
quickPanel,
|
||||||
|
quickPanelController,
|
||||||
|
accessiblePaths,
|
||||||
|
setText: onTextChange as React.Dispatch<React.SetStateAction<string>>
|
||||||
|
},
|
||||||
|
'manager'
|
||||||
|
)
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ActivityDirectoryQuickPanelManager
|
||||||
@@ -1,22 +1,18 @@
|
|||||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||||
import { QuickPanelReservedSymbol, useQuickPanel } from '@renderer/components/QuickPanel'
|
import { QuickPanelReservedSymbol, useQuickPanel } from '@renderer/components/QuickPanel'
|
||||||
import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
|
import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
|
||||||
|
import type { ToolQuickPanelApi } from '@renderer/pages/home/Inputbar/types'
|
||||||
import type { FileType, KnowledgeBase, KnowledgeItem } from '@renderer/types'
|
import type { FileType, KnowledgeBase, KnowledgeItem } from '@renderer/types'
|
||||||
import { filterSupportedFiles, formatFileSize } from '@renderer/utils/file'
|
import { filterSupportedFiles, formatFileSize } from '@renderer/utils/file'
|
||||||
import { Tooltip } from 'antd'
|
import { Tooltip } from 'antd'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { FileSearch, FileText, Paperclip, Upload } from 'lucide-react'
|
import { FileSearch, FileText, Paperclip, Upload } from 'lucide-react'
|
||||||
import type { Dispatch, FC, SetStateAction } from 'react'
|
import type { Dispatch, FC, SetStateAction } from 'react'
|
||||||
import { useCallback, useImperativeHandle, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export interface AttachmentButtonRef {
|
|
||||||
openQuickPanel: () => void
|
|
||||||
openFileSelectDialog: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
ref?: React.RefObject<AttachmentButtonRef | null>
|
quickPanel: ToolQuickPanelApi
|
||||||
couldAddImageFile: boolean
|
couldAddImageFile: boolean
|
||||||
extensions: string[]
|
extensions: string[]
|
||||||
files: FileType[]
|
files: FileType[]
|
||||||
@@ -24,9 +20,9 @@ interface Props {
|
|||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const AttachmentButton: FC<Props> = ({ ref, couldAddImageFile, extensions, files, setFiles, disabled }) => {
|
const AttachmentButton: FC<Props> = ({ quickPanel, couldAddImageFile, extensions, files, setFiles, disabled }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const quickPanel = useQuickPanel()
|
const quickPanelHook = useQuickPanel()
|
||||||
const { bases: knowledgeBases } = useKnowledgeBases()
|
const { bases: knowledgeBases } = useKnowledgeBases()
|
||||||
const [selecting, setSelecting] = useState<boolean>(false)
|
const [selecting, setSelecting] = useState<boolean>(false)
|
||||||
|
|
||||||
@@ -71,7 +67,7 @@ const AttachmentButton: FC<Props> = ({ ref, couldAddImageFile, extensions, files
|
|||||||
|
|
||||||
const openKnowledgeFileList = useCallback(
|
const openKnowledgeFileList = useCallback(
|
||||||
(base: KnowledgeBase) => {
|
(base: KnowledgeBase) => {
|
||||||
quickPanel.open({
|
quickPanelHook.open({
|
||||||
title: base.name,
|
title: base.name,
|
||||||
list: base.items
|
list: base.items
|
||||||
.filter((file): file is KnowledgeItem => ['file'].includes(file.type))
|
.filter((file): file is KnowledgeItem => ['file'].includes(file.type))
|
||||||
@@ -102,7 +98,7 @@ const AttachmentButton: FC<Props> = ({ ref, couldAddImageFile, extensions, files
|
|||||||
multiple: true
|
multiple: true
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[files, quickPanel, setFiles]
|
[files, quickPanelHook, setFiles]
|
||||||
)
|
)
|
||||||
|
|
||||||
const items = useMemo(() => {
|
const items = useMemo(() => {
|
||||||
@@ -130,17 +126,31 @@ const AttachmentButton: FC<Props> = ({ ref, couldAddImageFile, extensions, files
|
|||||||
}, [knowledgeBases, openFileSelectDialog, openKnowledgeFileList, t])
|
}, [knowledgeBases, openFileSelectDialog, openKnowledgeFileList, t])
|
||||||
|
|
||||||
const openQuickPanel = useCallback(() => {
|
const openQuickPanel = useCallback(() => {
|
||||||
quickPanel.open({
|
quickPanelHook.open({
|
||||||
title: t('chat.input.upload.attachment'),
|
title: t('chat.input.upload.attachment'),
|
||||||
list: items,
|
list: items,
|
||||||
symbol: QuickPanelReservedSymbol.File
|
symbol: QuickPanelReservedSymbol.File
|
||||||
})
|
})
|
||||||
}, [items, quickPanel, t])
|
}, [items, quickPanelHook, t])
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useEffect(() => {
|
||||||
openQuickPanel,
|
const disposeRootMenu = quickPanel.registerRootMenu([
|
||||||
openFileSelectDialog
|
{
|
||||||
}))
|
label: couldAddImageFile ? t('chat.input.upload.attachment') : t('chat.input.upload.document'),
|
||||||
|
description: '',
|
||||||
|
icon: <Paperclip />,
|
||||||
|
isMenu: true,
|
||||||
|
action: () => openQuickPanel()
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const disposeTrigger = quickPanel.registerTrigger(QuickPanelReservedSymbol.File, () => openQuickPanel())
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposeRootMenu()
|
||||||
|
disposeTrigger()
|
||||||
|
}
|
||||||
|
}, [couldAddImageFile, openQuickPanel, quickPanel, t])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
@@ -1,30 +1,27 @@
|
|||||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||||
import type { QuickPanelListItem } from '@renderer/components/QuickPanel'
|
import type { QuickPanelListItem } from '@renderer/components/QuickPanel'
|
||||||
import { QuickPanelReservedSymbol, useQuickPanel } from '@renderer/components/QuickPanel'
|
import { QuickPanelReservedSymbol, useQuickPanel } from '@renderer/components/QuickPanel'
|
||||||
|
import type { ToolQuickPanelApi } from '@renderer/pages/home/Inputbar/types'
|
||||||
import { useAppSelector } from '@renderer/store'
|
import { useAppSelector } from '@renderer/store'
|
||||||
import type { KnowledgeBase } from '@renderer/types'
|
import type { KnowledgeBase } from '@renderer/types'
|
||||||
import { Tooltip } from 'antd'
|
import { Tooltip } from 'antd'
|
||||||
import { CircleX, FileSearch, Plus } from 'lucide-react'
|
import { CircleX, FileSearch, Plus } from 'lucide-react'
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
import { memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react'
|
import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
|
|
||||||
export interface KnowledgeBaseButtonRef {
|
|
||||||
openQuickPanel: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
ref?: React.RefObject<KnowledgeBaseButtonRef | null>
|
quickPanel: ToolQuickPanelApi
|
||||||
selectedBases?: KnowledgeBase[]
|
selectedBases?: KnowledgeBase[]
|
||||||
onSelect: (bases: KnowledgeBase[]) => void
|
onSelect: (bases: KnowledgeBase[]) => void
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const KnowledgeBaseButton: FC<Props> = ({ ref, selectedBases, onSelect, disabled }) => {
|
const KnowledgeBaseButton: FC<Props> = ({ quickPanel, selectedBases, onSelect, disabled }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const quickPanel = useQuickPanel()
|
const quickPanelHook = useQuickPanel()
|
||||||
const knowledgeState = useAppSelector((state) => state.knowledge)
|
const knowledgeState = useAppSelector((state) => state.knowledge)
|
||||||
const selectedBasesRef = useRef(selectedBases)
|
const selectedBasesRef = useRef(selectedBases)
|
||||||
|
|
||||||
@@ -76,7 +73,7 @@ const KnowledgeBaseButton: FC<Props> = ({ ref, selectedBases, onSelect, disabled
|
|||||||
}, [knowledgeState.bases, t, selectedBases, handleBaseSelect, navigate, onSelect])
|
}, [knowledgeState.bases, t, selectedBases, handleBaseSelect, navigate, onSelect])
|
||||||
|
|
||||||
const openQuickPanel = useCallback(() => {
|
const openQuickPanel = useCallback(() => {
|
||||||
quickPanel.open({
|
quickPanelHook.open({
|
||||||
title: t('chat.input.knowledge_base'),
|
title: t('chat.input.knowledge_base'),
|
||||||
list: baseItems,
|
list: baseItems,
|
||||||
symbol: QuickPanelReservedSymbol.KnowledgeBase,
|
symbol: QuickPanelReservedSymbol.KnowledgeBase,
|
||||||
@@ -85,27 +82,42 @@ const KnowledgeBaseButton: FC<Props> = ({ ref, selectedBases, onSelect, disabled
|
|||||||
item.isSelected = !item.isSelected
|
item.isSelected = !item.isSelected
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [baseItems, quickPanel, t])
|
}, [baseItems, quickPanelHook, t])
|
||||||
|
|
||||||
const handleOpenQuickPanel = useCallback(() => {
|
const handleOpenQuickPanel = useCallback(() => {
|
||||||
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.KnowledgeBase) {
|
if (quickPanelHook.isVisible && quickPanelHook.symbol === QuickPanelReservedSymbol.KnowledgeBase) {
|
||||||
quickPanel.close()
|
quickPanelHook.close()
|
||||||
} else {
|
} else {
|
||||||
openQuickPanel()
|
openQuickPanel()
|
||||||
}
|
}
|
||||||
}, [openQuickPanel, quickPanel])
|
}, [openQuickPanel, quickPanelHook])
|
||||||
|
|
||||||
// 监听 selectedBases 变化,动态更新已打开的 QuickPanel 列表状态
|
// 监听 selectedBases 变化,动态更新已打开的 QuickPanel 列表状态
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.KnowledgeBase) {
|
if (quickPanelHook.isVisible && quickPanelHook.symbol === QuickPanelReservedSymbol.KnowledgeBase) {
|
||||||
// 直接使用重新计算的 baseItems,因为它已经包含了最新的 isSelected 状态
|
// 直接使用重新计算的 baseItems,因为它已经包含了最新的 isSelected 状态
|
||||||
quickPanel.updateList(baseItems)
|
quickPanelHook.updateList(baseItems)
|
||||||
}
|
}
|
||||||
}, [selectedBases, quickPanel, baseItems])
|
}, [selectedBases, quickPanelHook, baseItems])
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useEffect(() => {
|
||||||
openQuickPanel
|
const disposeRootMenu = quickPanel.registerRootMenu([
|
||||||
}))
|
{
|
||||||
|
label: t('chat.input.knowledge_base'),
|
||||||
|
description: '',
|
||||||
|
icon: <FileSearch />,
|
||||||
|
isMenu: true,
|
||||||
|
action: () => openQuickPanel()
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const disposeTrigger = quickPanel.registerTrigger(QuickPanelReservedSymbol.KnowledgeBase, () => openQuickPanel())
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposeRootMenu()
|
||||||
|
disposeTrigger()
|
||||||
|
}
|
||||||
|
}, [openQuickPanel, quickPanel, t])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip placement="top" title={t('chat.input.knowledge_base')} mouseLeaveDelay={0} arrow>
|
<Tooltip placement="top" title={t('chat.input.knowledge_base')} mouseLeaveDelay={0} arrow>
|
||||||
@@ -6,6 +6,7 @@ import { isGeminiWebSearchProvider, isSupportUrlContextProvider } from '@rendere
|
|||||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||||
import { useMCPServers } from '@renderer/hooks/useMCPServers'
|
import { useMCPServers } from '@renderer/hooks/useMCPServers'
|
||||||
import { useTimer } from '@renderer/hooks/useTimer'
|
import { useTimer } from '@renderer/hooks/useTimer'
|
||||||
|
import type { ToolQuickPanelApi } from '@renderer/pages/home/Inputbar/types'
|
||||||
import { getProviderByModel } from '@renderer/services/AssistantService'
|
import { getProviderByModel } from '@renderer/services/AssistantService'
|
||||||
import { EventEmitter } from '@renderer/services/EventService'
|
import { EventEmitter } from '@renderer/services/EventService'
|
||||||
import type { MCPPrompt, MCPResource, MCPServer } from '@renderer/types'
|
import type { MCPPrompt, MCPResource, MCPServer } from '@renderer/types'
|
||||||
@@ -13,19 +14,13 @@ import { isToolUseModeFunction } from '@renderer/utils/assistant'
|
|||||||
import { Form, Input, Tooltip } from 'antd'
|
import { Form, Input, Tooltip } from 'antd'
|
||||||
import { CircleX, Hammer, Plus } from 'lucide-react'
|
import { CircleX, Hammer, Plus } from 'lucide-react'
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
import React, { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
|
|
||||||
export interface MCPToolsButtonRef {
|
|
||||||
openQuickPanel: () => void
|
|
||||||
openPromptList: () => void
|
|
||||||
openResourcesList: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
assistantId: string
|
assistantId: string
|
||||||
ref?: React.RefObject<MCPToolsButtonRef | null>
|
quickPanel: ToolQuickPanelApi
|
||||||
setInputValue: React.Dispatch<React.SetStateAction<string>>
|
setInputValue: React.Dispatch<React.SetStateAction<string>>
|
||||||
resizeTextArea: () => void
|
resizeTextArea: () => void
|
||||||
}
|
}
|
||||||
@@ -115,10 +110,10 @@ const extractPromptContent = (response: any): string | null => {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, assistantId }) => {
|
const MCPToolsButton: FC<Props> = ({ quickPanel, setInputValue, resizeTextArea, assistantId }) => {
|
||||||
const { activedMcpServers } = useMCPServers()
|
const { activedMcpServers } = useMCPServers()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const quickPanel = useQuickPanel()
|
const quickPanelHook = useQuickPanel()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [form] = Form.useForm()
|
const [form] = Form.useForm()
|
||||||
|
|
||||||
@@ -219,15 +214,15 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, assista
|
|||||||
isSelected: false,
|
isSelected: false,
|
||||||
action: () => {
|
action: () => {
|
||||||
updateMcpEnabled(false)
|
updateMcpEnabled(false)
|
||||||
quickPanel.close()
|
quickPanelHook.close()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return newList
|
return newList
|
||||||
}, [activedMcpServers, t, assistantMcpServers, navigate, updateMcpEnabled, quickPanel])
|
}, [activedMcpServers, t, assistantMcpServers, navigate, updateMcpEnabled, quickPanelHook])
|
||||||
|
|
||||||
const openQuickPanel = useCallback(() => {
|
const openQuickPanel = useCallback(() => {
|
||||||
quickPanel.open({
|
quickPanelHook.open({
|
||||||
title: t('settings.mcp.title'),
|
title: t('settings.mcp.title'),
|
||||||
list: menuItems,
|
list: menuItems,
|
||||||
symbol: QuickPanelReservedSymbol.Mcp,
|
symbol: QuickPanelReservedSymbol.Mcp,
|
||||||
@@ -236,7 +231,7 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, assista
|
|||||||
item.isSelected = !item.isSelected
|
item.isSelected = !item.isSelected
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [menuItems, quickPanel, t])
|
}, [menuItems, quickPanelHook, t])
|
||||||
|
|
||||||
// 使用 useCallback 优化 insertPromptIntoTextArea
|
// 使用 useCallback 优化 insertPromptIntoTextArea
|
||||||
const insertPromptIntoTextArea = useCallback(
|
const insertPromptIntoTextArea = useCallback(
|
||||||
@@ -376,13 +371,13 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, assista
|
|||||||
|
|
||||||
const openPromptList = useCallback(async () => {
|
const openPromptList = useCallback(async () => {
|
||||||
const prompts = await promptList
|
const prompts = await promptList
|
||||||
quickPanel.open({
|
quickPanelHook.open({
|
||||||
title: t('settings.mcp.title'),
|
title: t('settings.mcp.title'),
|
||||||
list: prompts,
|
list: prompts,
|
||||||
symbol: QuickPanelReservedSymbol.McpPrompt,
|
symbol: QuickPanelReservedSymbol.McpPrompt,
|
||||||
multiple: true
|
multiple: true
|
||||||
})
|
})
|
||||||
}, [promptList, quickPanel, t])
|
}, [promptList, quickPanelHook, t])
|
||||||
|
|
||||||
const handleResourceSelect = useCallback(
|
const handleResourceSelect = useCallback(
|
||||||
(resource: MCPResource) => {
|
(resource: MCPResource) => {
|
||||||
@@ -464,27 +459,60 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, assista
|
|||||||
}, [activedMcpServers])
|
}, [activedMcpServers])
|
||||||
|
|
||||||
const openResourcesList = useCallback(async () => {
|
const openResourcesList = useCallback(async () => {
|
||||||
quickPanel.open({
|
quickPanelHook.open({
|
||||||
title: t('settings.mcp.title'),
|
title: t('settings.mcp.title'),
|
||||||
list: resourcesList,
|
list: resourcesList,
|
||||||
symbol: QuickPanelReservedSymbol.McpResource,
|
symbol: QuickPanelReservedSymbol.McpResource,
|
||||||
multiple: true
|
multiple: true
|
||||||
})
|
})
|
||||||
}, [resourcesList, quickPanel, t])
|
}, [resourcesList, quickPanelHook, t])
|
||||||
|
|
||||||
const handleOpenQuickPanel = useCallback(() => {
|
const handleOpenQuickPanel = useCallback(() => {
|
||||||
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.Mcp) {
|
if (quickPanelHook.isVisible && quickPanelHook.symbol === QuickPanelReservedSymbol.Mcp) {
|
||||||
quickPanel.close()
|
quickPanelHook.close()
|
||||||
} else {
|
} else {
|
||||||
openQuickPanel()
|
openQuickPanel()
|
||||||
}
|
}
|
||||||
}, [openQuickPanel, quickPanel])
|
}, [openQuickPanel, quickPanelHook])
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useEffect(() => {
|
||||||
openQuickPanel,
|
const disposeMain = quickPanel.registerRootMenu([
|
||||||
openPromptList,
|
{
|
||||||
openResourcesList
|
label: t('settings.mcp.title'),
|
||||||
}))
|
description: '',
|
||||||
|
icon: <Hammer />,
|
||||||
|
isMenu: true,
|
||||||
|
action: () => openQuickPanel()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: `MCP ${t('settings.mcp.tabs.prompts')}`,
|
||||||
|
description: '',
|
||||||
|
icon: <Hammer />,
|
||||||
|
isMenu: true,
|
||||||
|
action: () => openPromptList()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: `MCP ${t('settings.mcp.tabs.resources')}`,
|
||||||
|
description: '',
|
||||||
|
icon: <Hammer />,
|
||||||
|
isMenu: true,
|
||||||
|
action: () => openResourcesList()
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const disposeMainTrigger = quickPanel.registerTrigger(QuickPanelReservedSymbol.Mcp, () => openQuickPanel())
|
||||||
|
const disposePromptTrigger = quickPanel.registerTrigger(QuickPanelReservedSymbol.McpPrompt, () => openPromptList())
|
||||||
|
const disposeResourceTrigger = quickPanel.registerTrigger(QuickPanelReservedSymbol.McpResource, () =>
|
||||||
|
openResourcesList()
|
||||||
|
)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposeMain()
|
||||||
|
disposeMainTrigger()
|
||||||
|
disposePromptTrigger()
|
||||||
|
disposeResourceTrigger()
|
||||||
|
}
|
||||||
|
}, [openPromptList, openQuickPanel, openResourcesList, quickPanel, t])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip placement="top" title={t('settings.mcp.title')} mouseLeaveDelay={0} arrow>
|
<Tooltip placement="top" title={t('settings.mcp.title')} mouseLeaveDelay={0} arrow>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||||
|
import type { ToolQuickPanelApi, ToolQuickPanelController } from '@renderer/pages/home/Inputbar/types'
|
||||||
|
import type { FileType, Model } from '@renderer/types'
|
||||||
|
import { Tooltip } from 'antd'
|
||||||
|
import { AtSign } from 'lucide-react'
|
||||||
|
import type { FC } from 'react'
|
||||||
|
import type React from 'react'
|
||||||
|
import { memo } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
import { useMentionModelsPanel } from './useMentionModelsPanel'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
quickPanel: ToolQuickPanelApi
|
||||||
|
quickPanelController: ToolQuickPanelController
|
||||||
|
mentionedModels: Model[]
|
||||||
|
setMentionedModels: React.Dispatch<React.SetStateAction<Model[]>>
|
||||||
|
couldMentionNotVisionModel: boolean
|
||||||
|
files: FileType[]
|
||||||
|
setText: React.Dispatch<React.SetStateAction<string>>
|
||||||
|
}
|
||||||
|
|
||||||
|
const MentionModelsButton: FC<Props> = ({
|
||||||
|
quickPanel,
|
||||||
|
quickPanelController,
|
||||||
|
mentionedModels,
|
||||||
|
setMentionedModels,
|
||||||
|
couldMentionNotVisionModel,
|
||||||
|
files,
|
||||||
|
setText
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const { handleOpenQuickPanel } = useMentionModelsPanel(
|
||||||
|
{
|
||||||
|
quickPanel,
|
||||||
|
quickPanelController,
|
||||||
|
mentionedModels,
|
||||||
|
setMentionedModels,
|
||||||
|
couldMentionNotVisionModel,
|
||||||
|
files,
|
||||||
|
setText
|
||||||
|
},
|
||||||
|
'button'
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip placement="top" title={t('assistants.presets.edit.model.select.title')} mouseLeaveDelay={0} arrow>
|
||||||
|
<ActionIconButton onClick={handleOpenQuickPanel} active={mentionedModels.length > 0}>
|
||||||
|
<AtSign size={18} />
|
||||||
|
</ActionIconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(MentionModelsButton)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { ToolActionKey, ToolRenderContext, ToolStateKey } from '@renderer/pages/home/Inputbar/types'
|
||||||
|
import type { FileType, Model } from '@renderer/types'
|
||||||
|
import type React from 'react'
|
||||||
|
|
||||||
|
import { useMentionModelsPanel } from './useMentionModelsPanel'
|
||||||
|
|
||||||
|
interface ManagerProps {
|
||||||
|
context: ToolRenderContext<readonly ToolStateKey[], readonly ToolActionKey[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
const MentionModelsQuickPanelManager = ({ context }: ManagerProps) => {
|
||||||
|
const {
|
||||||
|
quickPanel,
|
||||||
|
quickPanelController,
|
||||||
|
state: { mentionedModels, files, couldMentionNotVisionModel },
|
||||||
|
actions: { setMentionedModels, onTextChange }
|
||||||
|
} = context
|
||||||
|
|
||||||
|
useMentionModelsPanel(
|
||||||
|
{
|
||||||
|
quickPanel,
|
||||||
|
quickPanelController,
|
||||||
|
mentionedModels: mentionedModels as Model[],
|
||||||
|
setMentionedModels: setMentionedModels as React.Dispatch<React.SetStateAction<Model[]>>,
|
||||||
|
couldMentionNotVisionModel,
|
||||||
|
files: files as FileType[],
|
||||||
|
setText: onTextChange as React.Dispatch<React.SetStateAction<string>>
|
||||||
|
},
|
||||||
|
'manager'
|
||||||
|
)
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MentionModelsQuickPanelManager
|
||||||
@@ -2,38 +2,39 @@ import { ActionIconButton } from '@renderer/components/Buttons'
|
|||||||
import {
|
import {
|
||||||
type QuickPanelListItem,
|
type QuickPanelListItem,
|
||||||
type QuickPanelOpenOptions,
|
type QuickPanelOpenOptions,
|
||||||
QuickPanelReservedSymbol
|
QuickPanelReservedSymbol,
|
||||||
|
type QuickPanelTriggerInfo
|
||||||
} from '@renderer/components/QuickPanel'
|
} from '@renderer/components/QuickPanel'
|
||||||
import { useQuickPanel } from '@renderer/components/QuickPanel'
|
import { useQuickPanel } from '@renderer/components/QuickPanel'
|
||||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||||
import { useTimer } from '@renderer/hooks/useTimer'
|
import { useTimer } from '@renderer/hooks/useTimer'
|
||||||
|
import type { ToolQuickPanelApi } from '@renderer/pages/home/Inputbar/types'
|
||||||
import QuickPhraseService from '@renderer/services/QuickPhraseService'
|
import QuickPhraseService from '@renderer/services/QuickPhraseService'
|
||||||
import type { QuickPhrase } from '@renderer/types'
|
import type { QuickPhrase } from '@renderer/types'
|
||||||
import { Input, Modal, Radio, Space, Tooltip } from 'antd'
|
import { Input, Modal, Radio, Space, Tooltip } from 'antd'
|
||||||
import { BotMessageSquare, Plus, Zap } from 'lucide-react'
|
import { BotMessageSquare, Plus, Zap } from 'lucide-react'
|
||||||
import { memo, useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react'
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
export interface QuickPhrasesButtonRef {
|
|
||||||
openQuickPanel: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
ref?: React.RefObject<QuickPhrasesButtonRef | null>
|
quickPanel: ToolQuickPanelApi
|
||||||
setInputValue: React.Dispatch<React.SetStateAction<string>>
|
setInputValue: React.Dispatch<React.SetStateAction<string>>
|
||||||
resizeTextArea: () => void
|
resizeTextArea: () => void
|
||||||
assistantId: string
|
assistantId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const QuickPhrasesButton = ({ ref, setInputValue, resizeTextArea, assistantId }: Props) => {
|
const QuickPhrasesButton = ({ quickPanel, setInputValue, resizeTextArea, assistantId }: Props) => {
|
||||||
const [quickPhrasesList, setQuickPhrasesList] = useState<QuickPhrase[]>([])
|
const [quickPhrasesList, setQuickPhrasesList] = useState<QuickPhrase[]>([])
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||||
const [formData, setFormData] = useState({ title: '', content: '', location: 'global' })
|
const [formData, setFormData] = useState({ title: '', content: '', location: 'global' })
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const quickPanel = useQuickPanel()
|
const quickPanelHook = useQuickPanel()
|
||||||
const { assistant, updateAssistant } = useAssistant(assistantId)
|
const { assistant, updateAssistant } = useAssistant(assistantId)
|
||||||
const { setTimeoutTimer } = useTimer()
|
const { setTimeoutTimer } = useTimer()
|
||||||
|
const triggerInfoRef = useRef<
|
||||||
|
(QuickPanelTriggerInfo & { symbol?: QuickPanelReservedSymbol; searchText?: string }) | undefined
|
||||||
|
>(undefined)
|
||||||
|
|
||||||
const loadQuickListPhrases = useCallback(
|
const loadQuickListPhrases = useCallback(
|
||||||
async (regularPhrases: QuickPhrase[] = []) => {
|
async (regularPhrases: QuickPhrase[] = []) => {
|
||||||
@@ -58,21 +59,60 @@ const QuickPhrasesButton = ({ ref, setInputValue, resizeTextArea, assistantId }:
|
|||||||
'handlePhraseSelect_1',
|
'handlePhraseSelect_1',
|
||||||
() => {
|
() => {
|
||||||
setInputValue((prev) => {
|
setInputValue((prev) => {
|
||||||
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement
|
const triggerInfo = triggerInfoRef.current
|
||||||
const cursorPosition = textArea.selectionStart
|
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement | null
|
||||||
const selectionStart = cursorPosition
|
|
||||||
const selectionEndPosition = cursorPosition + phrase.content.length
|
|
||||||
const newText = prev.slice(0, cursorPosition) + phrase.content + prev.slice(cursorPosition)
|
|
||||||
|
|
||||||
setTimeoutTimer(
|
const focusAndSelect = (start: number) => {
|
||||||
'handlePhraseSelect_2',
|
setTimeoutTimer(
|
||||||
() => {
|
'handlePhraseSelect_2',
|
||||||
textArea.focus()
|
() => {
|
||||||
textArea.setSelectionRange(selectionStart, selectionEndPosition)
|
if (textArea) {
|
||||||
resizeTextArea()
|
textArea.focus()
|
||||||
},
|
textArea.setSelectionRange(start, start + phrase.content.length)
|
||||||
10
|
}
|
||||||
)
|
resizeTextArea()
|
||||||
|
},
|
||||||
|
10
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (triggerInfo?.type === 'input' && triggerInfo.position !== undefined) {
|
||||||
|
const symbol = triggerInfo.symbol ?? QuickPanelReservedSymbol.Root
|
||||||
|
const searchText = triggerInfo.searchText ?? ''
|
||||||
|
const startIndex = triggerInfo.position
|
||||||
|
|
||||||
|
let endIndex = startIndex + 1
|
||||||
|
if (searchText) {
|
||||||
|
const expected = symbol + searchText
|
||||||
|
const actual = prev.slice(startIndex, startIndex + expected.length)
|
||||||
|
if (actual === expected) {
|
||||||
|
endIndex = startIndex + expected.length
|
||||||
|
} else {
|
||||||
|
while (endIndex < prev.length && !/\s/.test(prev[endIndex])) {
|
||||||
|
endIndex++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
while (endIndex < prev.length && !/\s/.test(prev[endIndex])) {
|
||||||
|
endIndex++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newText = prev.slice(0, startIndex) + phrase.content + prev.slice(endIndex)
|
||||||
|
triggerInfoRef.current = undefined
|
||||||
|
focusAndSelect(startIndex)
|
||||||
|
return newText
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!textArea) {
|
||||||
|
triggerInfoRef.current = undefined
|
||||||
|
return prev + phrase.content
|
||||||
|
}
|
||||||
|
|
||||||
|
const cursorPosition = textArea.selectionStart ?? prev.length
|
||||||
|
const newText = prev.slice(0, cursorPosition) + phrase.content + prev.slice(cursorPosition)
|
||||||
|
triggerInfoRef.current = undefined
|
||||||
|
focusAndSelect(cursorPosition)
|
||||||
return newText
|
return newText
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -138,21 +178,74 @@ const QuickPhrasesButton = ({ ref, setInputValue, resizeTextArea, assistantId }:
|
|||||||
[phraseItems, t]
|
[phraseItems, t]
|
||||||
)
|
)
|
||||||
|
|
||||||
const openQuickPanel = useCallback(() => {
|
type QuickPhraseTrigger =
|
||||||
quickPanel.open(quickPanelOpenOptions)
|
| (QuickPanelTriggerInfo & { symbol?: QuickPanelReservedSymbol; searchText?: string })
|
||||||
}, [quickPanel, quickPanelOpenOptions])
|
| undefined
|
||||||
|
|
||||||
|
const openQuickPanel = useCallback(
|
||||||
|
(triggerInfo?: QuickPhraseTrigger) => {
|
||||||
|
triggerInfoRef.current = triggerInfo
|
||||||
|
quickPanelHook.open({
|
||||||
|
...quickPanelOpenOptions,
|
||||||
|
triggerInfo:
|
||||||
|
triggerInfo && triggerInfo.type === 'input'
|
||||||
|
? {
|
||||||
|
type: triggerInfo.type,
|
||||||
|
position: triggerInfo.position,
|
||||||
|
originalText: triggerInfo.originalText
|
||||||
|
}
|
||||||
|
: triggerInfo,
|
||||||
|
onClose: () => {
|
||||||
|
triggerInfoRef.current = undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[quickPanelHook, quickPanelOpenOptions]
|
||||||
|
)
|
||||||
|
|
||||||
const handleOpenQuickPanel = useCallback(() => {
|
const handleOpenQuickPanel = useCallback(() => {
|
||||||
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.QuickPhrases) {
|
if (quickPanelHook.isVisible && quickPanelHook.symbol === QuickPanelReservedSymbol.QuickPhrases) {
|
||||||
quickPanel.close()
|
quickPanelHook.close()
|
||||||
} else {
|
} else {
|
||||||
openQuickPanel()
|
openQuickPanel()
|
||||||
}
|
}
|
||||||
}, [openQuickPanel, quickPanel])
|
}, [openQuickPanel, quickPanelHook])
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useEffect(() => {
|
||||||
openQuickPanel
|
const disposeRootMenu = quickPanel.registerRootMenu([
|
||||||
}))
|
{
|
||||||
|
label: t('settings.quickPhrase.title'),
|
||||||
|
description: '',
|
||||||
|
icon: <Zap />,
|
||||||
|
isMenu: true,
|
||||||
|
action: ({ context, searchText }) => {
|
||||||
|
const rootTrigger =
|
||||||
|
context.triggerInfo && context.triggerInfo.type === 'input'
|
||||||
|
? {
|
||||||
|
...context.triggerInfo,
|
||||||
|
symbol: QuickPanelReservedSymbol.Root,
|
||||||
|
searchText: searchText ?? ''
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
context.close('select')
|
||||||
|
setTimeout(() => {
|
||||||
|
openQuickPanel(rootTrigger)
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const disposeTrigger = quickPanel.registerTrigger(QuickPanelReservedSymbol.QuickPhrases, (payload) => {
|
||||||
|
const trigger = (payload || undefined) as QuickPhraseTrigger
|
||||||
|
openQuickPanel(trigger)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposeRootMenu()
|
||||||
|
disposeTrigger()
|
||||||
|
}
|
||||||
|
}, [openQuickPanel, quickPanel, t])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||||
|
import { QuickPanelReservedSymbol } from '@renderer/components/QuickPanel'
|
||||||
|
import type { ToolContext, ToolQuickPanelController } from '@renderer/pages/home/Inputbar/types'
|
||||||
|
import { Tooltip } from 'antd'
|
||||||
|
import { Terminal } from 'lucide-react'
|
||||||
|
import { type FC, type ReactElement, useCallback, useMemo } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
quickPanelController: ToolQuickPanelController
|
||||||
|
session: ToolContext['session']
|
||||||
|
openPanel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SlashCommandsButton
|
||||||
|
*
|
||||||
|
* Simple button component that opens the SlashCommands panel (second level menu).
|
||||||
|
* The openPanel handler is passed from the tool definition, keeping logic centralized.
|
||||||
|
*/
|
||||||
|
const SlashCommandsButton: FC<Props> = ({ quickPanelController, session, openPanel }): ReactElement => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const slashCommands = useMemo(() => session?.slashCommands || [], [session?.slashCommands])
|
||||||
|
|
||||||
|
const handleOpenQuickPanel = useCallback(() => {
|
||||||
|
if (quickPanelController.isVisible && quickPanelController.symbol === QuickPanelReservedSymbol.SlashCommands) {
|
||||||
|
quickPanelController.close()
|
||||||
|
} else {
|
||||||
|
openPanel()
|
||||||
|
}
|
||||||
|
}, [openPanel, quickPanelController])
|
||||||
|
|
||||||
|
const hasCommands = slashCommands.length > 0
|
||||||
|
const isActive =
|
||||||
|
quickPanelController.isVisible && quickPanelController.symbol === QuickPanelReservedSymbol.SlashCommands
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip placement="top" title={t('chat.input.slash_commands.title')} mouseLeaveDelay={0} arrow>
|
||||||
|
<ActionIconButton onClick={handleOpenQuickPanel} active={isActive} disabled={!hasCommands}>
|
||||||
|
<Terminal size={18} />
|
||||||
|
</ActionIconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SlashCommandsButton
|
||||||
@@ -17,25 +17,22 @@ import {
|
|||||||
} from '@renderer/config/models'
|
} from '@renderer/config/models'
|
||||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||||
import { getReasoningEffortOptionsLabel } from '@renderer/i18n/label'
|
import { getReasoningEffortOptionsLabel } from '@renderer/i18n/label'
|
||||||
|
import type { ToolQuickPanelApi } from '@renderer/pages/home/Inputbar/types'
|
||||||
import type { Model, ThinkingOption } from '@renderer/types'
|
import type { Model, ThinkingOption } from '@renderer/types'
|
||||||
import { Tooltip } from 'antd'
|
import { Tooltip } from 'antd'
|
||||||
import type { FC, ReactElement } from 'react'
|
import type { FC, ReactElement } from 'react'
|
||||||
import { useCallback, useImperativeHandle, useMemo } from 'react'
|
import { useCallback, useEffect, useMemo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export interface ThinkingButtonRef {
|
|
||||||
openQuickPanel: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
ref?: React.RefObject<ThinkingButtonRef | null>
|
quickPanel: ToolQuickPanelApi
|
||||||
model: Model
|
model: Model
|
||||||
assistantId: string
|
assistantId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const ThinkingButton: FC<Props> = ({ ref, model, assistantId }): ReactElement => {
|
const ThinkingButton: FC<Props> = ({ quickPanel, model, assistantId }): ReactElement => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const quickPanel = useQuickPanel()
|
const quickPanelHook = useQuickPanel()
|
||||||
const { assistant, updateAssistantSettings } = useAssistant(assistantId)
|
const { assistant, updateAssistantSettings } = useAssistant(assistantId)
|
||||||
|
|
||||||
const currentReasoningEffort = useMemo(() => {
|
const currentReasoningEffort = useMemo(() => {
|
||||||
@@ -106,16 +103,16 @@ const ThinkingButton: FC<Props> = ({ ref, model, assistantId }): ReactElement =>
|
|||||||
}, [onThinkingChange])
|
}, [onThinkingChange])
|
||||||
|
|
||||||
const openQuickPanel = useCallback(() => {
|
const openQuickPanel = useCallback(() => {
|
||||||
quickPanel.open({
|
quickPanelHook.open({
|
||||||
title: t('assistants.settings.reasoning_effort.label'),
|
title: t('assistants.settings.reasoning_effort.label'),
|
||||||
list: panelItems,
|
list: panelItems,
|
||||||
symbol: QuickPanelReservedSymbol.Thinking
|
symbol: QuickPanelReservedSymbol.Thinking
|
||||||
})
|
})
|
||||||
}, [quickPanel, panelItems, t])
|
}, [quickPanelHook, panelItems, t])
|
||||||
|
|
||||||
const handleOpenQuickPanel = useCallback(() => {
|
const handleOpenQuickPanel = useCallback(() => {
|
||||||
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.Thinking) {
|
if (quickPanelHook.isVisible && quickPanelHook.symbol === QuickPanelReservedSymbol.Thinking) {
|
||||||
quickPanel.close()
|
quickPanelHook.close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,11 +121,26 @@ const ThinkingButton: FC<Props> = ({ ref, model, assistantId }): ReactElement =>
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
openQuickPanel()
|
openQuickPanel()
|
||||||
}, [openQuickPanel, quickPanel, isThinkingEnabled, supportedOptions, disableThinking])
|
}, [openQuickPanel, quickPanelHook, isThinkingEnabled, supportedOptions, disableThinking])
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useEffect(() => {
|
||||||
openQuickPanel
|
const disposeMenu = quickPanel.registerRootMenu([
|
||||||
}))
|
{
|
||||||
|
label: t('assistants.settings.reasoning_effort.label'),
|
||||||
|
description: '',
|
||||||
|
icon: ThinkingIcon(currentReasoningEffort),
|
||||||
|
isMenu: true,
|
||||||
|
action: () => openQuickPanel()
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const disposeTrigger = quickPanel.registerTrigger(QuickPanelReservedSymbol.Thinking, () => openQuickPanel())
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposeMenu()
|
||||||
|
disposeTrigger()
|
||||||
|
}
|
||||||
|
}, [currentReasoningEffort, openQuickPanel, quickPanel, t])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||||
|
import type { ToolQuickPanelController } from '@renderer/pages/home/Inputbar/types'
|
||||||
|
import { Tooltip } from 'antd'
|
||||||
|
import type { FC } from 'react'
|
||||||
|
import { memo, useCallback } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
import { useWebSearchPanelController, WebSearchProviderIcon } from './WebSearchQuickPanelManager'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
quickPanelController: ToolQuickPanelController
|
||||||
|
assistantId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const WebSearchButton: FC<Props> = ({ quickPanelController, assistantId }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { enableWebSearch, toggleQuickPanel, updateWebSearchProvider, selectedProviderId } =
|
||||||
|
useWebSearchPanelController(assistantId, quickPanelController)
|
||||||
|
|
||||||
|
const onClick = useCallback(() => {
|
||||||
|
if (enableWebSearch) {
|
||||||
|
updateWebSearchProvider(undefined)
|
||||||
|
} else {
|
||||||
|
toggleQuickPanel()
|
||||||
|
}
|
||||||
|
}, [enableWebSearch, toggleQuickPanel, updateWebSearchProvider])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
placement="top"
|
||||||
|
title={enableWebSearch ? t('common.close') : t('chat.input.web_search.label')}
|
||||||
|
mouseLeaveDelay={0}
|
||||||
|
arrow>
|
||||||
|
<ActionIconButton onClick={onClick} active={!!enableWebSearch}>
|
||||||
|
<WebSearchProviderIcon pid={selectedProviderId} />
|
||||||
|
</ActionIconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(WebSearchButton)
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import { BaiduOutlined, GoogleOutlined } from '@ant-design/icons'
|
import { BaiduOutlined, GoogleOutlined } from '@ant-design/icons'
|
||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
|
||||||
import { BingLogo, BochaLogo, ExaLogo, SearXNGLogo, TavilyLogo, ZhipuLogo } from '@renderer/components/Icons'
|
import { BingLogo, BochaLogo, ExaLogo, SearXNGLogo, TavilyLogo, ZhipuLogo } from '@renderer/components/Icons'
|
||||||
import type { QuickPanelListItem } from '@renderer/components/QuickPanel'
|
import type { QuickPanelListItem } from '@renderer/components/QuickPanel'
|
||||||
import { QuickPanelReservedSymbol, useQuickPanel } from '@renderer/components/QuickPanel'
|
import { QuickPanelReservedSymbol } from '@renderer/components/QuickPanel'
|
||||||
import {
|
import {
|
||||||
isGeminiModel,
|
isGeminiModel,
|
||||||
isGPT5SeriesReasoningModel,
|
isGPT5SeriesReasoningModel,
|
||||||
@@ -14,66 +13,57 @@ import { isGeminiWebSearchProvider } from '@renderer/config/providers'
|
|||||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||||
import { useTimer } from '@renderer/hooks/useTimer'
|
import { useTimer } from '@renderer/hooks/useTimer'
|
||||||
import { useWebSearchProviders } from '@renderer/hooks/useWebSearchProviders'
|
import { useWebSearchProviders } from '@renderer/hooks/useWebSearchProviders'
|
||||||
|
import type { ToolQuickPanelController, ToolRenderContext } from '@renderer/pages/home/Inputbar/types'
|
||||||
import { getProviderByModel } from '@renderer/services/AssistantService'
|
import { getProviderByModel } from '@renderer/services/AssistantService'
|
||||||
import WebSearchService from '@renderer/services/WebSearchService'
|
import WebSearchService from '@renderer/services/WebSearchService'
|
||||||
import type { WebSearchProvider, WebSearchProviderId } from '@renderer/types'
|
import type { WebSearchProvider, WebSearchProviderId } from '@renderer/types'
|
||||||
import { hasObjectKey } from '@renderer/utils'
|
import { hasObjectKey } from '@renderer/utils'
|
||||||
import { isToolUseModeFunction } from '@renderer/utils/assistant'
|
import { isToolUseModeFunction } from '@renderer/utils/assistant'
|
||||||
import { Tooltip } from 'antd'
|
|
||||||
import { Globe } from 'lucide-react'
|
import { Globe } from 'lucide-react'
|
||||||
import type { FC } from 'react'
|
import { useCallback, useEffect, useMemo } from 'react'
|
||||||
import { memo, useCallback, useImperativeHandle, useMemo } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export interface WebSearchButtonRef {
|
const logger = loggerService.withContext('WebSearchQuickPanel')
|
||||||
openQuickPanel: () => void
|
|
||||||
|
export const WebSearchProviderIcon = ({
|
||||||
|
pid,
|
||||||
|
size = 18,
|
||||||
|
color
|
||||||
|
}: {
|
||||||
|
pid?: WebSearchProviderId
|
||||||
|
size?: number
|
||||||
|
color?: string
|
||||||
|
}) => {
|
||||||
|
switch (pid) {
|
||||||
|
case 'bocha':
|
||||||
|
return <BochaLogo className="icon" width={size} height={size} color={color} />
|
||||||
|
case 'exa':
|
||||||
|
return <ExaLogo className="icon" width={size - 2} height={size} color={color} />
|
||||||
|
case 'tavily':
|
||||||
|
return <TavilyLogo className="icon" width={size} height={size} color={color} />
|
||||||
|
case 'zhipu':
|
||||||
|
return <ZhipuLogo className="icon" width={size} height={size} color={color} />
|
||||||
|
case 'searxng':
|
||||||
|
return <SearXNGLogo className="icon" width={size} height={size} color={color} />
|
||||||
|
case 'local-baidu':
|
||||||
|
return <BaiduOutlined size={size} style={{ color, fontSize: size }} />
|
||||||
|
case 'local-bing':
|
||||||
|
return <BingLogo className="icon" width={size} height={size} color={color} />
|
||||||
|
case 'local-google':
|
||||||
|
return <GoogleOutlined size={size} style={{ color, fontSize: size }} />
|
||||||
|
default:
|
||||||
|
return <Globe className="icon" size={size} style={{ color, fontSize: size }} />
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
export const useWebSearchPanelController = (assistantId: string, quickPanelController: ToolQuickPanelController) => {
|
||||||
ref?: React.RefObject<WebSearchButtonRef | null>
|
|
||||||
assistantId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const logger = loggerService.withContext('WebSearchButton')
|
|
||||||
|
|
||||||
const WebSearchButton: FC<Props> = ({ ref, assistantId }) => {
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const quickPanel = useQuickPanel()
|
|
||||||
const { providers } = useWebSearchProviders()
|
|
||||||
const { assistant, updateAssistant } = useAssistant(assistantId)
|
const { assistant, updateAssistant } = useAssistant(assistantId)
|
||||||
|
const { providers } = useWebSearchProviders()
|
||||||
const { setTimeoutTimer } = useTimer()
|
const { setTimeoutTimer } = useTimer()
|
||||||
|
|
||||||
// 注意:assistant.enableWebSearch 有不同的语义
|
|
||||||
/** 表示是否启用网络搜索 */
|
|
||||||
const enableWebSearch = assistant?.webSearchProviderId || assistant.enableWebSearch
|
const enableWebSearch = assistant?.webSearchProviderId || assistant.enableWebSearch
|
||||||
|
|
||||||
const WebSearchIcon = useCallback(
|
|
||||||
({ pid, size = 18, color }: { pid?: WebSearchProviderId; size?: number; color?: string }) => {
|
|
||||||
switch (pid) {
|
|
||||||
case 'bocha':
|
|
||||||
return <BochaLogo className="icon" width={size} height={size} color={color} />
|
|
||||||
case 'exa':
|
|
||||||
// size微调,视觉上和其他图标平衡一些
|
|
||||||
return <ExaLogo className="icon" width={size - 2} height={size} color={color} />
|
|
||||||
case 'tavily':
|
|
||||||
return <TavilyLogo className="icon" width={size} height={size} color={color} />
|
|
||||||
case 'zhipu':
|
|
||||||
return <ZhipuLogo className="icon" width={size} height={size} color={color} />
|
|
||||||
case 'searxng':
|
|
||||||
return <SearXNGLogo className="icon" width={size} height={size} color={color} />
|
|
||||||
case 'local-baidu':
|
|
||||||
return <BaiduOutlined size={size} style={{ color, fontSize: size }} />
|
|
||||||
case 'local-bing':
|
|
||||||
return <BingLogo className="icon" width={size} height={size} color={color} />
|
|
||||||
case 'local-google':
|
|
||||||
return <GoogleOutlined size={size} style={{ color, fontSize: size }} />
|
|
||||||
default:
|
|
||||||
return <Globe className="icon" size={size} style={{ color, fontSize: size }} />
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
|
|
||||||
const updateWebSearchProvider = useCallback(
|
const updateWebSearchProvider = useCallback(
|
||||||
async (providerId?: WebSearchProvider['id']) => {
|
async (providerId?: WebSearchProvider['id']) => {
|
||||||
setTimeoutTimer('updateWebSearchProvider', () => {
|
setTimeoutTimer('updateWebSearchProvider', () => {
|
||||||
@@ -136,7 +126,6 @@ const WebSearchButton: FC<Props> = ({ ref, assistantId }) => {
|
|||||||
|
|
||||||
const providerItems = useMemo<QuickPanelListItem[]>(() => {
|
const providerItems = useMemo<QuickPanelListItem[]>(() => {
|
||||||
const isWebSearchModelEnabled = assistant.model && isWebSearchModel(assistant.model)
|
const isWebSearchModelEnabled = assistant.model && isWebSearchModel(assistant.model)
|
||||||
|
|
||||||
const items: QuickPanelListItem[] = providers
|
const items: QuickPanelListItem[] = providers
|
||||||
.map((p) => ({
|
.map((p) => ({
|
||||||
label: p.name,
|
label: p.name,
|
||||||
@@ -145,12 +134,12 @@ const WebSearchButton: FC<Props> = ({ ref, assistantId }) => {
|
|||||||
? t('settings.tool.websearch.apikey')
|
? t('settings.tool.websearch.apikey')
|
||||||
: t('settings.tool.websearch.free')
|
: t('settings.tool.websearch.free')
|
||||||
: t('chat.input.web_search.enable_content'),
|
: t('chat.input.web_search.enable_content'),
|
||||||
icon: <WebSearchIcon size={13} pid={p.id} />,
|
icon: <WebSearchProviderIcon size={13} pid={p.id} />,
|
||||||
isSelected: p.id === assistant?.webSearchProviderId,
|
isSelected: p.id === assistant?.webSearchProviderId,
|
||||||
disabled: !WebSearchService.isWebSearchEnabled(p.id),
|
disabled: !WebSearchService.isWebSearchEnabled(p.id),
|
||||||
action: () => updateQuickPanelItem(p.id)
|
action: () => updateQuickPanelItem(p.id)
|
||||||
}))
|
}))
|
||||||
.filter((o) => !o.disabled)
|
.filter((item) => !item.disabled)
|
||||||
|
|
||||||
if (isWebSearchModelEnabled) {
|
if (isWebSearchModelEnabled) {
|
||||||
items.unshift({
|
items.unshift({
|
||||||
@@ -167,7 +156,6 @@ const WebSearchButton: FC<Props> = ({ ref, assistantId }) => {
|
|||||||
|
|
||||||
return items
|
return items
|
||||||
}, [
|
}, [
|
||||||
WebSearchIcon,
|
|
||||||
assistant.enableWebSearch,
|
assistant.enableWebSearch,
|
||||||
assistant.model,
|
assistant.model,
|
||||||
assistant?.webSearchProviderId,
|
assistant?.webSearchProviderId,
|
||||||
@@ -178,45 +166,69 @@ const WebSearchButton: FC<Props> = ({ ref, assistantId }) => {
|
|||||||
])
|
])
|
||||||
|
|
||||||
const openQuickPanel = useCallback(() => {
|
const openQuickPanel = useCallback(() => {
|
||||||
quickPanel.open({
|
quickPanelController.open({
|
||||||
title: t('chat.input.web_search.label'),
|
title: t('chat.input.web_search.label'),
|
||||||
list: providerItems,
|
list: providerItems,
|
||||||
symbol: QuickPanelReservedSymbol.WebSearch,
|
symbol: QuickPanelReservedSymbol.WebSearch,
|
||||||
pageSize: 9
|
pageSize: 9
|
||||||
})
|
})
|
||||||
}, [quickPanel, t, providerItems])
|
}, [providerItems, quickPanelController, t])
|
||||||
|
|
||||||
const handleOpenQuickPanel = useCallback(() => {
|
const toggleQuickPanel = useCallback(() => {
|
||||||
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.WebSearch) {
|
if (quickPanelController.isVisible && quickPanelController.symbol === QuickPanelReservedSymbol.WebSearch) {
|
||||||
quickPanel.close()
|
quickPanelController.close()
|
||||||
} else {
|
} else {
|
||||||
openQuickPanel()
|
openQuickPanel()
|
||||||
}
|
}
|
||||||
}, [openQuickPanel, quickPanel])
|
}, [openQuickPanel, quickPanelController])
|
||||||
|
|
||||||
const onClick = useCallback(() => {
|
return {
|
||||||
if (enableWebSearch) {
|
enableWebSearch,
|
||||||
updateWebSearchProvider(undefined)
|
providerItems,
|
||||||
} else {
|
openQuickPanel,
|
||||||
handleOpenQuickPanel()
|
toggleQuickPanel,
|
||||||
}
|
updateWebSearchProvider,
|
||||||
}, [enableWebSearch, handleOpenQuickPanel, updateWebSearchProvider])
|
updateToModelBuiltinWebSearch,
|
||||||
|
selectedProviderId: assistant.webSearchProviderId
|
||||||
useImperativeHandle(ref, () => ({
|
}
|
||||||
openQuickPanel
|
|
||||||
}))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip
|
|
||||||
placement="top"
|
|
||||||
title={enableWebSearch ? t('common.close') : t('chat.input.web_search.label')}
|
|
||||||
mouseLeaveDelay={0}
|
|
||||||
arrow>
|
|
||||||
<ActionIconButton onClick={onClick} active={!!enableWebSearch}>
|
|
||||||
<WebSearchIcon pid={assistant.webSearchProviderId} />
|
|
||||||
</ActionIconButton>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(WebSearchButton)
|
interface ManagerProps {
|
||||||
|
context: ToolRenderContext<any, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
const WebSearchQuickPanelManager = ({ context }: ManagerProps) => {
|
||||||
|
const { assistant, quickPanel, quickPanelController, t } = context
|
||||||
|
const { providerItems, openQuickPanel } = useWebSearchPanelController(assistant.id, quickPanelController)
|
||||||
|
const { registerRootMenu, registerTrigger } = quickPanel
|
||||||
|
const { updateList, isVisible, symbol } = quickPanelController
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isVisible && symbol === QuickPanelReservedSymbol.WebSearch) {
|
||||||
|
updateList(providerItems)
|
||||||
|
}
|
||||||
|
}, [isVisible, providerItems, symbol, updateList])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const disposeMenu = registerRootMenu([
|
||||||
|
{
|
||||||
|
label: t('chat.input.web_search.label'),
|
||||||
|
description: '',
|
||||||
|
icon: <Globe size={18} />,
|
||||||
|
isMenu: true,
|
||||||
|
action: () => openQuickPanel()
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const disposeTrigger = registerTrigger(QuickPanelReservedSymbol.WebSearch, () => openQuickPanel())
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposeMenu()
|
||||||
|
disposeTrigger()
|
||||||
|
}
|
||||||
|
}, [openQuickPanel, registerRootMenu, registerTrigger, t])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WebSearchQuickPanelManager
|
||||||
@@ -0,0 +1,457 @@
|
|||||||
|
import { loggerService } from '@logger'
|
||||||
|
import type { QuickPanelListItem } from '@renderer/components/QuickPanel'
|
||||||
|
import { QuickPanelReservedSymbol } from '@renderer/components/QuickPanel'
|
||||||
|
import type { ToolQuickPanelApi, ToolQuickPanelController } from '@renderer/pages/home/Inputbar/types'
|
||||||
|
import { File, Folder } from 'lucide-react'
|
||||||
|
import type React from 'react'
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('useActivityDirectoryPanel')
|
||||||
|
const MAX_FILE_RESULTS = 500
|
||||||
|
const areFileListsEqual = (prev: string[], next: string[]) => {
|
||||||
|
if (prev === next) return true
|
||||||
|
if (prev.length !== next.length) return false
|
||||||
|
for (let index = 0; index < prev.length; index++) {
|
||||||
|
if (prev[index] !== next[index]) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ActivityDirectoryTriggerInfo = {
|
||||||
|
type: 'input' | 'button'
|
||||||
|
position?: number
|
||||||
|
originalText?: string
|
||||||
|
symbol?: QuickPanelReservedSymbol
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Params {
|
||||||
|
quickPanel: ToolQuickPanelApi
|
||||||
|
quickPanelController: ToolQuickPanelController
|
||||||
|
accessiblePaths: string[]
|
||||||
|
setText: React.Dispatch<React.SetStateAction<string>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useActivityDirectoryPanel = (params: Params, role: 'button' | 'manager' = 'button') => {
|
||||||
|
const { quickPanel, quickPanelController, accessiblePaths, setText } = params
|
||||||
|
const { registerTrigger, registerRootMenu } = quickPanel
|
||||||
|
const { open, close, updateList, isVisible, symbol } = quickPanelController
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const [fileList, setFileList] = useState<string[]>([])
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const triggerInfoRef = useRef<ActivityDirectoryTriggerInfo | undefined>(undefined)
|
||||||
|
const hasAttemptedLoadRef = useRef(false)
|
||||||
|
const fileListRef = useRef<string[]>([])
|
||||||
|
|
||||||
|
const updateFileListState = useCallback(
|
||||||
|
(nextFiles: string[]) => {
|
||||||
|
if (areFileListsEqual(fileListRef.current, nextFiles)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
fileListRef.current = nextFiles
|
||||||
|
setFileList(nextFiles)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
[setFileList]
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert absolute file path to relative path based on accessible directories
|
||||||
|
*/
|
||||||
|
const getRelativePath = useCallback(
|
||||||
|
(absolutePath: string): string => {
|
||||||
|
const normalizedAbsPath = absolutePath.replace(/\\/g, '/')
|
||||||
|
|
||||||
|
// Find the matching accessible path
|
||||||
|
for (const basePath of accessiblePaths) {
|
||||||
|
const normalizedBasePath = basePath.replace(/\\/g, '/')
|
||||||
|
const baseWithSlash = normalizedBasePath.endsWith('/') ? normalizedBasePath : normalizedBasePath + '/'
|
||||||
|
|
||||||
|
if (normalizedAbsPath.startsWith(baseWithSlash)) {
|
||||||
|
return normalizedAbsPath.slice(baseWithSlash.length)
|
||||||
|
}
|
||||||
|
if (normalizedAbsPath === normalizedBasePath) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no match found, return the original path
|
||||||
|
return absolutePath
|
||||||
|
},
|
||||||
|
[accessiblePaths]
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove trigger symbol (e.g., @ or /) and search text from input
|
||||||
|
*/
|
||||||
|
const removeTriggerSymbolAndText = useCallback(
|
||||||
|
(
|
||||||
|
currentText: string,
|
||||||
|
caretPosition: number,
|
||||||
|
symbol: QuickPanelReservedSymbol,
|
||||||
|
searchText?: string,
|
||||||
|
fallbackPosition?: number
|
||||||
|
) => {
|
||||||
|
const safeCaret = Math.max(0, Math.min(caretPosition ?? 0, currentText.length))
|
||||||
|
|
||||||
|
if (searchText !== undefined) {
|
||||||
|
const pattern = symbol + searchText
|
||||||
|
const fromIndex = Math.max(0, safeCaret - 1)
|
||||||
|
const start = currentText.lastIndexOf(pattern, fromIndex)
|
||||||
|
if (start !== -1) {
|
||||||
|
const end = start + pattern.length
|
||||||
|
return currentText.slice(0, start) + currentText.slice(end)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof fallbackPosition === 'number' && currentText[fallbackPosition] === symbol) {
|
||||||
|
const expected = pattern
|
||||||
|
const actual = currentText.slice(fallbackPosition, fallbackPosition + expected.length)
|
||||||
|
if (actual === expected) {
|
||||||
|
return currentText.slice(0, fallbackPosition) + currentText.slice(fallbackPosition + expected.length)
|
||||||
|
}
|
||||||
|
return currentText.slice(0, fallbackPosition) + currentText.slice(fallbackPosition + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentText
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromIndex = Math.max(0, safeCaret - 1)
|
||||||
|
const start = currentText.lastIndexOf(symbol, fromIndex)
|
||||||
|
if (start === -1) {
|
||||||
|
if (typeof fallbackPosition === 'number' && currentText[fallbackPosition] === symbol) {
|
||||||
|
let endPos = fallbackPosition + 1
|
||||||
|
while (endPos < currentText.length && !/\s/.test(currentText[endPos])) {
|
||||||
|
endPos++
|
||||||
|
}
|
||||||
|
return currentText.slice(0, fallbackPosition) + currentText.slice(endPos)
|
||||||
|
}
|
||||||
|
return currentText
|
||||||
|
}
|
||||||
|
|
||||||
|
let endPos = start + 1
|
||||||
|
while (endPos < currentText.length && !/\s/.test(currentText[endPos])) {
|
||||||
|
endPos++
|
||||||
|
}
|
||||||
|
return currentText.slice(0, start) + currentText.slice(endPos)
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert file path at @ position
|
||||||
|
*/
|
||||||
|
const insertFilePath = useCallback(
|
||||||
|
(filePath: string, triggerInfo?: ActivityDirectoryTriggerInfo) => {
|
||||||
|
const relativePath = getRelativePath(filePath)
|
||||||
|
setText((currentText) => {
|
||||||
|
const symbol = triggerInfo?.symbol ?? QuickPanelReservedSymbol.MentionModels
|
||||||
|
const triggerIndex =
|
||||||
|
triggerInfo?.position !== undefined
|
||||||
|
? triggerInfo.position
|
||||||
|
: symbol === QuickPanelReservedSymbol.Root
|
||||||
|
? currentText.lastIndexOf('/')
|
||||||
|
: currentText.lastIndexOf('@')
|
||||||
|
|
||||||
|
if (triggerIndex !== -1) {
|
||||||
|
let endPos = triggerIndex + 1
|
||||||
|
while (endPos < currentText.length && !/\s/.test(currentText[endPos])) {
|
||||||
|
endPos++
|
||||||
|
}
|
||||||
|
return currentText.slice(0, triggerIndex) + relativePath + ' ' + currentText.slice(endPos)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no trigger found, append at end
|
||||||
|
return currentText + ' ' + relativePath + ' '
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[getRelativePath, setText]
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load files from accessible directories
|
||||||
|
* @param searchPattern - Optional search pattern to filter files (default: '.')
|
||||||
|
*/
|
||||||
|
const loadFiles = useCallback(
|
||||||
|
async (searchPattern: string = '.') => {
|
||||||
|
if (accessiblePaths.length === 0) {
|
||||||
|
logger.warn('No accessible paths configured')
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
hasAttemptedLoadRef.current = true
|
||||||
|
setIsLoading(true)
|
||||||
|
const deduped = new Set<string>()
|
||||||
|
const collected: string[] = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const dirPath of accessiblePaths) {
|
||||||
|
if (collected.length >= MAX_FILE_RESULTS) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (!dirPath) continue
|
||||||
|
try {
|
||||||
|
const files = await window.api.file.listDirectory(dirPath, {
|
||||||
|
recursive: true,
|
||||||
|
maxDepth: 4,
|
||||||
|
includeHidden: false,
|
||||||
|
includeFiles: true,
|
||||||
|
includeDirectories: true,
|
||||||
|
maxEntries: MAX_FILE_RESULTS,
|
||||||
|
searchPattern: searchPattern || '.'
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const filePath of files) {
|
||||||
|
const normalizedPath = filePath.replace(/\\/g, '/')
|
||||||
|
if (deduped.has(normalizedPath)) continue
|
||||||
|
deduped.add(normalizedPath)
|
||||||
|
collected.push(normalizedPath)
|
||||||
|
if (collected.length >= MAX_FILE_RESULTS) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Failed to list directory: ${dirPath}`, error as Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return collected
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to load files', error as Error)
|
||||||
|
return []
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[accessiblePaths]
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle file selection
|
||||||
|
*/
|
||||||
|
const onSelectFile = useCallback(
|
||||||
|
(filePath: string) => {
|
||||||
|
const trigger = triggerInfoRef.current
|
||||||
|
insertFilePath(filePath, trigger)
|
||||||
|
close()
|
||||||
|
},
|
||||||
|
[close, insertFilePath]
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create file list items for QuickPanel from a file list
|
||||||
|
*/
|
||||||
|
const createFileItems = useCallback(
|
||||||
|
(files: string[], loading: boolean = false): QuickPanelListItem[] => {
|
||||||
|
if (loading && files.length === 0) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: t('common.loading'),
|
||||||
|
description: t('chat.input.activity_directory.loading'),
|
||||||
|
icon: <Folder size={16} />,
|
||||||
|
action: () => {},
|
||||||
|
isSelected: false,
|
||||||
|
alwaysVisible: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: t('chat.input.activity_directory.no_file_found.label'),
|
||||||
|
description: t('chat.input.activity_directory.no_file_found.description'),
|
||||||
|
icon: <Folder size={16} />,
|
||||||
|
action: () => {},
|
||||||
|
isSelected: false,
|
||||||
|
alwaysVisible: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return files.map((filePath) => {
|
||||||
|
const relativePath = getRelativePath(filePath)
|
||||||
|
const fileName = relativePath.split('/').pop() || relativePath
|
||||||
|
|
||||||
|
// Include both absolute path and relative path in filterText to improve matching
|
||||||
|
// This helps when server-side search returns files with different naming conventions
|
||||||
|
// (e.g., "app-updater" vs "appupdater")
|
||||||
|
const filterText = `${fileName} ${relativePath} ${filePath}`
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: relativePath,
|
||||||
|
icon: <File size={16} />,
|
||||||
|
filterText: filterText,
|
||||||
|
action: () => onSelectFile(filePath),
|
||||||
|
isSelected: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[getRelativePath, onSelectFile, t]
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create file list items for QuickPanel (for current state)
|
||||||
|
*/
|
||||||
|
const fileItems = useMemo<QuickPanelListItem[]>(
|
||||||
|
() => createFileItems(fileList, isLoading),
|
||||||
|
[createFileItems, fileList, isLoading]
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle search text change - load files and update list
|
||||||
|
*/
|
||||||
|
const handleSearchChange = useCallback(
|
||||||
|
async (searchText: string) => {
|
||||||
|
logger.debug('Search text changed', { searchText })
|
||||||
|
|
||||||
|
// Load files with search pattern
|
||||||
|
const searchPattern = searchText.trim() || '.'
|
||||||
|
const newFiles = await loadFiles(searchPattern)
|
||||||
|
|
||||||
|
const hasChanged = updateFileListState(newFiles)
|
||||||
|
if (hasChanged) {
|
||||||
|
const newItems = createFileItems(newFiles, false)
|
||||||
|
updateList(newItems)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadFiles, createFileItems, updateList, updateFileListState]
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open QuickPanel with file list
|
||||||
|
*/
|
||||||
|
const openQuickPanel = useCallback(
|
||||||
|
async (triggerInfo?: ActivityDirectoryTriggerInfo) => {
|
||||||
|
const normalizedTriggerInfo =
|
||||||
|
triggerInfo && triggerInfo.type === 'input'
|
||||||
|
? {
|
||||||
|
...triggerInfo,
|
||||||
|
symbol: triggerInfo.symbol ?? QuickPanelReservedSymbol.MentionModels
|
||||||
|
}
|
||||||
|
: triggerInfo
|
||||||
|
triggerInfoRef.current = normalizedTriggerInfo
|
||||||
|
|
||||||
|
// Always load fresh files when opening the panel
|
||||||
|
const files = await loadFiles()
|
||||||
|
updateFileListState(files)
|
||||||
|
|
||||||
|
// Create items from the loaded files immediately
|
||||||
|
const items = createFileItems(files, false)
|
||||||
|
|
||||||
|
open({
|
||||||
|
title: t('chat.input.activity_directory.description'),
|
||||||
|
list: items,
|
||||||
|
symbol: QuickPanelReservedSymbol.MentionModels, // Reuse @ symbol
|
||||||
|
manageListExternally: true,
|
||||||
|
triggerInfo: normalizedTriggerInfo
|
||||||
|
? {
|
||||||
|
type: normalizedTriggerInfo.type,
|
||||||
|
position: normalizedTriggerInfo.position,
|
||||||
|
originalText: normalizedTriggerInfo.originalText
|
||||||
|
}
|
||||||
|
: { type: 'button' },
|
||||||
|
onClose({ action, searchText }) {
|
||||||
|
if (action === 'esc') {
|
||||||
|
const activeTrigger = triggerInfoRef.current
|
||||||
|
if (activeTrigger?.type === 'input' && activeTrigger?.position !== undefined) {
|
||||||
|
setText((currentText) => {
|
||||||
|
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement | null
|
||||||
|
const caret = textArea ? (textArea.selectionStart ?? currentText.length) : currentText.length
|
||||||
|
const symbolForRemoval = activeTrigger.symbol ?? QuickPanelReservedSymbol.MentionModels
|
||||||
|
return removeTriggerSymbolAndText(
|
||||||
|
currentText,
|
||||||
|
caret,
|
||||||
|
symbolForRemoval,
|
||||||
|
searchText || '',
|
||||||
|
activeTrigger.position
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Clear file list and reset state when panel closes
|
||||||
|
updateFileListState([])
|
||||||
|
hasAttemptedLoadRef.current = false
|
||||||
|
triggerInfoRef.current = undefined
|
||||||
|
},
|
||||||
|
onSearchChange: handleSearchChange
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[loadFiles, open, removeTriggerSymbolAndText, setText, t, handleSearchChange, createFileItems, updateFileListState]
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle button click - toggle panel open/close
|
||||||
|
*/
|
||||||
|
const isMentionPanelActive = useCallback(() => {
|
||||||
|
return quickPanelController.isVisible && quickPanelController.symbol === QuickPanelReservedSymbol.MentionModels
|
||||||
|
}, [quickPanelController])
|
||||||
|
|
||||||
|
const handleOpenQuickPanel = useCallback(() => {
|
||||||
|
if (isMentionPanelActive()) {
|
||||||
|
close()
|
||||||
|
} else {
|
||||||
|
openQuickPanel({ type: 'button' })
|
||||||
|
}
|
||||||
|
}, [close, isMentionPanelActive, openQuickPanel])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update list when files change
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (role !== 'manager') return
|
||||||
|
if (!hasAttemptedLoadRef.current && fileList.length === 0 && !isLoading) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isVisible && symbol === QuickPanelReservedSymbol.MentionModels) {
|
||||||
|
updateList(fileItems)
|
||||||
|
}
|
||||||
|
}, [fileItems, fileList.length, isLoading, isVisible, role, symbol, updateList])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register trigger and root menu (manager only)
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (role !== 'manager') return
|
||||||
|
|
||||||
|
const disposeMenu = registerRootMenu([
|
||||||
|
{
|
||||||
|
label: t('chat.input.activity_directory.title'),
|
||||||
|
description: t('chat.input.activity_directory.description'),
|
||||||
|
icon: <Folder size={16} />,
|
||||||
|
isMenu: true,
|
||||||
|
action: ({ context }) => {
|
||||||
|
const rootTrigger =
|
||||||
|
context.triggerInfo && context.triggerInfo.type === 'input'
|
||||||
|
? {
|
||||||
|
...context.triggerInfo,
|
||||||
|
symbol: QuickPanelReservedSymbol.Root
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
context.close('select')
|
||||||
|
setTimeout(() => {
|
||||||
|
openQuickPanel(rootTrigger ?? { type: 'button' })
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const disposeTrigger = registerTrigger(QuickPanelReservedSymbol.MentionModels, (payload) => {
|
||||||
|
const trigger = (payload || {}) as ActivityDirectoryTriggerInfo
|
||||||
|
openQuickPanel(trigger)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposeMenu()
|
||||||
|
disposeTrigger()
|
||||||
|
}
|
||||||
|
}, [openQuickPanel, registerRootMenu, registerTrigger, role, t])
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleOpenQuickPanel,
|
||||||
|
openQuickPanel,
|
||||||
|
fileList,
|
||||||
|
isLoading
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
import ModelTagsWithLabel from '@renderer/components/ModelTagsWithLabel'
|
||||||
|
import type { QuickPanelListItem } from '@renderer/components/QuickPanel'
|
||||||
|
import { QuickPanelReservedSymbol } from '@renderer/components/QuickPanel'
|
||||||
|
import { getModelLogo, isEmbeddingModel, isRerankModel, isVisionModel } from '@renderer/config/models'
|
||||||
|
import db from '@renderer/databases'
|
||||||
|
import { useProviders } from '@renderer/hooks/useProvider'
|
||||||
|
import type { ToolQuickPanelApi, ToolQuickPanelController } from '@renderer/pages/home/Inputbar/types'
|
||||||
|
import { getModelUniqId } from '@renderer/services/ModelService'
|
||||||
|
import type { FileType, Model } from '@renderer/types'
|
||||||
|
import { FileTypes } from '@renderer/types'
|
||||||
|
import { getFancyProviderName } from '@renderer/utils'
|
||||||
|
import { Avatar } from 'antd'
|
||||||
|
import { useLiveQuery } from 'dexie-react-hooks'
|
||||||
|
import { first, sortBy } from 'lodash'
|
||||||
|
import { AtSign, CircleX, Plus } from 'lucide-react'
|
||||||
|
import type React from 'react'
|
||||||
|
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useNavigate } from 'react-router'
|
||||||
|
import styled from 'styled-components'
|
||||||
|
|
||||||
|
export type MentionTriggerInfo = { type: 'input' | 'button'; position?: number; originalText?: string }
|
||||||
|
|
||||||
|
interface Params {
|
||||||
|
quickPanel: ToolQuickPanelApi
|
||||||
|
quickPanelController: ToolQuickPanelController
|
||||||
|
mentionedModels: Model[]
|
||||||
|
setMentionedModels: React.Dispatch<React.SetStateAction<Model[]>>
|
||||||
|
couldMentionNotVisionModel: boolean
|
||||||
|
files: FileType[]
|
||||||
|
setText: React.Dispatch<React.SetStateAction<string>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useMentionModelsPanel = (params: Params, role: 'button' | 'manager' = 'button') => {
|
||||||
|
const {
|
||||||
|
quickPanel,
|
||||||
|
quickPanelController,
|
||||||
|
mentionedModels,
|
||||||
|
setMentionedModels,
|
||||||
|
couldMentionNotVisionModel,
|
||||||
|
files,
|
||||||
|
setText
|
||||||
|
} = params
|
||||||
|
const { registerRootMenu, registerTrigger } = quickPanel
|
||||||
|
const { open, close, updateList, isVisible, symbol } = quickPanelController
|
||||||
|
const { providers } = useProviders()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const hasModelActionRef = useRef(false)
|
||||||
|
const triggerInfoRef = useRef<MentionTriggerInfo | undefined>(undefined)
|
||||||
|
const filesRef = useRef(files)
|
||||||
|
|
||||||
|
const removeAtSymbolAndText = useCallback(
|
||||||
|
(currentText: string, caretPosition: number, searchText?: string, fallbackPosition?: number) => {
|
||||||
|
const safeCaret = Math.max(0, Math.min(caretPosition ?? 0, currentText.length))
|
||||||
|
|
||||||
|
if (searchText !== undefined) {
|
||||||
|
const pattern = '@' + searchText
|
||||||
|
const fromIndex = Math.max(0, safeCaret - 1)
|
||||||
|
const start = currentText.lastIndexOf(pattern, fromIndex)
|
||||||
|
if (start !== -1) {
|
||||||
|
const end = start + pattern.length
|
||||||
|
return currentText.slice(0, start) + currentText.slice(end)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof fallbackPosition === 'number' && currentText[fallbackPosition] === '@') {
|
||||||
|
const expected = pattern
|
||||||
|
const actual = currentText.slice(fallbackPosition, fallbackPosition + expected.length)
|
||||||
|
if (actual === expected) {
|
||||||
|
return currentText.slice(0, fallbackPosition) + currentText.slice(fallbackPosition + expected.length)
|
||||||
|
}
|
||||||
|
return currentText.slice(0, fallbackPosition) + currentText.slice(fallbackPosition + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentText
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromIndex = Math.max(0, safeCaret - 1)
|
||||||
|
const start = currentText.lastIndexOf('@', fromIndex)
|
||||||
|
if (start === -1) {
|
||||||
|
if (typeof fallbackPosition === 'number' && currentText[fallbackPosition] === '@') {
|
||||||
|
let endPos = fallbackPosition + 1
|
||||||
|
while (endPos < currentText.length && !/\s/.test(currentText[endPos])) {
|
||||||
|
endPos++
|
||||||
|
}
|
||||||
|
return currentText.slice(0, fallbackPosition) + currentText.slice(endPos)
|
||||||
|
}
|
||||||
|
return currentText
|
||||||
|
}
|
||||||
|
|
||||||
|
let endPos = start + 1
|
||||||
|
while (endPos < currentText.length && !/\s/.test(currentText[endPos])) {
|
||||||
|
endPos++
|
||||||
|
}
|
||||||
|
return currentText.slice(0, start) + currentText.slice(endPos)
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const onMentionModel = useCallback(
|
||||||
|
(model: Model) => {
|
||||||
|
const allowNonVision = !files.some((file) => file.type === FileTypes.IMAGE)
|
||||||
|
if (isVisionModel(model) || allowNonVision) {
|
||||||
|
setMentionedModels((prev) => {
|
||||||
|
const modelId = getModelUniqId(model)
|
||||||
|
const exists = prev.some((m) => getModelUniqId(m) === modelId)
|
||||||
|
return exists ? prev.filter((m) => getModelUniqId(m) !== modelId) : [...prev, model]
|
||||||
|
})
|
||||||
|
hasModelActionRef.current = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[files, setMentionedModels]
|
||||||
|
)
|
||||||
|
|
||||||
|
const onClearMentionModels = useCallback(() => {
|
||||||
|
setMentionedModels([])
|
||||||
|
}, [setMentionedModels])
|
||||||
|
|
||||||
|
const pinnedModels = useLiveQuery(
|
||||||
|
async () => {
|
||||||
|
const setting = await db.settings.get('pinned:models')
|
||||||
|
return setting?.value || []
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const modelItems = useMemo(() => {
|
||||||
|
const items: QuickPanelListItem[] = []
|
||||||
|
|
||||||
|
if (pinnedModels.length > 0) {
|
||||||
|
const pinnedItems = providers.flatMap((provider) =>
|
||||||
|
provider.models
|
||||||
|
.filter((model) => !isEmbeddingModel(model) && !isRerankModel(model))
|
||||||
|
.filter((model) => pinnedModels.includes(getModelUniqId(model)))
|
||||||
|
.filter((model) => couldMentionNotVisionModel || (!couldMentionNotVisionModel && isVisionModel(model)))
|
||||||
|
.map((model) => ({
|
||||||
|
label: (
|
||||||
|
<>
|
||||||
|
<ProviderName>{getFancyProviderName(provider)}</ProviderName>
|
||||||
|
<span style={{ opacity: 0.8 }}> | {model.name}</span>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
description: <ModelTagsWithLabel model={model} showLabel={false} size={10} style={{ opacity: 0.8 }} />,
|
||||||
|
icon: (
|
||||||
|
<Avatar src={getModelLogo(model)} size={20}>
|
||||||
|
{first(model.name)}
|
||||||
|
</Avatar>
|
||||||
|
),
|
||||||
|
filterText: getFancyProviderName(provider) + model.name,
|
||||||
|
action: () => onMentionModel(model),
|
||||||
|
isSelected: mentionedModels.some((selected) => getModelUniqId(selected) === getModelUniqId(model))
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
if (pinnedItems.length > 0) {
|
||||||
|
items.push(...sortBy(pinnedItems, ['label']))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
providers.forEach((provider) => {
|
||||||
|
const providerModels = sortBy(
|
||||||
|
provider.models
|
||||||
|
.filter((model) => !isEmbeddingModel(model) && !isRerankModel(model))
|
||||||
|
.filter((model) => !pinnedModels.includes(getModelUniqId(model)))
|
||||||
|
.filter((model) => couldMentionNotVisionModel || (!couldMentionNotVisionModel && isVisionModel(model))),
|
||||||
|
['group', 'name']
|
||||||
|
)
|
||||||
|
|
||||||
|
const providerItems = providerModels.map((model) => ({
|
||||||
|
label: (
|
||||||
|
<>
|
||||||
|
<ProviderName>{getFancyProviderName(provider)}</ProviderName>
|
||||||
|
<span style={{ opacity: 0.8 }}> | {model.name}</span>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
description: <ModelTagsWithLabel model={model} showLabel={false} size={10} style={{ opacity: 0.8 }} />,
|
||||||
|
icon: (
|
||||||
|
<Avatar src={getModelLogo(model)} size={20}>
|
||||||
|
{first(model.name)}
|
||||||
|
</Avatar>
|
||||||
|
),
|
||||||
|
filterText: getFancyProviderName(provider) + model.name,
|
||||||
|
action: () => onMentionModel(model),
|
||||||
|
isSelected: mentionedModels.some((selected) => getModelUniqId(selected) === getModelUniqId(model))
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (providerItems.length > 0) {
|
||||||
|
items.push(...providerItems)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
label: t('settings.models.add.add_model') + '...',
|
||||||
|
icon: <Plus />,
|
||||||
|
action: () => navigate('/settings/provider'),
|
||||||
|
isSelected: false
|
||||||
|
})
|
||||||
|
|
||||||
|
items.unshift({
|
||||||
|
label: t('settings.input.clear.all'),
|
||||||
|
description: t('settings.input.clear.models'),
|
||||||
|
icon: <CircleX />,
|
||||||
|
alwaysVisible: true,
|
||||||
|
isSelected: false,
|
||||||
|
action: ({ context }) => {
|
||||||
|
onClearMentionModels()
|
||||||
|
|
||||||
|
if (triggerInfoRef.current?.type === 'input') {
|
||||||
|
setText((currentText) => {
|
||||||
|
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement | null
|
||||||
|
const caret = textArea ? (textArea.selectionStart ?? currentText.length) : currentText.length
|
||||||
|
return removeAtSymbolAndText(currentText, caret, undefined, triggerInfoRef.current?.position)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
context.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return items
|
||||||
|
}, [
|
||||||
|
couldMentionNotVisionModel,
|
||||||
|
mentionedModels,
|
||||||
|
navigate,
|
||||||
|
onClearMentionModels,
|
||||||
|
onMentionModel,
|
||||||
|
pinnedModels,
|
||||||
|
providers,
|
||||||
|
removeAtSymbolAndText,
|
||||||
|
setText,
|
||||||
|
t
|
||||||
|
])
|
||||||
|
|
||||||
|
const openQuickPanel = useCallback(
|
||||||
|
(triggerInfo?: MentionTriggerInfo) => {
|
||||||
|
hasModelActionRef.current = false
|
||||||
|
triggerInfoRef.current = triggerInfo
|
||||||
|
|
||||||
|
open({
|
||||||
|
title: t('assistants.presets.edit.model.select.title'),
|
||||||
|
list: modelItems,
|
||||||
|
symbol: QuickPanelReservedSymbol.MentionModels,
|
||||||
|
multiple: true,
|
||||||
|
triggerInfo: triggerInfo || { type: 'button' },
|
||||||
|
afterAction({ item }) {
|
||||||
|
item.isSelected = !item.isSelected
|
||||||
|
},
|
||||||
|
onClose({ action, searchText, context }) {
|
||||||
|
if (action === 'esc') {
|
||||||
|
const trigger = context?.triggerInfo ?? triggerInfoRef.current
|
||||||
|
if (hasModelActionRef.current && trigger?.type === 'input' && trigger?.position !== undefined) {
|
||||||
|
setText((currentText) => {
|
||||||
|
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement | null
|
||||||
|
const caret = textArea ? (textArea.selectionStart ?? currentText.length) : currentText.length
|
||||||
|
return removeAtSymbolAndText(currentText, caret, searchText || '', trigger?.position!)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
triggerInfoRef.current = undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[modelItems, open, removeAtSymbolAndText, setText, t]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleOpenQuickPanel = useCallback(() => {
|
||||||
|
if (isVisible && symbol === QuickPanelReservedSymbol.MentionModels) {
|
||||||
|
close()
|
||||||
|
} else {
|
||||||
|
openQuickPanel({ type: 'button' })
|
||||||
|
}
|
||||||
|
}, [close, isVisible, openQuickPanel, symbol])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (role !== 'manager') return
|
||||||
|
if (filesRef.current !== files) {
|
||||||
|
if (isVisible && symbol === QuickPanelReservedSymbol.MentionModels) {
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
filesRef.current = files
|
||||||
|
}
|
||||||
|
}, [close, files, isVisible, role, symbol])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (role !== 'manager') return
|
||||||
|
if (isVisible && symbol === QuickPanelReservedSymbol.MentionModels) {
|
||||||
|
updateList(modelItems)
|
||||||
|
}
|
||||||
|
}, [isVisible, modelItems, role, symbol, updateList])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (role !== 'manager') return
|
||||||
|
const disposeRootMenu = registerRootMenu([
|
||||||
|
{
|
||||||
|
label: t('assistants.presets.edit.model.select.title'),
|
||||||
|
description: '',
|
||||||
|
icon: <AtSign />,
|
||||||
|
isMenu: true,
|
||||||
|
action: () => openQuickPanel({ type: 'button' })
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const disposeTrigger = registerTrigger(QuickPanelReservedSymbol.MentionModels, (payload) => {
|
||||||
|
const trigger = (payload || {}) as MentionTriggerInfo
|
||||||
|
openQuickPanel(trigger)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposeRootMenu()
|
||||||
|
disposeTrigger()
|
||||||
|
}
|
||||||
|
}, [openQuickPanel, registerRootMenu, registerTrigger, role, t])
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleOpenQuickPanel,
|
||||||
|
openQuickPanel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProviderName = styled.span`
|
||||||
|
font-weight: 500;
|
||||||
|
`
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { loggerService } from '@logger'
|
||||||
|
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||||
|
import { useCreateDefaultSession } from '@renderer/hooks/agents/useCreateDefaultSession'
|
||||||
|
import { useSettings } from '@renderer/hooks/useSettings'
|
||||||
|
import { useShortcutDisplay } from '@renderer/hooks/useShortcuts'
|
||||||
|
import { defineTool, registerTool, TopicType } from '@renderer/pages/home/Inputbar/types'
|
||||||
|
import { Tooltip } from 'antd'
|
||||||
|
import { MessageSquareDiff } from 'lucide-react'
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('CreateSessionTool')
|
||||||
|
|
||||||
|
const createSessionTool = defineTool({
|
||||||
|
key: 'create_session',
|
||||||
|
label: (t) => t('chat.input.new_session', { Command: '' }),
|
||||||
|
visibleInScopes: [TopicType.Session],
|
||||||
|
|
||||||
|
render: function CreateSessionRender(context) {
|
||||||
|
const { t, assistant, session } = context
|
||||||
|
const newTopicShortcut = useShortcutDisplay('new_topic')
|
||||||
|
const { apiServer } = useSettings()
|
||||||
|
const sessionAgentId = session?.agentId
|
||||||
|
|
||||||
|
const agentId = sessionAgentId || assistant.id
|
||||||
|
const { createDefaultSession, creatingSession } = useCreateDefaultSession(agentId)
|
||||||
|
|
||||||
|
const createSessionDisabled = creatingSession || !apiServer.enabled
|
||||||
|
|
||||||
|
const handleCreateSession = useCallback(async () => {
|
||||||
|
if (createSessionDisabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await createDefaultSession()
|
||||||
|
if (!created) {
|
||||||
|
logger.warn('Failed to create agent session')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to create agent session via toolbar:', error as Error)
|
||||||
|
}
|
||||||
|
}, [createDefaultSession, createSessionDisabled])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip placement="top" title={t('chat.input.new_topic', { Command: newTopicShortcut })}>
|
||||||
|
<ActionIconButton onClick={handleCreateSession} disabled={createSessionDisabled} loading={creatingSession}>
|
||||||
|
<MessageSquareDiff size={19} />
|
||||||
|
</ActionIconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Register the tool
|
||||||
|
registerTool(createSessionTool)
|
||||||
|
|
||||||
|
export default createSessionTool
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { isGenerateImageModel } from '@renderer/config/models'
|
||||||
|
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||||
|
import GenerateImageButton from '@renderer/pages/home/Inputbar/tools/components/GenerateImageButton'
|
||||||
|
import { defineTool, registerTool, TopicType } from '@renderer/pages/home/Inputbar/types'
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
|
||||||
|
const GenerateImageTool = ({ context }) => {
|
||||||
|
const { assistant, model } = context
|
||||||
|
const { updateAssistant } = useAssistant(assistant.id)
|
||||||
|
|
||||||
|
const handleToggle = useCallback(() => {
|
||||||
|
updateAssistant({ ...assistant, enableGenerateImage: !assistant.enableGenerateImage })
|
||||||
|
}, [assistant, updateAssistant])
|
||||||
|
|
||||||
|
return <GenerateImageButton assistant={assistant} model={model} onEnableGenerateImage={handleToggle} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateImageTool = defineTool({
|
||||||
|
key: 'generate_image',
|
||||||
|
label: (t) => t('chat.input.generate_image'),
|
||||||
|
visibleInScopes: [TopicType.Chat],
|
||||||
|
condition: ({ model }) => isGenerateImageModel(model),
|
||||||
|
render: (context) => <GenerateImageTool context={context} />
|
||||||
|
})
|
||||||
|
|
||||||
|
registerTool(generateImageTool)
|
||||||
|
|
||||||
|
export default generateImageTool
|
||||||
23
src/renderer/src/pages/home/Inputbar/tools/index.ts
Normal file
23
src/renderer/src/pages/home/Inputbar/tools/index.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// Tool registry loader
|
||||||
|
// Import all tool definitions to register them
|
||||||
|
|
||||||
|
import './attachmentTool'
|
||||||
|
import './mentionModelsTool'
|
||||||
|
import './newTopicTool'
|
||||||
|
import './quickPhrasesTool'
|
||||||
|
import './thinkingTool'
|
||||||
|
import './webSearchTool'
|
||||||
|
import './urlContextTool'
|
||||||
|
import './knowledgeBaseTool'
|
||||||
|
import './mcpToolsTool'
|
||||||
|
import './generateImageTool'
|
||||||
|
import './clearTopicTool'
|
||||||
|
import './toggleExpandTool'
|
||||||
|
import './newContextTool'
|
||||||
|
// Agent Session tools
|
||||||
|
import './createSessionTool'
|
||||||
|
import './slashCommandsTool'
|
||||||
|
import './activityDirectoryTool'
|
||||||
|
|
||||||
|
// Export registry functions
|
||||||
|
export { getAllTools, getTool, getToolsForScope, registerTool } from '../types'
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||||
|
import { useSidebarIconShow } from '@renderer/hooks/useSidebarIcon'
|
||||||
|
import { defineTool, registerTool, TopicType } from '@renderer/pages/home/Inputbar/types'
|
||||||
|
import type { KnowledgeBase } from '@renderer/types'
|
||||||
|
import { isPromptToolUse, isSupportedToolUse } from '@renderer/utils/mcp-tools'
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
|
||||||
|
import KnowledgeBaseButton from './components/KnowledgeBaseButton'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Knowledge Base Tool
|
||||||
|
*
|
||||||
|
* Allows users to select knowledge bases to provide context for their messages.
|
||||||
|
* Only visible when knowledge base sidebar is enabled.
|
||||||
|
*/
|
||||||
|
const knowledgeBaseTool = defineTool({
|
||||||
|
key: 'knowledge_base',
|
||||||
|
label: (t) => t('chat.input.knowledge_base'),
|
||||||
|
// ✅ 移除 icon 属性,不在 ToolDefinition 类型中
|
||||||
|
// icon: FileSearch,
|
||||||
|
|
||||||
|
visibleInScopes: [TopicType.Chat],
|
||||||
|
condition: ({ assistant }) => isSupportedToolUse(assistant) || isPromptToolUse(assistant),
|
||||||
|
|
||||||
|
dependencies: {
|
||||||
|
state: ['selectedKnowledgeBases', 'files'] as const,
|
||||||
|
actions: ['setSelectedKnowledgeBases'] as const
|
||||||
|
},
|
||||||
|
|
||||||
|
render: function KnowledgeBaseToolRender(context) {
|
||||||
|
const { assistant, state, actions, quickPanel } = context
|
||||||
|
|
||||||
|
const knowledgeSidebarEnabled = useSidebarIconShow('knowledge')
|
||||||
|
const { updateAssistant } = useAssistant(assistant.id)
|
||||||
|
|
||||||
|
const handleSelect = useCallback(
|
||||||
|
(bases: KnowledgeBase[]) => {
|
||||||
|
updateAssistant({ knowledge_bases: bases })
|
||||||
|
actions.setSelectedKnowledgeBases?.(bases)
|
||||||
|
},
|
||||||
|
[updateAssistant, actions]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!knowledgeSidebarEnabled) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KnowledgeBaseButton
|
||||||
|
quickPanel={quickPanel}
|
||||||
|
selectedBases={state.selectedKnowledgeBases}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
disabled={Array.isArray(state.files) && state.files.length > 0}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
registerTool(knowledgeBaseTool)
|
||||||
|
|
||||||
|
export default knowledgeBaseTool
|
||||||
26
src/renderer/src/pages/home/Inputbar/tools/mcpToolsTool.tsx
Normal file
26
src/renderer/src/pages/home/Inputbar/tools/mcpToolsTool.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { defineTool, registerTool, TopicType } from '@renderer/pages/home/Inputbar/types'
|
||||||
|
import { isPromptToolUse, isSupportedToolUse } from '@renderer/utils/mcp-tools'
|
||||||
|
|
||||||
|
import MCPToolsButton from './components/MCPToolsButton'
|
||||||
|
|
||||||
|
const mcpToolsTool = defineTool({
|
||||||
|
key: 'mcp_tools',
|
||||||
|
label: (t) => t('settings.mcp.title'),
|
||||||
|
visibleInScopes: [TopicType.Chat],
|
||||||
|
condition: ({ assistant }) => isSupportedToolUse(assistant) || isPromptToolUse(assistant),
|
||||||
|
dependencies: {
|
||||||
|
actions: ['onTextChange', 'resizeTextArea'] as const
|
||||||
|
},
|
||||||
|
render: ({ assistant, actions, quickPanel }) => (
|
||||||
|
<MCPToolsButton
|
||||||
|
assistantId={assistant.id}
|
||||||
|
quickPanel={quickPanel}
|
||||||
|
setInputValue={actions.onTextChange}
|
||||||
|
resizeTextArea={actions.resizeTextArea}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
registerTool(mcpToolsTool)
|
||||||
|
|
||||||
|
export default mcpToolsTool
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { defineTool, registerTool, TopicType } from '@renderer/pages/home/Inputbar/types'
|
||||||
|
import type React from 'react'
|
||||||
|
|
||||||
|
import MentionModelsButton from './components/MentionModelsButton'
|
||||||
|
import MentionModelsQuickPanelManager from './components/MentionModelsQuickPanelManager'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mention Models Tool
|
||||||
|
*
|
||||||
|
* Allows users to mention multiple AI models in their messages.
|
||||||
|
* Uses @ trigger to open model selection panel.
|
||||||
|
*/
|
||||||
|
const mentionModelsTool = defineTool({
|
||||||
|
key: 'mention_models',
|
||||||
|
label: (t) => t('assistants.presets.edit.model.select.title'),
|
||||||
|
|
||||||
|
visibleInScopes: [TopicType.Chat, 'mini-window'],
|
||||||
|
dependencies: {
|
||||||
|
state: ['mentionedModels', 'files', 'couldMentionNotVisionModel'] as const,
|
||||||
|
actions: ['setMentionedModels', 'onTextChange'] as const
|
||||||
|
},
|
||||||
|
|
||||||
|
render: function MentionModelsToolRender(context) {
|
||||||
|
const { state, actions, quickPanel, quickPanelController } = context
|
||||||
|
const { mentionedModels, files, couldMentionNotVisionModel } = state
|
||||||
|
const { setMentionedModels, onTextChange } = actions
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MentionModelsButton
|
||||||
|
quickPanel={quickPanel}
|
||||||
|
quickPanelController={quickPanelController}
|
||||||
|
mentionedModels={mentionedModels}
|
||||||
|
setMentionedModels={setMentionedModels}
|
||||||
|
couldMentionNotVisionModel={couldMentionNotVisionModel}
|
||||||
|
files={files}
|
||||||
|
setText={onTextChange as React.Dispatch<React.SetStateAction<string>>}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
quickPanelManager: MentionModelsQuickPanelManager
|
||||||
|
})
|
||||||
|
|
||||||
|
registerTool(mentionModelsTool)
|
||||||
|
|
||||||
|
export default mentionModelsTool
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineTool, registerTool, TopicType } from '@renderer/pages/home/Inputbar/types'
|
||||||
|
|
||||||
|
import NewContextButton from './components/NewContextButton'
|
||||||
|
|
||||||
|
const newContextTool = defineTool({
|
||||||
|
key: 'new_context',
|
||||||
|
label: (t) => t('chat.input.new.context', { Command: '' }),
|
||||||
|
visibleInScopes: [TopicType.Chat],
|
||||||
|
dependencies: {
|
||||||
|
actions: ['onNewContext'] as const
|
||||||
|
},
|
||||||
|
render: ({ actions }) => <NewContextButton onNewContext={actions.onNewContext} />
|
||||||
|
})
|
||||||
|
|
||||||
|
registerTool(newContextTool)
|
||||||
|
|
||||||
|
export default newContextTool
|
||||||
38
src/renderer/src/pages/home/Inputbar/tools/newTopicTool.tsx
Normal file
38
src/renderer/src/pages/home/Inputbar/tools/newTopicTool.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||||
|
import { useShortcutDisplay } from '@renderer/hooks/useShortcuts'
|
||||||
|
import { defineTool, registerTool, TopicType } from '@renderer/pages/home/Inputbar/types'
|
||||||
|
import { Tooltip } from 'antd'
|
||||||
|
import { MessageSquareDiff } from 'lucide-react'
|
||||||
|
|
||||||
|
const newTopicTool = defineTool({
|
||||||
|
key: 'new_topic',
|
||||||
|
label: (t) => t('chat.input.new_topic', { Command: '' }),
|
||||||
|
|
||||||
|
visibleInScopes: [TopicType.Chat],
|
||||||
|
|
||||||
|
dependencies: {
|
||||||
|
actions: ['addNewTopic'] as const
|
||||||
|
},
|
||||||
|
|
||||||
|
render: function NewTopicRender(context) {
|
||||||
|
const { actions, t } = context
|
||||||
|
const newTopicShortcut = useShortcutDisplay('new_topic')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
placement="top"
|
||||||
|
title={t('chat.input.new_topic', { Command: newTopicShortcut })}
|
||||||
|
mouseLeaveDelay={0}
|
||||||
|
arrow>
|
||||||
|
<ActionIconButton onClick={actions.addNewTopic}>
|
||||||
|
<MessageSquareDiff size={19} />
|
||||||
|
</ActionIconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Register the tool
|
||||||
|
registerTool(newTopicTool)
|
||||||
|
|
||||||
|
export default newTopicTool
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import QuickPhrasesButton from '@renderer/pages/home/Inputbar/tools/components/QuickPhrasesButton'
|
||||||
|
import { defineTool, registerTool, TopicType } from '@renderer/pages/home/Inputbar/types'
|
||||||
|
|
||||||
|
const quickPhrasesTool = defineTool({
|
||||||
|
key: 'quick_phrases',
|
||||||
|
label: (t) => t('settings.quickPhrase.title'),
|
||||||
|
|
||||||
|
visibleInScopes: [TopicType.Chat, TopicType.Session, 'mini-window'],
|
||||||
|
|
||||||
|
dependencies: {
|
||||||
|
actions: ['onTextChange', 'resizeTextArea'] as const
|
||||||
|
},
|
||||||
|
|
||||||
|
render: (context) => {
|
||||||
|
const { assistant, actions, quickPanel } = context
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QuickPhrasesButton
|
||||||
|
quickPanel={quickPanel}
|
||||||
|
setInputValue={actions.onTextChange}
|
||||||
|
resizeTextArea={actions.resizeTextArea}
|
||||||
|
assistantId={assistant.id}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
registerTool(quickPhrasesTool)
|
||||||
|
|
||||||
|
export default quickPhrasesTool
|
||||||
226
src/renderer/src/pages/home/Inputbar/tools/slashCommandsTool.tsx
Normal file
226
src/renderer/src/pages/home/Inputbar/tools/slashCommandsTool.tsx
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import { loggerService } from '@logger'
|
||||||
|
import { QuickPanelReservedSymbol } from '@renderer/components/QuickPanel'
|
||||||
|
import SlashCommandsButton from '@renderer/pages/home/Inputbar/tools/components/SlashCommandsButton'
|
||||||
|
import { defineTool, registerTool, TopicType } from '@renderer/pages/home/Inputbar/types'
|
||||||
|
import { Terminal } from 'lucide-react'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('SlashCommandsTool')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to insert slash command into textarea
|
||||||
|
* @param command - The command to insert (e.g., "/clear")
|
||||||
|
* @param replaceSlash - Whether to replace the preceding '/' character
|
||||||
|
*/
|
||||||
|
const insertSlashCommand = (
|
||||||
|
command: string,
|
||||||
|
onTextChange: (updater: (prev: string) => string) => void,
|
||||||
|
replaceSlash: boolean = false
|
||||||
|
) => {
|
||||||
|
onTextChange((prev: string) => {
|
||||||
|
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement | null
|
||||||
|
|
||||||
|
if (!textArea) {
|
||||||
|
logger.warn('TextArea not found')
|
||||||
|
return prev + ' ' + command
|
||||||
|
}
|
||||||
|
|
||||||
|
const cursorPosition = textArea.selectionStart || 0
|
||||||
|
|
||||||
|
let newText: string
|
||||||
|
let newCursorPos: number
|
||||||
|
|
||||||
|
if (replaceSlash) {
|
||||||
|
// Find the '/' that triggered the menu
|
||||||
|
const textBeforeCursor = prev.slice(0, cursorPosition)
|
||||||
|
const lastSlashIndex = textBeforeCursor.lastIndexOf('/')
|
||||||
|
|
||||||
|
if (lastSlashIndex !== -1 && cursorPosition > lastSlashIndex) {
|
||||||
|
// Replace from '/' to cursor with command
|
||||||
|
newText = prev.slice(0, lastSlashIndex) + command + ' ' + prev.slice(cursorPosition)
|
||||||
|
newCursorPos = lastSlashIndex + command.length + 1
|
||||||
|
} else {
|
||||||
|
// No '/' found, just insert at cursor
|
||||||
|
newText = prev.slice(0, cursorPosition) + command + ' ' + prev.slice(cursorPosition)
|
||||||
|
newCursorPos = cursorPosition + command.length + 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Just insert at cursor position
|
||||||
|
newText = prev.slice(0, cursorPosition) + command + ' ' + prev.slice(cursorPosition)
|
||||||
|
newCursorPos = cursorPosition + command.length + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set cursor position after the inserted command
|
||||||
|
setTimeout(() => {
|
||||||
|
if (textArea) {
|
||||||
|
textArea.focus()
|
||||||
|
textArea.setSelectionRange(newCursorPos, newCursorPos)
|
||||||
|
logger.debug('Cursor set', { newCursorPos })
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
return newText
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slash Commands Tool
|
||||||
|
*
|
||||||
|
* Integrates Agent Session slash commands into the Inputbar.
|
||||||
|
* Provides both a button UI and declarative QuickPanel integration.
|
||||||
|
* Only visible in Agent Session (TopicType.Session).
|
||||||
|
*
|
||||||
|
* Menu structure (declarative):
|
||||||
|
* - First level: "Slash Commands" parent menu item (isMenu: true) in "/" root menu
|
||||||
|
* - Second level: Individual slash commands opened via SlashCommands trigger
|
||||||
|
*/
|
||||||
|
const slashCommandsTool = defineTool({
|
||||||
|
key: 'slash_commands',
|
||||||
|
label: (t) => t('chat.input.slash_commands.title'),
|
||||||
|
|
||||||
|
// Only visible in Agent Session
|
||||||
|
visibleInScopes: [TopicType.Session],
|
||||||
|
|
||||||
|
dependencies: {
|
||||||
|
actions: ['onTextChange'] as const
|
||||||
|
},
|
||||||
|
|
||||||
|
// Declarative QuickPanel configuration
|
||||||
|
quickPanel: {
|
||||||
|
// Root menu contribution (first level menu item)
|
||||||
|
rootMenu: {
|
||||||
|
createMenuItems: (context) => {
|
||||||
|
const { t, session, actions, quickPanelController } = context
|
||||||
|
const slashCommands = session?.slashCommands || []
|
||||||
|
|
||||||
|
// Only show menu item if there are commands
|
||||||
|
if (slashCommands.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: t('chat.input.slash_commands.title'),
|
||||||
|
description: t('chat.input.slash_commands.description', 'Agent session slash commands'),
|
||||||
|
icon: <Terminal size={16} />,
|
||||||
|
isMenu: true, // Mark as parent menu item (first level)
|
||||||
|
action: () => {
|
||||||
|
// Close root panel and open secondary panel
|
||||||
|
quickPanelController.close()
|
||||||
|
setTimeout(() => {
|
||||||
|
quickPanelController.open({
|
||||||
|
title: t('chat.input.slash_commands.title'),
|
||||||
|
symbol: QuickPanelReservedSymbol.SlashCommands,
|
||||||
|
list: slashCommands.map((cmd) => ({
|
||||||
|
label: cmd.command,
|
||||||
|
description: cmd.description || '',
|
||||||
|
icon: <Terminal size={16} />,
|
||||||
|
filterText: `${cmd.command} ${cmd.description || ''}`,
|
||||||
|
action: () => {
|
||||||
|
// Replace the '/' that triggered the root menu
|
||||||
|
insertSlashCommand(cmd.command, actions.onTextChange, true)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Trigger configuration (allows direct access via symbol)
|
||||||
|
triggers: [
|
||||||
|
{
|
||||||
|
symbol: QuickPanelReservedSymbol.SlashCommands,
|
||||||
|
createHandler: (context) => {
|
||||||
|
const { session, actions, quickPanelController, t } = context
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const slashCommands = session?.slashCommands || []
|
||||||
|
|
||||||
|
if (slashCommands.length === 0) {
|
||||||
|
quickPanelController.open({
|
||||||
|
title: t('chat.input.slash_commands.title'),
|
||||||
|
symbol: QuickPanelReservedSymbol.SlashCommands,
|
||||||
|
list: [
|
||||||
|
{
|
||||||
|
label: t('chat.input.slash_commands.empty', 'No slash commands available'),
|
||||||
|
description: '',
|
||||||
|
icon: <Terminal size={16} />,
|
||||||
|
disabled: true,
|
||||||
|
action: () => {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
quickPanelController.open({
|
||||||
|
title: t('chat.input.slash_commands.title'),
|
||||||
|
symbol: QuickPanelReservedSymbol.SlashCommands,
|
||||||
|
list: slashCommands.map((cmd) => ({
|
||||||
|
label: cmd.command,
|
||||||
|
description: cmd.description || '',
|
||||||
|
icon: <Terminal size={16} />,
|
||||||
|
filterText: `${cmd.command} ${cmd.description || ''}`,
|
||||||
|
action: () => {
|
||||||
|
// Direct insert (no '/' to replace when triggered directly)
|
||||||
|
insertSlashCommand(cmd.command, actions.onTextChange, false)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// Render button UI
|
||||||
|
render: (context) => {
|
||||||
|
const { session, actions, quickPanelController, t } = context
|
||||||
|
|
||||||
|
// Pass the handler function to the button so it can open the panel
|
||||||
|
const openPanel = () => {
|
||||||
|
const slashCommands = session?.slashCommands || []
|
||||||
|
|
||||||
|
if (slashCommands.length === 0) {
|
||||||
|
quickPanelController.open({
|
||||||
|
title: t('chat.input.slash_commands.title'),
|
||||||
|
symbol: QuickPanelReservedSymbol.SlashCommands,
|
||||||
|
list: [
|
||||||
|
{
|
||||||
|
label: t('chat.input.slash_commands.empty', 'No slash commands available'),
|
||||||
|
description: '',
|
||||||
|
icon: <Terminal size={16} />,
|
||||||
|
disabled: true,
|
||||||
|
action: () => {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
quickPanelController.open({
|
||||||
|
title: t('chat.input.slash_commands.title'),
|
||||||
|
symbol: QuickPanelReservedSymbol.SlashCommands,
|
||||||
|
list: slashCommands.map((cmd) => ({
|
||||||
|
label: cmd.command,
|
||||||
|
description: cmd.description || '',
|
||||||
|
icon: <Terminal size={16} />,
|
||||||
|
filterText: `${cmd.command} ${cmd.description || ''}`,
|
||||||
|
action: () => {
|
||||||
|
// Direct insert (no '/' to replace when opening via button)
|
||||||
|
insertSlashCommand(cmd.command, actions.onTextChange, false)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return <SlashCommandsButton quickPanelController={quickPanelController} session={session} openPanel={openPanel} />
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Register the tool
|
||||||
|
registerTool(slashCommandsTool)
|
||||||
|
|
||||||
|
export default slashCommandsTool
|
||||||
17
src/renderer/src/pages/home/Inputbar/tools/thinkingTool.tsx
Normal file
17
src/renderer/src/pages/home/Inputbar/tools/thinkingTool.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { isSupportedReasoningEffortModel, isSupportedThinkingTokenModel } from '@renderer/config/models'
|
||||||
|
import ThinkingButton from '@renderer/pages/home/Inputbar/tools/components/ThinkingButton'
|
||||||
|
import { defineTool, registerTool, TopicType } from '@renderer/pages/home/Inputbar/types'
|
||||||
|
|
||||||
|
const thinkingTool = defineTool({
|
||||||
|
key: 'thinking',
|
||||||
|
label: (t) => t('chat.input.thinking.label'),
|
||||||
|
visibleInScopes: [TopicType.Chat],
|
||||||
|
condition: ({ model }) => isSupportedThinkingTokenModel(model) || isSupportedReasoningEffortModel(model),
|
||||||
|
render: ({ assistant, model, quickPanel }) => (
|
||||||
|
<ThinkingButton quickPanel={quickPanel} model={model} assistantId={assistant.id} />
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
registerTool(thinkingTool)
|
||||||
|
|
||||||
|
export default thinkingTool
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||||
|
import type { ToolRenderContext } from '@renderer/pages/home/Inputbar/types'
|
||||||
|
import { defineTool, registerTool, TopicType } from '@renderer/pages/home/Inputbar/types'
|
||||||
|
import { Tooltip } from 'antd'
|
||||||
|
import { Maximize, Minimize } from 'lucide-react'
|
||||||
|
import React, { useCallback } from 'react'
|
||||||
|
|
||||||
|
type ToggleExpandRenderContext = ToolRenderContext<readonly ['isExpanded'], readonly ['toggleExpanded']>
|
||||||
|
|
||||||
|
const ToggleExpandTool: React.FC<{ context: ToggleExpandRenderContext }> = ({ context }) => {
|
||||||
|
const { actions, state, t } = context
|
||||||
|
const isExpanded = Boolean(state.isExpanded)
|
||||||
|
|
||||||
|
const handleToggle = useCallback(() => {
|
||||||
|
actions.toggleExpanded?.()
|
||||||
|
}, [actions])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
placement="top"
|
||||||
|
title={isExpanded ? t('chat.input.collapse') : t('chat.input.expand')}
|
||||||
|
mouseLeaveDelay={0}
|
||||||
|
arrow>
|
||||||
|
<ActionIconButton onClick={handleToggle}>
|
||||||
|
{isExpanded ? <Minimize size={18} /> : <Maximize size={18} />}
|
||||||
|
</ActionIconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleExpandTool = defineTool({
|
||||||
|
key: 'toggle_expand',
|
||||||
|
label: (t) => t('chat.input.expand'),
|
||||||
|
visibleInScopes: [TopicType.Chat, TopicType.Session],
|
||||||
|
dependencies: {
|
||||||
|
state: ['isExpanded'] as const,
|
||||||
|
actions: ['toggleExpanded'] as const
|
||||||
|
},
|
||||||
|
render: (context) => <ToggleExpandTool context={context} />
|
||||||
|
})
|
||||||
|
|
||||||
|
registerTool(toggleExpandTool)
|
||||||
|
|
||||||
|
export default toggleExpandTool
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { isGeminiModel } from '@renderer/config/models'
|
||||||
|
import { isSupportUrlContextProvider } from '@renderer/config/providers'
|
||||||
|
import { defineTool, registerTool, TopicType } from '@renderer/pages/home/Inputbar/types'
|
||||||
|
import { getProviderByModel } from '@renderer/services/AssistantService'
|
||||||
|
|
||||||
|
import UrlContextButton from './components/UrlContextbutton'
|
||||||
|
|
||||||
|
const urlContextTool = defineTool({
|
||||||
|
key: 'url_context',
|
||||||
|
label: (t) => t('chat.input.url_context'),
|
||||||
|
visibleInScopes: [TopicType.Chat],
|
||||||
|
condition: ({ model }) => {
|
||||||
|
if (!isGeminiModel(model)) return false
|
||||||
|
const provider = getProviderByModel(model)
|
||||||
|
return !!provider && isSupportUrlContextProvider(provider)
|
||||||
|
},
|
||||||
|
render: ({ assistant }) => <UrlContextButton assistantId={assistant.id} />
|
||||||
|
})
|
||||||
|
|
||||||
|
registerTool(urlContextTool)
|
||||||
|
|
||||||
|
export default urlContextTool
|
||||||
30
src/renderer/src/pages/home/Inputbar/tools/webSearchTool.tsx
Normal file
30
src/renderer/src/pages/home/Inputbar/tools/webSearchTool.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { isMandatoryWebSearchModel } from '@renderer/config/models'
|
||||||
|
import { defineTool, registerTool, TopicType } from '@renderer/pages/home/Inputbar/types'
|
||||||
|
|
||||||
|
import WebSearchButton from './components/WebSearchButton'
|
||||||
|
import WebSearchQuickPanelManager from './components/WebSearchQuickPanelManager'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web Search Tool
|
||||||
|
*
|
||||||
|
* Allows users to enable web search for their messages.
|
||||||
|
* Supports both model built-in search and external search providers.
|
||||||
|
*/
|
||||||
|
const webSearchTool = defineTool({
|
||||||
|
key: 'web_search',
|
||||||
|
label: (t) => t('chat.input.web_search.label'),
|
||||||
|
|
||||||
|
visibleInScopes: [TopicType.Chat],
|
||||||
|
condition: ({ model }) => !isMandatoryWebSearchModel(model),
|
||||||
|
|
||||||
|
render: function WebSearchToolRender(context) {
|
||||||
|
const { assistant, quickPanelController } = context
|
||||||
|
|
||||||
|
return <WebSearchButton quickPanelController={quickPanelController} assistantId={assistant.id} />
|
||||||
|
},
|
||||||
|
quickPanelManager: WebSearchQuickPanelManager
|
||||||
|
})
|
||||||
|
|
||||||
|
registerTool(webSearchTool)
|
||||||
|
|
||||||
|
export default webSearchTool
|
||||||
228
src/renderer/src/pages/home/Inputbar/types.ts
Normal file
228
src/renderer/src/pages/home/Inputbar/types.ts
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import { loggerService } from '@logger'
|
||||||
|
import type {
|
||||||
|
QuickPanelContextType,
|
||||||
|
QuickPanelListItem,
|
||||||
|
QuickPanelReservedSymbol
|
||||||
|
} from '@renderer/components/QuickPanel'
|
||||||
|
import { type Assistant, type Model, TopicType } from '@renderer/types'
|
||||||
|
import type { InputBarToolType } from '@renderer/types/chat'
|
||||||
|
import type { TFunction } from 'i18next'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import type { InputbarToolsContextValue } from './context/InputbarToolsProvider'
|
||||||
|
|
||||||
|
export { TopicType }
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('InputbarToolsRegistry')
|
||||||
|
|
||||||
|
export type InputbarScope = TopicType | 'mini-window'
|
||||||
|
|
||||||
|
export interface InputbarScopeConfig {
|
||||||
|
placeholder?: string
|
||||||
|
minRows?: number
|
||||||
|
maxRows?: number
|
||||||
|
showTokenCount?: boolean
|
||||||
|
showTools?: boolean
|
||||||
|
toolsCollapsible?: boolean
|
||||||
|
enableQuickPanel?: boolean
|
||||||
|
enableDragDrop?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReadableKeys<T> = {
|
||||||
|
[K in keyof T]: T[K] extends (...args: any[]) => any ? never : K
|
||||||
|
}[keyof T]
|
||||||
|
|
||||||
|
type ActionKeys<T> = {
|
||||||
|
[K in keyof T]: T[K] extends (...args: any[]) => any ? K : never
|
||||||
|
}[keyof T]
|
||||||
|
|
||||||
|
// 工具按钮不应该访问这些内部 API
|
||||||
|
type ExcludedStateKeys = never // 没有需要排除的 state
|
||||||
|
type ExcludedActionKeys = 'toolsRegistry' | 'triggers' // 这些 API 由工具系统内部管理
|
||||||
|
|
||||||
|
type ToolStateKeys = Exclude<ReadableKeys<InputbarToolsContextValue>, ExcludedStateKeys>
|
||||||
|
type ToolActionKeys = Exclude<ActionKeys<InputbarToolsContextValue>, ExcludedActionKeys>
|
||||||
|
|
||||||
|
export type ToolStateMap = Pick<InputbarToolsContextValue, ToolStateKeys>
|
||||||
|
export type ToolActionMap = Pick<InputbarToolsContextValue, ToolActionKeys>
|
||||||
|
|
||||||
|
export type ToolStateKey = keyof ToolStateMap
|
||||||
|
export type ToolActionKey = keyof ToolActionMap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool dependencies configuration
|
||||||
|
*/
|
||||||
|
export interface ToolDependencies {
|
||||||
|
state?: ToolStateKeys[]
|
||||||
|
actions?: ToolActionKeys[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolContext {
|
||||||
|
scope: InputbarScope
|
||||||
|
assistant: Assistant
|
||||||
|
model: Model
|
||||||
|
// Session data for Agent Session scope (only available when scope is TopicType.Session)
|
||||||
|
session?: {
|
||||||
|
agentId?: string
|
||||||
|
sessionId?: string
|
||||||
|
slashCommands?: Array<{ command: string; description?: string }>
|
||||||
|
tools?: Array<{ id: string; name: string; type: string; description?: string }>
|
||||||
|
accessiblePaths?: string[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工具 QuickPanel 注册 API(声明式注册菜单和触发器)
|
||||||
|
*/
|
||||||
|
export interface ToolQuickPanelApi {
|
||||||
|
registerRootMenu: (entries: QuickPanelListItem[]) => () => void
|
||||||
|
registerTrigger: (symbol: QuickPanelReservedSymbol, handler: (payload?: unknown) => void) => () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime controller exposed给工具组件(完整 QuickPanel 能力)
|
||||||
|
*/
|
||||||
|
export type ToolQuickPanelController = QuickPanelContextType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool render context with injected dependencies
|
||||||
|
*/
|
||||||
|
export type ToolRenderContext<S extends readonly ToolStateKey[], A extends readonly ToolActionKey[]> = ToolContext & {
|
||||||
|
state: Pick<ToolStateMap, S[number]>
|
||||||
|
actions: Pick<ToolActionMap, A[number]>
|
||||||
|
quickPanel: ToolQuickPanelApi
|
||||||
|
quickPanelController: ToolQuickPanelController
|
||||||
|
t: TFunction
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* QuickPanel trigger configuration for a tool.
|
||||||
|
* Allows tools to declaratively register trigger handlers.
|
||||||
|
*/
|
||||||
|
export interface ToolQuickPanelTrigger<
|
||||||
|
S extends readonly ToolStateKey[] = readonly ToolStateKey[],
|
||||||
|
A extends readonly ToolActionKey[] = readonly ToolActionKey[]
|
||||||
|
> {
|
||||||
|
/** Trigger symbol (e.g., '@', '/', '#') */
|
||||||
|
symbol: QuickPanelReservedSymbol
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory function that creates the trigger handler.
|
||||||
|
* Receives the tool's render context to access state/actions.
|
||||||
|
*/
|
||||||
|
createHandler: (context: ToolRenderContext<S, A>) => (payload?: unknown) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root menu configuration for a tool.
|
||||||
|
* Allows tools to contribute menu items to the '/' root menu.
|
||||||
|
*/
|
||||||
|
export interface ToolQuickPanelRootMenu<
|
||||||
|
S extends readonly ToolStateKey[] = readonly ToolStateKey[],
|
||||||
|
A extends readonly ToolActionKey[] = readonly ToolActionKey[]
|
||||||
|
> {
|
||||||
|
/**
|
||||||
|
* Factory function that creates root menu items.
|
||||||
|
* Receives the tool's render context to access state/actions.
|
||||||
|
*/
|
||||||
|
createMenuItems: (context: ToolRenderContext<S, A>) => QuickPanelListItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolQuickPanelCapabilities<
|
||||||
|
S extends readonly ToolStateKey[] = readonly ToolStateKey[],
|
||||||
|
A extends readonly ToolActionKey[] = readonly ToolActionKey[]
|
||||||
|
> {
|
||||||
|
/** Root menu configuration (for '/' trigger) */
|
||||||
|
rootMenu?: ToolQuickPanelRootMenu<S, A>
|
||||||
|
|
||||||
|
/** Trigger configurations (for '@', '#', etc.) */
|
||||||
|
triggers?: ToolQuickPanelTrigger<S, A>[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool definition with full type inference for dependencies
|
||||||
|
*/
|
||||||
|
export interface ToolDefinition<
|
||||||
|
S extends readonly ToolStateKey[] = readonly ToolStateKey[],
|
||||||
|
A extends readonly ToolActionKey[] = readonly ToolActionKey[]
|
||||||
|
> {
|
||||||
|
key: string
|
||||||
|
label: string | ((t: TFunction) => string)
|
||||||
|
|
||||||
|
// Visibility and conditions
|
||||||
|
condition?: (context: ToolContext) => boolean
|
||||||
|
visibleInScopes?: InputbarScope[]
|
||||||
|
defaultHidden?: boolean
|
||||||
|
|
||||||
|
// Dependencies
|
||||||
|
dependencies?: {
|
||||||
|
state?: S
|
||||||
|
actions?: A
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick panel integration metadata (declarative trigger registration)
|
||||||
|
quickPanel?: ToolQuickPanelCapabilities<S, A>
|
||||||
|
|
||||||
|
// Render function (receives context with injected dependencies)
|
||||||
|
// If null, the tool is a pure menu contributor (no button)
|
||||||
|
render: ((context: ToolRenderContext<S, A>) => React.ReactNode) | null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional companion component that manages quick panel lifecycle for tools
|
||||||
|
* that need hooks (data fetching, side effects) before registering entries.
|
||||||
|
* It receives the same ToolRenderContext as the render function.
|
||||||
|
*/
|
||||||
|
quickPanelManager?: React.ComponentType<{ context: ToolRenderContext<S, A> }>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to define a tool with full type inference
|
||||||
|
*/
|
||||||
|
export const defineTool = <S extends readonly ToolStateKey[], A extends readonly ToolActionKey[]>(
|
||||||
|
tool: ToolDefinition<S, A>
|
||||||
|
): ToolDefinition<S, A> => tool
|
||||||
|
|
||||||
|
// Tool registry (use any for generics to accept all tool definitions)
|
||||||
|
const toolRegistry = new Map<string, ToolDefinition<any, any>>()
|
||||||
|
|
||||||
|
export const registerTool = (tool: ToolDefinition<any, any>): void => {
|
||||||
|
if (toolRegistry.has(tool.key)) {
|
||||||
|
logger.warn(`Tool with key "${tool.key}" is already registered. Overwriting.`)
|
||||||
|
}
|
||||||
|
toolRegistry.set(tool.key, tool)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getTool = (key: string): ToolDefinition<any, any> | undefined => {
|
||||||
|
return toolRegistry.get(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getAllTools = (): ToolDefinition<any, any>[] => {
|
||||||
|
return Array.from(toolRegistry.values())
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getToolsForScope = (
|
||||||
|
scope: InputbarScope,
|
||||||
|
context: Omit<ToolContext, 'scope'>
|
||||||
|
): ToolDefinition<any, any>[] => {
|
||||||
|
const fullContext: ToolContext = { ...context, scope }
|
||||||
|
|
||||||
|
return getAllTools().filter((tool) => {
|
||||||
|
// Check scope visibility
|
||||||
|
if (tool.visibleInScopes && !tool.visibleInScopes.includes(scope)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check custom condition
|
||||||
|
if (tool.condition && !tool.condition(fullContext)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool order configuration
|
||||||
|
export interface ToolOrderConfig {
|
||||||
|
visible: InputBarToolType[]
|
||||||
|
hidden: InputBarToolType[]
|
||||||
|
}
|
||||||
@@ -7,7 +7,12 @@ import ImageViewer from '@renderer/components/ImageViewer'
|
|||||||
import MarkdownShadowDOMRenderer from '@renderer/components/MarkdownShadowDOMRenderer'
|
import MarkdownShadowDOMRenderer from '@renderer/components/MarkdownShadowDOMRenderer'
|
||||||
import { useSettings } from '@renderer/hooks/useSettings'
|
import { useSettings } from '@renderer/hooks/useSettings'
|
||||||
import { useSmoothStream } from '@renderer/hooks/useSmoothStream'
|
import { useSmoothStream } from '@renderer/hooks/useSmoothStream'
|
||||||
import type { MainTextMessageBlock, ThinkingMessageBlock, TranslationMessageBlock } from '@renderer/types/newMessage'
|
import type {
|
||||||
|
CompactMessageBlock,
|
||||||
|
MainTextMessageBlock,
|
||||||
|
ThinkingMessageBlock,
|
||||||
|
TranslationMessageBlock
|
||||||
|
} from '@renderer/types/newMessage'
|
||||||
import { removeSvgEmptyLines } from '@renderer/utils/formats'
|
import { removeSvgEmptyLines } from '@renderer/utils/formats'
|
||||||
import { processLatexBrackets } from '@renderer/utils/markdown'
|
import { processLatexBrackets } from '@renderer/utils/markdown'
|
||||||
import { isEmpty } from 'lodash'
|
import { isEmpty } from 'lodash'
|
||||||
@@ -38,7 +43,7 @@ const DISALLOWED_ELEMENTS = ['iframe', 'script']
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
// message: Message & { content: string }
|
// message: Message & { content: string }
|
||||||
block: MainTextMessageBlock | TranslationMessageBlock | ThinkingMessageBlock
|
block: MainTextMessageBlock | TranslationMessageBlock | ThinkingMessageBlock | CompactMessageBlock
|
||||||
// 可选的后处理函数,用于在流式渲染过程中处理文本(如引用标签转换)
|
// 可选的后处理函数,用于在流式渲染过程中处理文本(如引用标签转换)
|
||||||
postProcess?: (text: string) => string
|
postProcess?: (text: string) => string
|
||||||
}
|
}
|
||||||
|
|||||||
93
src/renderer/src/pages/home/Messages/Blocks/CompactBlock.tsx
Normal file
93
src/renderer/src/pages/home/Messages/Blocks/CompactBlock.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import type { CompactMessageBlock } from '@renderer/types/newMessage'
|
||||||
|
import type { CollapseProps } from 'antd'
|
||||||
|
import { Collapse } from 'antd'
|
||||||
|
import { ChevronDown } from 'lucide-react'
|
||||||
|
import React from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import styled from 'styled-components'
|
||||||
|
|
||||||
|
import Markdown from '../../Markdown/Markdown'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
block: CompactMessageBlock
|
||||||
|
}
|
||||||
|
|
||||||
|
const CompactBlock: React.FC<Props> = ({ block }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const items: CollapseProps['items'] = [
|
||||||
|
{
|
||||||
|
key: 'summary',
|
||||||
|
label: (
|
||||||
|
<TitleWrapper>
|
||||||
|
<TitleIcon>📦</TitleIcon>
|
||||||
|
<TitleText>{t('message.message.compact.title')}</TitleText>
|
||||||
|
</TitleWrapper>
|
||||||
|
),
|
||||||
|
children: (
|
||||||
|
<SummaryContent>
|
||||||
|
<Markdown block={block} />
|
||||||
|
</SummaryContent>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<StyledCollapse items={items} expandIcon={() => <ChevronDown size={16} />} />
|
||||||
|
|
||||||
|
{block.compactedContent && (
|
||||||
|
<CompactedContentWrapper>
|
||||||
|
<CompactedText>{block.compactedContent}</CompactedText>
|
||||||
|
</CompactedContentWrapper>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Container = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 8px 0;
|
||||||
|
`
|
||||||
|
|
||||||
|
const StyledCollapse = styled(Collapse)`
|
||||||
|
border-radius: 8px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const TitleWrapper = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const TitleIcon = styled.span`
|
||||||
|
font-size: 18px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const TitleText = styled.span`
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
`
|
||||||
|
|
||||||
|
const SummaryContent = styled.div`
|
||||||
|
padding: 8px 0;
|
||||||
|
color: var(--color-text-2);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
`
|
||||||
|
|
||||||
|
const CompactedContentWrapper = styled.div`
|
||||||
|
margin-top: 8px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const CompactedText = styled.div`
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-2);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
line-height: 1.6;
|
||||||
|
`
|
||||||
|
|
||||||
|
export default React.memo(CompactBlock)
|
||||||
@@ -10,6 +10,7 @@ import { useSelector } from 'react-redux'
|
|||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
import CitationBlock from './CitationBlock'
|
import CitationBlock from './CitationBlock'
|
||||||
|
import CompactBlock from './CompactBlock'
|
||||||
import ErrorBlock from './ErrorBlock'
|
import ErrorBlock from './ErrorBlock'
|
||||||
import FileBlock from './FileBlock'
|
import FileBlock from './FileBlock'
|
||||||
import ImageBlock from './ImageBlock'
|
import ImageBlock from './ImageBlock'
|
||||||
@@ -200,6 +201,9 @@ const MessageBlockRenderer: React.FC<Props> = ({ blocks, message }) => {
|
|||||||
case MessageBlockType.VIDEO:
|
case MessageBlockType.VIDEO:
|
||||||
blockComponent = <VideoBlock key={block.id} block={block} />
|
blockComponent = <VideoBlock key={block.id} block={block} />
|
||||||
break
|
break
|
||||||
|
case MessageBlockType.COMPACT:
|
||||||
|
blockComponent = <CompactBlock key={block.id} block={block} />
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
logger.warn('Unsupported block type in MessageBlockRenderer:', (block as any).type, block)
|
logger.warn('Unsupported block type in MessageBlockRenderer:', (block as any).type, block)
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { isGenerateImageModel, isVisionModel } from '@renderer/config/models'
|
|||||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||||
import { useSettings } from '@renderer/hooks/useSettings'
|
import { useSettings } from '@renderer/hooks/useSettings'
|
||||||
import { useTimer } from '@renderer/hooks/useTimer'
|
import { useTimer } from '@renderer/hooks/useTimer'
|
||||||
|
import type { ToolQuickPanelApi } from '@renderer/pages/home/Inputbar/types'
|
||||||
import FileManager from '@renderer/services/FileManager'
|
import FileManager from '@renderer/services/FileManager'
|
||||||
import PasteService from '@renderer/services/PasteService'
|
import PasteService from '@renderer/services/PasteService'
|
||||||
import { useAppSelector } from '@renderer/store'
|
import { useAppSelector } from '@renderer/store'
|
||||||
@@ -28,9 +29,8 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
import type { AttachmentButtonRef } from '../Inputbar/AttachmentButton'
|
|
||||||
import AttachmentButton from '../Inputbar/AttachmentButton'
|
|
||||||
import { FileNameRender, getFileIcon } from '../Inputbar/AttachmentPreview'
|
import { FileNameRender, getFileIcon } from '../Inputbar/AttachmentPreview'
|
||||||
|
import AttachmentButton from '../Inputbar/tools/components/AttachmentButton'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
message: Message
|
message: Message
|
||||||
@@ -53,12 +53,19 @@ const MessageBlockEditor: FC<Props> = ({ message, topicId, onSave, onResend, onC
|
|||||||
const { pasteLongTextThreshold, fontSize, sendMessageShortcut, enableSpellCheck } = useSettings()
|
const { pasteLongTextThreshold, fontSize, sendMessageShortcut, enableSpellCheck } = useSettings()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const textareaRef = useRef<TextAreaRef>(null)
|
const textareaRef = useRef<TextAreaRef>(null)
|
||||||
const attachmentButtonRef = useRef<AttachmentButtonRef>(null)
|
|
||||||
const isUserMessage = message.role === 'user'
|
const isUserMessage = message.role === 'user'
|
||||||
|
|
||||||
const topicMessages = useAppSelector((state) => selectMessagesForTopic(state, topicId))
|
const topicMessages = useAppSelector((state) => selectMessagesForTopic(state, topicId))
|
||||||
const { setTimeoutTimer } = useTimer()
|
const { setTimeoutTimer } = useTimer()
|
||||||
|
|
||||||
|
const noopQuickPanel = useMemo<ToolQuickPanelApi>(
|
||||||
|
() => ({
|
||||||
|
registerRootMenu: () => () => {},
|
||||||
|
registerTrigger: () => () => {}
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
const couldAddImageFile = useMemo(() => {
|
const couldAddImageFile = useMemo(() => {
|
||||||
const relatedAssistantMessages = topicMessages.filter((m) => m.askId === message.id && m.role === 'assistant')
|
const relatedAssistantMessages = topicMessages.filter((m) => m.askId === message.id && m.role === 'assistant')
|
||||||
if (relatedAssistantMessages.length === 0) {
|
if (relatedAssistantMessages.length === 0) {
|
||||||
@@ -346,7 +353,7 @@ const MessageBlockEditor: FC<Props> = ({ message, topicId, onSave, onResend, onC
|
|||||||
<ActionBarLeft>
|
<ActionBarLeft>
|
||||||
{isUserMessage && (
|
{isUserMessage && (
|
||||||
<AttachmentButton
|
<AttachmentButton
|
||||||
ref={attachmentButtonRef}
|
quickPanel={noopQuickPanel}
|
||||||
files={files}
|
files={files}
|
||||||
setFiles={setFiles}
|
setFiles={setFiles}
|
||||||
couldAddImageFile={couldAddImageFile}
|
couldAddImageFile={couldAddImageFile}
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ export interface StreamProcessorCallbacks {
|
|||||||
onVideoSearched?: (video?: { type: 'url' | 'path'; content: string }, metadata?: Record<string, any>) => void
|
onVideoSearched?: (video?: { type: 'url' | 'path'; content: string }, metadata?: Record<string, any>) => void
|
||||||
// Called when a block is created
|
// Called when a block is created
|
||||||
onBlockCreated?: () => void
|
onBlockCreated?: () => void
|
||||||
|
// Called when raw data is received (e.g., session_id updates from Agent SDK)
|
||||||
|
onRawData?: (content: unknown, metadata?: Record<string, any>) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to create a stream processor instance
|
// Function to create a stream processor instance
|
||||||
@@ -147,6 +149,10 @@ export function createStreamProcessor(callbacks: StreamProcessorCallbacks = {})
|
|||||||
if (callbacks.onBlockCreated) callbacks.onBlockCreated()
|
if (callbacks.onBlockCreated) callbacks.onBlockCreated()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
case ChunkType.RAW: {
|
||||||
|
if (callbacks.onRawData) callbacks.onRawData(data.content, data.metadata)
|
||||||
|
break
|
||||||
|
}
|
||||||
default: {
|
default: {
|
||||||
// Handle unknown chunk types or log an error
|
// Handle unknown chunk types or log an error
|
||||||
logger.warn(`Unknown chunk type: ${data.type}`)
|
logger.warn(`Unknown chunk type: ${data.type}`)
|
||||||
|
|||||||
@@ -307,25 +307,39 @@ export class DexieMessageDataSource implements MessageDataSource {
|
|||||||
|
|
||||||
async clearMessages(topicId: string): Promise<void> {
|
async clearMessages(topicId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await db.transaction('rw', db.topics, db.message_blocks, db.files, async () => {
|
// First, collect file information and block IDs within a read transaction
|
||||||
|
let blockIds: string[] = []
|
||||||
|
let files: any[] = []
|
||||||
|
|
||||||
|
await db.transaction('r', db.topics, db.message_blocks, async () => {
|
||||||
const topic = await db.topics.get(topicId)
|
const topic = await db.topics.get(topicId)
|
||||||
if (!topic) return
|
if (!topic) return
|
||||||
|
|
||||||
// Get all block IDs
|
// Get all block IDs
|
||||||
const blockIds = topic.messages.flatMap((m) => m.blocks || [])
|
blockIds = topic.messages.flatMap((m) => m.blocks || [])
|
||||||
|
|
||||||
// Delete blocks and handle files
|
// Get blocks and extract file info
|
||||||
if (blockIds.length > 0) {
|
if (blockIds.length > 0) {
|
||||||
const blocks = await db.message_blocks.where('id').anyOf(blockIds).toArray()
|
const blocks = await db.message_blocks.where('id').anyOf(blockIds).toArray()
|
||||||
const files = blocks
|
files = blocks
|
||||||
.filter((block) => block.type === 'file' || block.type === 'image')
|
.filter((block) => block.type === 'file' || block.type === 'image')
|
||||||
.map((block: any) => block.file)
|
.map((block: any) => block.file)
|
||||||
.filter((file) => file !== undefined)
|
.filter((file) => file !== undefined)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
if (!isEmpty(files)) {
|
// Delete files outside the transaction to avoid transaction timeout
|
||||||
await Promise.all(files.map((file) => FileManager.deleteFile(file.id, false)))
|
if (!isEmpty(files)) {
|
||||||
}
|
await Promise.all(files.map((file) => FileManager.deleteFile(file.id, false)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform the actual database cleanup in a separate write transaction
|
||||||
|
await db.transaction('rw', db.topics, db.message_blocks, async () => {
|
||||||
|
const topic = await db.topics.get(topicId)
|
||||||
|
if (!topic) return
|
||||||
|
|
||||||
|
// Delete blocks
|
||||||
|
if (blockIds.length > 0) {
|
||||||
await db.message_blocks.bulkDelete(blockIds)
|
await db.message_blocks.bulkDelete(blockIds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { loggerService } from '@logger'
|
||||||
|
import type { AppDispatch, RootState } from '@renderer/store'
|
||||||
|
import { updateOneBlock } from '@renderer/store/messageBlock'
|
||||||
|
import { newMessagesActions } from '@renderer/store/newMessage'
|
||||||
|
import type { MainTextMessageBlock } from '@renderer/types/newMessage'
|
||||||
|
import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
|
||||||
|
import type { ClaudeCodeRawValue } from '@shared/agents/claudecode/types'
|
||||||
|
|
||||||
|
import type { BlockManager } from '../BlockManager'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('CompactCallbacks')
|
||||||
|
|
||||||
|
interface CompactCallbacksDeps {
|
||||||
|
blockManager: BlockManager
|
||||||
|
assistantMsgId: string
|
||||||
|
dispatch: AppDispatch
|
||||||
|
getState: () => RootState
|
||||||
|
topicId: string
|
||||||
|
saveUpdatesToDB: any
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompactState {
|
||||||
|
compactBoundaryDetected: boolean
|
||||||
|
summaryBlockId: string | null
|
||||||
|
isFirstBlockAfterCompact: boolean
|
||||||
|
summaryText: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createCompactCallbacks = (deps: CompactCallbacksDeps) => {
|
||||||
|
const { blockManager, assistantMsgId, dispatch, getState, topicId, saveUpdatesToDB } = deps
|
||||||
|
|
||||||
|
// State to track compact command processing
|
||||||
|
const compactState: CompactState = {
|
||||||
|
compactBoundaryDetected: false,
|
||||||
|
summaryBlockId: null,
|
||||||
|
isFirstBlockAfterCompact: false,
|
||||||
|
summaryText: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts content from <local-command-stdout> XML tags
|
||||||
|
*/
|
||||||
|
const extractCompactedContent = (text: string): string => {
|
||||||
|
const match = text.match(/<local-command-(stdout|stderr)>(.*?)<\/local-command-(stdout|stderr)>/s)
|
||||||
|
return match ? match[2].trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if text contains local-command-stdout tags
|
||||||
|
*/
|
||||||
|
const hasCompactedContent = (text: string): boolean => {
|
||||||
|
return /<local-command-(stdout|stderr)>.*?<\/local-command-(stdout|stderr)>/s.test(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when raw data is received from the stream
|
||||||
|
*/
|
||||||
|
const onRawData = (content: unknown, metadata?: Record<string, any>) => {
|
||||||
|
logger.debug('Raw data received', { content, metadata })
|
||||||
|
|
||||||
|
const rawValue = content as ClaudeCodeRawValue
|
||||||
|
|
||||||
|
// Check if this is a compact_boundary message
|
||||||
|
if (rawValue.type === 'compact') {
|
||||||
|
logger.info('Compact boundary detected')
|
||||||
|
compactState.compactBoundaryDetected = true
|
||||||
|
compactState.summaryBlockId = null
|
||||||
|
compactState.isFirstBlockAfterCompact = true
|
||||||
|
compactState.summaryText = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intercept text complete to detect compacted content and create compact block
|
||||||
|
*/
|
||||||
|
const handleTextComplete = async (text: string, currentMainTextBlockId: string | null) => {
|
||||||
|
if (!compactState.compactBoundaryDetected || !currentMainTextBlockId) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the current main text block to check its full content
|
||||||
|
const state = getState()
|
||||||
|
const currentBlock = state.messageBlocks.entities[currentMainTextBlockId] as MainTextMessageBlock | undefined
|
||||||
|
|
||||||
|
if (!currentBlock) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullContent = currentBlock.content || text
|
||||||
|
|
||||||
|
// First block after compact_boundary: This is the summary
|
||||||
|
if (compactState.isFirstBlockAfterCompact) {
|
||||||
|
logger.info('Detected first block after compact boundary (summary)', { fullContent })
|
||||||
|
|
||||||
|
// Store the summary text and block ID
|
||||||
|
compactState.summaryText = fullContent
|
||||||
|
compactState.summaryBlockId = currentMainTextBlockId
|
||||||
|
compactState.isFirstBlockAfterCompact = false
|
||||||
|
|
||||||
|
// Hide this block by marking it as a placeholder temporarily
|
||||||
|
// We'll convert it to compact block when we get the second block
|
||||||
|
dispatch(
|
||||||
|
updateOneBlock({
|
||||||
|
id: currentMainTextBlockId,
|
||||||
|
changes: {
|
||||||
|
status: MessageBlockStatus.PROCESSING
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return true // Prevent normal text block completion
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second block after compact_boundary: Should contain the XML tags
|
||||||
|
if (compactState.summaryBlockId && hasCompactedContent(fullContent)) {
|
||||||
|
logger.info('Detected second block with compacted content', { fullContent })
|
||||||
|
|
||||||
|
const compactedContent = extractCompactedContent(fullContent)
|
||||||
|
const summaryBlockId = compactState.summaryBlockId
|
||||||
|
|
||||||
|
logger.info('Converting summary block to compact block', {
|
||||||
|
summaryText: compactState.summaryText,
|
||||||
|
compactedContent,
|
||||||
|
summaryBlockId
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update the summary block to compact type
|
||||||
|
dispatch(
|
||||||
|
updateOneBlock({
|
||||||
|
id: summaryBlockId,
|
||||||
|
changes: {
|
||||||
|
type: MessageBlockType.COMPACT,
|
||||||
|
content: compactState.summaryText,
|
||||||
|
compactedContent: compactedContent,
|
||||||
|
status: MessageBlockStatus.SUCCESS
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// Update block reference
|
||||||
|
dispatch(
|
||||||
|
newMessagesActions.upsertBlockReference({
|
||||||
|
messageId: assistantMsgId,
|
||||||
|
blockId: summaryBlockId,
|
||||||
|
status: MessageBlockStatus.SUCCESS,
|
||||||
|
blockType: MessageBlockType.COMPACT
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// Clear active block info and update lastBlockType since the compact block is now complete
|
||||||
|
blockManager.activeBlockInfo = null
|
||||||
|
blockManager.lastBlockType = MessageBlockType.COMPACT
|
||||||
|
|
||||||
|
// Remove the current block (the one with XML tags) from message.blocks
|
||||||
|
const currentState = getState()
|
||||||
|
const currentMessage = currentState.messages.entities[assistantMsgId]
|
||||||
|
if (currentMessage && currentMessage.blocks) {
|
||||||
|
const updatedBlocks = currentMessage.blocks.filter((id) => id !== currentMainTextBlockId)
|
||||||
|
dispatch(
|
||||||
|
newMessagesActions.updateMessage({
|
||||||
|
topicId,
|
||||||
|
messageId: assistantMsgId,
|
||||||
|
updates: { blocks: updatedBlocks }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to DB
|
||||||
|
const updatedState = getState()
|
||||||
|
const updatedMessage = updatedState.messages.entities[assistantMsgId]
|
||||||
|
const updatedBlock = updatedState.messageBlocks.entities[summaryBlockId]
|
||||||
|
if (updatedMessage && updatedBlock) {
|
||||||
|
await saveUpdatesToDB(assistantMsgId, topicId, { blocks: updatedMessage.blocks }, [updatedBlock])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset compact state
|
||||||
|
compactState.compactBoundaryDetected = false
|
||||||
|
compactState.summaryBlockId = null
|
||||||
|
compactState.summaryText = ''
|
||||||
|
compactState.isFirstBlockAfterCompact = false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
onRawData,
|
||||||
|
handleTextComplete
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import type { Assistant } from '@renderer/types'
|
|||||||
import type { BlockManager } from '../BlockManager'
|
import type { BlockManager } from '../BlockManager'
|
||||||
import { createBaseCallbacks } from './baseCallbacks'
|
import { createBaseCallbacks } from './baseCallbacks'
|
||||||
import { createCitationCallbacks } from './citationCallbacks'
|
import { createCitationCallbacks } from './citationCallbacks'
|
||||||
|
import { createCompactCallbacks } from './compactCallbacks'
|
||||||
import { createImageCallbacks } from './imageCallbacks'
|
import { createImageCallbacks } from './imageCallbacks'
|
||||||
import { createTextCallbacks } from './textCallbacks'
|
import { createTextCallbacks } from './textCallbacks'
|
||||||
import { createThinkingCallbacks } from './thinkingCallbacks'
|
import { createThinkingCallbacks } from './thinkingCallbacks'
|
||||||
@@ -55,17 +56,27 @@ export const createCallbacks = (deps: CallbacksDependencies) => {
|
|||||||
getState
|
getState
|
||||||
})
|
})
|
||||||
|
|
||||||
// 创建textCallbacks时传入citationCallbacks的getCitationBlockId方法
|
const videoCallbacks = createVideoCallbacks({ blockManager, assistantMsgId })
|
||||||
|
|
||||||
|
const compactCallbacks = createCompactCallbacks({
|
||||||
|
blockManager,
|
||||||
|
assistantMsgId,
|
||||||
|
dispatch,
|
||||||
|
getState,
|
||||||
|
topicId,
|
||||||
|
saveUpdatesToDB
|
||||||
|
})
|
||||||
|
|
||||||
|
// 创建textCallbacks时传入citationCallbacks的getCitationBlockId方法和compactCallbacks的handleTextComplete方法
|
||||||
const textCallbacks = createTextCallbacks({
|
const textCallbacks = createTextCallbacks({
|
||||||
blockManager,
|
blockManager,
|
||||||
getState,
|
getState,
|
||||||
assistantMsgId,
|
assistantMsgId,
|
||||||
getCitationBlockId: citationCallbacks.getCitationBlockId,
|
getCitationBlockId: citationCallbacks.getCitationBlockId,
|
||||||
getCitationBlockIdFromTool: toolCallbacks.getCitationBlockId
|
getCitationBlockIdFromTool: toolCallbacks.getCitationBlockId,
|
||||||
|
handleCompactTextComplete: compactCallbacks.handleTextComplete
|
||||||
})
|
})
|
||||||
|
|
||||||
const videoCallbacks = createVideoCallbacks({ blockManager, assistantMsgId })
|
|
||||||
|
|
||||||
// 组合所有回调
|
// 组合所有回调
|
||||||
return {
|
return {
|
||||||
...baseCallbacks,
|
...baseCallbacks,
|
||||||
@@ -75,6 +86,7 @@ export const createCallbacks = (deps: CallbacksDependencies) => {
|
|||||||
...imageCallbacks,
|
...imageCallbacks,
|
||||||
...citationCallbacks,
|
...citationCallbacks,
|
||||||
...videoCallbacks,
|
...videoCallbacks,
|
||||||
|
...compactCallbacks,
|
||||||
// 清理资源的方法
|
// 清理资源的方法
|
||||||
cleanup: () => {
|
cleanup: () => {
|
||||||
// 清理由 messageThunk 中的节流函数管理,这里不需要特别处理
|
// 清理由 messageThunk 中的节流函数管理,这里不需要特别处理
|
||||||
|
|||||||
@@ -14,15 +14,24 @@ interface TextCallbacksDependencies {
|
|||||||
assistantMsgId: string
|
assistantMsgId: string
|
||||||
getCitationBlockId: () => string | null
|
getCitationBlockId: () => string | null
|
||||||
getCitationBlockIdFromTool: () => string | null
|
getCitationBlockIdFromTool: () => string | null
|
||||||
|
handleCompactTextComplete?: (text: string, mainTextBlockId: string | null) => Promise<boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createTextCallbacks = (deps: TextCallbacksDependencies) => {
|
export const createTextCallbacks = (deps: TextCallbacksDependencies) => {
|
||||||
const { blockManager, getState, assistantMsgId, getCitationBlockId, getCitationBlockIdFromTool } = deps
|
const {
|
||||||
|
blockManager,
|
||||||
|
getState,
|
||||||
|
assistantMsgId,
|
||||||
|
getCitationBlockId,
|
||||||
|
getCitationBlockIdFromTool,
|
||||||
|
handleCompactTextComplete
|
||||||
|
} = deps
|
||||||
|
|
||||||
// 内部维护的状态
|
// 内部维护的状态
|
||||||
let mainTextBlockId: string | null = null
|
let mainTextBlockId: string | null = null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
getCurrentMainTextBlockId: () => mainTextBlockId,
|
||||||
onTextStart: async () => {
|
onTextStart: async () => {
|
||||||
if (blockManager.hasInitialPlaceholder) {
|
if (blockManager.hasInitialPlaceholder) {
|
||||||
const changes = {
|
const changes = {
|
||||||
@@ -63,6 +72,9 @@ export const createTextCallbacks = (deps: TextCallbacksDependencies) => {
|
|||||||
status: MessageBlockStatus.SUCCESS
|
status: MessageBlockStatus.SUCCESS
|
||||||
}
|
}
|
||||||
blockManager.smartBlockUpdate(mainTextBlockId, changes, MessageBlockType.MAIN_TEXT, true)
|
blockManager.smartBlockUpdate(mainTextBlockId, changes, MessageBlockType.MAIN_TEXT, true)
|
||||||
|
if (handleCompactTextComplete) {
|
||||||
|
await handleCompactTextComplete(finalText, mainTextBlockId)
|
||||||
|
}
|
||||||
mainTextBlockId = null
|
mainTextBlockId = null
|
||||||
} else {
|
} else {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// @ts-nocheck
|
||||||
import type { PayloadAction } from '@reduxjs/toolkit'
|
import type { PayloadAction } from '@reduxjs/toolkit'
|
||||||
import { createSelector, createSlice } from '@reduxjs/toolkit'
|
import { createSelector, createSlice } from '@reduxjs/toolkit'
|
||||||
import { DEFAULT_CONTEXTCOUNT, DEFAULT_TEMPERATURE } from '@renderer/config/constant'
|
import { DEFAULT_CONTEXTCOUNT, DEFAULT_TEMPERATURE } from '@renderer/config/constant'
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ const persistedReducer = persistReducer(
|
|||||||
{
|
{
|
||||||
key: 'cherry-studio',
|
key: 'cherry-studio',
|
||||||
storage,
|
storage,
|
||||||
version: 172,
|
version: 173,
|
||||||
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs', 'toolPermissions'],
|
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs', 'toolPermissions'],
|
||||||
migrate
|
migrate
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { PayloadAction } from '@reduxjs/toolkit'
|
import type { PayloadAction } from '@reduxjs/toolkit'
|
||||||
import { createSlice } from '@reduxjs/toolkit'
|
import { createSlice } from '@reduxjs/toolkit'
|
||||||
|
import type { InputbarScope } from '@renderer/pages/home/Inputbar/types'
|
||||||
|
import { TopicType } from '@renderer/types'
|
||||||
import type { InputBarToolType } from '@renderer/types/chat'
|
import type { InputBarToolType } from '@renderer/types/chat'
|
||||||
|
|
||||||
type ToolOrder = {
|
type ToolOrder = {
|
||||||
@@ -22,13 +24,30 @@ export const DEFAULT_TOOL_ORDER: ToolOrder = {
|
|||||||
hidden: ['quick_phrases', 'clear_topic', 'toggle_expand', 'new_context']
|
hidden: ['quick_phrases', 'clear_topic', 'toggle_expand', 'new_context']
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Default tool order per scope
|
||||||
|
// Note: New tools not listed here will auto-show at the end.
|
||||||
|
// Tools are filtered by visibleInScopes first, so this only controls order/visibility of available tools.
|
||||||
|
export const DEFAULT_TOOL_ORDER_BY_SCOPE: Record<InputbarScope, ToolOrder> = {
|
||||||
|
[TopicType.Chat]: DEFAULT_TOOL_ORDER,
|
||||||
|
[TopicType.Session]: {
|
||||||
|
visible: ['create_session', 'slash_commands', 'attachment'],
|
||||||
|
hidden: []
|
||||||
|
},
|
||||||
|
'mini-window': {
|
||||||
|
visible: ['attachment', 'mention_models', 'quick_phrases'],
|
||||||
|
hidden: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type InputToolsState = {
|
type InputToolsState = {
|
||||||
toolOrder: ToolOrder
|
toolOrder: ToolOrder
|
||||||
|
sessionToolOrder: ToolOrder
|
||||||
isCollapsed: boolean
|
isCollapsed: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: InputToolsState = {
|
const initialState: InputToolsState = {
|
||||||
toolOrder: DEFAULT_TOOL_ORDER,
|
toolOrder: DEFAULT_TOOL_ORDER,
|
||||||
|
sessionToolOrder: DEFAULT_TOOL_ORDER_BY_SCOPE[TopicType.Session],
|
||||||
isCollapsed: true
|
isCollapsed: true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,8 +55,12 @@ const inputToolsSlice = createSlice({
|
|||||||
name: 'inputTools',
|
name: 'inputTools',
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
setToolOrder: (state, action: PayloadAction<ToolOrder>) => {
|
setToolOrder: (state, action: PayloadAction<{ scope: InputbarScope; toolOrder: ToolOrder }>) => {
|
||||||
state.toolOrder = action.payload
|
if (action.payload.scope === TopicType.Session) {
|
||||||
|
state.sessionToolOrder = action.payload.toolOrder
|
||||||
|
} else {
|
||||||
|
state.toolOrder = action.payload.toolOrder
|
||||||
|
}
|
||||||
},
|
},
|
||||||
setIsCollapsed: (state, action: PayloadAction<boolean>) => {
|
setIsCollapsed: (state, action: PayloadAction<boolean>) => {
|
||||||
state.isCollapsed = action.payload
|
state.isCollapsed = action.payload
|
||||||
@@ -47,4 +70,9 @@ const inputToolsSlice = createSlice({
|
|||||||
|
|
||||||
export const { setToolOrder, setIsCollapsed } = inputToolsSlice.actions
|
export const { setToolOrder, setIsCollapsed } = inputToolsSlice.actions
|
||||||
|
|
||||||
|
// Selector to get tool order for a specific scope
|
||||||
|
export const selectToolOrderForScope = (state: { inputTools: InputToolsState }, scope: InputbarScope): ToolOrder => {
|
||||||
|
return scope === TopicType.Session ? state.inputTools.sessionToolOrder : state.inputTools.toolOrder
|
||||||
|
}
|
||||||
|
|
||||||
export default inputToolsSlice.reducer
|
export default inputToolsSlice.reducer
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import { isEmpty } from 'lodash'
|
|||||||
import { createMigrate } from 'redux-persist'
|
import { createMigrate } from 'redux-persist'
|
||||||
|
|
||||||
import type { RootState } from '.'
|
import type { RootState } from '.'
|
||||||
import { DEFAULT_TOOL_ORDER } from './inputTools'
|
import { DEFAULT_TOOL_ORDER, DEFAULT_TOOL_ORDER_BY_SCOPE } from './inputTools'
|
||||||
import { initialState as llmInitialState, moveProvider } from './llm'
|
import { initialState as llmInitialState, moveProvider } from './llm'
|
||||||
import { mcpSlice } from './mcp'
|
import { mcpSlice } from './mcp'
|
||||||
import { initialState as notesInitialState } from './note'
|
import { initialState as notesInitialState } from './note'
|
||||||
@@ -1626,6 +1626,7 @@ const migrateConfig = {
|
|||||||
},
|
},
|
||||||
'108': (state: RootState) => {
|
'108': (state: RootState) => {
|
||||||
try {
|
try {
|
||||||
|
// @ts-ignore
|
||||||
state.inputTools.toolOrder = DEFAULT_TOOL_ORDER
|
state.inputTools.toolOrder = DEFAULT_TOOL_ORDER
|
||||||
state.inputTools.isCollapsed = false
|
state.inputTools.isCollapsed = false
|
||||||
return state
|
return state
|
||||||
@@ -1905,14 +1906,20 @@ const migrateConfig = {
|
|||||||
try {
|
try {
|
||||||
const { toolOrder } = state.inputTools
|
const { toolOrder } = state.inputTools
|
||||||
const urlContextKey = 'url_context'
|
const urlContextKey = 'url_context'
|
||||||
|
// @ts-ignore
|
||||||
if (!toolOrder.visible.includes(urlContextKey)) {
|
if (!toolOrder.visible.includes(urlContextKey)) {
|
||||||
|
// @ts-ignore
|
||||||
const webSearchIndex = toolOrder.visible.indexOf('web_search')
|
const webSearchIndex = toolOrder.visible.indexOf('web_search')
|
||||||
|
// @ts-ignore
|
||||||
const knowledgeBaseIndex = toolOrder.visible.indexOf('knowledge_base')
|
const knowledgeBaseIndex = toolOrder.visible.indexOf('knowledge_base')
|
||||||
if (webSearchIndex !== -1) {
|
if (webSearchIndex !== -1) {
|
||||||
|
// @ts-ignore
|
||||||
toolOrder.visible.splice(webSearchIndex, 0, urlContextKey)
|
toolOrder.visible.splice(webSearchIndex, 0, urlContextKey)
|
||||||
} else if (knowledgeBaseIndex !== -1) {
|
} else if (knowledgeBaseIndex !== -1) {
|
||||||
|
// @ts-ignore
|
||||||
toolOrder.visible.splice(knowledgeBaseIndex, 0, urlContextKey)
|
toolOrder.visible.splice(knowledgeBaseIndex, 0, urlContextKey)
|
||||||
} else {
|
} else {
|
||||||
|
// @ts-ignore
|
||||||
toolOrder.visible.push(urlContextKey)
|
toolOrder.visible.push(urlContextKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2783,6 +2790,18 @@ const migrateConfig = {
|
|||||||
logger.error('migrate 172 error', error as Error)
|
logger.error('migrate 172 error', error as Error)
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
'173': (state: RootState) => {
|
||||||
|
try {
|
||||||
|
// Migrate toolOrder from global state to scope-based state
|
||||||
|
if (state.inputTools && !state.inputTools.sessionToolOrder) {
|
||||||
|
state.inputTools.sessionToolOrder = DEFAULT_TOOL_ORDER_BY_SCOPE.session
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('migrate 173 error', error as Error)
|
||||||
|
return state
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -576,7 +576,9 @@ const fetchAndProcessAgentResponseImpl = async (
|
|||||||
abortController.signal
|
abortController.signal
|
||||||
)
|
)
|
||||||
|
|
||||||
let latestAgentSessionId = ''
|
// Store the previous session ID to detect /clear command
|
||||||
|
let latestAgentSessionId = agentSession.agentSessionId || ''
|
||||||
|
let sessionWasCleared = false
|
||||||
|
|
||||||
const persistAgentSessionId = async (sessionId: string) => {
|
const persistAgentSessionId = async (sessionId: string) => {
|
||||||
if (!sessionId || sessionId === latestAgentSessionId) {
|
if (!sessionId || sessionId === latestAgentSessionId) {
|
||||||
@@ -585,6 +587,7 @@ const fetchAndProcessAgentResponseImpl = async (
|
|||||||
|
|
||||||
latestAgentSessionId = sessionId
|
latestAgentSessionId = sessionId
|
||||||
agentSession.agentSessionId = sessionId
|
agentSession.agentSessionId = sessionId
|
||||||
|
sessionWasCleared = true
|
||||||
|
|
||||||
logger.debug(`Agent session ID updated`, {
|
logger.debug(`Agent session ID updated`, {
|
||||||
topicId,
|
topicId,
|
||||||
@@ -624,14 +627,40 @@ const fetchAndProcessAgentResponseImpl = async (
|
|||||||
if (persistTasks.length > 0) {
|
if (persistTasks.length > 0) {
|
||||||
await Promise.all(persistTasks)
|
await Promise.all(persistTasks)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Refresh session data to get updated slash_commands from backend
|
||||||
|
// This happens after the SDK init message updates the session in the database
|
||||||
|
const apiServer = stateAfterUpdate.settings.apiServer
|
||||||
|
if (apiServer?.apiKey) {
|
||||||
|
const baseURL = buildAgentBaseURL(apiServer)
|
||||||
|
const client = new AgentApiClient({
|
||||||
|
baseURL,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiServer.apiKey}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const paths = client.getSessionPaths(agentSession.agentId)
|
||||||
|
await mutate(paths.withId(agentSession.sessionId))
|
||||||
|
logger.info('Refreshed session data after sessionId update', {
|
||||||
|
agentId: agentSession.agentId,
|
||||||
|
sessionId: agentSession.sessionId
|
||||||
|
})
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to persist agent session ID during stream', error as Error)
|
logger.error('Failed to persist agent session ID during stream', error as Error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const adapter = new AiSdkToChunkAdapter(streamProcessorCallbacks, [], false, false, (sessionId) => {
|
const adapter = new AiSdkToChunkAdapter(
|
||||||
persistAgentSessionId(sessionId)
|
streamProcessorCallbacks,
|
||||||
})
|
[],
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
(sessionId) => {
|
||||||
|
persistAgentSessionId(sessionId)
|
||||||
|
},
|
||||||
|
() => sessionWasCleared // Provide getter for session cleared flag
|
||||||
|
)
|
||||||
|
|
||||||
await adapter.processStream({
|
await adapter.processStream({
|
||||||
fullStream: stream,
|
fullStream: stream,
|
||||||
@@ -649,9 +678,9 @@ const fetchAndProcessAgentResponseImpl = async (
|
|||||||
callbacks.onError?.(error)
|
callbacks.onError?.(error)
|
||||||
} catch (callbackError) {
|
} catch (callbackError) {
|
||||||
logger.error('Error in agent onError callback:', callbackError as Error)
|
logger.error('Error in agent onError callback:', callbackError as Error)
|
||||||
} finally {
|
|
||||||
dispatch(newMessagesActions.setTopicLoading({ topicId, loading: false }))
|
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
dispatch(newMessagesActions.setTopicLoading({ topicId, loading: false }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export const AgentBaseSchema = z.object({
|
|||||||
// Tools
|
// Tools
|
||||||
mcps: z.array(z.string()).optional(), // Array of MCP tool IDs
|
mcps: z.array(z.string()).optional(), // Array of MCP tool IDs
|
||||||
allowed_tools: z.array(z.string()).optional(), // Array of allowed tool IDs (whitelist)
|
allowed_tools: z.array(z.string()).optional(), // Array of allowed tool IDs (whitelist)
|
||||||
|
slash_commands: z.array(SlashCommandSchema).optional(), // Array of slash commands merged from builtin and SDK
|
||||||
|
|
||||||
// Configuration
|
// Configuration
|
||||||
configuration: AgentConfigurationSchema.optional() // Extensible settings like temperature, top_p, etc.
|
configuration: AgentConfigurationSchema.optional() // Extensible settings like temperature, top_p, etc.
|
||||||
@@ -286,7 +287,6 @@ export interface UpdateSessionRequest extends Partial<AgentBase> {}
|
|||||||
export const GetAgentSessionResponseSchema = AgentSessionEntitySchema.extend({
|
export const GetAgentSessionResponseSchema = AgentSessionEntitySchema.extend({
|
||||||
tools: z.array(ToolSchema).optional(), // All tools available to the session (including built-in and custom)
|
tools: z.array(ToolSchema).optional(), // All tools available to the session (including built-in and custom)
|
||||||
messages: z.array(AgentSessionMessageEntitySchema).optional(), // Messages in the session
|
messages: z.array(AgentSessionMessageEntitySchema).optional(), // Messages in the session
|
||||||
slash_commands: z.array(SlashCommandSchema).optional(), // Array of slash commands to trigger the agent
|
|
||||||
plugins: z
|
plugins: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
|
|||||||
@@ -14,3 +14,7 @@ export type InputBarToolType =
|
|||||||
| 'clear_topic'
|
| 'clear_topic'
|
||||||
| 'toggle_expand'
|
| 'toggle_expand'
|
||||||
| 'new_context'
|
| 'new_context'
|
||||||
|
// Agent Session tools
|
||||||
|
| 'create_session'
|
||||||
|
| 'slash_commands'
|
||||||
|
| 'activity_directory'
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ export enum MessageBlockType {
|
|||||||
FILE = 'file', // 文件内容
|
FILE = 'file', // 文件内容
|
||||||
ERROR = 'error', // 错误信息
|
ERROR = 'error', // 错误信息
|
||||||
CITATION = 'citation', // 引用类型 (Now includes web search, grounding, etc.)
|
CITATION = 'citation', // 引用类型 (Now includes web search, grounding, etc.)
|
||||||
VIDEO = 'video' // 视频内容
|
VIDEO = 'video', // 视频内容
|
||||||
|
COMPACT = 'compact' // Compact command response
|
||||||
}
|
}
|
||||||
|
|
||||||
// 块状态定义
|
// 块状态定义
|
||||||
@@ -145,6 +146,13 @@ export interface ErrorMessageBlock extends BaseMessageBlock {
|
|||||||
type: MessageBlockType.ERROR
|
type: MessageBlockType.ERROR
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compact块 - 用于显示 /compact 命令的响应
|
||||||
|
export interface CompactMessageBlock extends BaseMessageBlock {
|
||||||
|
type: MessageBlockType.COMPACT
|
||||||
|
content: string // 总结消息
|
||||||
|
compactedContent: string // 从 <local-command-stdout> 提取的内容
|
||||||
|
}
|
||||||
|
|
||||||
// MessageBlock 联合类型
|
// MessageBlock 联合类型
|
||||||
export type MessageBlock =
|
export type MessageBlock =
|
||||||
| PlaceholderMessageBlock
|
| PlaceholderMessageBlock
|
||||||
@@ -158,6 +166,7 @@ export type MessageBlock =
|
|||||||
| ErrorMessageBlock
|
| ErrorMessageBlock
|
||||||
| CitationMessageBlock
|
| CitationMessageBlock
|
||||||
| VideoMessageBlock
|
| VideoMessageBlock
|
||||||
|
| CompactMessageBlock
|
||||||
|
|
||||||
export enum UserMessageStatus {
|
export enum UserMessageStatus {
|
||||||
SUCCESS = 'success'
|
SUCCESS = 'success'
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user