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:
SuYao
2025-11-12 20:04:58 +08:00
committed by GitHub
parent 649f9420a4
commit a6182eaf85
103 changed files with 7037 additions and 2428 deletions

View File

@@ -1,5 +1,5 @@
diff --git a/dist/index.js b/dist/index.js
index cac044aab0255fa72f68b36ecd2c5b12d424c379..ad6ee8ecfc5cbc3ec43ba59a44eda21e8e4d353f 100644
index ff305b112779b718f21a636a27b1196125a332d9..cf32ff5086d4d9e56f8fe90c98724559083bafc3 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -471,7 +471,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
@@ -12,7 +12,7 @@ index cac044aab0255fa72f68b36ecd2c5b12d424c379..ad6ee8ecfc5cbc3ec43ba59a44eda21e
// src/google-generative-ai-options.ts
diff --git a/dist/index.mjs b/dist/index.mjs
index 0793085005d7968638d355f2f1e127939d965165..1c8bf852baf025d56dc35a0691eb95967de7e5c8 100644
index 57659290f1cec74878a385626ad75b2a4d5cd3fc..d04e5927ec3725b6ffdb80868bfa1b5a48849537 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -477,7 +477,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {

View File

@@ -1,5 +1,5 @@
diff --git a/sdk.mjs b/sdk.mjs
index 10162e5b1624f8ce667768943347a6e41089ad2f..32568ae08946590e382270c88d85fba81187568e 100755
index 8cc6aaf0b25bcdf3c579ec95cde12d419fcb2a71..3b3b8beaea5ad2bbac26a15f792058306d0b059f 100755
--- a/sdk.mjs
+++ b/sdk.mjs
@@ -6213,7 +6213,7 @@ function createAbortController(maxListeners = DEFAULT_MAX_LISTENERS) {
@@ -11,7 +11,7 @@ index 10162e5b1624f8ce667768943347a6e41089ad2f..32568ae08946590e382270c88d85fba8
import { createInterface } from "readline";
// ../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?`;
throw new ReferenceError(errorMessage);
}

View File

@@ -78,7 +78,7 @@
"release:aicore": "yarn workspace @cherrystudio/ai-core version patch --immediate && yarn workspace @cherrystudio/ai-core npm publish --access public"
},
"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/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",
@@ -107,7 +107,7 @@
"@agentic/searxng": "^7.3.3",
"@agentic/tavily": "^7.3.3",
"@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/mistral": "^2.0.23",
"@ai-sdk/perplexity": "^2.0.17",
@@ -394,7 +394,6 @@
"undici": "6.21.2",
"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",
"@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",
"@img/sharp-darwin-arm64": "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.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",
"@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.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",
"lint-staged": {

View File

@@ -39,6 +39,7 @@
"@ai-sdk/anthropic": "^2.0.43",
"@ai-sdk/azure": "^2.0.66",
"@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-compatible": "^1.0.26",
"@ai-sdk/provider": "^2.0.0",

View File

@@ -189,6 +189,7 @@ export enum IpcChannel {
Fs_ReadText = 'fs:readText',
File_OpenWithRelativePath = 'file:openWithRelativePath',
File_IsTextFile = 'file:isTextFile',
File_ListDirectory = 'file:listDirectory',
File_GetDirectoryStructure = 'file:getDirectoryStructure',
File_CheckFileName = 'file:checkFileName',
File_ValidateNotesDirectory = 'file:validateNotesDirectory',

View File

@@ -0,0 +1 @@
ALTER TABLE `sessions` ADD `slash_commands` text;

View 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": {}
}
}

View File

@@ -15,6 +15,13 @@
"when": 1758187378775,
"tag": "0001_woozy_captain_flint",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1762526423527,
"tag": "0002_wealthy_naoko",
"breakpoints": true
}
]
}

View File

@@ -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_OpenWithRelativePath, fileManager.openFileWithRelativePath.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_CheckFileName, fileManager.fileNameGuard.bind(fileManager))
ipcMain.handle(IpcChannel.File_ValidateNotesDirectory, fileManager.validateNotesDirectory.bind(fileManager))

View File

@@ -16,6 +16,7 @@ import type { FSWatcher } from 'chokidar'
import chokidar from 'chokidar'
import * as crypto from 'crypto'
import type { OpenDialogOptions, OpenDialogReturnValue, SaveDialogOptions, SaveDialogReturnValue } from 'electron'
import { app } from 'electron'
import { dialog, net, shell } from 'electron'
import * as fs from 'fs'
import { writeFileSync } from 'fs'
@@ -30,6 +31,73 @@ import WordExtractor from 'word-extractor'
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 {
watchExtensions?: string[]
ignoredPatterns?: (string | RegExp)[]
@@ -54,6 +122,26 @@ const DEFAULT_WATCHER_CONFIG: Required<FileWatcherConfig> = {
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 {
private storageDir = getFilesDir()
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> => {
try {
if (!dirPath || typeof dirPath !== 'string') {

View File

@@ -36,7 +36,14 @@ export abstract class BaseService {
protected static db: LibSQLDatabase<typeof schema> | null = null
protected static isInitialized = false
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

View File

@@ -22,6 +22,7 @@ export const sessionsTable = sqliteTable('sessions', {
mcps: text('mcps'), // JSON array of MCP tool IDs
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

View File

@@ -1,4 +1,5 @@
import type { UpdateSessionResponse } from '@types'
import { loggerService } from '@logger'
import type { SlashCommand, UpdateSessionResponse } from '@types'
import {
AgentBaseSchema,
type AgentEntity,
@@ -13,6 +14,10 @@ import { and, count, desc, eq, type SQL } from 'drizzle-orm'
import { BaseService } from '../BaseService'
import { agentsTable, type InsertSessionRow, type SessionRow, sessionsTable } from '../database/schema'
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 {
private static instance: SessionService | null = null
@@ -29,6 +34,52 @@ export class SessionService extends BaseService {
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(
agentId: string,
req: Partial<CreateSessionRequest> = {}
@@ -111,7 +162,13 @@ export class SessionService extends BaseService {
const session = this.deserializeJsonFields(result[0]) as GetAgentSessionResponse
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
}

View File

@@ -1,7 +1,7 @@
import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'
import { describe, expect, it } from 'vitest'
import { ClaudeStreamState, transformSDKMessageToStreamParts } from '../transform'
import { ClaudeStreamState, stripLocalCommandTags, transformSDKMessageToStreamParts } from '../transform'
const baseStreamMetadata = {
parent_tool_use_id: null,
@@ -10,6 +10,19 @@ const baseStreamMetadata = {
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', () => {
it('handles tool call streaming lifecycle', () => {
const state = new ClaudeStreamState()

View File

@@ -1,25 +1,12 @@
import type { SlashCommand } from '@types'
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: '/compact', description: 'Compact conversation with optional focus instructions' },
{ command: '/config', description: 'View/modify configuration' },
{ command: '/cost', description: 'Show token usage statistics' },
{ command: '/doctor', description: 'Checks the health of your Claude Code installation' },
{ command: '/help', description: 'Get usage help' },
{ command: '/init', description: 'Initialize project with CLAUDE.md guide' },
{ command: '/login', description: 'Switch Anthropic accounts' },
{ 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' }
{ command: '/context', description: 'Visualize current context usage as a colored grid' },
{
command: '/cost',
description: 'Show token usage statistics (see cost tracking guide for subscription-specific details)'
},
{ command: '/todos', description: 'List current todo items' }
]

View File

@@ -12,6 +12,7 @@ import { app } from 'electron'
import type { GetAgentSessionResponse } from '../..'
import type { AgentServiceInterface, AgentStream, AgentStreamEvent } from '../../interfaces/AgentStreamInterface'
import { sessionService } from '../SessionService'
import { promptForToolApproval } from './tool-permissions'
import { ClaudeStreamState, transformSDKMessageToStreamParts } from './transform'
@@ -19,6 +20,7 @@ const require_ = createRequire(import.meta.url)
const logger = loggerService.withContext('ClaudeCodeService')
const DEFAULT_AUTO_ALLOW_TOOLS = new Set(['Read', 'Glob', 'Grep'])
const shouldAutoApproveTools = process.env.CHERRY_AUTO_ALLOW_TOOLS === '1'
const NO_RESUME_COMMANDS = ['/clear']
type UserInputMessage = {
type: 'user'
@@ -197,7 +199,7 @@ class ClaudeCodeService implements AgentServiceInterface {
options.strictMcpConfig = true
}
if (lastAgentSessionId) {
if (lastAgentSessionId && !NO_RESUME_COMMANDS.some((cmd) => prompt.includes(cmd))) {
options.resume = lastAgentSessionId
// TODO: use fork session when we support branching sessions
// options.forkSession = true
@@ -220,7 +222,15 @@ class ClaudeCodeService implements AgentServiceInterface {
// Start async processing on the next tick so listeners can subscribe first
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', {
error: error instanceof Error ? { name: error.name, message: error.message } : String(error)
})
@@ -329,7 +339,9 @@ class ClaudeCodeService implements AgentServiceInterface {
closePromptStream: () => void,
options: Options,
stream: ClaudeCodeStream,
errorChunks: string[]
errorChunks: string[],
agentId: string,
sessionId: string
): Promise<void> {
const jsonOutput: SDKMessage[] = []
let hasCompleted = false
@@ -342,6 +354,62 @@ class ClaudeCodeService implements AgentServiceInterface {
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') {
logger.silly('claude response', {
message,
@@ -378,7 +446,6 @@ class ClaudeCodeService implements AgentServiceInterface {
}
}
hasCompleted = true
const duration = Date.now() - startTime
logger.debug('SDK query completed successfully', {

View File

@@ -73,13 +73,21 @@ const emptyUsage: LanguageModelUsage = {
*/
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
* messages from appearing in the user-facing UI.
* Removes tags like <command-message>...</command-message> and <command-name>...</command-name>
*/
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.
*/
export function transformSDKMessageToStreamParts(sdkMessage: SDKMessage, state: ClaudeStreamState): AgentStreamPart[] {
logger.silly('Transforming SDKMessage', { message: sdkMessage })
switch (sdkMessage.type) {
case 'assistant':
return handleAssistantMessage(sdkMessage, state)
@@ -135,7 +144,8 @@ function handleAssistantMessage(
const isStreamingActive = state.hasActiveStep()
if (typeof content === 'string') {
if (!content) {
const sanitizedContent = stripLocalCommandTags(content)
if (!sanitizedContent) {
return chunks
}
@@ -157,7 +167,7 @@ function handleAssistantMessage(
chunks.push({
type: 'text-delta',
id: textId,
text: content,
text: sanitizedContent,
providerMetadata
})
chunks.push({
@@ -178,7 +188,10 @@ function handleAssistantMessage(
switch (block.type) {
case 'text':
if (!isStreamingActive) {
textBlocks.push(block.text)
const sanitizedText = stripLocalCommandTags(block.text)
if (sanitizedText) {
textBlocks.push(sanitizedText)
}
}
break
case 'tool_use':
@@ -537,6 +550,10 @@ function handleContentBlockDelta(
logger.warn('Received text_delta for unknown block', { index })
return
}
block.text = stripLocalCommandTags(block.text)
if (!block.text) {
break
}
chunks.push({
type: 'text-delta',
id: block.id,

View File

@@ -48,6 +48,16 @@ import type {
} from '../renderer/src/types/plugin'
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[]) {
if (spanContext) {
const data = { type: 'trace', context: spanContext }
@@ -201,6 +211,8 @@ const api = {
openFileWithRelativePath: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_OpenWithRelativePath, file),
isTextFile: (filePath: string): Promise<boolean> => ipcRenderer.invoke(IpcChannel.File_IsTextFile, filePath),
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) =>
ipcRenderer.invoke(IpcChannel.File_CheckFileName, dirPath, fileName, isFile),
validateNotesDirectory: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_ValidateNotesDirectory, dirPath),

View File

@@ -30,18 +30,22 @@ export class AiSdkToChunkAdapter {
private onSessionUpdate?: (sessionId: string) => void
private responseStartTimestamp: number | null = null
private firstTokenTimestamp: number | null = null
private hasTextContent = false
private getSessionWasCleared?: () => boolean
constructor(
private onChunk: (chunk: Chunk) => void,
mcpTools: MCPTool[] = [],
accumulate?: boolean,
enableWebSearch?: boolean,
onSessionUpdate?: (sessionId: string) => void
onSessionUpdate?: (sessionId: string) => void,
getSessionWasCleared?: () => boolean
) {
this.toolCallHandler = new ToolCallChunkHandler(onChunk, mcpTools)
this.accumulate = accumulate
this.enableWebSearch = enableWebSearch || false
this.onSessionUpdate = onSessionUpdate
this.getSessionWasCleared = getSessionWasCleared
}
private markFirstTokenIfNeeded() {
@@ -84,8 +88,9 @@ export class AiSdkToChunkAdapter {
}
this.resetTimingState()
this.responseStartTimestamp = Date.now()
// Reset link converter state at the start of stream
// Reset state at the start of stream
this.isFirstChunk = true
this.hasTextContent = false
try {
while (true) {
@@ -129,6 +134,8 @@ export class AiSdkToChunkAdapter {
const agentRawMessage = chunk.rawValue as ClaudeCodeRawValue
if (agentRawMessage.type === 'init' && agentRawMessage.session_id) {
this.onSessionUpdate?.(agentRawMessage.session_id)
} else if (agentRawMessage.type === 'compact' && agentRawMessage.session_id) {
this.onSessionUpdate?.(agentRawMessage.session_id)
}
this.onChunk({
type: ChunkType.RAW,
@@ -143,6 +150,7 @@ export class AiSdkToChunkAdapter {
})
break
case 'text-delta': {
this.hasTextContent = true
const processedText = chunk.text || ''
let finalText: string
@@ -301,6 +309,25 @@ export class AiSdkToChunkAdapter {
}
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 = {
completion_tokens: chunk.totalUsage?.outputTokens || 0,
prompt_tokens: chunk.totalUsage?.inputTokens || 0,

View 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
})
}

View File

@@ -1,3 +1,4 @@
export * from './defaultStrategies'
export * from './hook'
export * from './provider'
export * from './types'

View File

@@ -4,11 +4,12 @@ import type {
QuickPanelCallBackOptions,
QuickPanelCloseAction,
QuickPanelContextType,
QuickPanelFilterFn,
QuickPanelListItem,
QuickPanelOpenOptions,
QuickPanelSortFn,
QuickPanelTriggerInfo
} from './types'
const QuickPanelContext = createContext<QuickPanelContextType | null>(null)
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 [title, setTitle] = useState<string | undefined>()
const [defaultIndex, setDefaultIndex] = useState<number>(0)
const [defaultIndex, setDefaultIndex] = useState<number>(-1)
const [pageSize, setPageSize] = useState<number>(7)
const [multiple, setMultiple] = useState<boolean>(false)
const [manageListExternally, setManageListExternally] = useState<boolean>(false)
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 [beforeAction, setBeforeAction] = 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)
// 添加更新item选中状态的方法
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
}
setLastCloseAction(undefined)
setTitle(options.title)
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)
setMultiple(options.multiple ?? false)
setManageListExternally(options.manageListExternally ?? false)
setSymbol(options.symbol)
setTriggerInfo(options.triggerInfo)
setOnClose(() => options.onClose)
setBeforeAction(() => options.beforeAction)
setAfterAction(() => options.afterAction)
setOnSearchChange(() => options.onSearchChange)
setFilterFn(() => options.filterFn)
setSortFn(() => options.sortFn)
setIsVisible(true)
}, [])
@@ -61,6 +88,8 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
const close = useCallback(
(action?: QuickPanelCloseAction, searchText?: string) => {
setIsVisible(false)
setManageListExternally(false)
setLastCloseAction(action)
onClose?.({ action, searchText, item: {} as QuickPanelListItem, context: this })
clearTimer.current = setTimeout(() => {
@@ -68,9 +97,13 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
setOnClose(undefined)
setBeforeAction(undefined)
setAfterAction(undefined)
setOnSearchChange(undefined)
setFilterFn(undefined)
setSortFn(undefined)
setTitle(undefined)
setSymbol('')
setTriggerInfo(undefined)
setManageListExternally(false)
}, 200)
},
[onClose]
@@ -100,10 +133,15 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
defaultIndex,
pageSize,
multiple,
manageListExternally,
triggerInfo,
lastCloseAction,
filterFn,
sortFn,
onClose,
beforeAction,
afterAction
afterAction,
onSearchChange
}),
[
open,
@@ -117,10 +155,15 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
defaultIndex,
pageSize,
multiple,
manageListExternally,
triggerInfo,
lastCloseAction,
filterFn,
sortFn,
onClose,
beforeAction,
afterAction
afterAction,
onSearchChange
]
)

View File

@@ -10,7 +10,8 @@ export enum QuickPanelReservedSymbol {
WebSearch = '?',
Mcp = 'mcp',
McpPrompt = 'mcp-prompt',
McpResource = 'mcp-resource'
McpResource = 'mcp-resource',
SlashCommands = 'slash-commands'
}
export type QuickPanelCloseAction = 'enter' | 'click' | 'esc' | 'outsideclick' | 'enter_empty' | string | undefined
@@ -27,6 +28,29 @@ export type QuickPanelCallBackOptions = {
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 = {
/** 显示在底部左边类似于Placeholder */
title?: string
@@ -48,6 +72,14 @@ export type QuickPanelOpenOptions = {
beforeAction?: (options: QuickPanelCallBackOptions) => void
afterAction?: (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 = {
@@ -88,10 +120,15 @@ export interface QuickPanelContextType {
readonly pageSize: number
readonly multiple: boolean
readonly triggerInfo?: QuickPanelTriggerInfo
readonly manageListExternally?: boolean
readonly lastCloseAction?: QuickPanelCloseAction
readonly filterFn?: QuickPanelFilterFn
readonly sortFn?: QuickPanelSortFn
readonly onClose?: (Options: QuickPanelCallBackOptions) => void
readonly beforeAction?: (Options: QuickPanelCallBackOptions) => void
readonly afterAction?: (Options: QuickPanelCallBackOptions) => void
readonly onSearchChange?: (searchText: string) => void
}
export type QuickPanelScrollTrigger = 'initial' | 'keyboard' | 'none'

View File

@@ -10,8 +10,8 @@ import { debounce } from 'lodash'
import { Check } from 'lucide-react'
import React, { use, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import styled from 'styled-components'
import * as tinyPinyin from 'tiny-pinyin'
import { defaultFilterFn, defaultSortFn } from './defaultStrategies'
import { QuickPanelContext } from './provider'
import type {
QuickPanelCallBackOptions,
@@ -62,21 +62,50 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
const [_searchText, setSearchText] = useState('')
const searchText = useDeferredValue(_searchText)
const setSearchTextDebounced = useMemo(() => debounce((val: string) => setSearchText(val), 50), [])
const searchTextRef = useRef('')
// 缓存:按 item 缓存拼音文本,避免重复转换
const pinyinCacheRef = useRef<WeakMap<QuickPanelListItem, string>>(new WeakMap())
// 轻量防抖:减少高频输入时的过滤调用
const setSearchTextDebounced = useMemo(() => debounce((val: string) => setSearchText(val), 50), [])
// 跟踪上一次的搜索文本和符号用于判断是否需要重置index
const prevSearchTextRef = useRef('')
const prevSymbolRef = useRef('')
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 项在顶部)
const list = useMemo(() => {
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 lowerSearchText = _searchText.toLowerCase()
const fuzzyPattern = lowerSearchText
@@ -86,52 +115,35 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
const fuzzyRegex = new RegExp(fuzzyPattern, 'ig')
// 拆分:固定显示项(不参与过滤)与普通项
const pinnedItems = (ctx.list || []).filter((item) => item.alwaysVisible)
const normalItems = (ctx.list || []).filter((item) => !item.alwaysVisible)
const pinnedItems = baseList.filter((item) => item.alwaysVisible)
const normalItems = baseList.filter((item) => !item.alwaysVisible)
// Filter normal items using injected filter function
const filteredNormalItems = normalItems.filter((item) => {
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()
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())
}
return filterFn(item, _searchText, fuzzyRegex, pinyinCacheRef.current)
})
// Sort filtered items using injected sort function
const sortedNormalItems = sortFn(filteredNormalItems, _searchText)
// 只有在搜索文本变化或面板符号变化时才重置index
const isSearchChanged = prevSearchTextRef.current !== searchText
const isSymbolChanged = prevSymbolRef.current !== ctx.symbol
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 {
// 如果当前index超出范围调整到有效范围内
setIndex((prevIndex) => {
const combinedLength = pinnedItems.length + filteredNormalItems.length
const combinedLength = pinnedItems.length + sortedNormalItems.length
if (prevIndex >= combinedLength) {
return combinedLength > 0 ? combinedLength - 1 : -1
}
@@ -142,10 +154,9 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
prevSearchTextRef.current = searchText
prevSymbolRef.current = ctx.symbol
// 固定项置顶 + 过滤后的普通项
const pinnedFiltered = [...pinnedItems, ...filteredNormalItems]
return pinnedFiltered.filter((item) => !item.hidden)
}, [ctx.isVisible, ctx.symbol, ctx.list, searchText])
// 固定项置顶 + 排序后的普通项
return [...pinnedItems, ...sortedNormalItems]
}, [ctx.isVisible, ctx.symbol, ctx.manageListExternally, ctx.list, ctx.defaultIndex, searchText, filterFn, sortFn])
const canForwardAndBackward = useMemo(() => {
return list.some((item) => item.isMenu) || historyPanel.length > 0
@@ -179,19 +190,64 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
if (deleteStart >= deleteEnd) return
// 删除文本
const newText = textArea.value.slice(0, deleteStart) + textArea.value.slice(deleteEnd)
setInputText(newText)
const activeSearchText = searchTextRef.current ?? ''
// 设置光标位置
setTimeoutTimer(
'quickpanel_focus',
() => {
textArea.focus()
textArea.setSelectionRange(deleteStart, deleteStart)
},
0
)
setInputText((currentText) => {
const safeText = currentText ?? ''
const expectedSegment = includeSymbol ? symbolSegment : symbolSegment.slice(1)
const typedSearch = activeSearchText
const normalizedTyped = includeSymbol
? typedSearch
: typedSearch.startsWith(symbolSegment[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('')
},
@@ -211,11 +267,21 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
if (textArea) {
setInputText(textArea.value)
}
} else if (action && !['outsideclick', 'esc', 'enter_empty', 'no_result'].includes(action)) {
clearSearchText(true)
} else if (
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(
@@ -285,12 +351,86 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
searchTextRef.current = 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)
useEffect(() => {
return () => {
setSearchTextDebounced.cancel()
}
}, [setSearchTextDebounced])
useEffect(() => {
if (!ctx.isVisible) return
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement
if (!textArea) return
const handleInput = (e: Event) => {
if (isComposing.current) return
@@ -305,6 +445,8 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
if (lastSymbolIndex !== -1) {
const newSearchText = textBeforeCursor.slice(lastSymbolIndex)
setSearchTextDebounced(newSearchText)
// Trigger server-side search callback immediately (with its own debounce)
triggerSearchChange(newSearchText)
} else {
// 使用本地 handleClose确保在删除触发符时同步受控输入值
handleClose('delete-symbol')
@@ -328,16 +470,17 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
textArea.removeEventListener('input', handleInput)
textArea.removeEventListener('compositionupdate', handleCompositionUpdate)
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])
useLayoutEffect(() => {
@@ -545,19 +688,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
const hasSearchText = useMemo(() => searchText.replace(/^[/@]/, '').length > 0, [searchText])
// 折叠仅依据“非固定项”的匹配数;仅剩固定项(如“清除”)时仍视为无匹配,保持折叠
const visibleNonPinnedCount = useMemo(() => list.filter((i) => !i.alwaysVisible).length, [list])
const collapsed = 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 collapsed = !ctx.manageListExternally && hasSearchText && visibleNonPinnedCount === 0
const estimateSize = useCallback(() => ITEM_HEIGHT, [])
@@ -616,7 +747,9 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
return prev ? prev : true
})
}>
{!collapsed && (
{collapsed ? (
<QuickPanelEmpty>{t('settings.quickPanel.noResult', 'No results')}</QuickPanelEmpty>
) : (
<DynamicVirtualList
ref={listRef}
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`
display: flex;
width: 100%;

View File

@@ -1,3 +1,4 @@
import { loggerService } from '@logger'
import { useAgent } from '@renderer/hooks/agents/useAgent'
import { useSessions } from '@renderer/hooks/agents/useSessions'
import { useAppDispatch } from '@renderer/store'
@@ -6,6 +7,8 @@ import type { CreateSessionForm } from '@renderer/types'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
const logger = loggerService.withContext('useCreateDefaultSession')
/**
* 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
} catch (error) {
logger.error('Error creating default session:', error as Error)
return null
} finally {
setCreatingSession(false)
}

View 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
}
}

View 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
}

View 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
}
}

View File

@@ -631,6 +631,15 @@
"view_full_content": "View Full Content"
},
"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",
"clear": {
"content": "Do you want to clear all messages of the current topic?",
@@ -654,6 +663,7 @@
"new": {
"context": "Clear Context {{Command}}"
},
"new_session": "New Session {{Command}}",
"new_topic": "New Topic {{Command}}",
"paste_text_file_confirm": "Paste into input bar?",
"pause": "Pause",
@@ -661,6 +671,10 @@
"placeholder_without_triggers": "Type your message here, press {{key}} to send",
"send": "Send",
"settings": "Settings",
"slash_commands": {
"description": "Agent session slash commands",
"title": "Slash Commands"
},
"thinking": {
"budget_exceeds_max": "Thinking budget exceeds the maximum token number",
"label": "Thinking",
@@ -1771,6 +1785,9 @@
},
"message": {
"code_style": "Code style",
"compact": {
"title": "Conversation Compacted"
},
"delete": {
"content": "Are you sure you want to delete this message?",
"title": "Delete Message"
@@ -4459,6 +4476,7 @@
"confirm": "Confirm",
"forward": "Forward",
"multiple": "Multiple Select",
"noResult": "No results found",
"page": "Page",
"select": "Select",
"title": "Quick Menu"

View File

@@ -631,6 +631,15 @@
"view_full_content": "查看完整内容"
},
"input": {
"activity_directory": {
"description": "从活动目录中选择文件",
"loading": "正在加载文件...",
"no_file_found": {
"description": "可访问目录中没有可用文件",
"label": "未找到文件"
},
"title": "活动目录"
},
"auto_resize": "自动调整高度",
"clear": {
"content": "确定要清除当前会话所有消息吗?",
@@ -654,6 +663,7 @@
"new": {
"context": "清除上下文 {{Command}}"
},
"new_session": "新会话 {{Command}}",
"new_topic": "新话题 {{Command}}",
"paste_text_file_confirm": "粘贴到输入框?",
"pause": "暂停",
@@ -661,6 +671,10 @@
"placeholder_without_triggers": "在这里输入消息,按 {{key}} 发送",
"send": "发送",
"settings": "设置",
"slash_commands": {
"description": "代理会话斜杠命令",
"title": "斜杠命令"
},
"thinking": {
"budget_exceeds_max": "思考预算超过最大 Token 数",
"label": "思考",
@@ -1771,6 +1785,9 @@
},
"message": {
"code_style": "代码风格",
"compact": {
"title": "对话已压缩"
},
"delete": {
"content": "确定要删除此消息吗?",
"title": "删除消息"
@@ -4459,6 +4476,7 @@
"confirm": "确认",
"forward": "前进",
"multiple": "多选",
"noResult": "[to be translated]:No results found",
"page": "翻页",
"select": "选择",
"title": "快捷菜单"

View File

@@ -631,6 +631,15 @@
"view_full_content": "查看完整內容"
},
"input": {
"activity_directory": {
"description": "從活動目錄中選擇檔案",
"loading": "載入檔案中...",
"no_file_found": {
"description": "可存取的目錄中沒有檔案",
"label": "找不到檔案"
},
"title": "活動目錄"
},
"auto_resize": "自動調整高度",
"clear": {
"content": "您想要清除目前話題的所有訊息嗎?",
@@ -654,6 +663,7 @@
"new": {
"context": "清除上下文 {{Command}}"
},
"new_session": "新工作階段 {{Command}}",
"new_topic": "新話題 {{Command}}",
"paste_text_file_confirm": "貼到輸入框?",
"pause": "暫停",
@@ -661,6 +671,10 @@
"placeholder_without_triggers": "在此輸入您的訊息,按 {{key}} 傳送",
"send": "傳送",
"settings": "設定",
"slash_commands": {
"description": "代理會話斜線命令",
"title": "斜線指令"
},
"thinking": {
"budget_exceeds_max": "思考預算超過最大 Token 數",
"label": "思考",
@@ -1771,6 +1785,9 @@
},
"message": {
"code_style": "程式碼風格",
"compact": {
"title": "對話已壓縮"
},
"delete": {
"content": "確定要刪除此訊息嗎?",
"title": "刪除訊息"
@@ -4459,6 +4476,7 @@
"confirm": "確認",
"forward": "前進",
"multiple": "多選",
"noResult": "[to be translated]:No results found",
"page": "翻頁",
"select": "選擇",
"title": "快捷選單"

View File

@@ -631,6 +631,15 @@
"view_full_content": "Vollständigen Inhalt anzeigen"
},
"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",
"clear": {
"content": "Möchten Sie wirklich alle Nachrichten der aktuellen Sitzung löschen?",
@@ -654,6 +663,7 @@
"new": {
"context": "Kontext löschen {{Command}}"
},
"new_session": "Neue Sitzung {{Command}}",
"new_topic": "Neues Thema {{Command}}",
"paste_text_file_confirm": "In Eingabefeld einfügen?",
"pause": "Pause",
@@ -661,6 +671,10 @@
"placeholder_without_triggers": "Geben Sie hier eine Nachricht ein, drücken Sie {{key}} zum Senden",
"send": "Senden",
"settings": "Einstellungen",
"slash_commands": {
"description": "Agent-Session-Slash-Befehle",
"title": "Schrägstrich-Befehle"
},
"thinking": {
"budget_exceeds_max": "Denkbudget übersteigt maximale Token-Anzahl",
"label": "Denken",
@@ -1771,6 +1785,9 @@
},
"message": {
"code_style": "Code-Stil",
"compact": {
"title": "Gespräch komprimiert"
},
"delete": {
"content": "Möchten Sie diese Nachricht wirklich löschen?",
"title": "Nachricht löschen"
@@ -4459,6 +4476,7 @@
"confirm": "Bestätigen",
"forward": "Vorwärts",
"multiple": "Mehrfachauswahl",
"noResult": "[to be translated]:No results found",
"page": "Seite umblättern",
"select": "Auswählen",
"title": "Schnellmenü"

View File

@@ -631,6 +631,15 @@
"view_full_content": "Προβολή πλήρους περιεχομένου"
},
"input": {
"activity_directory": {
"description": "Επιλέξτε αρχείο από τον κατάλογο δραστηριότητας",
"loading": "Φόρτωση Αρχείων...",
"no_file_found": {
"description": "Δεν υπάρχουν διαθέσιμα αρχεία σε προσβάσιμους καταλόγους",
"label": "Δεν Βρέθηκε Αρχείο"
},
"title": "Κατάλογος Δραστηριοτήτων"
},
"auto_resize": "Αυτόματη μείωση ύψους",
"clear": {
"content": "Είσαι σίγουρος ότι θέλεις να διαγράψεις όλα τα μηνύματα της τρέχουσας συζήτησης;",
@@ -654,6 +663,7 @@
"new": {
"context": "Καθαρισμός ενδιάμεσων {{Command}}"
},
"new_session": "Νέα Συνεδρία {{Command}}",
"new_topic": "Νέο θέμα {{Command}}",
"paste_text_file_confirm": "Επικόλληση στο πεδίο εισαγωγής;",
"pause": "Παύση",
@@ -661,6 +671,10 @@
"placeholder_without_triggers": "Γράψτε το μήνυμά σας εδώ, πατήστε {{key}} για αποστολή",
"send": "Αποστολή",
"settings": "Ρυθμίσεις",
"slash_commands": {
"description": "Εντολές κάθετης γραμμής για συνεδρία πράκτορα",
"title": "Εντολές Κάθετης Γραμμής"
},
"thinking": {
"budget_exceeds_max": "Ο προϋπολογισμός σκέψης υπερβαίνει τον μέγιστο αριθμό token",
"label": "Σκέψη",
@@ -1771,6 +1785,9 @@
},
"message": {
"code_style": "Στυλ κώδικα",
"compact": {
"title": "Συνομιλία Συμπυκνωμένη"
},
"delete": {
"content": "Θέλετε να διαγράψετε αυτό το μήνυμα;",
"title": "Διαγραφή μηνύματος"
@@ -4459,6 +4476,7 @@
"confirm": "Επιβεβαίωση",
"forward": "Μπρος",
"multiple": "Πολλαπλή επιλογή",
"noResult": "[to be translated]:No results found",
"page": "Σελίδα",
"select": "Επιλογή",
"title": "Γρήγορη Πρόσβαση"

View File

@@ -631,6 +631,15 @@
"view_full_content": "Ver contenido completo"
},
"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",
"clear": {
"content": "¿Estás seguro de que quieres eliminar todos los mensajes de la sesión actual?",
@@ -654,6 +663,7 @@
"new": {
"context": "Limpiar contexto {{Command}}"
},
"new_session": "Nueva Sesión {{Command}}",
"new_topic": "Nuevo tema {{Command}}",
"paste_text_file_confirm": "¿Pegar en el cuadro de entrada?",
"pause": "Pausar",
@@ -661,6 +671,10 @@
"placeholder_without_triggers": "Escribe tu mensaje aquí, presiona {{key}} para enviar",
"send": "Enviar",
"settings": "Configuración",
"slash_commands": {
"description": "Comandos de sesión de agente con barra",
"title": "Comandos de barra"
},
"thinking": {
"budget_exceeds_max": "El presupuesto de pensamiento excede el número máximo de tokens",
"label": "Pensando",
@@ -1771,6 +1785,9 @@
},
"message": {
"code_style": "Estilo de código",
"compact": {
"title": "Conversación Compactada"
},
"delete": {
"content": "¿Está seguro de querer eliminar este mensaje?",
"title": "Eliminar mensaje"
@@ -4459,6 +4476,7 @@
"confirm": "Confirmar",
"forward": "Adelante",
"multiple": "Selección múltiple",
"noResult": "[to be translated]:No results found",
"page": "Página",
"select": "Seleccionar",
"title": "Menú de acceso rápido"

View File

@@ -631,6 +631,15 @@
"view_full_content": "Voir le contenu complet"
},
"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",
"clear": {
"content": "Êtes-vous sûr de vouloir effacer tous les messages de la conversation actuelle ?",
@@ -654,6 +663,7 @@
"new": {
"context": "Effacer le contexte {{Command}}"
},
"new_session": "Nouvelle Session {{Command}}",
"new_topic": "Nouveau sujet {{Command}}",
"paste_text_file_confirm": "Coller dans la zone de saisie ?",
"pause": "Pause",
@@ -661,6 +671,10 @@
"placeholder_without_triggers": "Tapez votre message ici, appuyez sur {{key}} pour envoyer",
"send": "Envoyer",
"settings": "Paramètres",
"slash_commands": {
"description": "Commandes slash de session d'agent",
"title": "Commandes Slash"
},
"thinking": {
"budget_exceeds_max": "Le budget de réflexion dépasse le nombre maximum de tokens",
"label": "Pensée",
@@ -1771,6 +1785,9 @@
},
"message": {
"code_style": "Style de code",
"compact": {
"title": "Conversation Compactée"
},
"delete": {
"content": "Êtes-vous sûr de vouloir supprimer ce message?",
"title": "Supprimer le message"
@@ -4459,6 +4476,7 @@
"confirm": "Подтвердить",
"forward": "Вперед",
"multiple": "Множественный выбор",
"noResult": "[to be translated]:No results found",
"page": "Перелистнуть страницу",
"select": "Выбрать",
"title": "Быстрое меню"

View File

@@ -631,6 +631,15 @@
"view_full_content": "完全な内容を表示"
},
"input": {
"activity_directory": {
"description": "アクティビティディレクトリからファイルを選択",
"loading": "ファイルを読み込んでいます...",
"no_file_found": {
"description": "アクセス可能なディレクトリに利用可能なファイルがありません",
"label": "ファイルが見つかりません"
},
"title": "アクティビティディレクトリ"
},
"auto_resize": "高さを自動調整",
"clear": {
"content": "現在のトピックのすべてのメッセージをクリアしますか?",
@@ -654,6 +663,7 @@
"new": {
"context": "コンテキストをクリア {{Command}}"
},
"new_session": "新しいセッション {{Command}}",
"new_topic": "新しいトピック {{Command}}",
"paste_text_file_confirm": "入力欄に貼り付けますか?",
"pause": "一時停止",
@@ -661,6 +671,10 @@
"placeholder_without_triggers": "ここにメッセージを入力し、{{key}} を押して送信...",
"send": "送信",
"settings": "設定",
"slash_commands": {
"description": "エージェントセッションスラッシュコマンド",
"title": "スラッシュコマンド"
},
"thinking": {
"budget_exceeds_max": "思考予算が最大トークン数を超えました",
"label": "思考",
@@ -1771,6 +1785,9 @@
},
"message": {
"code_style": "コードスタイル",
"compact": {
"title": "会話圧縮"
},
"delete": {
"content": "このメッセージを削除してもよろしいですか?",
"title": "メッセージを削除"
@@ -4459,6 +4476,7 @@
"confirm": "確認",
"forward": "進む",
"multiple": "複数選択",
"noResult": "[to be translated]:No results found",
"page": "ページ",
"select": "選択",
"title": "クイックメニュー"

View File

@@ -631,6 +631,15 @@
"view_full_content": "Ver conteúdo completo"
},
"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",
"clear": {
"content": "Tem certeza de que deseja limpar todas as mensagens da sessão atual?",
@@ -654,6 +663,7 @@
"new": {
"context": "Limpar contexto {{Command}}"
},
"new_session": "Nova Sessão {{Command}}",
"new_topic": "Novo tópico {{Command}}",
"paste_text_file_confirm": "Colar na caixa de entrada?",
"pause": "Pausar",
@@ -661,6 +671,10 @@
"placeholder_without_triggers": "Escreve a tua mensagem aqui, pressiona {{key}} para enviar",
"send": "Enviar",
"settings": "Configurações",
"slash_commands": {
"description": "Comandos de barra da sessão do agente",
"title": "Comandos de Barra"
},
"thinking": {
"budget_exceeds_max": "Orçamento de pensamento excede o número máximo de tokens",
"label": "Pensando",
@@ -1771,6 +1785,9 @@
},
"message": {
"code_style": "Estilo de código",
"compact": {
"title": "Conversa Compactada"
},
"delete": {
"content": "Tem certeza de que deseja excluir esta mensagem?",
"title": "Excluir mensagem"
@@ -4459,6 +4476,7 @@
"confirm": "Confirmar",
"forward": "Avançar",
"multiple": "Múltipla Seleção",
"noResult": "[to be translated]:No results found",
"page": "Página",
"select": "Selecionar",
"title": "Menu de Atalho"

View File

@@ -631,6 +631,15 @@
"view_full_content": "Показать полное содержимое"
},
"input": {
"activity_directory": {
"description": "Выбрать файл из каталога активности",
"loading": "Загрузка файлов...",
"no_file_found": {
"description": "Нет доступных файлов в доступных каталогах",
"label": "Файл не найден"
},
"title": "Каталог активностей"
},
"auto_resize": "Автоматическая высота",
"clear": {
"content": "Хотите очистить все сообщения текущего топика?",
@@ -654,6 +663,7 @@
"new": {
"context": "Очистить контекст {{Command}}"
},
"new_session": "Новая сессия {{Команда}}",
"new_topic": "Новый топик {{Command}}",
"paste_text_file_confirm": "Вставить в поле ввода?",
"pause": "Остановить",
@@ -661,6 +671,10 @@
"placeholder_without_triggers": "Напишите сообщение здесь, нажмите {{key}} для отправки",
"send": "Отправить",
"settings": "Настройки",
"slash_commands": {
"description": "Слэш-команды сеанса агента",
"title": "Слэш-команды"
},
"thinking": {
"budget_exceeds_max": "Бюджет размышления превышает максимальное количество токенов",
"label": "Мыслим",
@@ -1771,6 +1785,9 @@
},
"message": {
"code_style": "Стиль кода",
"compact": {
"title": "Сжатый разговор"
},
"delete": {
"content": "Вы уверены, что хотите удалить это сообщение?",
"title": "Удалить сообщение"
@@ -4459,6 +4476,7 @@
"confirm": "Подтвердить",
"forward": "Вперед",
"multiple": "Множественный выбор",
"noResult": "[to be translated]:No results found",
"page": "Страница",
"select": "Выбрать",
"title": "Быстрое меню"

View File

@@ -20,7 +20,7 @@ import { Alert, Flex } from 'antd'
import { debounce } from 'lodash'
import { AnimatePresence, motion } from 'motion/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 { useTranslation } from 'react-i18next'
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 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
const AgentInvalid = useCallback(() => {
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 && (
<>
<SessionMessages />
<SessionInputBar />
{!apiServer.enabled ? (
<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} />}

View File

@@ -1,63 +1,201 @@
import { loggerService } from '@logger'
import { ActionIconButton } from '@renderer/components/Buttons'
import { QuickPanelView } from '@renderer/components/QuickPanel'
import { useCreateDefaultSession } from '@renderer/hooks/agents/useCreateDefaultSession'
import type { QuickPanelTriggerInfo } from '@renderer/components/QuickPanel'
import { QuickPanelReservedSymbol, useQuickPanel } from '@renderer/components/QuickPanel'
import { isGenerateImageModel, isVisionModel } from '@renderer/config/models'
import { useSession } from '@renderer/hooks/agents/useSession'
import { useInputText } from '@renderer/hooks/useInputText'
import { selectNewTopicLoading } from '@renderer/hooks/useMessageOperations'
import { getModel } from '@renderer/hooks/useModel'
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 PasteService from '@renderer/services/PasteService'
import { pauseTrace } from '@renderer/services/SpanManagerService'
import { estimateUserPromptUsage } from '@renderer/services/TokenService'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { newMessagesActions, selectMessagesForTopic } from '@renderer/store/newMessage'
import { sendMessage as dispatchSendMessage } from '@renderer/store/thunk/messageThunk'
import type { Assistant, Message, Model, Topic } from '@renderer/types'
import type { FileType } from '@renderer/types'
import type { MessageBlock } from '@renderer/types/newMessage'
import { MessageBlockStatus } from '@renderer/types/newMessage'
import { classNames } from '@renderer/utils'
import { abortCompletion } from '@renderer/utils/abortController'
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 { Tooltip } from 'antd'
import type { TextAreaRef } from 'antd/es/input/TextArea'
import TextArea from 'antd/es/input/TextArea'
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 { documentExts, imageExts, textExts } from '@shared/config/constant'
import type { FC } from 'react'
import React, { useCallback, useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { v4 as uuid } from 'uuid'
import NarrowLayout from '../Messages/NarrowLayout'
import SendMessageButton from './SendMessageButton'
import { InputbarCore } from './components/InputbarCore'
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 = {
agentId: string
sessionId: string
}
const _text = ''
const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
const [text, setText] = useState(_text)
const [inputFocus, setInputFocus] = useState(false)
const { session } = useSession(agentId, sessionId)
const { apiServer } = useSettings()
const { createDefaultSession, creatingSession } = useCreateDefaultSession(agentId)
const newTopicShortcut = useShortcutDisplay('new_topic')
// FIXME: 不应该使用ref将action传到context提供给tool权宜之计
const actionsRef = useRef({
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 quickPanel = useQuickPanel()
const containerRef = useRef(null)
const { files } = useInputbarToolsState()
const { toolsRegistry, setIsExpanded } = useInputbarToolsDispatch()
const { setCouldAddImageFile } = useInputbarToolsInternalDispatch()
const { setTimeoutTimer } = useTimer()
const dispatch = useAppDispatch()
@@ -65,12 +203,152 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
const topicMessages = useAppSelector((state) => selectMessagesForTopic(state, sessionTopicId))
const loading = useAppSelector((state) => selectNewTopicLoading(state, sessionTopicId))
const focusTextarea = useCallback(() => {
textareaRef.current?.focus()
}, [])
// Calculate vision and image generation support
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)
const sendDisabled = inputEmpty || !apiServer.enabled
// Agent sessions don't support model mentions yet, so we only check the assistant's model
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(() => {
if (!topicMessages) {
@@ -93,64 +371,6 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
}, [topicMessages])
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 () => {
if (!streamingAskIds.length) {
@@ -180,79 +400,43 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
try {
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
})
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
const usage = await estimateUserPromptUsage({ content: text })
const userMessage: Message = createMessage('user', sessionTopicId, agentId, {
id: userMessageId,
blocks: userMessageBlocks.map((block) => block?.id),
model,
modelId: model?.id,
model: assistant.model,
modelId: assistant.model?.id,
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(
dispatchSendMessage(userMessage, userMessageBlocks, assistantStub, sessionTopicId, {
dispatchSendMessage(userMessage, userMessageBlocks, assistant, sessionTopicId, {
agentId,
sessionId
})
)
setText('')
setTimeoutTimer('sendMessage_1', () => setText(''), 500)
setTimeoutTimer('agentSession_sendMessage', () => setText(''), 500)
} catch (error) {
logger.warn('Failed to send message:', error as Error)
}
}, [
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)
}, [])
}, [sendDisabled, agentId, dispatch, assistant, sessionId, sessionTopicId, setText, setTimeoutTimer, text, files])
useEffect(() => {
if (!document.querySelector('.topview-fullscreen-container')) {
@@ -260,137 +444,57 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
}
}, [focusTextarea])
useEffect(() => {
const onFocus = () => {
if (document.activeElement?.closest('.ant-modal')) {
return
}
const lastFocusedComponent = PasteService.getLastFocusedComponent()
if (!lastFocusedComponent || lastFocusedComponent === 'inputbar') {
focusTextarea()
}
const supportedExts = useMemo(() => {
if (canAddImageFile && canAddTextFile) {
return [...imageExts, ...documentExts, ...textExts]
}
window.addEventListener('focus', onFocus)
return () => window.removeEventListener('focus', onFocus)
}, [focusTextarea])
if (canAddImageFile) {
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 (
<NarrowLayout style={{ width: '100%' }}>
<Container className="inputbar">
<QuickPanelView setInputText={setText} />
<InputBarContainer
id="inputbar"
className={classNames('inputbar-container', inputFocus && 'focus')}
ref={containerRef}>
<Textarea
value={text}
onChange={onChange}
onKeyDown={handleKeyDown}
placeholder={t('chat.input.placeholder_without_triggers', {
key: getSendMessageShortcutLabel(sendMessageShortcut)
})}
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>
<InputbarCore
scope={TopicType.Session}
text={text}
onTextChange={setText}
textareaRef={textareaRef}
resizeTextArea={resizeTextArea}
focusTextarea={focusTextarea}
placeholder={placeholderText}
supportedExts={supportedExts}
onPause={abortAgentSession}
isLoading={canAbort}
handleSendMessage={sendMessage}
leftToolbar={leftToolbar}
forceEnableQuickPanelTriggers
/>
)
}
// 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`
display: flex;
flex-direction: row;
@@ -398,26 +502,4 @@ const ToolbarGroup = styled.div`
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

File diff suppressed because it is too large Load Diff

View File

@@ -1,205 +1,241 @@
import '@renderer/pages/home/Inputbar/tools'
import type { DropResult } from '@hello-pangea/dnd'
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd'
import { loggerService } from '@logger'
import { ActionIconButton } from '@renderer/components/Buttons'
import { MdiLightbulbOn } from '@renderer/components/Icons'
import type { QuickPanelListItem } from '@renderer/components/QuickPanel'
import {
isAnthropicModel,
isGeminiModel,
isGenerateImageModel,
isMandatoryWebSearchModel,
isSupportedReasoningEffortModel,
isSupportedThinkingTokenModel,
isVisionModel
} from '@renderer/config/models'
import { isSupportUrlContextProvider } from '@renderer/config/providers'
import type { QuickPanelListItem, QuickPanelReservedSymbol } from '@renderer/components/QuickPanel'
import { useQuickPanel } from '@renderer/components/QuickPanel'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useShortcutDisplay } from '@renderer/hooks/useShortcuts'
import { useSidebarIconShow } from '@renderer/hooks/useSidebarIcon'
import { getProviderByModel } from '@renderer/services/AssistantService'
import { getModelUniqId } from '@renderer/services/ModelService'
import { useInputbarTools } from '@renderer/pages/home/Inputbar/context/InputbarToolsProvider'
import type {
InputbarScope,
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 { setIsCollapsed, setToolOrder } from '@renderer/store/inputTools'
import type { FileType, KnowledgeBase, Model } from '@renderer/types'
import { FileTypes } from '@renderer/types'
import { selectToolOrderForScope, setIsCollapsed, setToolOrder } from '@renderer/store/inputTools'
import type { InputBarToolType } from '@renderer/types/chat'
import { classNames } from '@renderer/utils'
import { isPromptToolUse, isSupportedToolUse } from '@renderer/utils/mcp-tools'
import { Divider, Dropdown, Tooltip } from 'antd'
import { Divider, Dropdown } from 'antd'
import type { ItemType } from 'antd/es/menu/interface'
import {
AtSign,
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 { Check, CircleChevronRight } from 'lucide-react'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import type { AttachmentButtonRef } from './AttachmentButton'
import AttachmentButton from './AttachmentButton'
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 {
export interface InputbarToolsNewProps {
scope: InputbarScope
assistantId: string
model: Model
files: FileType[]
setFiles: Dispatch<SetStateAction<FileType[]>>
extensions: string[]
setText: Dispatch<SetStateAction<string>>
resizeTextArea: () => void
selectedKnowledgeBases: KnowledgeBase[]
setSelectedKnowledgeBases: Dispatch<SetStateAction<KnowledgeBase[]>>
mentionedModels: Model[]
setMentionedModels: Dispatch<SetStateAction<Model[]>>
couldAddImageFile: boolean
isExpanded: boolean
onToggleExpanded: () => void
addNewTopic: () => void
clearTopic: () => void
onNewContext: () => void
// Session data for Agent Session scope (optional)
session?: {
agentId?: string
sessionId?: string
slashCommands?: Array<{ command: string; description?: string }>
tools?: Array<{ id: string; name: string; type: string; description?: string }>
}
}
interface ToolButtonConfig {
interface ToolConfig {
key: InputBarToolType
component: ReactNode
condition?: boolean
visible?: boolean
label?: string
icon?: ReactNode
label: string
tool: ToolDefinition
visible: boolean
}
const DraggablePortal = ({ children, isDragging }) => {
const DraggablePortal = ({ children, isDragging }: { children: React.ReactNode; isDragging: boolean }) => {
return isDragging ? createPortal(children, document.body) : children
}
const InputbarTools = ({
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 InputbarTools = ({ scope, assistantId, session }: InputbarToolsNewProps) => {
const { t } = useTranslation()
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 mentionModelsButtonRef = useRef<MentionModelsButtonRef>(null)
const knowledgeBaseButtonRef = useRef<KnowledgeBaseButtonRef>(null)
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 getQuickPanelApiForTool = useCallback(
(toolKey: string): ToolQuickPanelApi => {
const cache = quickPanelApiCacheRef.current
const toolOrder = useAppSelector((state) => state.inputTools.toolOrder)
const isCollapse = useAppSelector((state) => state.inputTools.isCollapsed)
const [targetTool, setTargetTool] = useState<ToolButtonConfig | null>(null)
const showThinkingButton = useMemo(
() => 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]
if (!cache.has(toolKey)) {
cache.set(toolKey, {
registerRootMenu: (entries: QuickPanelListItem[]) =>
toolsContext.toolsRegistry.registerRootMenu(toolKey, entries),
registerTrigger: (symbol: QuickPanelReservedSymbol, handler: (payload?: unknown) => void) =>
toolsContext.toolsRegistry.registerTrigger(toolKey, symbol, handler)
})
} 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(() => {
updateAssistant({ enableGenerateImage: !assistant.enableGenerateImage })
}, [assistant.enableGenerateImage, updateAssistant])
// Get tools for current scope
const availableTools = useMemo(() => {
return getToolsForScope(scope, { assistant, model, session })
}, [scope, assistant, model, session])
const newTopicShortcut = useShortcutDisplay('new_topic')
const clearTopicShortcut = useShortcutDisplay('clear_topic')
// Get tool order for current scope
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(
(toolKey: InputBarToolType, isVisible: boolean | undefined) => {
const newToolOrder = {
const newToolOrder: ToolOrderConfig = {
visible: [...toolOrder.visible],
hidden: [...toolOrder.hidden]
}
@@ -212,129 +248,20 @@ const InputbarTools = ({
newToolOrder.visible.push(toolKey)
}
dispatch(setToolOrder(newToolOrder))
dispatch(setToolOrder({ scope, toolOrder: newToolOrder }))
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 { source, destination } = result
if (!destination) return
const sourceId = source.droppableId
const destinationId = destination.droppableId
const newToolOrder = {
const newToolOrder: ToolOrderConfig = {
visible: [...toolOrder.visible],
hidden: [...toolOrder.hidden]
}
@@ -352,216 +279,9 @@ const InputbarTools = ({
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 baseItems: ItemType[] = [...visibleTools, ...hiddenTools].map((tool) => ({
label: tool.label,
@@ -571,87 +291,88 @@ const InputbarTools = ({
{tool.visible ? <Check size={16} /> : undefined}
</div>
),
onClick: () => {
toggleToolVisibility(tool.key, tool.visible)
}
onClick: () => toggleToolVisibility(tool.key, tool.visible)
}))
if (targetTool) {
baseItems.push({
type: 'divider'
})
baseItems.push({ type: 'divider' })
baseItems.push({
label: `${targetTool.visible ? t('chat.input.tools.collapse_in') : t('chat.input.tools.collapse_out')} "${targetTool.label}"`,
key: 'selected_' + targetTool.key,
icon: <div style={{ width: 20, height: 20 }}></div>,
onClick: () => {
toggleToolVisibility(targetTool.key, targetTool.visible)
}
onClick: () => toggleToolVisibility(targetTool.key, targetTool.visible)
})
}
return baseItems
}, [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 (
<Dropdown menu={{ items: getMenuItems }} trigger={['contextMenu']}>
<ToolsContainer
onContextMenu={(e) => {
const target = e.target as HTMLElement
const isToolButton = target.closest('[data-key]')
if (!isToolButton) {
setTargetTool(null)
}
}}>
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId="inputbar-tools-visible" direction="horizontal">
{(provided) => (
<VisibleTools ref={provided.innerRef} {...provided.droppableProps}>
{visibleTools.map(
(tool, index) =>
(tool.condition ?? true) && (
<Draggable key={tool.key} draggableId={tool.key} index={index}>
<>
<Dropdown menu={{ items: getMenuItems }} trigger={['contextMenu']}>
<ToolsContainer
onContextMenu={(e) => {
const target = e.target as HTMLElement
const isToolButton = target.closest('[data-key]')
if (!isToolButton) {
setTargetTool(null)
}
}}>
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId="inputbar-tools-visible" direction="horizontal">
{(provided) => (
<VisibleTools ref={provided.innerRef} {...provided.droppableProps}>
{visibleTools.map((toolConfig, index) => {
const context = buildRenderContext(toolConfig.tool)
return (
<Draggable key={toolConfig.key} draggableId={toolConfig.key} index={index}>
{(provided, snapshot) => (
<DraggablePortal isDragging={snapshot.isDragging}>
<ToolWrapper
data-key={tool.key}
onContextMenu={() => setTargetTool(tool)}
data-key={toolConfig.key}
onContextMenu={() => setTargetTool(toolConfig)}
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
style={{
...provided.draggableProps.style
}}>
{tool.component}
style={provided.draggableProps.style}>
{toolConfig.tool.render?.(context)}
</ToolWrapper>
</DraggablePortal>
)}
</Draggable>
)
)}
})}
{provided.placeholder}
</VisibleTools>
)}
</Droppable>
{provided.placeholder}
</VisibleTools>
)}
</Droppable>
{showDivider && <Divider type="vertical" style={{ margin: '0 4px' }} />}
{showDivider && <Divider type="vertical" style={{ margin: '0 4px' }} />}
<Droppable droppableId="inputbar-tools-hidden" direction="horizontal">
{(provided) => (
<HiddenTools ref={provided.innerRef} {...provided.droppableProps}>
{hiddenTools.map(
(tool, index) =>
(tool.condition ?? true) && (
<Draggable key={tool.key} draggableId={tool.key} index={index}>
<Droppable droppableId="inputbar-tools-hidden" direction="horizontal">
{(provided) => (
<HiddenTools ref={provided.innerRef} {...provided.droppableProps}>
{hiddenTools.map((toolConfig, index) => {
const context = buildRenderContext(toolConfig.tool)
return (
<Draggable key={toolConfig.key} draggableId={toolConfig.key} index={index}>
{(provided, snapshot) => (
<DraggablePortal isDragging={snapshot.isDragging}>
<ToolWrapper
data-key={tool.key}
className={classNames({
'is-collapsed': isCollapse
})}
onContextMenu={() => setTargetTool(tool)}
data-key={toolConfig.key}
className={classNames({ 'is-collapsed': isCollapse })}
onContextMenu={() => setTargetTool(toolConfig)}
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
@@ -659,39 +380,35 @@ const InputbarTools = ({
...provided.draggableProps.style,
transitionDelay: `${index * 0.02}s`
}}>
{tool.component}
{toolConfig.tool.render?.(context)}
</ToolWrapper>
</DraggablePortal>
)}
</Draggable>
)
)}
{provided.placeholder}
</HiddenTools>
)}
</Droppable>
</DragDropContext>
})}
{provided.placeholder}
</HiddenTools>
)}
</Droppable>
</DragDropContext>
{showCollapseButton && (
<Tooltip
placement="top"
title={isCollapse ? t('chat.input.tools.expand') : t('chat.input.tools.collapse')}
arrow>
<ActionIconButton onClick={() => dispatch(setIsCollapsed(!isCollapse))}>
<CircleChevronRight
size={18}
style={{
transform: isCollapse ? 'scaleX(1)' : 'scaleX(-1)'
}}
/>
{showCollapseButton && (
<ActionIconButton
onClick={() => dispatch(setIsCollapsed(!isCollapse))}
title={isCollapse ? t('chat.input.tools.expand') : t('chat.input.tools.collapse')}>
<CircleChevronRight size={18} style={{ transform: isCollapse ? 'scaleX(1)' : 'scaleX(-1)' }} />
</ActionIconButton>
</Tooltip>
)}
</ToolsContainer>
</Dropdown>
)}
</ToolsContainer>
</Dropdown>
{managerElements}
</>
)
}
InputbarTools.displayName = 'InputbarTools'
const ToolsContainer = styled.div`
min-width: 0;
display: flex;

View File

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

View 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;
`

View File

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

View File

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

View File

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

View 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)!
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,22 +1,18 @@
import { ActionIconButton } from '@renderer/components/Buttons'
import { QuickPanelReservedSymbol, useQuickPanel } from '@renderer/components/QuickPanel'
import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
import type { ToolQuickPanelApi } from '@renderer/pages/home/Inputbar/types'
import type { FileType, KnowledgeBase, KnowledgeItem } from '@renderer/types'
import { filterSupportedFiles, formatFileSize } from '@renderer/utils/file'
import { Tooltip } from 'antd'
import dayjs from 'dayjs'
import { FileSearch, FileText, Paperclip, Upload } from 'lucide-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'
export interface AttachmentButtonRef {
openQuickPanel: () => void
openFileSelectDialog: () => void
}
interface Props {
ref?: React.RefObject<AttachmentButtonRef | null>
quickPanel: ToolQuickPanelApi
couldAddImageFile: boolean
extensions: string[]
files: FileType[]
@@ -24,9 +20,9 @@ interface Props {
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 quickPanel = useQuickPanel()
const quickPanelHook = useQuickPanel()
const { bases: knowledgeBases } = useKnowledgeBases()
const [selecting, setSelecting] = useState<boolean>(false)
@@ -71,7 +67,7 @@ const AttachmentButton: FC<Props> = ({ ref, couldAddImageFile, extensions, files
const openKnowledgeFileList = useCallback(
(base: KnowledgeBase) => {
quickPanel.open({
quickPanelHook.open({
title: base.name,
list: base.items
.filter((file): file is KnowledgeItem => ['file'].includes(file.type))
@@ -102,7 +98,7 @@ const AttachmentButton: FC<Props> = ({ ref, couldAddImageFile, extensions, files
multiple: true
})
},
[files, quickPanel, setFiles]
[files, quickPanelHook, setFiles]
)
const items = useMemo(() => {
@@ -130,17 +126,31 @@ const AttachmentButton: FC<Props> = ({ ref, couldAddImageFile, extensions, files
}, [knowledgeBases, openFileSelectDialog, openKnowledgeFileList, t])
const openQuickPanel = useCallback(() => {
quickPanel.open({
quickPanelHook.open({
title: t('chat.input.upload.attachment'),
list: items,
symbol: QuickPanelReservedSymbol.File
})
}, [items, quickPanel, t])
}, [items, quickPanelHook, t])
useImperativeHandle(ref, () => ({
openQuickPanel,
openFileSelectDialog
}))
useEffect(() => {
const disposeRootMenu = quickPanel.registerRootMenu([
{
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 (
<Tooltip

View File

@@ -1,30 +1,27 @@
import { ActionIconButton } from '@renderer/components/Buttons'
import type { QuickPanelListItem } 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 type { KnowledgeBase } from '@renderer/types'
import { Tooltip } from 'antd'
import { CircleX, FileSearch, Plus } from 'lucide-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 { useNavigate } from 'react-router'
export interface KnowledgeBaseButtonRef {
openQuickPanel: () => void
}
interface Props {
ref?: React.RefObject<KnowledgeBaseButtonRef | null>
quickPanel: ToolQuickPanelApi
selectedBases?: KnowledgeBase[]
onSelect: (bases: KnowledgeBase[]) => void
disabled?: boolean
}
const KnowledgeBaseButton: FC<Props> = ({ ref, selectedBases, onSelect, disabled }) => {
const KnowledgeBaseButton: FC<Props> = ({ quickPanel, selectedBases, onSelect, disabled }) => {
const { t } = useTranslation()
const navigate = useNavigate()
const quickPanel = useQuickPanel()
const quickPanelHook = useQuickPanel()
const knowledgeState = useAppSelector((state) => state.knowledge)
const selectedBasesRef = useRef(selectedBases)
@@ -76,7 +73,7 @@ const KnowledgeBaseButton: FC<Props> = ({ ref, selectedBases, onSelect, disabled
}, [knowledgeState.bases, t, selectedBases, handleBaseSelect, navigate, onSelect])
const openQuickPanel = useCallback(() => {
quickPanel.open({
quickPanelHook.open({
title: t('chat.input.knowledge_base'),
list: baseItems,
symbol: QuickPanelReservedSymbol.KnowledgeBase,
@@ -85,27 +82,42 @@ const KnowledgeBaseButton: FC<Props> = ({ ref, selectedBases, onSelect, disabled
item.isSelected = !item.isSelected
}
})
}, [baseItems, quickPanel, t])
}, [baseItems, quickPanelHook, t])
const handleOpenQuickPanel = useCallback(() => {
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.KnowledgeBase) {
quickPanel.close()
if (quickPanelHook.isVisible && quickPanelHook.symbol === QuickPanelReservedSymbol.KnowledgeBase) {
quickPanelHook.close()
} else {
openQuickPanel()
}
}, [openQuickPanel, quickPanel])
}, [openQuickPanel, quickPanelHook])
// 监听 selectedBases 变化,动态更新已打开的 QuickPanel 列表状态
useEffect(() => {
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.KnowledgeBase) {
if (quickPanelHook.isVisible && quickPanelHook.symbol === QuickPanelReservedSymbol.KnowledgeBase) {
// 直接使用重新计算的 baseItems因为它已经包含了最新的 isSelected 状态
quickPanel.updateList(baseItems)
quickPanelHook.updateList(baseItems)
}
}, [selectedBases, quickPanel, baseItems])
}, [selectedBases, quickPanelHook, baseItems])
useImperativeHandle(ref, () => ({
openQuickPanel
}))
useEffect(() => {
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 (
<Tooltip placement="top" title={t('chat.input.knowledge_base')} mouseLeaveDelay={0} arrow>

View File

@@ -6,6 +6,7 @@ import { isGeminiWebSearchProvider, isSupportUrlContextProvider } from '@rendere
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useMCPServers } from '@renderer/hooks/useMCPServers'
import { useTimer } from '@renderer/hooks/useTimer'
import type { ToolQuickPanelApi } from '@renderer/pages/home/Inputbar/types'
import { getProviderByModel } from '@renderer/services/AssistantService'
import { EventEmitter } from '@renderer/services/EventService'
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 { CircleX, Hammer, Plus } from 'lucide-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 { useNavigate } from 'react-router'
export interface MCPToolsButtonRef {
openQuickPanel: () => void
openPromptList: () => void
openResourcesList: () => void
}
interface Props {
assistantId: string
ref?: React.RefObject<MCPToolsButtonRef | null>
quickPanel: ToolQuickPanelApi
setInputValue: React.Dispatch<React.SetStateAction<string>>
resizeTextArea: () => void
}
@@ -115,10 +110,10 @@ const extractPromptContent = (response: any): string | null => {
return null
}
const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, assistantId }) => {
const MCPToolsButton: FC<Props> = ({ quickPanel, setInputValue, resizeTextArea, assistantId }) => {
const { activedMcpServers } = useMCPServers()
const { t } = useTranslation()
const quickPanel = useQuickPanel()
const quickPanelHook = useQuickPanel()
const navigate = useNavigate()
const [form] = Form.useForm()
@@ -219,15 +214,15 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, assista
isSelected: false,
action: () => {
updateMcpEnabled(false)
quickPanel.close()
quickPanelHook.close()
}
})
return newList
}, [activedMcpServers, t, assistantMcpServers, navigate, updateMcpEnabled, quickPanel])
}, [activedMcpServers, t, assistantMcpServers, navigate, updateMcpEnabled, quickPanelHook])
const openQuickPanel = useCallback(() => {
quickPanel.open({
quickPanelHook.open({
title: t('settings.mcp.title'),
list: menuItems,
symbol: QuickPanelReservedSymbol.Mcp,
@@ -236,7 +231,7 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, assista
item.isSelected = !item.isSelected
}
})
}, [menuItems, quickPanel, t])
}, [menuItems, quickPanelHook, t])
// 使用 useCallback 优化 insertPromptIntoTextArea
const insertPromptIntoTextArea = useCallback(
@@ -376,13 +371,13 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, assista
const openPromptList = useCallback(async () => {
const prompts = await promptList
quickPanel.open({
quickPanelHook.open({
title: t('settings.mcp.title'),
list: prompts,
symbol: QuickPanelReservedSymbol.McpPrompt,
multiple: true
})
}, [promptList, quickPanel, t])
}, [promptList, quickPanelHook, t])
const handleResourceSelect = useCallback(
(resource: MCPResource) => {
@@ -464,27 +459,60 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, assista
}, [activedMcpServers])
const openResourcesList = useCallback(async () => {
quickPanel.open({
quickPanelHook.open({
title: t('settings.mcp.title'),
list: resourcesList,
symbol: QuickPanelReservedSymbol.McpResource,
multiple: true
})
}, [resourcesList, quickPanel, t])
}, [resourcesList, quickPanelHook, t])
const handleOpenQuickPanel = useCallback(() => {
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.Mcp) {
quickPanel.close()
if (quickPanelHook.isVisible && quickPanelHook.symbol === QuickPanelReservedSymbol.Mcp) {
quickPanelHook.close()
} else {
openQuickPanel()
}
}, [openQuickPanel, quickPanel])
}, [openQuickPanel, quickPanelHook])
useImperativeHandle(ref, () => ({
openQuickPanel,
openPromptList,
openResourcesList
}))
useEffect(() => {
const disposeMain = quickPanel.registerRootMenu([
{
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 (
<Tooltip placement="top" title={t('settings.mcp.title')} mouseLeaveDelay={0} arrow>

View File

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

View File

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

View File

@@ -2,38 +2,39 @@ import { ActionIconButton } from '@renderer/components/Buttons'
import {
type QuickPanelListItem,
type QuickPanelOpenOptions,
QuickPanelReservedSymbol
QuickPanelReservedSymbol,
type QuickPanelTriggerInfo
} from '@renderer/components/QuickPanel'
import { useQuickPanel } from '@renderer/components/QuickPanel'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useTimer } from '@renderer/hooks/useTimer'
import type { ToolQuickPanelApi } from '@renderer/pages/home/Inputbar/types'
import QuickPhraseService from '@renderer/services/QuickPhraseService'
import type { QuickPhrase } from '@renderer/types'
import { Input, Modal, Radio, Space, Tooltip } from 'antd'
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 styled from 'styled-components'
export interface QuickPhrasesButtonRef {
openQuickPanel: () => void
}
interface Props {
ref?: React.RefObject<QuickPhrasesButtonRef | null>
quickPanel: ToolQuickPanelApi
setInputValue: React.Dispatch<React.SetStateAction<string>>
resizeTextArea: () => void
assistantId: string
}
const QuickPhrasesButton = ({ ref, setInputValue, resizeTextArea, assistantId }: Props) => {
const QuickPhrasesButton = ({ quickPanel, setInputValue, resizeTextArea, assistantId }: Props) => {
const [quickPhrasesList, setQuickPhrasesList] = useState<QuickPhrase[]>([])
const [isModalOpen, setIsModalOpen] = useState(false)
const [formData, setFormData] = useState({ title: '', content: '', location: 'global' })
const { t } = useTranslation()
const quickPanel = useQuickPanel()
const quickPanelHook = useQuickPanel()
const { assistant, updateAssistant } = useAssistant(assistantId)
const { setTimeoutTimer } = useTimer()
const triggerInfoRef = useRef<
(QuickPanelTriggerInfo & { symbol?: QuickPanelReservedSymbol; searchText?: string }) | undefined
>(undefined)
const loadQuickListPhrases = useCallback(
async (regularPhrases: QuickPhrase[] = []) => {
@@ -58,21 +59,60 @@ const QuickPhrasesButton = ({ ref, setInputValue, resizeTextArea, assistantId }:
'handlePhraseSelect_1',
() => {
setInputValue((prev) => {
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement
const cursorPosition = textArea.selectionStart
const selectionStart = cursorPosition
const selectionEndPosition = cursorPosition + phrase.content.length
const newText = prev.slice(0, cursorPosition) + phrase.content + prev.slice(cursorPosition)
const triggerInfo = triggerInfoRef.current
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement | null
setTimeoutTimer(
'handlePhraseSelect_2',
() => {
textArea.focus()
textArea.setSelectionRange(selectionStart, selectionEndPosition)
resizeTextArea()
},
10
)
const focusAndSelect = (start: number) => {
setTimeoutTimer(
'handlePhraseSelect_2',
() => {
if (textArea) {
textArea.focus()
textArea.setSelectionRange(start, start + phrase.content.length)
}
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
})
},
@@ -138,21 +178,74 @@ const QuickPhrasesButton = ({ ref, setInputValue, resizeTextArea, assistantId }:
[phraseItems, t]
)
const openQuickPanel = useCallback(() => {
quickPanel.open(quickPanelOpenOptions)
}, [quickPanel, quickPanelOpenOptions])
type QuickPhraseTrigger =
| (QuickPanelTriggerInfo & { symbol?: QuickPanelReservedSymbol; searchText?: string })
| 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(() => {
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.QuickPhrases) {
quickPanel.close()
if (quickPanelHook.isVisible && quickPanelHook.symbol === QuickPanelReservedSymbol.QuickPhrases) {
quickPanelHook.close()
} else {
openQuickPanel()
}
}, [openQuickPanel, quickPanel])
}, [openQuickPanel, quickPanelHook])
useImperativeHandle(ref, () => ({
openQuickPanel
}))
useEffect(() => {
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 (
<>

View File

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

View File

@@ -17,25 +17,22 @@ import {
} from '@renderer/config/models'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { getReasoningEffortOptionsLabel } from '@renderer/i18n/label'
import type { ToolQuickPanelApi } from '@renderer/pages/home/Inputbar/types'
import type { Model, ThinkingOption } from '@renderer/types'
import { Tooltip } from 'antd'
import type { FC, ReactElement } from 'react'
import { useCallback, useImperativeHandle, useMemo } from 'react'
import { useCallback, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
export interface ThinkingButtonRef {
openQuickPanel: () => void
}
interface Props {
ref?: React.RefObject<ThinkingButtonRef | null>
quickPanel: ToolQuickPanelApi
model: Model
assistantId: string
}
const ThinkingButton: FC<Props> = ({ ref, model, assistantId }): ReactElement => {
const ThinkingButton: FC<Props> = ({ quickPanel, model, assistantId }): ReactElement => {
const { t } = useTranslation()
const quickPanel = useQuickPanel()
const quickPanelHook = useQuickPanel()
const { assistant, updateAssistantSettings } = useAssistant(assistantId)
const currentReasoningEffort = useMemo(() => {
@@ -106,16 +103,16 @@ const ThinkingButton: FC<Props> = ({ ref, model, assistantId }): ReactElement =>
}, [onThinkingChange])
const openQuickPanel = useCallback(() => {
quickPanel.open({
quickPanelHook.open({
title: t('assistants.settings.reasoning_effort.label'),
list: panelItems,
symbol: QuickPanelReservedSymbol.Thinking
})
}, [quickPanel, panelItems, t])
}, [quickPanelHook, panelItems, t])
const handleOpenQuickPanel = useCallback(() => {
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.Thinking) {
quickPanel.close()
if (quickPanelHook.isVisible && quickPanelHook.symbol === QuickPanelReservedSymbol.Thinking) {
quickPanelHook.close()
return
}
@@ -124,11 +121,26 @@ const ThinkingButton: FC<Props> = ({ ref, model, assistantId }): ReactElement =>
return
}
openQuickPanel()
}, [openQuickPanel, quickPanel, isThinkingEnabled, supportedOptions, disableThinking])
}, [openQuickPanel, quickPanelHook, isThinkingEnabled, supportedOptions, disableThinking])
useImperativeHandle(ref, () => ({
openQuickPanel
}))
useEffect(() => {
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 (
<Tooltip

View File

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

View File

@@ -1,9 +1,8 @@
import { BaiduOutlined, GoogleOutlined } from '@ant-design/icons'
import { loggerService } from '@logger'
import { ActionIconButton } from '@renderer/components/Buttons'
import { BingLogo, BochaLogo, ExaLogo, SearXNGLogo, TavilyLogo, ZhipuLogo } from '@renderer/components/Icons'
import type { QuickPanelListItem } from '@renderer/components/QuickPanel'
import { QuickPanelReservedSymbol, useQuickPanel } from '@renderer/components/QuickPanel'
import { QuickPanelReservedSymbol } from '@renderer/components/QuickPanel'
import {
isGeminiModel,
isGPT5SeriesReasoningModel,
@@ -14,66 +13,57 @@ import { isGeminiWebSearchProvider } from '@renderer/config/providers'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useTimer } from '@renderer/hooks/useTimer'
import { useWebSearchProviders } from '@renderer/hooks/useWebSearchProviders'
import type { ToolQuickPanelController, ToolRenderContext } from '@renderer/pages/home/Inputbar/types'
import { getProviderByModel } from '@renderer/services/AssistantService'
import WebSearchService from '@renderer/services/WebSearchService'
import type { WebSearchProvider, WebSearchProviderId } from '@renderer/types'
import { hasObjectKey } from '@renderer/utils'
import { isToolUseModeFunction } from '@renderer/utils/assistant'
import { Tooltip } from 'antd'
import { Globe } from 'lucide-react'
import type { FC } from 'react'
import { memo, useCallback, useImperativeHandle, useMemo } from 'react'
import { useCallback, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
export interface WebSearchButtonRef {
openQuickPanel: () => void
const logger = loggerService.withContext('WebSearchQuickPanel')
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 {
ref?: React.RefObject<WebSearchButtonRef | null>
assistantId: string
}
const logger = loggerService.withContext('WebSearchButton')
const WebSearchButton: FC<Props> = ({ ref, assistantId }) => {
export const useWebSearchPanelController = (assistantId: string, quickPanelController: ToolQuickPanelController) => {
const { t } = useTranslation()
const quickPanel = useQuickPanel()
const { providers } = useWebSearchProviders()
const { assistant, updateAssistant } = useAssistant(assistantId)
const { providers } = useWebSearchProviders()
const { setTimeoutTimer } = useTimer()
// 注意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(
async (providerId?: WebSearchProvider['id']) => {
setTimeoutTimer('updateWebSearchProvider', () => {
@@ -136,7 +126,6 @@ const WebSearchButton: FC<Props> = ({ ref, assistantId }) => {
const providerItems = useMemo<QuickPanelListItem[]>(() => {
const isWebSearchModelEnabled = assistant.model && isWebSearchModel(assistant.model)
const items: QuickPanelListItem[] = providers
.map((p) => ({
label: p.name,
@@ -145,12 +134,12 @@ const WebSearchButton: FC<Props> = ({ ref, assistantId }) => {
? t('settings.tool.websearch.apikey')
: t('settings.tool.websearch.free')
: 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,
disabled: !WebSearchService.isWebSearchEnabled(p.id),
action: () => updateQuickPanelItem(p.id)
}))
.filter((o) => !o.disabled)
.filter((item) => !item.disabled)
if (isWebSearchModelEnabled) {
items.unshift({
@@ -167,7 +156,6 @@ const WebSearchButton: FC<Props> = ({ ref, assistantId }) => {
return items
}, [
WebSearchIcon,
assistant.enableWebSearch,
assistant.model,
assistant?.webSearchProviderId,
@@ -178,45 +166,69 @@ const WebSearchButton: FC<Props> = ({ ref, assistantId }) => {
])
const openQuickPanel = useCallback(() => {
quickPanel.open({
quickPanelController.open({
title: t('chat.input.web_search.label'),
list: providerItems,
symbol: QuickPanelReservedSymbol.WebSearch,
pageSize: 9
})
}, [quickPanel, t, providerItems])
}, [providerItems, quickPanelController, t])
const handleOpenQuickPanel = useCallback(() => {
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.WebSearch) {
quickPanel.close()
const toggleQuickPanel = useCallback(() => {
if (quickPanelController.isVisible && quickPanelController.symbol === QuickPanelReservedSymbol.WebSearch) {
quickPanelController.close()
} else {
openQuickPanel()
}
}, [openQuickPanel, quickPanel])
}, [openQuickPanel, quickPanelController])
const onClick = useCallback(() => {
if (enableWebSearch) {
updateWebSearchProvider(undefined)
} else {
handleOpenQuickPanel()
}
}, [enableWebSearch, handleOpenQuickPanel, updateWebSearchProvider])
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>
)
return {
enableWebSearch,
providerItems,
openQuickPanel,
toggleQuickPanel,
updateWebSearchProvider,
updateToModelBuiltinWebSearch,
selectedProviderId: assistant.webSearchProviderId
}
}
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

View File

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

View File

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

View File

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

View File

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

View 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'

View File

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

View 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

View File

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

View File

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

View 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

View File

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

View 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

View 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

View File

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

View File

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

View 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

View 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[]
}

View File

@@ -7,7 +7,12 @@ import ImageViewer from '@renderer/components/ImageViewer'
import MarkdownShadowDOMRenderer from '@renderer/components/MarkdownShadowDOMRenderer'
import { useSettings } from '@renderer/hooks/useSettings'
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 { processLatexBrackets } from '@renderer/utils/markdown'
import { isEmpty } from 'lodash'
@@ -38,7 +43,7 @@ const DISALLOWED_ELEMENTS = ['iframe', 'script']
interface Props {
// message: Message & { content: string }
block: MainTextMessageBlock | TranslationMessageBlock | ThinkingMessageBlock
block: MainTextMessageBlock | TranslationMessageBlock | ThinkingMessageBlock | CompactMessageBlock
// 可选的后处理函数,用于在流式渲染过程中处理文本(如引用标签转换)
postProcess?: (text: string) => string
}

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

View File

@@ -10,6 +10,7 @@ import { useSelector } from 'react-redux'
import styled from 'styled-components'
import CitationBlock from './CitationBlock'
import CompactBlock from './CompactBlock'
import ErrorBlock from './ErrorBlock'
import FileBlock from './FileBlock'
import ImageBlock from './ImageBlock'
@@ -200,6 +201,9 @@ const MessageBlockRenderer: React.FC<Props> = ({ blocks, message }) => {
case MessageBlockType.VIDEO:
blockComponent = <VideoBlock key={block.id} block={block} />
break
case MessageBlockType.COMPACT:
blockComponent = <CompactBlock key={block.id} block={block} />
break
default:
logger.warn('Unsupported block type in MessageBlockRenderer:', (block as any).type, block)
break

View File

@@ -6,6 +6,7 @@ import { isGenerateImageModel, isVisionModel } from '@renderer/config/models'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useSettings } from '@renderer/hooks/useSettings'
import { useTimer } from '@renderer/hooks/useTimer'
import type { ToolQuickPanelApi } from '@renderer/pages/home/Inputbar/types'
import FileManager from '@renderer/services/FileManager'
import PasteService from '@renderer/services/PasteService'
import { useAppSelector } from '@renderer/store'
@@ -28,9 +29,8 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import type { AttachmentButtonRef } from '../Inputbar/AttachmentButton'
import AttachmentButton from '../Inputbar/AttachmentButton'
import { FileNameRender, getFileIcon } from '../Inputbar/AttachmentPreview'
import AttachmentButton from '../Inputbar/tools/components/AttachmentButton'
interface Props {
message: Message
@@ -53,12 +53,19 @@ const MessageBlockEditor: FC<Props> = ({ message, topicId, onSave, onResend, onC
const { pasteLongTextThreshold, fontSize, sendMessageShortcut, enableSpellCheck } = useSettings()
const { t } = useTranslation()
const textareaRef = useRef<TextAreaRef>(null)
const attachmentButtonRef = useRef<AttachmentButtonRef>(null)
const isUserMessage = message.role === 'user'
const topicMessages = useAppSelector((state) => selectMessagesForTopic(state, topicId))
const { setTimeoutTimer } = useTimer()
const noopQuickPanel = useMemo<ToolQuickPanelApi>(
() => ({
registerRootMenu: () => () => {},
registerTrigger: () => () => {}
}),
[]
)
const couldAddImageFile = useMemo(() => {
const relatedAssistantMessages = topicMessages.filter((m) => m.askId === message.id && m.role === 'assistant')
if (relatedAssistantMessages.length === 0) {
@@ -346,7 +353,7 @@ const MessageBlockEditor: FC<Props> = ({ message, topicId, onSave, onResend, onC
<ActionBarLeft>
{isUserMessage && (
<AttachmentButton
ref={attachmentButtonRef}
quickPanel={noopQuickPanel}
files={files}
setFiles={setFiles}
couldAddImageFile={couldAddImageFile}

View File

@@ -46,6 +46,8 @@ export interface StreamProcessorCallbacks {
onVideoSearched?: (video?: { type: 'url' | 'path'; content: string }, metadata?: Record<string, any>) => void
// Called when a block is created
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
@@ -147,6 +149,10 @@ export function createStreamProcessor(callbacks: StreamProcessorCallbacks = {})
if (callbacks.onBlockCreated) callbacks.onBlockCreated()
break
}
case ChunkType.RAW: {
if (callbacks.onRawData) callbacks.onRawData(data.content, data.metadata)
break
}
default: {
// Handle unknown chunk types or log an error
logger.warn(`Unknown chunk type: ${data.type}`)

View File

@@ -307,25 +307,39 @@ export class DexieMessageDataSource implements MessageDataSource {
async clearMessages(topicId: string): Promise<void> {
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)
if (!topic) return
// 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) {
const blocks = await db.message_blocks.where('id').anyOf(blockIds).toArray()
const files = blocks
files = blocks
.filter((block) => block.type === 'file' || block.type === 'image')
.map((block: any) => block.file)
.filter((file) => file !== undefined)
}
})
if (!isEmpty(files)) {
await Promise.all(files.map((file) => FileManager.deleteFile(file.id, false)))
}
// Delete files outside the transaction to avoid transaction timeout
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)
}

View File

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

View File

@@ -3,6 +3,7 @@ import type { Assistant } from '@renderer/types'
import type { BlockManager } from '../BlockManager'
import { createBaseCallbacks } from './baseCallbacks'
import { createCitationCallbacks } from './citationCallbacks'
import { createCompactCallbacks } from './compactCallbacks'
import { createImageCallbacks } from './imageCallbacks'
import { createTextCallbacks } from './textCallbacks'
import { createThinkingCallbacks } from './thinkingCallbacks'
@@ -55,17 +56,27 @@ export const createCallbacks = (deps: CallbacksDependencies) => {
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({
blockManager,
getState,
assistantMsgId,
getCitationBlockId: citationCallbacks.getCitationBlockId,
getCitationBlockIdFromTool: toolCallbacks.getCitationBlockId
getCitationBlockIdFromTool: toolCallbacks.getCitationBlockId,
handleCompactTextComplete: compactCallbacks.handleTextComplete
})
const videoCallbacks = createVideoCallbacks({ blockManager, assistantMsgId })
// 组合所有回调
return {
...baseCallbacks,
@@ -75,6 +86,7 @@ export const createCallbacks = (deps: CallbacksDependencies) => {
...imageCallbacks,
...citationCallbacks,
...videoCallbacks,
...compactCallbacks,
// 清理资源的方法
cleanup: () => {
// 清理由 messageThunk 中的节流函数管理,这里不需要特别处理

View File

@@ -14,15 +14,24 @@ interface TextCallbacksDependencies {
assistantMsgId: string
getCitationBlockId: () => string | null
getCitationBlockIdFromTool: () => string | null
handleCompactTextComplete?: (text: string, mainTextBlockId: string | null) => Promise<boolean>
}
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
return {
getCurrentMainTextBlockId: () => mainTextBlockId,
onTextStart: async () => {
if (blockManager.hasInitialPlaceholder) {
const changes = {
@@ -63,6 +72,9 @@ export const createTextCallbacks = (deps: TextCallbacksDependencies) => {
status: MessageBlockStatus.SUCCESS
}
blockManager.smartBlockUpdate(mainTextBlockId, changes, MessageBlockType.MAIN_TEXT, true)
if (handleCompactTextComplete) {
await handleCompactTextComplete(finalText, mainTextBlockId)
}
mainTextBlockId = null
} else {
logger.warn(

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
import type { PayloadAction } from '@reduxjs/toolkit'
import { createSelector, createSlice } from '@reduxjs/toolkit'
import { DEFAULT_CONTEXTCOUNT, DEFAULT_TEMPERATURE } from '@renderer/config/constant'

View File

@@ -67,7 +67,7 @@ const persistedReducer = persistReducer(
{
key: 'cherry-studio',
storage,
version: 172,
version: 173,
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs', 'toolPermissions'],
migrate
},

View File

@@ -1,5 +1,7 @@
import type { PayloadAction } 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'
type ToolOrder = {
@@ -22,13 +24,30 @@ export const DEFAULT_TOOL_ORDER: ToolOrder = {
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 = {
toolOrder: ToolOrder
sessionToolOrder: ToolOrder
isCollapsed: boolean
}
const initialState: InputToolsState = {
toolOrder: DEFAULT_TOOL_ORDER,
sessionToolOrder: DEFAULT_TOOL_ORDER_BY_SCOPE[TopicType.Session],
isCollapsed: true
}
@@ -36,8 +55,12 @@ const inputToolsSlice = createSlice({
name: 'inputTools',
initialState,
reducers: {
setToolOrder: (state, action: PayloadAction<ToolOrder>) => {
state.toolOrder = action.payload
setToolOrder: (state, action: PayloadAction<{ scope: InputbarScope; toolOrder: ToolOrder }>) => {
if (action.payload.scope === TopicType.Session) {
state.sessionToolOrder = action.payload.toolOrder
} else {
state.toolOrder = action.payload.toolOrder
}
},
setIsCollapsed: (state, action: PayloadAction<boolean>) => {
state.isCollapsed = action.payload
@@ -47,4 +70,9 @@ const inputToolsSlice = createSlice({
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

View File

@@ -37,7 +37,7 @@ import { isEmpty } from 'lodash'
import { createMigrate } from 'redux-persist'
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 { mcpSlice } from './mcp'
import { initialState as notesInitialState } from './note'
@@ -1626,6 +1626,7 @@ const migrateConfig = {
},
'108': (state: RootState) => {
try {
// @ts-ignore
state.inputTools.toolOrder = DEFAULT_TOOL_ORDER
state.inputTools.isCollapsed = false
return state
@@ -1905,14 +1906,20 @@ const migrateConfig = {
try {
const { toolOrder } = state.inputTools
const urlContextKey = 'url_context'
// @ts-ignore
if (!toolOrder.visible.includes(urlContextKey)) {
// @ts-ignore
const webSearchIndex = toolOrder.visible.indexOf('web_search')
// @ts-ignore
const knowledgeBaseIndex = toolOrder.visible.indexOf('knowledge_base')
if (webSearchIndex !== -1) {
// @ts-ignore
toolOrder.visible.splice(webSearchIndex, 0, urlContextKey)
} else if (knowledgeBaseIndex !== -1) {
// @ts-ignore
toolOrder.visible.splice(knowledgeBaseIndex, 0, urlContextKey)
} else {
// @ts-ignore
toolOrder.visible.push(urlContextKey)
}
}
@@ -2783,6 +2790,18 @@ const migrateConfig = {
logger.error('migrate 172 error', error as Error)
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
}
}
}

View File

@@ -576,7 +576,9 @@ const fetchAndProcessAgentResponseImpl = async (
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) => {
if (!sessionId || sessionId === latestAgentSessionId) {
@@ -585,6 +587,7 @@ const fetchAndProcessAgentResponseImpl = async (
latestAgentSessionId = sessionId
agentSession.agentSessionId = sessionId
sessionWasCleared = true
logger.debug(`Agent session ID updated`, {
topicId,
@@ -624,14 +627,40 @@ const fetchAndProcessAgentResponseImpl = async (
if (persistTasks.length > 0) {
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) {
logger.error('Failed to persist agent session ID during stream', error as Error)
}
}
const adapter = new AiSdkToChunkAdapter(streamProcessorCallbacks, [], false, false, (sessionId) => {
persistAgentSessionId(sessionId)
})
const adapter = new AiSdkToChunkAdapter(
streamProcessorCallbacks,
[],
false,
false,
(sessionId) => {
persistAgentSessionId(sessionId)
},
() => sessionWasCleared // Provide getter for session cleared flag
)
await adapter.processStream({
fullStream: stream,
@@ -649,9 +678,9 @@ const fetchAndProcessAgentResponseImpl = async (
callbacks.onError?.(error)
} catch (callbackError) {
logger.error('Error in agent onError callback:', callbackError as Error)
} finally {
dispatch(newMessagesActions.setTopicLoading({ topicId, loading: false }))
}
} finally {
dispatch(newMessagesActions.setTopicLoading({ topicId, loading: false }))
}
}

View File

@@ -82,6 +82,7 @@ export const AgentBaseSchema = z.object({
// Tools
mcps: z.array(z.string()).optional(), // Array of MCP tool IDs
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: AgentConfigurationSchema.optional() // Extensible settings like temperature, top_p, etc.
@@ -286,7 +287,6 @@ export interface UpdateSessionRequest extends Partial<AgentBase> {}
export const GetAgentSessionResponseSchema = AgentSessionEntitySchema.extend({
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
slash_commands: z.array(SlashCommandSchema).optional(), // Array of slash commands to trigger the agent
plugins: z
.array(
z.object({

View File

@@ -14,3 +14,7 @@ export type InputBarToolType =
| 'clear_topic'
| 'toggle_expand'
| 'new_context'
// Agent Session tools
| 'create_session'
| 'slash_commands'
| 'activity_directory'

View File

@@ -31,7 +31,8 @@ export enum MessageBlockType {
FILE = 'file', // 文件内容
ERROR = 'error', // 错误信息
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
}
// Compact块 - 用于显示 /compact 命令的响应
export interface CompactMessageBlock extends BaseMessageBlock {
type: MessageBlockType.COMPACT
content: string // 总结消息
compactedContent: string // 从 <local-command-stdout> 提取的内容
}
// MessageBlock 联合类型
export type MessageBlock =
| PlaceholderMessageBlock
@@ -158,6 +166,7 @@ export type MessageBlock =
| ErrorMessageBlock
| CitationMessageBlock
| VideoMessageBlock
| CompactMessageBlock
export enum UserMessageStatus {
SUCCESS = 'success'

Some files were not shown because too many files have changed in this diff Show More