Compare commits
5 Commits
fix/openmi
...
copilot/ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4269a32cfb | ||
|
|
6493f1853d | ||
|
|
cbd4f418f6 | ||
|
|
7d6ffe472c | ||
|
|
f16b63bd69 |
@@ -51,12 +51,6 @@
|
||||
"node": true
|
||||
},
|
||||
"files": ["src/preload/**"]
|
||||
},
|
||||
{
|
||||
"files": ["packages/ai-sdk-provider/**"],
|
||||
"globals": {
|
||||
"fetch": "readonly"
|
||||
}
|
||||
}
|
||||
],
|
||||
"plugins": ["unicorn", "typescript", "oxc", "import"],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
diff --git a/dist/index.js b/dist/index.js
|
||||
index ff305b112779b718f21a636a27b1196125a332d9..cf32ff5086d4d9e56f8fe90c98724559083bafc3 100644
|
||||
index cac044aab0255fa72f68b36ecd2c5b12d424c379..ad6ee8ecfc5cbc3ec43ba59a44eda21e8e4d353f 100644
|
||||
--- a/dist/index.js
|
||||
+++ b/dist/index.js
|
||||
@@ -471,7 +471,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
|
||||
@@ -12,7 +12,7 @@ index ff305b112779b718f21a636a27b1196125a332d9..cf32ff5086d4d9e56f8fe90c98724559
|
||||
|
||||
// src/google-generative-ai-options.ts
|
||||
diff --git a/dist/index.mjs b/dist/index.mjs
|
||||
index 57659290f1cec74878a385626ad75b2a4d5cd3fc..d04e5927ec3725b6ffdb80868bfa1b5a48849537 100644
|
||||
index 0793085005d7968638d355f2f1e127939d965165..1c8bf852baf025d56dc35a0691eb95967de7e5c8 100644
|
||||
--- a/dist/index.mjs
|
||||
+++ b/dist/index.mjs
|
||||
@@ -477,7 +477,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
|
||||
@@ -1,5 +1,5 @@
|
||||
diff --git a/sdk.mjs b/sdk.mjs
|
||||
index 8cc6aaf0b25bcdf3c579ec95cde12d419fcb2a71..3b3b8beaea5ad2bbac26a15f792058306d0b059f 100755
|
||||
index 10162e5b1624f8ce667768943347a6e41089ad2f..32568ae08946590e382270c88d85fba81187568e 100755
|
||||
--- a/sdk.mjs
|
||||
+++ b/sdk.mjs
|
||||
@@ -6213,7 +6213,7 @@ function createAbortController(maxListeners = DEFAULT_MAX_LISTENERS) {
|
||||
@@ -11,7 +11,7 @@ index 8cc6aaf0b25bcdf3c579ec95cde12d419fcb2a71..3b3b8beaea5ad2bbac26a15f79205830
|
||||
import { createInterface } from "readline";
|
||||
|
||||
// ../src/utils/fsOperations.ts
|
||||
@@ -6505,14 +6505,11 @@ class ProcessTransport {
|
||||
@@ -6487,14 +6487,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);
|
||||
}
|
||||
@@ -135,42 +135,50 @@ artifactBuildCompleted: scripts/artifact-build-completed.js
|
||||
releaseInfo:
|
||||
releaseNotes: |
|
||||
<!--LANG:en-->
|
||||
What's New in v1.7.0-beta.6
|
||||
What's New in v1.7.0-beta.5
|
||||
|
||||
New Features:
|
||||
- Enhanced Input Bar: Completely redesigned input bar with improved responsiveness and functionality
|
||||
- Better File Handling: Improved drag-and-drop and paste support for images and documents
|
||||
- Smart Tool Suggestions: Enhanced quick panel with better item selection and keyboard shortcuts
|
||||
- MCPRouter Provider: Added MCPRouter provider integration with token management and server synchronization
|
||||
- MCP Marketplace: Enhanced MCP server discovery and management with multi-provider marketplace support
|
||||
- Agent Permission Mode Display: Visual permission mode cards in empty session states
|
||||
- Assistant Subscription Settings: Added subscription URL management in assistant presets
|
||||
|
||||
Improvements:
|
||||
- Smoother Input Experience: Better auto-resizing and text handling in chat input
|
||||
- Enhanced AI Performance: Improved connection stability and response speed
|
||||
- More Reliable File Uploads: Better support for various file types and upload scenarios
|
||||
- Cleaner Interface: Optimized UI elements for better visual consistency
|
||||
- UI Optimization: Sidebar tooltip placement improved on macOS to avoid overlapping window controls
|
||||
- MCP Server Logos: Display server logos in Agent settings tooling section
|
||||
- Long Command Handling: Bash command tags now auto-truncate (hover to view full command for commands over 100 chars)
|
||||
- MCP OAuth Callback: Fixed callback page hanging and added multilingual support (10 languages)
|
||||
- Error Display: Improved error block display order for better readability
|
||||
- Plugin Browser: Centered tab alignment for better visual consistency
|
||||
|
||||
Bug Fixes:
|
||||
- Fixed image selection issue when adding custom AI providers
|
||||
- Fixed file upload problems with certain API configurations
|
||||
- Fixed input bar responsiveness issues
|
||||
- Fixed quick panel not working properly in some situations
|
||||
- Fixed Agent sessions not inheriting allowed_tools configuration
|
||||
- Fixed Gemini endpoint thinking budget spelling error
|
||||
- Fixed MCP card description text overflow
|
||||
- Fixed unnecessary message timestamp updates on UI-only state changes
|
||||
- Updated dependencies: Bun to 1.3.1, uv to 0.9.5
|
||||
|
||||
<!--LANG:zh-CN-->
|
||||
v1.7.0-beta.6 新特性
|
||||
v1.7.0-beta.5 新特性
|
||||
|
||||
新功能:
|
||||
- 增强输入栏:完全重新设计的输入栏,响应更灵敏,功能更强大
|
||||
- 更好的文件处理:改进的拖拽和粘贴功能,支持图片和文档
|
||||
- 智能工具建议:增强的快速面板,更好的项目选择和键盘快捷键
|
||||
- MCPRouter 提供商:新增 MCPRouter 提供商集成,支持 token 管理和服务器同步
|
||||
- MCP 市场:增强 MCP 服务器发现和管理功能,支持多提供商市场
|
||||
- Agent 权限模式展示:空会话状态显示可视化权限模式卡片
|
||||
- 助手订阅设置:在助手预设中添加订阅 URL 管理功能
|
||||
|
||||
改进:
|
||||
- 更流畅的输入体验:聊天输入框的自动调整和文本处理更佳
|
||||
- 增强 AI 性能:改进连接稳定性和响应速度
|
||||
- 更可靠的文件上传:更好地支持各种文件类型和上传场景
|
||||
- 更简洁的界面:优化 UI 元素,视觉一致性更好
|
||||
- UI 优化:macOS 上侧边栏工具提示位置优化,避免与窗口控制按钮重叠
|
||||
- MCP 服务器标志:在 Agent 设置工具部分显示服务器 logo
|
||||
- 长命令处理:Bash 命令标签自动截断(超过 100 字符时悬停查看完整内容)
|
||||
- MCP OAuth 回调:修复回调页面挂起问题并添加多语言支持(10 种语言)
|
||||
- 错误信息展示:改进错误块显示顺序,提高可读性
|
||||
- 插件浏览器:标签页居中对齐,视觉效果更统一
|
||||
|
||||
问题修复:
|
||||
- 修复添加自定义 AI 提供商时的图片选择问题
|
||||
- 修复某些 API 配置下的文件上传问题
|
||||
- 修复输入栏响应性问题
|
||||
- 修复快速面板在某些情况下无法正常工作的问题
|
||||
- 修复 Agent 会话未继承 allowed_tools 配置
|
||||
- 修复 Gemini 端点 thinking budget 拼写错误
|
||||
- 修复 MCP 卡片描述文本溢出问题
|
||||
- 修复仅 UI 状态变化时消息时间戳不必要的更新
|
||||
- 依赖更新:Bun 升级到 1.3.1,uv 升级到 0.9.5
|
||||
<!--LANG:END-->
|
||||
|
||||
@@ -95,8 +95,7 @@ export default defineConfig({
|
||||
'@cherrystudio/ai-core/provider': resolve('packages/aiCore/src/core/providers'),
|
||||
'@cherrystudio/ai-core/built-in/plugins': resolve('packages/aiCore/src/core/plugins/built-in'),
|
||||
'@cherrystudio/ai-core': resolve('packages/aiCore/src'),
|
||||
'@cherrystudio/extension-table-plus': resolve('packages/extension-table-plus/src'),
|
||||
'@cherrystudio/ai-sdk-provider': resolve('packages/ai-sdk-provider/src')
|
||||
'@cherrystudio/extension-table-plus': resolve('packages/extension-table-plus/src')
|
||||
}
|
||||
},
|
||||
optimizeDeps: {
|
||||
|
||||
11
package.json
11
package.json
@@ -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.30#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.30-b50a299674.patch",
|
||||
"@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",
|
||||
"@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,9 +107,7 @@
|
||||
"@agentic/searxng": "^7.3.3",
|
||||
"@agentic/tavily": "^7.3.3",
|
||||
"@ai-sdk/amazon-bedrock": "^3.0.53",
|
||||
"@ai-sdk/cerebras": "^1.0.31",
|
||||
"@ai-sdk/gateway": "^2.0.9",
|
||||
"@ai-sdk/google-vertex": "^3.0.62",
|
||||
"@ai-sdk/google-vertex": "^3.0.61",
|
||||
"@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",
|
||||
@@ -396,6 +394,7 @@
|
||||
"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",
|
||||
@@ -407,9 +406,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/google@npm:2.0.31": "patch:@ai-sdk/google@npm%3A2.0.31#~/.yarn/patches/@ai-sdk-google-npm-2.0.31-b0de047210.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"
|
||||
},
|
||||
"packageManager": "yarn@4.9.1",
|
||||
"lint-staged": {
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
# @cherrystudio/ai-sdk-provider
|
||||
|
||||
CherryIN provider bundle for the [Vercel AI SDK](https://ai-sdk.dev/).
|
||||
It exposes the CherryIN OpenAI-compatible entrypoints and dynamically routes Anthropic and Gemini model ids to their CherryIN upstream equivalents.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install ai @cherrystudio/ai-sdk-provider @ai-sdk/anthropic @ai-sdk/google @ai-sdk/openai
|
||||
# or
|
||||
yarn add ai @cherrystudio/ai-sdk-provider @ai-sdk/anthropic @ai-sdk/google @ai-sdk/openai
|
||||
```
|
||||
|
||||
> **Note**: This package requires peer dependencies `ai`, `@ai-sdk/anthropic`, `@ai-sdk/google`, and `@ai-sdk/openai` to be installed.
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { createCherryIn, cherryIn } from '@cherrystudio/ai-sdk-provider'
|
||||
|
||||
const cherryInProvider = createCherryIn({
|
||||
apiKey: process.env.CHERRYIN_API_KEY,
|
||||
// optional overrides:
|
||||
// baseURL: 'https://open.cherryin.net/v1',
|
||||
// anthropicBaseURL: 'https://open.cherryin.net/anthropic',
|
||||
// geminiBaseURL: 'https://open.cherryin.net/gemini/v1beta',
|
||||
})
|
||||
|
||||
// Chat models will auto-route based on the model id prefix:
|
||||
const openaiModel = cherryInProvider.chat('gpt-4o-mini')
|
||||
const anthropicModel = cherryInProvider.chat('claude-3-5-sonnet-latest')
|
||||
const geminiModel = cherryInProvider.chat('gemini-2.0-pro-exp')
|
||||
|
||||
const { text } = await openaiModel.invoke('Hello CherryIN!')
|
||||
```
|
||||
|
||||
The provider also exposes `completion`, `responses`, `embedding`, `image`, `transcription`, and `speech` helpers aligned with the upstream APIs.
|
||||
|
||||
See [AI SDK docs](https://ai-sdk.dev/providers/community-providers/custom-providers) for configuring custom providers.
|
||||
@@ -1,64 +0,0 @@
|
||||
{
|
||||
"name": "@cherrystudio/ai-sdk-provider",
|
||||
"version": "0.1.0",
|
||||
"description": "Cherry Studio AI SDK provider bundle with CherryIN routing.",
|
||||
"keywords": [
|
||||
"ai-sdk",
|
||||
"provider",
|
||||
"cherryin",
|
||||
"vercel-ai-sdk",
|
||||
"cherry-studio"
|
||||
],
|
||||
"author": "Cherry Studio",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/CherryHQ/cherry-studio",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/CherryHQ/cherry-studio.git",
|
||||
"directory": "packages/ai-sdk-provider"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/CherryHQ/cherry-studio/issues"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "dist/index.cjs",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsdown",
|
||||
"dev": "tsc -w",
|
||||
"clean": "rm -rf dist",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ai-sdk/anthropic": "^2.0.29",
|
||||
"@ai-sdk/google": "^2.0.23",
|
||||
"@ai-sdk/openai": "^2.0.64",
|
||||
"ai": "^5.0.26"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "^2.0.0",
|
||||
"@ai-sdk/provider-utils": "^3.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsdown": "^0.13.3",
|
||||
"typescript": "^5.8.2",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,319 +0,0 @@
|
||||
import { AnthropicMessagesLanguageModel } from '@ai-sdk/anthropic/internal'
|
||||
import { GoogleGenerativeAILanguageModel } from '@ai-sdk/google/internal'
|
||||
import type { OpenAIProviderSettings } from '@ai-sdk/openai'
|
||||
import {
|
||||
OpenAIChatLanguageModel,
|
||||
OpenAICompletionLanguageModel,
|
||||
OpenAIEmbeddingModel,
|
||||
OpenAIImageModel,
|
||||
OpenAIResponsesLanguageModel,
|
||||
OpenAISpeechModel,
|
||||
OpenAITranscriptionModel
|
||||
} from '@ai-sdk/openai/internal'
|
||||
import {
|
||||
type EmbeddingModelV2,
|
||||
type ImageModelV2,
|
||||
type LanguageModelV2,
|
||||
type ProviderV2,
|
||||
type SpeechModelV2,
|
||||
type TranscriptionModelV2
|
||||
} from '@ai-sdk/provider'
|
||||
import type { FetchFunction } from '@ai-sdk/provider-utils'
|
||||
import { loadApiKey, withoutTrailingSlash } from '@ai-sdk/provider-utils'
|
||||
|
||||
export const CHERRYIN_PROVIDER_NAME = 'cherryin' as const
|
||||
export const DEFAULT_CHERRYIN_BASE_URL = 'https://open.cherryin.net/v1'
|
||||
export const DEFAULT_CHERRYIN_ANTHROPIC_BASE_URL = 'https://open.cherryin.net/v1'
|
||||
export const DEFAULT_CHERRYIN_GEMINI_BASE_URL = 'https://open.cherryin.net/v1beta/models'
|
||||
|
||||
const ANTHROPIC_PREFIX = /^anthropic\//i
|
||||
const GEMINI_PREFIX = /^google\//i
|
||||
// const GEMINI_EXCLUDED_SUFFIXES = ['-nothink', '-search']
|
||||
|
||||
type HeaderValue = string | undefined
|
||||
|
||||
type HeadersInput = Record<string, HeaderValue> | (() => Record<string, HeaderValue>)
|
||||
|
||||
export interface CherryInProviderSettings {
|
||||
/**
|
||||
* CherryIN API key.
|
||||
*
|
||||
* If omitted, the provider will read the `CHERRYIN_API_KEY` environment variable.
|
||||
*/
|
||||
apiKey?: string
|
||||
/**
|
||||
* Optional custom fetch implementation.
|
||||
*/
|
||||
fetch?: FetchFunction
|
||||
/**
|
||||
* Base URL for OpenAI-compatible CherryIN endpoints.
|
||||
*
|
||||
* Defaults to `https://open.cherryin.net/v1`.
|
||||
*/
|
||||
baseURL?: string
|
||||
/**
|
||||
* Base URL for Anthropic-compatible endpoints.
|
||||
*
|
||||
* Defaults to `https://open.cherryin.net/anthropic`.
|
||||
*/
|
||||
anthropicBaseURL?: string
|
||||
/**
|
||||
* Base URL for Gemini-compatible endpoints.
|
||||
*
|
||||
* Defaults to `https://open.cherryin.net/gemini/v1beta`.
|
||||
*/
|
||||
geminiBaseURL?: string
|
||||
/**
|
||||
* Optional static headers applied to every request.
|
||||
*/
|
||||
headers?: HeadersInput
|
||||
}
|
||||
|
||||
export interface CherryInProvider extends ProviderV2 {
|
||||
(modelId: string, settings?: OpenAIProviderSettings): LanguageModelV2
|
||||
languageModel(modelId: string, settings?: OpenAIProviderSettings): LanguageModelV2
|
||||
chat(modelId: string, settings?: OpenAIProviderSettings): LanguageModelV2
|
||||
responses(modelId: string): LanguageModelV2
|
||||
completion(modelId: string, settings?: OpenAIProviderSettings): LanguageModelV2
|
||||
embedding(modelId: string, settings?: OpenAIProviderSettings): EmbeddingModelV2<string>
|
||||
textEmbedding(modelId: string, settings?: OpenAIProviderSettings): EmbeddingModelV2<string>
|
||||
textEmbeddingModel(modelId: string, settings?: OpenAIProviderSettings): EmbeddingModelV2<string>
|
||||
image(modelId: string, settings?: OpenAIProviderSettings): ImageModelV2
|
||||
imageModel(modelId: string, settings?: OpenAIProviderSettings): ImageModelV2
|
||||
transcription(modelId: string): TranscriptionModelV2
|
||||
transcriptionModel(modelId: string): TranscriptionModelV2
|
||||
speech(modelId: string): SpeechModelV2
|
||||
speechModel(modelId: string): SpeechModelV2
|
||||
}
|
||||
|
||||
const resolveApiKey = (options: CherryInProviderSettings): string =>
|
||||
loadApiKey({
|
||||
apiKey: options.apiKey,
|
||||
environmentVariableName: 'CHERRYIN_API_KEY',
|
||||
description: 'CherryIN'
|
||||
})
|
||||
|
||||
const isAnthropicModel = (modelId: string) => ANTHROPIC_PREFIX.test(modelId)
|
||||
const isGeminiModel = (modelId: string) => GEMINI_PREFIX.test(modelId)
|
||||
|
||||
const createCustomFetch = (originalFetch?: any) => {
|
||||
return async (url: string, options: any) => {
|
||||
if (options?.body) {
|
||||
try {
|
||||
const body = JSON.parse(options.body)
|
||||
if (body.tools && Array.isArray(body.tools) && body.tools.length === 0 && body.tool_choice) {
|
||||
delete body.tool_choice
|
||||
options.body = JSON.stringify(body)
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore error
|
||||
}
|
||||
}
|
||||
|
||||
return originalFetch ? originalFetch(url, options) : fetch(url, options)
|
||||
}
|
||||
}
|
||||
class CherryInOpenAIChatLanguageModel extends OpenAIChatLanguageModel {
|
||||
constructor(modelId: string, settings: any) {
|
||||
super(modelId, {
|
||||
...settings,
|
||||
fetch: createCustomFetch(settings.fetch)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const resolveConfiguredHeaders = (headers?: HeadersInput): Record<string, HeaderValue> => {
|
||||
if (typeof headers === 'function') {
|
||||
return { ...headers() }
|
||||
}
|
||||
return headers ? { ...headers } : {}
|
||||
}
|
||||
|
||||
const toBearerToken = (authorization?: string) => (authorization ? authorization.replace(/^Bearer\s+/i, '') : undefined)
|
||||
|
||||
const createJsonHeadersGetter = (options: CherryInProviderSettings): (() => Record<string, HeaderValue>) => {
|
||||
return () => ({
|
||||
Authorization: `Bearer ${resolveApiKey(options)}`,
|
||||
'Content-Type': 'application/json',
|
||||
...resolveConfiguredHeaders(options.headers)
|
||||
})
|
||||
}
|
||||
|
||||
const createAuthHeadersGetter = (options: CherryInProviderSettings): (() => Record<string, HeaderValue>) => {
|
||||
return () => ({
|
||||
Authorization: `Bearer ${resolveApiKey(options)}`,
|
||||
...resolveConfiguredHeaders(options.headers)
|
||||
})
|
||||
}
|
||||
|
||||
export const createCherryIn = (options: CherryInProviderSettings = {}): CherryInProvider => {
|
||||
const {
|
||||
baseURL = DEFAULT_CHERRYIN_BASE_URL,
|
||||
anthropicBaseURL = DEFAULT_CHERRYIN_ANTHROPIC_BASE_URL,
|
||||
geminiBaseURL = DEFAULT_CHERRYIN_GEMINI_BASE_URL,
|
||||
fetch
|
||||
} = options
|
||||
|
||||
const getJsonHeaders = createJsonHeadersGetter(options)
|
||||
const getAuthHeaders = createAuthHeadersGetter(options)
|
||||
|
||||
const url = ({ path }: { path: string; modelId: string }) => `${withoutTrailingSlash(baseURL)}${path}`
|
||||
|
||||
const createAnthropicModel = (modelId: string) =>
|
||||
new AnthropicMessagesLanguageModel(modelId, {
|
||||
provider: `${CHERRYIN_PROVIDER_NAME}.anthropic`,
|
||||
baseURL: anthropicBaseURL,
|
||||
headers: () => {
|
||||
const headers = getJsonHeaders()
|
||||
const apiKey = toBearerToken(headers.Authorization)
|
||||
return {
|
||||
...headers,
|
||||
'x-api-key': apiKey
|
||||
}
|
||||
},
|
||||
fetch,
|
||||
supportedUrls: () => ({
|
||||
'image/*': [/^https?:\/\/.*$/]
|
||||
})
|
||||
})
|
||||
|
||||
const createGeminiModel = (modelId: string) =>
|
||||
new GoogleGenerativeAILanguageModel(modelId, {
|
||||
provider: `${CHERRYIN_PROVIDER_NAME}.google`,
|
||||
baseURL: geminiBaseURL,
|
||||
headers: () => {
|
||||
const headers = getJsonHeaders()
|
||||
const apiKey = toBearerToken(headers.Authorization)
|
||||
return {
|
||||
...headers,
|
||||
'x-goog-api-key': apiKey
|
||||
}
|
||||
},
|
||||
fetch,
|
||||
generateId: () => `${CHERRYIN_PROVIDER_NAME}-${Date.now()}`,
|
||||
supportedUrls: () => ({})
|
||||
})
|
||||
|
||||
const createOpenAIChatModel = (modelId: string, settings: OpenAIProviderSettings = {}) =>
|
||||
new CherryInOpenAIChatLanguageModel(modelId, {
|
||||
provider: `${CHERRYIN_PROVIDER_NAME}.openai-chat`,
|
||||
url,
|
||||
headers: () => ({
|
||||
...getJsonHeaders(),
|
||||
...settings.headers
|
||||
}),
|
||||
fetch
|
||||
})
|
||||
|
||||
const createChatModel = (modelId: string, settings: OpenAIProviderSettings = {}) => {
|
||||
if (isAnthropicModel(modelId)) {
|
||||
return createAnthropicModel(modelId)
|
||||
}
|
||||
if (isGeminiModel(modelId)) {
|
||||
return createGeminiModel(modelId)
|
||||
}
|
||||
return new OpenAIResponsesLanguageModel(modelId, {
|
||||
provider: `${CHERRYIN_PROVIDER_NAME}.openai`,
|
||||
url,
|
||||
headers: () => ({
|
||||
...getJsonHeaders(),
|
||||
...settings.headers
|
||||
}),
|
||||
fetch
|
||||
})
|
||||
}
|
||||
|
||||
const createCompletionModel = (modelId: string, settings: OpenAIProviderSettings = {}) =>
|
||||
new OpenAICompletionLanguageModel(modelId, {
|
||||
provider: `${CHERRYIN_PROVIDER_NAME}.completion`,
|
||||
url,
|
||||
headers: () => ({
|
||||
...getJsonHeaders(),
|
||||
...settings.headers
|
||||
}),
|
||||
fetch
|
||||
})
|
||||
|
||||
const createEmbeddingModel = (modelId: string, settings: OpenAIProviderSettings = {}) =>
|
||||
new OpenAIEmbeddingModel(modelId, {
|
||||
provider: `${CHERRYIN_PROVIDER_NAME}.embeddings`,
|
||||
url,
|
||||
headers: () => ({
|
||||
...getJsonHeaders(),
|
||||
...settings.headers
|
||||
}),
|
||||
fetch
|
||||
})
|
||||
|
||||
const createResponsesModel = (modelId: string) =>
|
||||
new OpenAIResponsesLanguageModel(modelId, {
|
||||
provider: `${CHERRYIN_PROVIDER_NAME}.responses`,
|
||||
url,
|
||||
headers: () => ({
|
||||
...getJsonHeaders()
|
||||
}),
|
||||
fetch
|
||||
})
|
||||
|
||||
const createImageModel = (modelId: string, settings: OpenAIProviderSettings = {}) =>
|
||||
new OpenAIImageModel(modelId, {
|
||||
provider: `${CHERRYIN_PROVIDER_NAME}.image`,
|
||||
url,
|
||||
headers: () => ({
|
||||
...getJsonHeaders(),
|
||||
...settings.headers
|
||||
}),
|
||||
fetch
|
||||
})
|
||||
|
||||
const createTranscriptionModel = (modelId: string) =>
|
||||
new OpenAITranscriptionModel(modelId, {
|
||||
provider: `${CHERRYIN_PROVIDER_NAME}.transcription`,
|
||||
url,
|
||||
headers: () => ({
|
||||
...getAuthHeaders()
|
||||
}),
|
||||
fetch
|
||||
})
|
||||
|
||||
const createSpeechModel = (modelId: string) =>
|
||||
new OpenAISpeechModel(modelId, {
|
||||
provider: `${CHERRYIN_PROVIDER_NAME}.speech`,
|
||||
url,
|
||||
headers: () => ({
|
||||
...getJsonHeaders()
|
||||
}),
|
||||
fetch
|
||||
})
|
||||
|
||||
const provider: CherryInProvider = function (modelId: string, settings?: OpenAIProviderSettings) {
|
||||
if (new.target) {
|
||||
throw new Error('CherryIN provider function cannot be called with the new keyword.')
|
||||
}
|
||||
|
||||
return createChatModel(modelId, settings)
|
||||
}
|
||||
|
||||
provider.languageModel = createChatModel
|
||||
provider.chat = createOpenAIChatModel
|
||||
|
||||
provider.responses = createResponsesModel
|
||||
provider.completion = createCompletionModel
|
||||
|
||||
provider.embedding = createEmbeddingModel
|
||||
provider.textEmbedding = createEmbeddingModel
|
||||
provider.textEmbeddingModel = createEmbeddingModel
|
||||
|
||||
provider.image = createImageModel
|
||||
provider.imageModel = createImageModel
|
||||
|
||||
provider.transcription = createTranscriptionModel
|
||||
provider.transcriptionModel = createTranscriptionModel
|
||||
|
||||
provider.speech = createSpeechModel
|
||||
provider.speechModel = createSpeechModel
|
||||
|
||||
return provider
|
||||
}
|
||||
|
||||
export const cherryIn = createCherryIn()
|
||||
@@ -1 +0,0 @@
|
||||
export * from './cherryin-provider'
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"declaration": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"noEmitOnError": false,
|
||||
"outDir": "./dist",
|
||||
"resolveJsonModule": true,
|
||||
"rootDir": "./src",
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"target": "ES2020"
|
||||
},
|
||||
"exclude": ["node_modules", "dist"],
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
export default defineConfig({
|
||||
entry: {
|
||||
index: 'src/index.ts'
|
||||
},
|
||||
outDir: 'dist',
|
||||
format: ['esm', 'cjs'],
|
||||
clean: true,
|
||||
dts: true,
|
||||
tsconfig: 'tsconfig.json'
|
||||
})
|
||||
@@ -39,13 +39,11 @@
|
||||
"@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",
|
||||
"@ai-sdk/provider-utils": "^3.0.16",
|
||||
"@ai-sdk/xai": "^2.0.31",
|
||||
"@cherrystudio/ai-sdk-provider": "workspace:*",
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { anthropic } from '@ai-sdk/anthropic'
|
||||
import { google } from '@ai-sdk/google'
|
||||
import { openai } from '@ai-sdk/openai'
|
||||
import type { anthropic } from '@ai-sdk/anthropic'
|
||||
import type { google } from '@ai-sdk/google'
|
||||
import type { openai } from '@ai-sdk/openai'
|
||||
import type { InferToolInput, InferToolOutput } from 'ai'
|
||||
import { type Tool } from 'ai'
|
||||
|
||||
import { createOpenRouterOptions, createXaiOptions, mergeProviderOptions } from '../../../options'
|
||||
import type { ProviderOptionsMap } from '../../../options/types'
|
||||
import type { OpenRouterSearchConfig } from './openrouter'
|
||||
|
||||
@@ -96,56 +95,3 @@ export type WebSearchToolInputSchema = {
|
||||
google: InferToolInput<GoogleWebSearchTool>
|
||||
'openai-chat': InferToolInput<OpenAIChatWebSearchTool>
|
||||
}
|
||||
|
||||
export const switchWebSearchTool = (providerId: string, config: WebSearchPluginConfig, params: any) => {
|
||||
switch (providerId) {
|
||||
case 'openai': {
|
||||
if (config.openai) {
|
||||
if (!params.tools) params.tools = {}
|
||||
params.tools.web_search = openai.tools.webSearch(config.openai)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'openai-chat': {
|
||||
if (config['openai-chat']) {
|
||||
if (!params.tools) params.tools = {}
|
||||
params.tools.web_search_preview = openai.tools.webSearchPreview(config['openai-chat'])
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'anthropic': {
|
||||
if (config.anthropic) {
|
||||
if (!params.tools) params.tools = {}
|
||||
params.tools.web_search = anthropic.tools.webSearch_20250305(config.anthropic)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'google': {
|
||||
// case 'google-vertex':
|
||||
if (!params.tools) params.tools = {}
|
||||
params.tools.web_search = google.tools.googleSearch(config.google || {})
|
||||
break
|
||||
}
|
||||
|
||||
case 'xai': {
|
||||
if (config.xai) {
|
||||
const searchOptions = createXaiOptions({
|
||||
searchParameters: { ...config.xai, mode: 'on' }
|
||||
})
|
||||
params.providerOptions = mergeProviderOptions(params.providerOptions, searchOptions)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'openrouter': {
|
||||
if (config.openrouter) {
|
||||
const searchOptions = createOpenRouterOptions(config.openrouter)
|
||||
params.providerOptions = mergeProviderOptions(params.providerOptions, searchOptions)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
* Web Search Plugin
|
||||
* 提供统一的网络搜索能力,支持多个 AI Provider
|
||||
*/
|
||||
import { anthropic } from '@ai-sdk/anthropic'
|
||||
import { google } from '@ai-sdk/google'
|
||||
import { openai } from '@ai-sdk/openai'
|
||||
|
||||
import { createOpenRouterOptions, createXaiOptions, mergeProviderOptions } from '../../../options'
|
||||
import { definePlugin } from '../../'
|
||||
import type { AiRequestContext } from '../../types'
|
||||
import type { WebSearchPluginConfig } from './helper'
|
||||
import { DEFAULT_WEB_SEARCH_CONFIG, switchWebSearchTool } from './helper'
|
||||
import { DEFAULT_WEB_SEARCH_CONFIG } from './helper'
|
||||
|
||||
/**
|
||||
* 网络搜索插件
|
||||
@@ -20,13 +24,56 @@ export const webSearchPlugin = (config: WebSearchPluginConfig = DEFAULT_WEB_SEAR
|
||||
|
||||
transformParams: async (params: any, context: AiRequestContext) => {
|
||||
const { providerId } = context
|
||||
switchWebSearchTool(providerId, config, params)
|
||||
switch (providerId) {
|
||||
case 'openai': {
|
||||
if (config.openai) {
|
||||
if (!params.tools) params.tools = {}
|
||||
params.tools.web_search = openai.tools.webSearch(config.openai)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'openai-chat': {
|
||||
if (config['openai-chat']) {
|
||||
if (!params.tools) params.tools = {}
|
||||
params.tools.web_search_preview = openai.tools.webSearchPreview(config['openai-chat'])
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if (providerId === 'cherryin' || providerId === 'cherryin-chat') {
|
||||
// cherryin.gemini
|
||||
const _providerId = params.model.provider.split('.')[1]
|
||||
switchWebSearchTool(_providerId, config, params)
|
||||
case 'anthropic': {
|
||||
if (config.anthropic) {
|
||||
if (!params.tools) params.tools = {}
|
||||
params.tools.web_search = anthropic.tools.webSearch_20250305(config.anthropic)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'google': {
|
||||
// case 'google-vertex':
|
||||
if (!params.tools) params.tools = {}
|
||||
params.tools.web_search = google.tools.googleSearch(config.google || {})
|
||||
break
|
||||
}
|
||||
|
||||
case 'xai': {
|
||||
if (config.xai) {
|
||||
const searchOptions = createXaiOptions({
|
||||
searchParameters: { ...config.xai, mode: 'on' }
|
||||
})
|
||||
params.providerOptions = mergeProviderOptions(params.providerOptions, searchOptions)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'openrouter': {
|
||||
if (config.openrouter) {
|
||||
const searchOptions = createOpenRouterOptions(config.openrouter)
|
||||
params.providerOptions = mergeProviderOptions(params.providerOptions, searchOptions)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
})
|
||||
|
||||
@@ -12,7 +12,6 @@ import { createOpenAI, type OpenAIProviderSettings } from '@ai-sdk/openai'
|
||||
import { createOpenAICompatible } from '@ai-sdk/openai-compatible'
|
||||
import type { LanguageModelV2 } from '@ai-sdk/provider'
|
||||
import { createXai } from '@ai-sdk/xai'
|
||||
import { type CherryInProviderSettings, createCherryIn } from '@cherrystudio/ai-sdk-provider'
|
||||
import { createOpenRouter } from '@openrouter/ai-sdk-provider'
|
||||
import type { Provider } from 'ai'
|
||||
import { customProvider } from 'ai'
|
||||
@@ -32,8 +31,6 @@ export const baseProviderIds = [
|
||||
'azure-responses',
|
||||
'deepseek',
|
||||
'openrouter',
|
||||
'cherryin',
|
||||
'cherryin-chat',
|
||||
'huggingface'
|
||||
] as const
|
||||
|
||||
@@ -139,26 +136,6 @@ export const baseProviders = [
|
||||
creator: createOpenRouter,
|
||||
supportsImageGeneration: true
|
||||
},
|
||||
{
|
||||
id: 'cherryin',
|
||||
name: 'CherryIN',
|
||||
creator: createCherryIn,
|
||||
supportsImageGeneration: true
|
||||
},
|
||||
{
|
||||
id: 'cherryin-chat',
|
||||
name: 'CherryIN Chat',
|
||||
creator: (options: CherryInProviderSettings) => {
|
||||
const provider = createCherryIn(options)
|
||||
return customProvider({
|
||||
fallbackProvider: {
|
||||
...provider,
|
||||
languageModel: (modelId: string) => provider.chat(modelId)
|
||||
}
|
||||
})
|
||||
},
|
||||
supportsImageGeneration: true
|
||||
},
|
||||
{
|
||||
id: 'huggingface',
|
||||
name: 'HuggingFace',
|
||||
|
||||
@@ -189,7 +189,6 @@ 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',
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE `sessions` ADD `slash_commands` text;
|
||||
@@ -1,346 +0,0 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "0cf3d79e-69bf-4dba-8df4-996b9b67d2e8",
|
||||
"prevId": "dabab6db-a2cd-4e96-b06e-6cb87d445a87",
|
||||
"tables": {
|
||||
"agents": {
|
||||
"name": "agents",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"accessible_paths": {
|
||||
"name": "accessible_paths",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"instructions": {
|
||||
"name": "instructions",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"plan_model": {
|
||||
"name": "plan_model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"small_model": {
|
||||
"name": "small_model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mcps": {
|
||||
"name": "mcps",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"allowed_tools": {
|
||||
"name": "allowed_tools",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"configuration": {
|
||||
"name": "configuration",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"session_messages": {
|
||||
"name": "session_messages",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"session_id": {
|
||||
"name": "session_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_session_id": {
|
||||
"name": "agent_session_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"metadata": {
|
||||
"name": "metadata",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"migrations": {
|
||||
"name": "migrations",
|
||||
"columns": {
|
||||
"version": {
|
||||
"name": "version",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"tag": {
|
||||
"name": "tag",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"executed_at": {
|
||||
"name": "executed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"sessions": {
|
||||
"name": "sessions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_type": {
|
||||
"name": "agent_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"accessible_paths": {
|
||||
"name": "accessible_paths",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"instructions": {
|
||||
"name": "instructions",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"plan_model": {
|
||||
"name": "plan_model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"small_model": {
|
||||
"name": "small_model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mcps": {
|
||||
"name": "mcps",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"allowed_tools": {
|
||||
"name": "allowed_tools",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"slash_commands": {
|
||||
"name": "slash_commands",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"configuration": {
|
||||
"name": "configuration",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -15,13 +15,6 @@
|
||||
"when": 1758187378775,
|
||||
"tag": "0001_woozy_captain_flint",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1762526423527,
|
||||
"tag": "0002_wealthy_naoko",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -551,7 +551,6 @@ 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))
|
||||
|
||||
@@ -124,7 +124,7 @@ export default class OpenMineruPreprocessProvider extends BasePreprocessProvider
|
||||
const formData = new FormData()
|
||||
formData.append('return_md', 'true')
|
||||
formData.append('response_format_zip', 'true')
|
||||
formData.append('file', fileBuffer, {
|
||||
formData.append('files', fileBuffer, {
|
||||
filename: file.origin_name
|
||||
})
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import * as path from 'path'
|
||||
import type { CreateDirectoryOptions, FileStat } from 'webdav'
|
||||
|
||||
import { getDataPath } from '../utils'
|
||||
import { expandNotesPath } from '../utils/file'
|
||||
import S3Storage from './S3Storage'
|
||||
import WebDav from './WebDav'
|
||||
import { windowService } from './WindowService'
|
||||
@@ -240,11 +241,49 @@ class BackupManager {
|
||||
// 使用流式复制
|
||||
await this.copyDirWithProgress(sourcePath, tempDataDir, (size) => {
|
||||
copiedSize += size
|
||||
const progress = Math.min(50, Math.floor((copiedSize / totalSize) * 50))
|
||||
const progress = Math.min(45, Math.floor((copiedSize / totalSize) * 45))
|
||||
onProgress({ stage: 'copying_files', progress, total: 100 })
|
||||
})
|
||||
|
||||
await this.setWritableRecursive(tempDataDir)
|
||||
|
||||
// 检查并备份 notes 目录(如果配置在 Data 目录外)
|
||||
try {
|
||||
const backupData = JSON.parse(data)
|
||||
const persistData = JSON.parse(backupData.localStorage?.['persist:cherry-studio'] || '{}')
|
||||
const noteState = JSON.parse(persistData.note || '{}')
|
||||
const notesPath = noteState.notesPath
|
||||
|
||||
if (notesPath) {
|
||||
// 展开路径获取绝对路径
|
||||
const expandedNotesPath = expandNotesPath(notesPath)
|
||||
const dataPath = path.join(app.getPath('userData'), 'Data')
|
||||
const normalizedDataPath = path.normalize(dataPath)
|
||||
const normalizedNotesPath = path.normalize(expandedNotesPath)
|
||||
|
||||
// 检查 notes 是否在 Data 目录外
|
||||
const isOutsideData =
|
||||
!normalizedNotesPath.startsWith(normalizedDataPath + path.sep) &&
|
||||
normalizedNotesPath !== normalizedDataPath
|
||||
|
||||
if (isOutsideData && fs.existsSync(expandedNotesPath)) {
|
||||
logger.info(`Backing up notes from external location: ${expandedNotesPath}`)
|
||||
const tempNotesDir = path.join(this.tempDir, 'Notes')
|
||||
await this.copyDirWithProgress(expandedNotesPath, tempNotesDir, (size) => {
|
||||
// Notes backup progress from 45% to 50%
|
||||
copiedSize += size
|
||||
const notesProgress = 45 + Math.min(5, Math.floor((size / totalSize) * 5))
|
||||
onProgress({ stage: 'copying_notes', progress: notesProgress, total: 100 })
|
||||
})
|
||||
await this.setWritableRecursive(tempNotesDir)
|
||||
logger.info('External notes directory backed up successfully')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 如果解析失败或获取 notes 路径失败,继续备份其他内容
|
||||
logger.warn('Failed to parse notes path from backup data, skipping external notes backup', error as Error)
|
||||
}
|
||||
|
||||
onProgress({ stage: 'preparing_compression', progress: 50, total: 100 })
|
||||
} else {
|
||||
logger.debug('Skip the backup of the file')
|
||||
@@ -399,13 +438,52 @@ class BackupManager {
|
||||
// 使用流式复制
|
||||
await this.copyDirWithProgress(sourcePath, destPath, (size) => {
|
||||
copiedSize += size
|
||||
const progress = Math.min(85, 35 + Math.floor((copiedSize / totalSize) * 50))
|
||||
const progress = Math.min(75, 35 + Math.floor((copiedSize / totalSize) * 40))
|
||||
onProgress({ stage: 'copying_files', progress, total: 100 })
|
||||
})
|
||||
} else {
|
||||
logger.debug('skipBackupFile is true, skip restoring Data directory')
|
||||
}
|
||||
|
||||
// 检查并恢复外部 Notes 目录
|
||||
logger.debug('step 3.5: check and restore external Notes directory')
|
||||
const notesBackupPath = path.join(this.tempDir, 'Notes')
|
||||
const notesExists = await fs.pathExists(notesBackupPath)
|
||||
|
||||
if (notesExists) {
|
||||
try {
|
||||
// 从 data.json 中获取 notes 路径配置
|
||||
const backupData = JSON.parse(data)
|
||||
const persistData = JSON.parse(backupData.localStorage?.['persist:cherry-studio'] || '{}')
|
||||
const noteState = JSON.parse(persistData.note || '{}')
|
||||
const notesPath = noteState.notesPath
|
||||
|
||||
if (notesPath) {
|
||||
const expandedNotesPath = expandNotesPath(notesPath)
|
||||
logger.info(`Restoring notes to configured location: ${expandedNotesPath}`)
|
||||
|
||||
// 确保目标目录的父目录存在
|
||||
await fs.ensureDir(path.dirname(expandedNotesPath))
|
||||
|
||||
// 如果目标已存在,先删除
|
||||
if (await fs.pathExists(expandedNotesPath)) {
|
||||
await this.setWritableRecursive(expandedNotesPath)
|
||||
await fs.remove(expandedNotesPath)
|
||||
}
|
||||
|
||||
// 复制 Notes 目录
|
||||
await this.copyDirWithProgress(notesBackupPath, expandedNotesPath, (size) => {
|
||||
const progress = Math.min(85, 75 + Math.floor(size / 1000000))
|
||||
onProgress({ stage: 'copying_notes', progress, total: 100 })
|
||||
})
|
||||
|
||||
logger.info('External notes directory restored successfully')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to restore external notes directory', error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('step 4: clean up temp directory')
|
||||
// 清理临时目录
|
||||
await this.setWritableRecursive(this.tempDir)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { loggerService } from '@logger'
|
||||
import {
|
||||
checkName,
|
||||
expandNotesPath,
|
||||
getFilesDir,
|
||||
getFileType,
|
||||
getName,
|
||||
getNotesDir,
|
||||
getNotesDirAbsolute,
|
||||
getTempDir,
|
||||
readTextFileWithAutoEncoding,
|
||||
scanDir
|
||||
@@ -16,7 +17,6 @@ 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'
|
||||
@@ -31,73 +31,6 @@ 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)[]
|
||||
@@ -122,29 +55,9 @@ 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()
|
||||
private notesDir = getNotesDirAbsolute()
|
||||
private tempDir = getTempDir()
|
||||
private watcher?: FSWatcher
|
||||
private watcherSender?: Electron.WebContents
|
||||
@@ -829,299 +742,23 @@ class FileStorage {
|
||||
|
||||
public getDirectoryStructure = async (_: Electron.IpcMainInvokeEvent, dirPath: string): Promise<NotesTreeNode[]> => {
|
||||
try {
|
||||
return await scanDir(dirPath)
|
||||
// Expand relative paths before scanning
|
||||
const expandedPath = expandNotesPath(dirPath)
|
||||
return await scanDir(expandedPath)
|
||||
} catch (error) {
|
||||
logger.error('Failed to get directory structure:', error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
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') {
|
||||
return false
|
||||
}
|
||||
|
||||
// Normalize path
|
||||
const normalizedPath = path.resolve(dirPath)
|
||||
// Expand and normalize path (handles ~, ., and .. paths)
|
||||
const normalizedPath = expandNotesPath(dirPath)
|
||||
|
||||
// Check if directory exists
|
||||
if (!fs.existsSync(normalizedPath)) {
|
||||
@@ -1137,7 +774,7 @@ class FileStorage {
|
||||
// Get app paths to prevent selection of restricted directories
|
||||
const appDataPath = path.resolve(process.env.APPDATA || path.join(require('os').homedir(), '.config'))
|
||||
const filesDir = path.resolve(getFilesDir())
|
||||
const currentNotesDir = path.resolve(getNotesDir())
|
||||
const currentNotesDir = getNotesDirAbsolute()
|
||||
|
||||
// Prevent selecting app data directories
|
||||
if (
|
||||
@@ -1374,7 +1011,8 @@ class FileStorage {
|
||||
throw new Error('Directory path is required')
|
||||
}
|
||||
|
||||
const normalizedPath = path.resolve(dirPath.trim())
|
||||
// Expand relative paths before watching
|
||||
const normalizedPath = expandNotesPath(dirPath.trim())
|
||||
|
||||
if (!fs.existsSync(normalizedPath)) {
|
||||
throw new Error(`Directory does not exist: ${normalizedPath}`)
|
||||
|
||||
@@ -36,14 +36,7 @@ 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',
|
||||
'slash_commands'
|
||||
]
|
||||
protected jsonFields: string[] = ['tools', 'mcps', 'configuration', 'accessible_paths', 'allowed_tools']
|
||||
|
||||
/**
|
||||
* Initialize database with retry logic and proper error handling
|
||||
|
||||
@@ -22,7 +22,6 @@ 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
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { loggerService } from '@logger'
|
||||
import type { SlashCommand, UpdateSessionResponse } from '@types'
|
||||
import type { UpdateSessionResponse } from '@types'
|
||||
import {
|
||||
AgentBaseSchema,
|
||||
type AgentEntity,
|
||||
@@ -14,10 +13,6 @@ 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
|
||||
@@ -34,52 +29,6 @@ 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> = {}
|
||||
@@ -162,13 +111,7 @@ export class SessionService extends BaseService {
|
||||
|
||||
const session = this.deserializeJsonFields(result[0]) as GetAgentSessionResponse
|
||||
session.tools = await this.listMcpTools(session.agent_type, session.mcps)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
session.slash_commands = await this.listSlashCommands(session.agent_type)
|
||||
return session
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { ClaudeStreamState, stripLocalCommandTags, transformSDKMessageToStreamParts } from '../transform'
|
||||
import { ClaudeStreamState, transformSDKMessageToStreamParts } from '../transform'
|
||||
|
||||
const baseStreamMetadata = {
|
||||
parent_tool_use_id: null,
|
||||
@@ -10,19 +10,6 @@ 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()
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
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: '/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' }
|
||||
{ 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' }
|
||||
]
|
||||
|
||||
@@ -12,7 +12,6 @@ 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'
|
||||
|
||||
@@ -20,7 +19,6 @@ 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'
|
||||
@@ -199,7 +197,7 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
options.strictMcpConfig = true
|
||||
}
|
||||
|
||||
if (lastAgentSessionId && !NO_RESUME_COMMANDS.some((cmd) => prompt.includes(cmd))) {
|
||||
if (lastAgentSessionId) {
|
||||
options.resume = lastAgentSessionId
|
||||
// TODO: use fork session when we support branching sessions
|
||||
// options.forkSession = true
|
||||
@@ -222,15 +220,7 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
|
||||
// Start async processing on the next tick so listeners can subscribe first
|
||||
setImmediate(() => {
|
||||
this.processSDKQuery(
|
||||
userInputStream,
|
||||
closeUserStream,
|
||||
options,
|
||||
aiStream,
|
||||
errorChunks,
|
||||
session.agent_id,
|
||||
session.id
|
||||
).catch((error) => {
|
||||
this.processSDKQuery(userInputStream, closeUserStream, options, aiStream, errorChunks).catch((error) => {
|
||||
logger.error('Unhandled Claude Code stream error', {
|
||||
error: error instanceof Error ? { name: error.name, message: error.message } : String(error)
|
||||
})
|
||||
@@ -339,9 +329,7 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
closePromptStream: () => void,
|
||||
options: Options,
|
||||
stream: ClaudeCodeStream,
|
||||
errorChunks: string[],
|
||||
agentId: string,
|
||||
sessionId: string
|
||||
errorChunks: string[]
|
||||
): Promise<void> {
|
||||
const jsonOutput: SDKMessage[] = []
|
||||
let hasCompleted = false
|
||||
@@ -354,62 +342,6 @@ 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,
|
||||
@@ -446,6 +378,7 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
}
|
||||
}
|
||||
|
||||
hasCompleted = true
|
||||
const duration = Date.now() - startTime
|
||||
|
||||
logger.debug('SDK query completed successfully', {
|
||||
|
||||
@@ -73,21 +73,13 @@ 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 => {
|
||||
const withoutLocalCommandTags = stripLocalCommandTags(text)
|
||||
return withoutLocalCommandTags.replace(/<command-[^>]+>.*?<\/command-[^>]+>/gs, '').trim()
|
||||
return text.replace(/<command-[^>]+>.*?<\/command-[^>]+>/gs, '').trim()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -110,7 +102,6 @@ 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)
|
||||
@@ -144,8 +135,7 @@ function handleAssistantMessage(
|
||||
const isStreamingActive = state.hasActiveStep()
|
||||
|
||||
if (typeof content === 'string') {
|
||||
const sanitizedContent = stripLocalCommandTags(content)
|
||||
if (!sanitizedContent) {
|
||||
if (!content) {
|
||||
return chunks
|
||||
}
|
||||
|
||||
@@ -167,7 +157,7 @@ function handleAssistantMessage(
|
||||
chunks.push({
|
||||
type: 'text-delta',
|
||||
id: textId,
|
||||
text: sanitizedContent,
|
||||
text: content,
|
||||
providerMetadata
|
||||
})
|
||||
chunks.push({
|
||||
@@ -188,10 +178,7 @@ function handleAssistantMessage(
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
if (!isStreamingActive) {
|
||||
const sanitizedText = stripLocalCommandTags(block.text)
|
||||
if (sanitizedText) {
|
||||
textBlocks.push(sanitizedText)
|
||||
}
|
||||
textBlocks.push(block.text)
|
||||
}
|
||||
break
|
||||
case 'tool_use':
|
||||
@@ -550,10 +537,6 @@ 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,
|
||||
|
||||
@@ -10,11 +10,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { readTextFileWithAutoEncoding } from '../file'
|
||||
import {
|
||||
expandNotesPath,
|
||||
getAllFiles,
|
||||
getAppConfigDir,
|
||||
getConfigDir,
|
||||
getFilesDir,
|
||||
getFileType,
|
||||
getNotesDir,
|
||||
getNotesDirAbsolute,
|
||||
getTempDir,
|
||||
isPathInside,
|
||||
untildify
|
||||
@@ -244,6 +247,20 @@ describe('file', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNotesDir', () => {
|
||||
it('should return relative path for portability', () => {
|
||||
const notesDir = getNotesDir()
|
||||
expect(notesDir).toBe('./Data/Notes')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNotesDirAbsolute', () => {
|
||||
it('should return absolute notes directory path', () => {
|
||||
const notesDirAbsolute = getNotesDirAbsolute()
|
||||
expect(notesDirAbsolute).toBe('/mock/userData/Data/Notes')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAppConfigDir', () => {
|
||||
it('should return correct app config directory path', () => {
|
||||
const appConfigDir = getAppConfigDir('test-app')
|
||||
@@ -331,6 +348,64 @@ describe('file', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('expandNotesPath', () => {
|
||||
beforeEach(() => {
|
||||
// Mock path.isAbsolute
|
||||
vi.mocked(path.isAbsolute).mockImplementation((p) => {
|
||||
return p.startsWith('/') || /^[A-Za-z]:/.test(p)
|
||||
})
|
||||
|
||||
// Mock path.resolve
|
||||
vi.mocked(path.resolve).mockImplementation((...args) => {
|
||||
const joined = args.join('/')
|
||||
return joined.startsWith('/') ? joined : `/${joined}`
|
||||
})
|
||||
|
||||
// Mock path.normalize
|
||||
vi.mocked(path.normalize).mockImplementation((p) => p.replace(/\/+/g, '/'))
|
||||
})
|
||||
|
||||
it('should expand tilde paths to home directory', () => {
|
||||
const result = expandNotesPath('~/Notes')
|
||||
expect(result).toBe('/mock/home/Notes')
|
||||
})
|
||||
|
||||
it('should expand relative paths using userData as base', () => {
|
||||
const result = expandNotesPath('./Notes')
|
||||
expect(result).toContain('userData')
|
||||
})
|
||||
|
||||
it('should return absolute paths unchanged', () => {
|
||||
const result = expandNotesPath('/absolute/path/Notes')
|
||||
expect(result).toBe('/absolute/path/Notes')
|
||||
})
|
||||
|
||||
it('should handle Windows absolute paths', () => {
|
||||
const result = expandNotesPath('C:\\Users\\Notes')
|
||||
expect(result).toBe('C:\\Users\\Notes')
|
||||
})
|
||||
|
||||
it('should handle empty string', () => {
|
||||
const result = expandNotesPath('')
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('should expand parent directory paths', () => {
|
||||
const result = expandNotesPath('../Notes')
|
||||
expect(result).toContain('userData')
|
||||
})
|
||||
|
||||
it('should use custom base path when provided', () => {
|
||||
const result = expandNotesPath('./Notes', '/custom/base')
|
||||
expect(result).toContain('/custom/base')
|
||||
})
|
||||
|
||||
it('should handle complex relative paths', () => {
|
||||
const result = expandNotesPath('../../Notes')
|
||||
expect(result).toContain('userData')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPathInside', () => {
|
||||
beforeEach(() => {
|
||||
// Mock path.resolve to simulate path resolution
|
||||
|
||||
@@ -38,6 +38,33 @@ export function untildify(pathWithTilde: string) {
|
||||
return pathWithTilde
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand relative paths to absolute paths.
|
||||
* Handles paths starting with ~, ., or ..
|
||||
* @param pathString - The path to expand
|
||||
* @param basePath - Optional base path for relative paths (defaults to userData directory)
|
||||
* @returns Absolute path
|
||||
*/
|
||||
export function expandNotesPath(pathString: string, basePath?: string): string {
|
||||
if (!pathString) {
|
||||
return pathString
|
||||
}
|
||||
|
||||
// First handle tilde expansion
|
||||
let expandedPath = untildify(pathString)
|
||||
|
||||
// If it's already an absolute path, return it
|
||||
if (path.isAbsolute(expandedPath)) {
|
||||
return path.normalize(expandedPath)
|
||||
}
|
||||
|
||||
// For relative paths, resolve against the base path (default to userData)
|
||||
const base = basePath || app.getPath('userData')
|
||||
expandedPath = path.resolve(base, expandedPath)
|
||||
|
||||
return path.normalize(expandedPath)
|
||||
}
|
||||
|
||||
export async function hasWritePermission(dir: string) {
|
||||
try {
|
||||
logger.info(`Checking write permission for ${dir}`)
|
||||
@@ -156,7 +183,12 @@ export function getNotesDir() {
|
||||
fs.mkdirSync(notesDir, { recursive: true })
|
||||
logger.info(`Notes directory created at: ${notesDir}`)
|
||||
}
|
||||
return notesDir
|
||||
// Return relative path for better portability across devices
|
||||
return './Data/Notes'
|
||||
}
|
||||
|
||||
export function getNotesDirAbsolute() {
|
||||
return path.join(app.getPath('userData'), 'Data', 'Notes')
|
||||
}
|
||||
|
||||
export function getConfigDir() {
|
||||
|
||||
@@ -48,16 +48,6 @@ 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 }
|
||||
@@ -211,8 +201,6 @@ 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),
|
||||
|
||||
@@ -30,22 +30,18 @@ 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,
|
||||
getSessionWasCleared?: () => boolean
|
||||
onSessionUpdate?: (sessionId: string) => void
|
||||
) {
|
||||
this.toolCallHandler = new ToolCallChunkHandler(onChunk, mcpTools)
|
||||
this.accumulate = accumulate
|
||||
this.enableWebSearch = enableWebSearch || false
|
||||
this.onSessionUpdate = onSessionUpdate
|
||||
this.getSessionWasCleared = getSessionWasCleared
|
||||
}
|
||||
|
||||
private markFirstTokenIfNeeded() {
|
||||
@@ -88,9 +84,8 @@ export class AiSdkToChunkAdapter {
|
||||
}
|
||||
this.resetTimingState()
|
||||
this.responseStartTimestamp = Date.now()
|
||||
// Reset state at the start of stream
|
||||
// Reset link converter state at the start of stream
|
||||
this.isFirstChunk = true
|
||||
this.hasTextContent = false
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
@@ -134,8 +129,6 @@ 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,
|
||||
@@ -150,7 +143,6 @@ export class AiSdkToChunkAdapter {
|
||||
})
|
||||
break
|
||||
case 'text-delta': {
|
||||
this.hasTextContent = true
|
||||
const processedText = chunk.text || ''
|
||||
let finalText: string
|
||||
|
||||
@@ -309,25 +301,6 @@ 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,
|
||||
|
||||
@@ -7,17 +7,16 @@
|
||||
* 2. 暂时保持接口兼容性
|
||||
*/
|
||||
|
||||
import type { GatewayLanguageModelEntry } from '@ai-sdk/gateway'
|
||||
import { createExecutor } from '@cherrystudio/ai-core'
|
||||
import { loggerService } from '@logger'
|
||||
import { getEnableDeveloperMode } from '@renderer/hooks/useSettings'
|
||||
import { addSpan, endSpan } from '@renderer/services/SpanManagerService'
|
||||
import type { StartSpanParams } from '@renderer/trace/types/ModelSpanEntity'
|
||||
import { type Assistant, type GenerateImageParams, type Model, type Provider, SystemProviderIds } from '@renderer/types'
|
||||
import type { Assistant, GenerateImageParams, Model, Provider } from '@renderer/types'
|
||||
import type { AiSdkModel, StreamTextParams } from '@renderer/types/aiCoreTypes'
|
||||
import { SUPPORTED_IMAGE_ENDPOINT_LIST } from '@renderer/utils'
|
||||
import { buildClaudeCodeSystemModelMessage } from '@shared/anthropic'
|
||||
import { gateway, type ImageModel, type LanguageModel, type Provider as AiSdkProvider, wrapLanguageModel } from 'ai'
|
||||
import { type ImageModel, type LanguageModel, type Provider as AiSdkProvider, wrapLanguageModel } from 'ai'
|
||||
|
||||
import AiSdkToChunkAdapter from './chunk/AiSdkToChunkAdapter'
|
||||
import LegacyAiProvider from './legacy/index'
|
||||
@@ -440,18 +439,6 @@ export default class ModernAiProvider {
|
||||
|
||||
// 代理其他方法到原有实现
|
||||
public async models() {
|
||||
if (this.actualProvider.id === SystemProviderIds['ai-gateway']) {
|
||||
const formatModel = function (models: GatewayLanguageModelEntry[]): Model[] {
|
||||
return models.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
provider: 'gateway',
|
||||
group: m.id.split('/')[0],
|
||||
description: m.description ?? undefined
|
||||
}))
|
||||
}
|
||||
return formatModel((await gateway.getAvailableModels()).models)
|
||||
}
|
||||
return this.legacyProvider.models()
|
||||
}
|
||||
|
||||
|
||||
@@ -84,8 +84,6 @@ export async function createAiSdkProvider(config) {
|
||||
config.providerId = `${config.providerId}-chat`
|
||||
} else if (config.providerId === 'azure' && config.options?.mode === 'responses') {
|
||||
config.providerId = `${config.providerId}-responses`
|
||||
} else if (config.providerId === 'cherryin' && config.options?.mode === 'chat') {
|
||||
config.providerId = 'cherryin-chat'
|
||||
}
|
||||
localProvider = await createProviderCore(config.providerId, config.options)
|
||||
|
||||
|
||||
@@ -171,7 +171,7 @@ export function providerToAiSdkConfig(
|
||||
extraOptions.endpoint = endpoint
|
||||
if (actualProvider.type === 'openai-response' && !isOpenAIChatCompletionOnlyModel(model)) {
|
||||
extraOptions.mode = 'responses'
|
||||
} else if (aiSdkProviderId === 'openai' || (aiSdkProviderId === 'cherryin' && actualProvider.type === 'openai')) {
|
||||
} else if (aiSdkProviderId === 'openai') {
|
||||
extraOptions.mode = 'chat'
|
||||
}
|
||||
|
||||
|
||||
@@ -71,21 +71,6 @@ export const NEW_PROVIDER_CONFIGS: ProviderConfig[] = [
|
||||
creatorFunctionName: 'createHuggingFace',
|
||||
supportsImageGeneration: true,
|
||||
aliases: ['hf', 'hugging-face']
|
||||
},
|
||||
{
|
||||
id: 'ai-gateway',
|
||||
name: 'AI Gateway',
|
||||
import: () => import('@ai-sdk/gateway'),
|
||||
creatorFunctionName: 'createGateway',
|
||||
supportsImageGeneration: true,
|
||||
aliases: ['gateway']
|
||||
},
|
||||
{
|
||||
id: 'cerebras',
|
||||
name: 'Cerebras',
|
||||
import: () => import('@ai-sdk/cerebras'),
|
||||
creatorFunctionName: 'createCerebras',
|
||||
supportsImageGeneration: false
|
||||
}
|
||||
] as const
|
||||
|
||||
|
||||
@@ -113,9 +113,6 @@ export function buildProviderOptions(
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'cherryin':
|
||||
providerSpecificOptions = buildCherryInProviderOptions(assistant, model, capabilities, actualProvider)
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unsupported base provider ${baseProviderId}`)
|
||||
}
|
||||
@@ -151,12 +148,11 @@ export function buildProviderOptions(
|
||||
...providerSpecificOptions,
|
||||
...getCustomParameters(assistant)
|
||||
}
|
||||
|
||||
// vertex需要映射到google或anthropic
|
||||
const rawProviderKey =
|
||||
{
|
||||
'google-vertex': 'google',
|
||||
'google-vertex-anthropic': 'anthropic',
|
||||
'ai-gateway': 'gateway'
|
||||
'google-vertex-anthropic': 'anthropic'
|
||||
}[rawProviderId] || rawProviderId
|
||||
|
||||
// 返回 AI Core SDK 要求的格式:{ 'providerId': providerOptions }
|
||||
@@ -274,34 +270,6 @@ function buildXAIProviderOptions(
|
||||
return providerOptions
|
||||
}
|
||||
|
||||
function buildCherryInProviderOptions(
|
||||
assistant: Assistant,
|
||||
model: Model,
|
||||
capabilities: {
|
||||
enableReasoning: boolean
|
||||
enableWebSearch: boolean
|
||||
enableGenerateImage: boolean
|
||||
},
|
||||
actualProvider: Provider
|
||||
): Record<string, any> {
|
||||
const serviceTierSetting = getServiceTier(model, actualProvider)
|
||||
|
||||
switch (actualProvider.type) {
|
||||
case 'openai':
|
||||
return {
|
||||
...buildOpenAIProviderOptions(assistant, model, capabilities),
|
||||
serviceTier: serviceTierSetting
|
||||
}
|
||||
|
||||
case 'anthropic':
|
||||
return buildAnthropicProviderOptions(assistant, model, capabilities)
|
||||
|
||||
case 'gemini':
|
||||
return buildGeminiProviderOptions(assistant, model, capabilities)
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Bedrock providerOptions
|
||||
*/
|
||||
|
||||
@@ -109,11 +109,6 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
|
||||
|
||||
// use thinking, doubao, zhipu, etc.
|
||||
if (isSupportedThinkingTokenDoubaoModel(model) || isSupportedThinkingTokenZhipuModel(model)) {
|
||||
if (provider.id === SystemProviderIds.cerebras) {
|
||||
return {
|
||||
disable_reasoning: true
|
||||
}
|
||||
}
|
||||
return { thinking: { type: 'disabled' } }
|
||||
}
|
||||
|
||||
@@ -311,9 +306,6 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
|
||||
return {}
|
||||
}
|
||||
if (isSupportedThinkingTokenZhipuModel(model)) {
|
||||
if (provider.id === SystemProviderIds.cerebras) {
|
||||
return {}
|
||||
}
|
||||
return { thinking: { type: 'enabled' } }
|
||||
}
|
||||
|
||||
|
||||
@@ -107,11 +107,6 @@ export function buildProviderBuiltinWebSearchConfig(
|
||||
}
|
||||
}
|
||||
}
|
||||
case 'cherryin': {
|
||||
const _providerId =
|
||||
{ 'openai-response': 'openai', openai: 'openai-chat' }[model?.endpoint_type ?? ''] ?? model?.endpoint_type
|
||||
return buildProviderBuiltinWebSearchConfig(_providerId, webSearchConfig, model)
|
||||
}
|
||||
default: {
|
||||
return {}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 37 KiB |
@@ -1 +0,0 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Vercel</title><path d="M12 0l12 20.785H0L12 0z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 225 B |
@@ -1,104 +0,0 @@
|
||||
import * as tinyPinyin from 'tiny-pinyin'
|
||||
|
||||
import type { QuickPanelFilterFn, QuickPanelListItem, QuickPanelSortFn } from './types'
|
||||
|
||||
/**
|
||||
* Default filter function
|
||||
* Implements standard filtering logic with pinyin support
|
||||
*/
|
||||
export const defaultFilterFn: QuickPanelFilterFn = (item, searchText, fuzzyRegex, pinyinCache) => {
|
||||
if (!searchText) return true
|
||||
|
||||
let filterText = item.filterText || ''
|
||||
if (typeof item.label === 'string') {
|
||||
filterText += item.label
|
||||
}
|
||||
if (typeof item.description === 'string') {
|
||||
filterText += item.description
|
||||
}
|
||||
|
||||
const lowerFilterText = filterText.toLowerCase()
|
||||
const lowerSearchText = searchText.toLowerCase()
|
||||
|
||||
// Direct substring match
|
||||
if (lowerFilterText.includes(lowerSearchText)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Pinyin fuzzy match for Chinese characters
|
||||
if (tinyPinyin.isSupported() && /[\u4e00-\u9fa5]/.test(filterText)) {
|
||||
try {
|
||||
let pinyinText = pinyinCache.get(item)
|
||||
if (!pinyinText) {
|
||||
pinyinText = tinyPinyin.convertToPinyin(filterText, '', true).toLowerCase()
|
||||
pinyinCache.set(item, pinyinText)
|
||||
}
|
||||
return fuzzyRegex.test(pinyinText)
|
||||
} catch (error) {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
return fuzzyRegex.test(filterText.toLowerCase())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate match score for sorting
|
||||
* Higher score = better match
|
||||
*/
|
||||
const calculateMatchScore = (item: QuickPanelListItem, searchText: string): number => {
|
||||
let filterText = item.filterText || ''
|
||||
if (typeof item.label === 'string') {
|
||||
filterText += item.label
|
||||
}
|
||||
if (typeof item.description === 'string') {
|
||||
filterText += item.description
|
||||
}
|
||||
|
||||
const lowerFilterText = filterText.toLowerCase()
|
||||
const lowerSearchText = searchText.toLowerCase()
|
||||
|
||||
// Exact match (highest priority)
|
||||
if (lowerFilterText === lowerSearchText) {
|
||||
return 1000
|
||||
}
|
||||
|
||||
// Label exact match (very high priority)
|
||||
if (typeof item.label === 'string' && item.label.toLowerCase() === lowerSearchText) {
|
||||
return 900
|
||||
}
|
||||
|
||||
// Starts with search text (high priority)
|
||||
if (lowerFilterText.startsWith(lowerSearchText)) {
|
||||
return 800
|
||||
}
|
||||
|
||||
// Label starts with search text
|
||||
if (typeof item.label === 'string' && item.label.toLowerCase().startsWith(lowerSearchText)) {
|
||||
return 700
|
||||
}
|
||||
|
||||
// Contains search text (medium priority)
|
||||
if (lowerFilterText.includes(lowerSearchText)) {
|
||||
// Earlier position = higher score
|
||||
const position = lowerFilterText.indexOf(lowerSearchText)
|
||||
return 600 - position
|
||||
}
|
||||
|
||||
// Pinyin fuzzy match (lower priority)
|
||||
return 100
|
||||
}
|
||||
|
||||
/**
|
||||
* Default sort function
|
||||
* Sorts items by match score in descending order
|
||||
*/
|
||||
export const defaultSortFn: QuickPanelSortFn = (items, searchText) => {
|
||||
if (!searchText) return items
|
||||
|
||||
return [...items].sort((a, b) => {
|
||||
const scoreA = calculateMatchScore(a, searchText)
|
||||
const scoreB = calculateMatchScore(b, searchText)
|
||||
return scoreB - scoreA
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from './defaultStrategies'
|
||||
export * from './hook'
|
||||
export * from './provider'
|
||||
export * from './types'
|
||||
|
||||
@@ -4,12 +4,11 @@ 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 }) => {
|
||||
@@ -18,39 +17,19 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
||||
|
||||
const [list, setList] = useState<QuickPanelListItem[]>([])
|
||||
const [title, setTitle] = useState<string | undefined>()
|
||||
const [defaultIndex, setDefaultIndex] = useState<number>(-1)
|
||||
const [defaultIndex, setDefaultIndex] = useState<number>(0)
|
||||
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) => {
|
||||
// 先尝试引用匹配(快速路径)
|
||||
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
|
||||
})
|
||||
})
|
||||
setList((prevList) => prevList.map((item) => (item === targetItem ? { ...item, isSelected } : item)))
|
||||
}, [])
|
||||
|
||||
// 添加更新整个列表的方法
|
||||
@@ -64,23 +43,17 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
||||
clearTimer.current = null
|
||||
}
|
||||
|
||||
setLastCloseAction(undefined)
|
||||
setTitle(options.title)
|
||||
setList(options.list)
|
||||
const nextDefaultIndex = typeof options.defaultIndex === 'number' ? Math.max(-1, options.defaultIndex) : -1
|
||||
setDefaultIndex(nextDefaultIndex)
|
||||
setDefaultIndex(options.defaultIndex ?? 0)
|
||||
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)
|
||||
}, [])
|
||||
@@ -88,8 +61,6 @@ 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(() => {
|
||||
@@ -97,13 +68,9 @@ 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]
|
||||
@@ -133,15 +100,10 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
||||
defaultIndex,
|
||||
pageSize,
|
||||
multiple,
|
||||
manageListExternally,
|
||||
triggerInfo,
|
||||
lastCloseAction,
|
||||
filterFn,
|
||||
sortFn,
|
||||
onClose,
|
||||
beforeAction,
|
||||
afterAction,
|
||||
onSearchChange
|
||||
afterAction
|
||||
}),
|
||||
[
|
||||
open,
|
||||
@@ -155,15 +117,10 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
||||
defaultIndex,
|
||||
pageSize,
|
||||
multiple,
|
||||
manageListExternally,
|
||||
triggerInfo,
|
||||
lastCloseAction,
|
||||
filterFn,
|
||||
sortFn,
|
||||
onClose,
|
||||
beforeAction,
|
||||
afterAction,
|
||||
onSearchChange
|
||||
afterAction
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -10,8 +10,7 @@ export enum QuickPanelReservedSymbol {
|
||||
WebSearch = '?',
|
||||
Mcp = 'mcp',
|
||||
McpPrompt = 'mcp-prompt',
|
||||
McpResource = 'mcp-resource',
|
||||
SlashCommands = 'slash-commands'
|
||||
McpResource = 'mcp-resource'
|
||||
}
|
||||
|
||||
export type QuickPanelCloseAction = 'enter' | 'click' | 'esc' | 'outsideclick' | 'enter_empty' | string | undefined
|
||||
@@ -28,29 +27,6 @@ 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
|
||||
@@ -72,14 +48,6 @@ 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 = {
|
||||
@@ -120,15 +88,10 @@ 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'
|
||||
|
||||
@@ -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,50 +62,21 @@ 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
|
||||
@@ -115,35 +86,52 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
const fuzzyRegex = new RegExp(fuzzyPattern, 'ig')
|
||||
|
||||
// 拆分:固定显示项(不参与过滤)与普通项
|
||||
const pinnedItems = baseList.filter((item) => item.alwaysVisible)
|
||||
const normalItems = baseList.filter((item) => !item.alwaysVisible)
|
||||
const pinnedItems = (ctx.list || []).filter((item) => item.alwaysVisible)
|
||||
const normalItems = (ctx.list || []).filter((item) => !item.alwaysVisible)
|
||||
|
||||
// Filter normal items using injected filter function
|
||||
const filteredNormalItems = normalItems.filter((item) => {
|
||||
return filterFn(item, _searchText, fuzzyRegex, pinyinCacheRef.current)
|
||||
})
|
||||
if (!_searchText) return true
|
||||
|
||||
// Sort filtered items using injected sort function
|
||||
const sortedNormalItems = sortFn(filteredNormalItems, _searchText)
|
||||
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())
|
||||
}
|
||||
})
|
||||
|
||||
// 只有在搜索文本变化或面板符号变化时才重置index
|
||||
const isSearchChanged = prevSearchTextRef.current !== searchText
|
||||
const isSymbolChanged = prevSymbolRef.current !== ctx.symbol
|
||||
|
||||
if (isSearchChanged || isSymbolChanged) {
|
||||
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) // 搜索文本变化时不默认高亮
|
||||
}
|
||||
setIndex(-1) // 不默认高亮任何项,让用户主动选择
|
||||
} else {
|
||||
// 如果当前index超出范围,调整到有效范围内
|
||||
setIndex((prevIndex) => {
|
||||
const combinedLength = pinnedItems.length + sortedNormalItems.length
|
||||
const combinedLength = pinnedItems.length + filteredNormalItems.length
|
||||
if (prevIndex >= combinedLength) {
|
||||
return combinedLength > 0 ? combinedLength - 1 : -1
|
||||
}
|
||||
@@ -154,9 +142,10 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
prevSearchTextRef.current = searchText
|
||||
prevSymbolRef.current = ctx.symbol
|
||||
|
||||
// 固定项置顶 + 排序后的普通项
|
||||
return [...pinnedItems, ...sortedNormalItems]
|
||||
}, [ctx.isVisible, ctx.symbol, ctx.manageListExternally, ctx.list, ctx.defaultIndex, searchText, filterFn, sortFn])
|
||||
// 固定项置顶 + 过滤后的普通项
|
||||
const pinnedFiltered = [...pinnedItems, ...filteredNormalItems]
|
||||
return pinnedFiltered.filter((item) => !item.hidden)
|
||||
}, [ctx.isVisible, ctx.symbol, ctx.list, searchText])
|
||||
|
||||
const canForwardAndBackward = useMemo(() => {
|
||||
return list.some((item) => item.isMenu) || historyPanel.length > 0
|
||||
@@ -190,64 +179,19 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
|
||||
if (deleteStart >= deleteEnd) return
|
||||
|
||||
const activeSearchText = searchTextRef.current ?? ''
|
||||
// 删除文本
|
||||
const newText = textArea.value.slice(0, deleteStart) + textArea.value.slice(deleteEnd)
|
||||
setInputText(newText)
|
||||
|
||||
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
|
||||
})
|
||||
// 设置光标位置
|
||||
setTimeoutTimer(
|
||||
'quickpanel_focus',
|
||||
() => {
|
||||
textArea.focus()
|
||||
textArea.setSelectionRange(deleteStart, deleteStart)
|
||||
},
|
||||
0
|
||||
)
|
||||
|
||||
setSearchText('')
|
||||
},
|
||||
@@ -267,21 +211,11 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
if (textArea) {
|
||||
setInputText(textArea.value)
|
||||
}
|
||||
} else if (
|
||||
action &&
|
||||
!['outsideclick', 'esc', 'enter_empty', 'no_result'].includes(action) &&
|
||||
ctx.triggerInfo?.type === 'input'
|
||||
) {
|
||||
setTimeoutTimer(
|
||||
'quickpanel_deferred_clear',
|
||||
() => {
|
||||
clearSearchText(true)
|
||||
},
|
||||
0
|
||||
)
|
||||
} else if (action && !['outsideclick', 'esc', 'enter_empty', 'no_result'].includes(action)) {
|
||||
clearSearchText(true)
|
||||
}
|
||||
},
|
||||
[ctx, clearSearchText, setInputText, searchText, setTimeoutTimer]
|
||||
[ctx, clearSearchText, setInputText, searchText]
|
||||
)
|
||||
|
||||
const handleItemAction = useCallback(
|
||||
@@ -351,86 +285,12 @@ 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
|
||||
@@ -445,8 +305,6 @@ 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')
|
||||
@@ -470,17 +328,16 @@ 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
|
||||
) // 等待面板关闭动画结束后,再清空搜索词
|
||||
}
|
||||
}, [ctx.isVisible, ctx.symbol, handleClose, setSearchTextDebounced, triggerSearchChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (ctx.isVisible) return
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setSearchText('')
|
||||
}, 200)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ctx.isVisible])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -688,7 +545,19 @@ 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 = !ctx.manageListExternally && hasSearchText && visibleNonPinnedCount === 0
|
||||
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 estimateSize = useCallback(() => ITEM_HEIGHT, [])
|
||||
|
||||
@@ -747,9 +616,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
return prev ? prev : true
|
||||
})
|
||||
}>
|
||||
{collapsed ? (
|
||||
<QuickPanelEmpty>{t('settings.quickPanel.noResult', 'No results')}</QuickPanelEmpty>
|
||||
) : (
|
||||
{!collapsed && (
|
||||
<DynamicVirtualList
|
||||
ref={listRef}
|
||||
list={list}
|
||||
@@ -859,13 +726,6 @@ 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%;
|
||||
|
||||
@@ -1840,26 +1840,5 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
|
||||
group: 'LongCat'
|
||||
}
|
||||
],
|
||||
huggingface: [],
|
||||
'ai-gateway': [],
|
||||
cerebras: [
|
||||
{
|
||||
id: 'gpt-oss-120b',
|
||||
name: 'GPT oss 120B',
|
||||
provider: 'cerebras',
|
||||
group: 'openai'
|
||||
},
|
||||
{
|
||||
id: 'zai-glm-4.6',
|
||||
name: 'GLM 4.6',
|
||||
provider: 'cerebras',
|
||||
group: 'zai'
|
||||
},
|
||||
{
|
||||
id: 'qwen-3-235b-a22b-instruct-2507',
|
||||
name: 'Qwen 3 235B A22B Instruct',
|
||||
provider: 'cerebras',
|
||||
group: 'qwen'
|
||||
}
|
||||
]
|
||||
huggingface: []
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import BaiduCloudProviderLogo from '@renderer/assets/images/providers/baidu-clou
|
||||
import BailianProviderLogo from '@renderer/assets/images/providers/bailian.png'
|
||||
import BurnCloudProviderLogo from '@renderer/assets/images/providers/burncloud.png'
|
||||
import CephalonProviderLogo from '@renderer/assets/images/providers/cephalon.jpeg'
|
||||
import CerebrasProviderLogo from '@renderer/assets/images/providers/cerebras.webp'
|
||||
import CherryInProviderLogo from '@renderer/assets/images/providers/cherryin.png'
|
||||
import DeepSeekProviderLogo from '@renderer/assets/images/providers/deepseek.png'
|
||||
import DmxapiProviderLogo from '@renderer/assets/images/providers/DMXAPI.png'
|
||||
@@ -52,7 +51,6 @@ import StepProviderLogo from '@renderer/assets/images/providers/step.png'
|
||||
import TencentCloudProviderLogo from '@renderer/assets/images/providers/tencent-cloud-ti.png'
|
||||
import TogetherProviderLogo from '@renderer/assets/images/providers/together.png'
|
||||
import TokenFluxProviderLogo from '@renderer/assets/images/providers/tokenflux.png'
|
||||
import AIGatewayProviderLogo from '@renderer/assets/images/providers/vercel.svg'
|
||||
import VertexAIProviderLogo from '@renderer/assets/images/providers/vertexai.svg'
|
||||
import BytedanceProviderLogo from '@renderer/assets/images/providers/volcengine.png'
|
||||
import VoyageAIProviderLogo from '@renderer/assets/images/providers/voyageai.png'
|
||||
@@ -472,7 +470,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
|
||||
name: 'MiniMax',
|
||||
type: 'openai',
|
||||
apiKey: '',
|
||||
apiHost: 'https://api.minimax.com/v1/',
|
||||
apiHost: 'https://api.minimax.chat/v1/',
|
||||
models: SYSTEM_MODELS.minimax,
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
@@ -677,26 +675,6 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
|
||||
models: [],
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
'ai-gateway': {
|
||||
id: 'ai-gateway',
|
||||
name: 'AI Gateway',
|
||||
type: 'ai-gateway',
|
||||
apiKey: '',
|
||||
apiHost: 'https://ai-gateway.vercel.sh/v1',
|
||||
models: [],
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
cerebras: {
|
||||
id: 'cerebras',
|
||||
name: 'Cerebras AI',
|
||||
type: 'openai',
|
||||
apiKey: '',
|
||||
apiHost: 'https://api.cerebras.ai/v1',
|
||||
models: SYSTEM_MODELS.cerebras,
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
}
|
||||
} as const
|
||||
|
||||
@@ -763,9 +741,7 @@ export const PROVIDER_LOGO_MAP: AtLeast<SystemProviderId, string> = {
|
||||
aionly: AiOnlyProviderLogo,
|
||||
longcat: LongCatProviderLogo,
|
||||
huggingface: HuggingfaceProviderLogo,
|
||||
sophnet: SophnetProviderLogo,
|
||||
'ai-gateway': AIGatewayProviderLogo,
|
||||
cerebras: CerebrasProviderLogo
|
||||
sophnet: SophnetProviderLogo
|
||||
} as const
|
||||
|
||||
export function getProviderLogo(providerId: string) {
|
||||
@@ -1072,7 +1048,7 @@ export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = {
|
||||
},
|
||||
minimax: {
|
||||
api: {
|
||||
url: 'https://api.minimax.com/v1/'
|
||||
url: 'https://api.minimax.chat/v1/'
|
||||
},
|
||||
websites: {
|
||||
official: 'https://platform.minimaxi.com/',
|
||||
@@ -1414,28 +1390,6 @@ export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = {
|
||||
docs: 'https://huggingface.co/docs',
|
||||
models: 'https://huggingface.co/models'
|
||||
}
|
||||
},
|
||||
'ai-gateway': {
|
||||
api: {
|
||||
url: 'https://ai-gateway.vercel.sh/v1/ai'
|
||||
},
|
||||
websites: {
|
||||
official: 'https://vercel.com/ai-gateway',
|
||||
apiKey: 'https://vercel.com/',
|
||||
docs: 'https://vercel.com/docs/ai-gateway',
|
||||
models: 'https://vercel.com/ai-gateway/models'
|
||||
}
|
||||
},
|
||||
cerebras: {
|
||||
api: {
|
||||
url: 'https://api.cerebras.ai/v1'
|
||||
},
|
||||
websites: {
|
||||
official: 'https://www.cerebras.ai',
|
||||
apiKey: 'https://cloud.cerebras.ai',
|
||||
docs: 'https://inference-docs.cerebras.ai/introduction',
|
||||
models: 'https://inference-docs.cerebras.ai/models/overview'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1498,7 +1452,7 @@ export const isSupportEnableThinkingProvider = (provider: Provider) => {
|
||||
)
|
||||
}
|
||||
|
||||
const NOT_SUPPORT_SERVICE_TIER_PROVIDERS = ['github', 'copilot', 'cerebras'] as const satisfies SystemProviderId[]
|
||||
const NOT_SUPPORT_SERVICE_TIER_PROVIDERS = ['github', 'copilot'] as const satisfies SystemProviderId[]
|
||||
|
||||
/**
|
||||
* 判断提供商是否支持 service_tier 设置。 Only for OpenAI API.
|
||||
@@ -1565,10 +1519,6 @@ export function isGeminiProvider(provider: Provider): boolean {
|
||||
return provider.type === 'gemini'
|
||||
}
|
||||
|
||||
export function isAIGatewayProvider(provider: Provider): boolean {
|
||||
return provider.type === 'ai-gateway'
|
||||
}
|
||||
|
||||
const NOT_SUPPORT_API_VERSION_PROVIDERS = ['github', 'copilot', 'perplexity'] as const satisfies SystemProviderId[]
|
||||
|
||||
export const isSupportAPIVersionProvider = (provider: Provider) => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { useAgent } from '@renderer/hooks/agents/useAgent'
|
||||
import { useSessions } from '@renderer/hooks/agents/useSessions'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
@@ -7,8 +6,6 @@ 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.
|
||||
*/
|
||||
@@ -40,9 +37,6 @@ export const useCreateDefaultSession = (agentId: string | null) => {
|
||||
}
|
||||
|
||||
return created
|
||||
} catch (error) {
|
||||
logger.error('Error creating default session:', error as Error)
|
||||
return null
|
||||
} finally {
|
||||
setCreatingSession(false)
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -86,9 +86,7 @@ const providerKeyMap = {
|
||||
aionly: 'provider.aionly',
|
||||
longcat: 'provider.longcat',
|
||||
huggingface: 'provider.huggingface',
|
||||
sophnet: 'provider.sophnet',
|
||||
'ai-gateway': 'provider.ai-gateway',
|
||||
cerebras: 'provider.cerebras'
|
||||
sophnet: 'provider.sophnet'
|
||||
} as const
|
||||
|
||||
/**
|
||||
|
||||
@@ -631,15 +631,6 @@
|
||||
"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?",
|
||||
@@ -663,7 +654,6 @@
|
||||
"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",
|
||||
@@ -671,10 +661,6 @@
|
||||
"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",
|
||||
@@ -1785,9 +1771,6 @@
|
||||
},
|
||||
"message": {
|
||||
"code_style": "Code style",
|
||||
"compact": {
|
||||
"title": "Conversation Compacted"
|
||||
},
|
||||
"delete": {
|
||||
"content": "Are you sure you want to delete this message?",
|
||||
"title": "Delete Message"
|
||||
@@ -2119,8 +2102,8 @@
|
||||
"select": "Select",
|
||||
"select_directory_failed": "Failed to select directory",
|
||||
"title": "Data Settings",
|
||||
"work_directory_description": "Work directory is where all note files are stored. Changing the work directory won't move existing files, please migrate files manually.",
|
||||
"work_directory_placeholder": "Select notes work directory"
|
||||
"work_directory_description": "Work directory is where all note files are stored. Supports relative paths like ~/Notes or ./Notes for multi-device sync. Changing the work directory won't move existing files, please migrate files manually.",
|
||||
"work_directory_placeholder": "Enter or select notes work directory (e.g., ~/Notes)"
|
||||
},
|
||||
"display": {
|
||||
"compress_content": "Content Compression",
|
||||
@@ -2484,7 +2467,6 @@
|
||||
},
|
||||
"provider": {
|
||||
"302ai": "302.AI",
|
||||
"ai-gateway": "AI Gateway",
|
||||
"aihubmix": "AiHubMix",
|
||||
"aionly": "AiOnly",
|
||||
"alayanew": "Alaya NeW",
|
||||
@@ -2495,7 +2477,6 @@
|
||||
"baidu-cloud": "Baidu Cloud",
|
||||
"burncloud": "BurnCloud",
|
||||
"cephalon": "Cephalon",
|
||||
"cerebras": "Cerebras AI",
|
||||
"cherryin": "CherryIN",
|
||||
"copilot": "GitHub Copilot",
|
||||
"dashscope": "Alibaba Cloud",
|
||||
@@ -4478,7 +4459,6 @@
|
||||
"confirm": "Confirm",
|
||||
"forward": "Forward",
|
||||
"multiple": "Multiple Select",
|
||||
"noResult": "No results found",
|
||||
"page": "Page",
|
||||
"select": "Select",
|
||||
"title": "Quick Menu"
|
||||
|
||||
@@ -631,15 +631,6 @@
|
||||
"view_full_content": "查看完整内容"
|
||||
},
|
||||
"input": {
|
||||
"activity_directory": {
|
||||
"description": "从活动目录中选择文件",
|
||||
"loading": "正在加载文件...",
|
||||
"no_file_found": {
|
||||
"description": "可访问目录中没有可用文件",
|
||||
"label": "未找到文件"
|
||||
},
|
||||
"title": "活动目录"
|
||||
},
|
||||
"auto_resize": "自动调整高度",
|
||||
"clear": {
|
||||
"content": "确定要清除当前会话所有消息吗?",
|
||||
@@ -663,7 +654,6 @@
|
||||
"new": {
|
||||
"context": "清除上下文 {{Command}}"
|
||||
},
|
||||
"new_session": "新会话 {{Command}}",
|
||||
"new_topic": "新话题 {{Command}}",
|
||||
"paste_text_file_confirm": "粘贴到输入框?",
|
||||
"pause": "暂停",
|
||||
@@ -671,10 +661,6 @@
|
||||
"placeholder_without_triggers": "在这里输入消息,按 {{key}} 发送",
|
||||
"send": "发送",
|
||||
"settings": "设置",
|
||||
"slash_commands": {
|
||||
"description": "代理会话斜杠命令",
|
||||
"title": "斜杠命令"
|
||||
},
|
||||
"thinking": {
|
||||
"budget_exceeds_max": "思考预算超过最大 Token 数",
|
||||
"label": "思考",
|
||||
@@ -904,7 +890,7 @@
|
||||
"show_line_numbers": "代码显示行号",
|
||||
"temperature": {
|
||||
"label": "模型温度",
|
||||
"tip": "模型生成文本的随机程度。值越大,回复内容越富有多样性、创造性、随机性;设为 0 根据事实回答。日常聊天建议设置为 0.7"
|
||||
"tip": "模型生成文本的随机程度。值越大,回复内容越赋有多样性、创造性、随机性;设为 0 根据事实回答。日常聊天建议设置为 0.7"
|
||||
},
|
||||
"thought_auto_collapse": {
|
||||
"label": "思考内容自动折叠",
|
||||
@@ -1785,9 +1771,6 @@
|
||||
},
|
||||
"message": {
|
||||
"code_style": "代码风格",
|
||||
"compact": {
|
||||
"title": "对话已压缩"
|
||||
},
|
||||
"delete": {
|
||||
"content": "确定要删除此消息吗?",
|
||||
"title": "删除消息"
|
||||
@@ -2119,8 +2102,8 @@
|
||||
"select": "选择",
|
||||
"select_directory_failed": "选择目录失败",
|
||||
"title": "数据设置",
|
||||
"work_directory_description": "工作目录是存储所有笔记文件的位置。更改工作目录不会移动现有文件,请手动迁移文件。",
|
||||
"work_directory_placeholder": "选择笔记工作目录"
|
||||
"work_directory_description": "工作目录是存储所有笔记文件的位置。支持相对路径如 ~/笔记 或 ./笔记 以实现多设备同步。更改工作目录不会移动现有文件,请手动迁移文件。",
|
||||
"work_directory_placeholder": "输入或选择笔记工作目录(例如:~/笔记)"
|
||||
},
|
||||
"display": {
|
||||
"compress_content": "缩减栏宽",
|
||||
@@ -2484,7 +2467,6 @@
|
||||
},
|
||||
"provider": {
|
||||
"302ai": "302.AI",
|
||||
"ai-gateway": "AI Gateway",
|
||||
"aihubmix": "AiHubMix",
|
||||
"aionly": "唯一AI (AiOnly)",
|
||||
"alayanew": "Alaya NeW",
|
||||
@@ -2495,7 +2477,6 @@
|
||||
"baidu-cloud": "百度云千帆",
|
||||
"burncloud": "BurnCloud",
|
||||
"cephalon": "Cephalon",
|
||||
"cerebras": "Cerebras AI",
|
||||
"cherryin": "CherryIN",
|
||||
"copilot": "GitHub Copilot",
|
||||
"dashscope": "阿里云百炼",
|
||||
@@ -4478,7 +4459,6 @@
|
||||
"confirm": "确认",
|
||||
"forward": "前进",
|
||||
"multiple": "多选",
|
||||
"noResult": "[to be translated]:No results found",
|
||||
"page": "翻页",
|
||||
"select": "选择",
|
||||
"title": "快捷菜单"
|
||||
|
||||
@@ -631,15 +631,6 @@
|
||||
"view_full_content": "查看完整內容"
|
||||
},
|
||||
"input": {
|
||||
"activity_directory": {
|
||||
"description": "從活動目錄中選擇檔案",
|
||||
"loading": "載入檔案中...",
|
||||
"no_file_found": {
|
||||
"description": "可存取的目錄中沒有檔案",
|
||||
"label": "找不到檔案"
|
||||
},
|
||||
"title": "活動目錄"
|
||||
},
|
||||
"auto_resize": "自動調整高度",
|
||||
"clear": {
|
||||
"content": "您想要清除目前話題的所有訊息嗎?",
|
||||
@@ -663,7 +654,6 @@
|
||||
"new": {
|
||||
"context": "清除上下文 {{Command}}"
|
||||
},
|
||||
"new_session": "新工作階段 {{Command}}",
|
||||
"new_topic": "新話題 {{Command}}",
|
||||
"paste_text_file_confirm": "貼到輸入框?",
|
||||
"pause": "暫停",
|
||||
@@ -671,10 +661,6 @@
|
||||
"placeholder_without_triggers": "在此輸入您的訊息,按 {{key}} 傳送",
|
||||
"send": "傳送",
|
||||
"settings": "設定",
|
||||
"slash_commands": {
|
||||
"description": "代理會話斜線命令",
|
||||
"title": "斜線指令"
|
||||
},
|
||||
"thinking": {
|
||||
"budget_exceeds_max": "思考預算超過最大 Token 數",
|
||||
"label": "思考",
|
||||
@@ -1785,9 +1771,6 @@
|
||||
},
|
||||
"message": {
|
||||
"code_style": "程式碼風格",
|
||||
"compact": {
|
||||
"title": "對話已壓縮"
|
||||
},
|
||||
"delete": {
|
||||
"content": "確定要刪除此訊息嗎?",
|
||||
"title": "刪除訊息"
|
||||
@@ -2119,8 +2102,8 @@
|
||||
"select": "選擇",
|
||||
"select_directory_failed": "選擇目錄失敗",
|
||||
"title": "數據設置",
|
||||
"work_directory_description": "工作目錄是存儲所有筆記文件的位置。\n更改工作目錄不會移動現有文件,請手動遷移文件。",
|
||||
"work_directory_placeholder": "選擇筆記工作目錄"
|
||||
"work_directory_description": "工作目錄是存儲所有筆記文件的位置。支持相對路徑如 ~/筆記 或 ./筆記 以實現多設備同步。\n更改工作目錄不會移動現有文件,請手動遷移文件。",
|
||||
"work_directory_placeholder": "輸入或選擇筆記工作目錄(例如:~/筆記)"
|
||||
},
|
||||
"display": {
|
||||
"compress_content": "縮減欄寬",
|
||||
@@ -2484,7 +2467,6 @@
|
||||
},
|
||||
"provider": {
|
||||
"302ai": "302.AI",
|
||||
"ai-gateway": "AI 閘道器",
|
||||
"aihubmix": "AiHubMix",
|
||||
"aionly": "唯一AI (AiOnly)",
|
||||
"alayanew": "Alaya NeW",
|
||||
@@ -2495,7 +2477,6 @@
|
||||
"baidu-cloud": "百度雲千帆",
|
||||
"burncloud": "BurnCloud",
|
||||
"cephalon": "Cephalon",
|
||||
"cerebras": "Cerebras AI",
|
||||
"cherryin": "CherryIN",
|
||||
"copilot": "GitHub Copilot",
|
||||
"dashscope": "阿里雲百鍊",
|
||||
@@ -4478,7 +4459,6 @@
|
||||
"confirm": "確認",
|
||||
"forward": "前進",
|
||||
"multiple": "多選",
|
||||
"noResult": "[to be translated]:No results found",
|
||||
"page": "翻頁",
|
||||
"select": "選擇",
|
||||
"title": "快捷選單"
|
||||
|
||||
@@ -631,15 +631,6 @@
|
||||
"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?",
|
||||
@@ -663,7 +654,6 @@
|
||||
"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",
|
||||
@@ -671,10 +661,6 @@
|
||||
"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",
|
||||
@@ -1785,9 +1771,6 @@
|
||||
},
|
||||
"message": {
|
||||
"code_style": "Code-Stil",
|
||||
"compact": {
|
||||
"title": "Gespräch komprimiert"
|
||||
},
|
||||
"delete": {
|
||||
"content": "Möchten Sie diese Nachricht wirklich löschen?",
|
||||
"title": "Nachricht löschen"
|
||||
@@ -2484,7 +2467,6 @@
|
||||
},
|
||||
"provider": {
|
||||
"302ai": "302.AI",
|
||||
"ai-gateway": "KI-Gateway",
|
||||
"aihubmix": "AiHubMix",
|
||||
"aionly": "Einzige KI (AiOnly)",
|
||||
"alayanew": "Alaya NeW",
|
||||
@@ -2495,7 +2477,6 @@
|
||||
"baidu-cloud": "Baidu Cloud Qianfan",
|
||||
"burncloud": "BurnCloud",
|
||||
"cephalon": "Cephalon",
|
||||
"cerebras": "Cerebras AI",
|
||||
"cherryin": "CherryIN",
|
||||
"copilot": "GitHub Copilot",
|
||||
"dashscope": "Alibaba Cloud Bailian",
|
||||
@@ -4478,7 +4459,6 @@
|
||||
"confirm": "Bestätigen",
|
||||
"forward": "Vorwärts",
|
||||
"multiple": "Mehrfachauswahl",
|
||||
"noResult": "[to be translated]:No results found",
|
||||
"page": "Seite umblättern",
|
||||
"select": "Auswählen",
|
||||
"title": "Schnellmenü"
|
||||
|
||||
@@ -631,15 +631,6 @@
|
||||
"view_full_content": "Προβολή πλήρους περιεχομένου"
|
||||
},
|
||||
"input": {
|
||||
"activity_directory": {
|
||||
"description": "Επιλέξτε αρχείο από τον κατάλογο δραστηριότητας",
|
||||
"loading": "Φόρτωση Αρχείων...",
|
||||
"no_file_found": {
|
||||
"description": "Δεν υπάρχουν διαθέσιμα αρχεία σε προσβάσιμους καταλόγους",
|
||||
"label": "Δεν Βρέθηκε Αρχείο"
|
||||
},
|
||||
"title": "Κατάλογος Δραστηριοτήτων"
|
||||
},
|
||||
"auto_resize": "Αυτόματη μείωση ύψους",
|
||||
"clear": {
|
||||
"content": "Είσαι σίγουρος ότι θέλεις να διαγράψεις όλα τα μηνύματα της τρέχουσας συζήτησης;",
|
||||
@@ -663,7 +654,6 @@
|
||||
"new": {
|
||||
"context": "Καθαρισμός ενδιάμεσων {{Command}}"
|
||||
},
|
||||
"new_session": "Νέα Συνεδρία {{Command}}",
|
||||
"new_topic": "Νέο θέμα {{Command}}",
|
||||
"paste_text_file_confirm": "Επικόλληση στο πεδίο εισαγωγής;",
|
||||
"pause": "Παύση",
|
||||
@@ -671,10 +661,6 @@
|
||||
"placeholder_without_triggers": "Γράψτε το μήνυμά σας εδώ, πατήστε {{key}} για αποστολή",
|
||||
"send": "Αποστολή",
|
||||
"settings": "Ρυθμίσεις",
|
||||
"slash_commands": {
|
||||
"description": "Εντολές κάθετης γραμμής για συνεδρία πράκτορα",
|
||||
"title": "Εντολές Κάθετης Γραμμής"
|
||||
},
|
||||
"thinking": {
|
||||
"budget_exceeds_max": "Ο προϋπολογισμός σκέψης υπερβαίνει τον μέγιστο αριθμό token",
|
||||
"label": "Σκέψη",
|
||||
@@ -1785,9 +1771,6 @@
|
||||
},
|
||||
"message": {
|
||||
"code_style": "Στυλ κώδικα",
|
||||
"compact": {
|
||||
"title": "Συνομιλία Συμπυκνωμένη"
|
||||
},
|
||||
"delete": {
|
||||
"content": "Θέλετε να διαγράψετε αυτό το μήνυμα;",
|
||||
"title": "Διαγραφή μηνύματος"
|
||||
@@ -2484,7 +2467,6 @@
|
||||
},
|
||||
"provider": {
|
||||
"302ai": "302.AI",
|
||||
"ai-gateway": "Πύλη Τεχνητής Νοημοσύνης",
|
||||
"aihubmix": "AiHubMix",
|
||||
"aionly": "AiOnly",
|
||||
"alayanew": "Alaya NeW",
|
||||
@@ -2495,7 +2477,6 @@
|
||||
"baidu-cloud": "Baidu Cloud Qianfan",
|
||||
"burncloud": "BurnCloud",
|
||||
"cephalon": "Cephalon",
|
||||
"cerebras": "Cerebras AI",
|
||||
"cherryin": "CherryIN",
|
||||
"copilot": "GitHub Copilot",
|
||||
"dashscope": "AliCloud Bailian",
|
||||
@@ -4478,7 +4459,6 @@
|
||||
"confirm": "Επιβεβαίωση",
|
||||
"forward": "Μπρος",
|
||||
"multiple": "Πολλαπλή επιλογή",
|
||||
"noResult": "[to be translated]:No results found",
|
||||
"page": "Σελίδα",
|
||||
"select": "Επιλογή",
|
||||
"title": "Γρήγορη Πρόσβαση"
|
||||
|
||||
@@ -631,15 +631,6 @@
|
||||
"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?",
|
||||
@@ -663,7 +654,6 @@
|
||||
"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",
|
||||
@@ -671,10 +661,6 @@
|
||||
"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",
|
||||
@@ -1785,9 +1771,6 @@
|
||||
},
|
||||
"message": {
|
||||
"code_style": "Estilo de código",
|
||||
"compact": {
|
||||
"title": "Conversación Compactada"
|
||||
},
|
||||
"delete": {
|
||||
"content": "¿Está seguro de querer eliminar este mensaje?",
|
||||
"title": "Eliminar mensaje"
|
||||
@@ -2484,7 +2467,6 @@
|
||||
},
|
||||
"provider": {
|
||||
"302ai": "302.AI",
|
||||
"ai-gateway": "Puerta de enlace de IA",
|
||||
"aihubmix": "AiHubMix",
|
||||
"aionly": "AiOnly",
|
||||
"alayanew": "Alaya NeW",
|
||||
@@ -2495,7 +2477,6 @@
|
||||
"baidu-cloud": "Baidu Nube Qiánfān",
|
||||
"burncloud": "BurnCloud",
|
||||
"cephalon": "Cephalon",
|
||||
"cerebras": "Cerebras AI",
|
||||
"cherryin": "CherryIN",
|
||||
"copilot": "GitHub Copiloto",
|
||||
"dashscope": "Álibaba Nube BaiLiàn",
|
||||
@@ -4478,7 +4459,6 @@
|
||||
"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"
|
||||
|
||||
@@ -631,15 +631,6 @@
|
||||
"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 ?",
|
||||
@@ -663,7 +654,6 @@
|
||||
"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",
|
||||
@@ -671,10 +661,6 @@
|
||||
"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",
|
||||
@@ -1785,9 +1771,6 @@
|
||||
},
|
||||
"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"
|
||||
@@ -2484,7 +2467,6 @@
|
||||
},
|
||||
"provider": {
|
||||
"302ai": "302.AI",
|
||||
"ai-gateway": "Passerelle IA",
|
||||
"aihubmix": "AiHubMix",
|
||||
"aionly": "AiOnly",
|
||||
"alayanew": "Alaya NeW",
|
||||
@@ -2495,7 +2477,6 @@
|
||||
"baidu-cloud": "Baidu Cloud Qianfan",
|
||||
"burncloud": "BurnCloud",
|
||||
"cephalon": "Cephalon",
|
||||
"cerebras": "Cerebras AI",
|
||||
"cherryin": "CherryIN",
|
||||
"copilot": "GitHub Copilote",
|
||||
"dashscope": "AliCloud BaiLian",
|
||||
@@ -4478,7 +4459,6 @@
|
||||
"confirm": "Подтвердить",
|
||||
"forward": "Вперед",
|
||||
"multiple": "Множественный выбор",
|
||||
"noResult": "[to be translated]:No results found",
|
||||
"page": "Перелистнуть страницу",
|
||||
"select": "Выбрать",
|
||||
"title": "Быстрое меню"
|
||||
|
||||
@@ -631,15 +631,6 @@
|
||||
"view_full_content": "完全な内容を表示"
|
||||
},
|
||||
"input": {
|
||||
"activity_directory": {
|
||||
"description": "アクティビティディレクトリからファイルを選択",
|
||||
"loading": "ファイルを読み込んでいます...",
|
||||
"no_file_found": {
|
||||
"description": "アクセス可能なディレクトリに利用可能なファイルがありません",
|
||||
"label": "ファイルが見つかりません"
|
||||
},
|
||||
"title": "アクティビティディレクトリ"
|
||||
},
|
||||
"auto_resize": "高さを自動調整",
|
||||
"clear": {
|
||||
"content": "現在のトピックのすべてのメッセージをクリアしますか?",
|
||||
@@ -663,7 +654,6 @@
|
||||
"new": {
|
||||
"context": "コンテキストをクリア {{Command}}"
|
||||
},
|
||||
"new_session": "新しいセッション {{Command}}",
|
||||
"new_topic": "新しいトピック {{Command}}",
|
||||
"paste_text_file_confirm": "入力欄に貼り付けますか?",
|
||||
"pause": "一時停止",
|
||||
@@ -671,10 +661,6 @@
|
||||
"placeholder_without_triggers": "ここにメッセージを入力し、{{key}} を押して送信...",
|
||||
"send": "送信",
|
||||
"settings": "設定",
|
||||
"slash_commands": {
|
||||
"description": "エージェントセッションスラッシュコマンド",
|
||||
"title": "スラッシュコマンド"
|
||||
},
|
||||
"thinking": {
|
||||
"budget_exceeds_max": "思考予算が最大トークン数を超えました",
|
||||
"label": "思考",
|
||||
@@ -1785,9 +1771,6 @@
|
||||
},
|
||||
"message": {
|
||||
"code_style": "コードスタイル",
|
||||
"compact": {
|
||||
"title": "会話圧縮"
|
||||
},
|
||||
"delete": {
|
||||
"content": "このメッセージを削除してもよろしいですか?",
|
||||
"title": "メッセージを削除"
|
||||
@@ -2484,7 +2467,6 @@
|
||||
},
|
||||
"provider": {
|
||||
"302ai": "302.AI",
|
||||
"ai-gateway": "AIゲートウェイ",
|
||||
"aihubmix": "AiHubMix",
|
||||
"aionly": "AiOnly",
|
||||
"alayanew": "Alaya NeW",
|
||||
@@ -2495,7 +2477,6 @@
|
||||
"baidu-cloud": "Baidu Cloud",
|
||||
"burncloud": "BurnCloud",
|
||||
"cephalon": "Cephalon",
|
||||
"cerebras": "Cerebras AI",
|
||||
"cherryin": "CherryIN",
|
||||
"copilot": "GitHub Copilot",
|
||||
"dashscope": "Alibaba Cloud",
|
||||
@@ -4478,7 +4459,6 @@
|
||||
"confirm": "確認",
|
||||
"forward": "進む",
|
||||
"multiple": "複数選択",
|
||||
"noResult": "[to be translated]:No results found",
|
||||
"page": "ページ",
|
||||
"select": "選択",
|
||||
"title": "クイックメニュー"
|
||||
|
||||
@@ -631,15 +631,6 @@
|
||||
"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?",
|
||||
@@ -663,7 +654,6 @@
|
||||
"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",
|
||||
@@ -671,10 +661,6 @@
|
||||
"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",
|
||||
@@ -1785,9 +1771,6 @@
|
||||
},
|
||||
"message": {
|
||||
"code_style": "Estilo de código",
|
||||
"compact": {
|
||||
"title": "Conversa Compactada"
|
||||
},
|
||||
"delete": {
|
||||
"content": "Tem certeza de que deseja excluir esta mensagem?",
|
||||
"title": "Excluir mensagem"
|
||||
@@ -2484,7 +2467,6 @@
|
||||
},
|
||||
"provider": {
|
||||
"302ai": "302.AI",
|
||||
"ai-gateway": "Gateway de IA",
|
||||
"aihubmix": "AiHubMix",
|
||||
"aionly": "AiOnly",
|
||||
"alayanew": "Alaya NeW",
|
||||
@@ -2495,7 +2477,6 @@
|
||||
"baidu-cloud": "Nuvem Baidu",
|
||||
"burncloud": "BurnCloud",
|
||||
"cephalon": "Cephalon",
|
||||
"cerebras": "Cerebras AI",
|
||||
"cherryin": "CherryIN",
|
||||
"copilot": "GitHub Copiloto",
|
||||
"dashscope": "Área de Atuação AliCloud",
|
||||
@@ -4478,7 +4459,6 @@
|
||||
"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"
|
||||
|
||||
@@ -631,15 +631,6 @@
|
||||
"view_full_content": "Показать полное содержимое"
|
||||
},
|
||||
"input": {
|
||||
"activity_directory": {
|
||||
"description": "Выбрать файл из каталога активности",
|
||||
"loading": "Загрузка файлов...",
|
||||
"no_file_found": {
|
||||
"description": "Нет доступных файлов в доступных каталогах",
|
||||
"label": "Файл не найден"
|
||||
},
|
||||
"title": "Каталог активностей"
|
||||
},
|
||||
"auto_resize": "Автоматическая высота",
|
||||
"clear": {
|
||||
"content": "Хотите очистить все сообщения текущего топика?",
|
||||
@@ -663,7 +654,6 @@
|
||||
"new": {
|
||||
"context": "Очистить контекст {{Command}}"
|
||||
},
|
||||
"new_session": "Новая сессия {{Команда}}",
|
||||
"new_topic": "Новый топик {{Command}}",
|
||||
"paste_text_file_confirm": "Вставить в поле ввода?",
|
||||
"pause": "Остановить",
|
||||
@@ -671,10 +661,6 @@
|
||||
"placeholder_without_triggers": "Напишите сообщение здесь, нажмите {{key}} для отправки",
|
||||
"send": "Отправить",
|
||||
"settings": "Настройки",
|
||||
"slash_commands": {
|
||||
"description": "Слэш-команды сеанса агента",
|
||||
"title": "Слэш-команды"
|
||||
},
|
||||
"thinking": {
|
||||
"budget_exceeds_max": "Бюджет размышления превышает максимальное количество токенов",
|
||||
"label": "Мыслим",
|
||||
@@ -1785,9 +1771,6 @@
|
||||
},
|
||||
"message": {
|
||||
"code_style": "Стиль кода",
|
||||
"compact": {
|
||||
"title": "Сжатый разговор"
|
||||
},
|
||||
"delete": {
|
||||
"content": "Вы уверены, что хотите удалить это сообщение?",
|
||||
"title": "Удалить сообщение"
|
||||
@@ -2484,7 +2467,6 @@
|
||||
},
|
||||
"provider": {
|
||||
"302ai": "302.AI",
|
||||
"ai-gateway": "AI-шлюз",
|
||||
"aihubmix": "AiHubMix",
|
||||
"aionly": "AiOnly",
|
||||
"alayanew": "Alaya NeW",
|
||||
@@ -2495,7 +2477,6 @@
|
||||
"baidu-cloud": "Baidu Cloud",
|
||||
"burncloud": "BurnCloud",
|
||||
"cephalon": "Cephalon",
|
||||
"cerebras": "Cerebras AI",
|
||||
"cherryin": "CherryIN",
|
||||
"copilot": "GitHub Copilot",
|
||||
"dashscope": "Alibaba Cloud",
|
||||
@@ -4478,7 +4459,6 @@
|
||||
"confirm": "Подтвердить",
|
||||
"forward": "Вперед",
|
||||
"multiple": "Множественный выбор",
|
||||
"noResult": "[to be translated]:No results found",
|
||||
"page": "Страница",
|
||||
"select": "Выбрать",
|
||||
"title": "Быстрое меню"
|
||||
|
||||
@@ -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, useState } from 'react'
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
@@ -161,6 +161,29 @@ 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' }} />
|
||||
@@ -227,12 +250,8 @@ const Chat: FC<Props> = (props) => {
|
||||
{activeTopicOrSession === 'session' && activeAgentId && !activeSessionId && <SessionInvalid />}
|
||||
{activeTopicOrSession === 'session' && activeAgentId && activeSessionId && (
|
||||
<>
|
||||
{!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} />
|
||||
<SessionMessages />
|
||||
<SessionInputBar />
|
||||
</>
|
||||
)}
|
||||
{isMultiSelectMode && <MultiSelectActionPopup topic={props.activeTopic} />}
|
||||
|
||||
@@ -1,201 +1,63 @@
|
||||
import { loggerService } from '@logger'
|
||||
import type { QuickPanelTriggerInfo } from '@renderer/components/QuickPanel'
|
||||
import { QuickPanelReservedSymbol, useQuickPanel } from '@renderer/components/QuickPanel'
|
||||
import { isGenerateImageModel, isVisionModel } from '@renderer/config/models'
|
||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||
import { QuickPanelView } from '@renderer/components/QuickPanel'
|
||||
import { useCreateDefaultSession } from '@renderer/hooks/agents/useCreateDefaultSession'
|
||||
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 { useTextareaResize } from '@renderer/hooks/useTextareaResize'
|
||||
import { useShortcutDisplay } from '@renderer/hooks/useShortcuts'
|
||||
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 } from '@renderer/utils/input'
|
||||
import { getSendMessageShortcutLabel, isSendMessageKeyPressed } from '@renderer/utils/input'
|
||||
import { createMainTextBlock, createMessage } from '@renderer/utils/messageUtils/create'
|
||||
import { documentExts, imageExts, textExts } from '@shared/config/constant'
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
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 { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
|
||||
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'
|
||||
import NarrowLayout from '../Messages/NarrowLayout'
|
||||
import SendMessageButton from './SendMessageButton'
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
const logger = loggerService.withContext('Inputbar')
|
||||
|
||||
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)
|
||||
// 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 { apiServer } = useSettings()
|
||||
const { createDefaultSession, creatingSession } = useCreateDefaultSession(agentId)
|
||||
const newTopicShortcut = useShortcutDisplay('new_topic')
|
||||
|
||||
const { sendMessageShortcut, fontSize, enableSpellCheck } = useSettings()
|
||||
const textareaRef = useRef<TextAreaRef>(null)
|
||||
const { t } = useTranslation()
|
||||
const quickPanel = useQuickPanel()
|
||||
|
||||
const { files } = useInputbarToolsState()
|
||||
const { toolsRegistry, setIsExpanded } = useInputbarToolsDispatch()
|
||||
const { setCouldAddImageFile } = useInputbarToolsInternalDispatch()
|
||||
const containerRef = useRef(null)
|
||||
|
||||
const { setTimeoutTimer } = useTimer()
|
||||
const dispatch = useAppDispatch()
|
||||
@@ -203,152 +65,12 @@ const AgentSessionInputbarInner: FC<InnerProps> = ({ assistant, agentId, session
|
||||
const topicMessages = useAppSelector((state) => selectMessagesForTopic(state, sessionTopicId))
|
||||
const loading = useAppSelector((state) => selectNewTopicLoading(state, sessionTopicId))
|
||||
|
||||
// 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 focusTextarea = useCallback(() => {
|
||||
textareaRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
// 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 inputEmpty = isEmpty(text)
|
||||
const sendDisabled = inputEmpty || !apiServer.enabled
|
||||
|
||||
const streamingAskIds = useMemo(() => {
|
||||
if (!topicMessages) {
|
||||
@@ -371,6 +93,64 @@ const AgentSessionInputbarInner: FC<InnerProps> = ({ assistant, agentId, session
|
||||
}, [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) {
|
||||
@@ -400,43 +180,79 @@ const AgentSessionInputbarInner: FC<InnerProps> = ({ assistant, agentId, session
|
||||
|
||||
try {
|
||||
const userMessageId = uuid()
|
||||
|
||||
// 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, {
|
||||
const mainBlock = createMainTextBlock(userMessageId, text, {
|
||||
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: assistant.model,
|
||||
modelId: assistant.model?.id,
|
||||
model,
|
||||
modelId: 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, assistant, sessionTopicId, {
|
||||
dispatchSendMessage(userMessage, userMessageBlocks, assistantStub, sessionTopicId, {
|
||||
agentId,
|
||||
sessionId
|
||||
})
|
||||
)
|
||||
|
||||
setText('')
|
||||
setTimeoutTimer('agentSession_sendMessage', () => setText(''), 500)
|
||||
setTimeoutTimer('sendMessage_1', () => setText(''), 500)
|
||||
} catch (error) {
|
||||
logger.warn('Failed to send message:', error as Error)
|
||||
}
|
||||
}, [sendDisabled, agentId, dispatch, assistant, sessionId, sessionTopicId, setText, setTimeoutTimer, text, files])
|
||||
}, [
|
||||
session?.model,
|
||||
agentId,
|
||||
dispatch,
|
||||
sendDisabled,
|
||||
session?.agent_id,
|
||||
session?.instructions,
|
||||
session?.name,
|
||||
sessionId,
|
||||
sessionTopicId,
|
||||
setTimeoutTimer,
|
||||
text
|
||||
])
|
||||
|
||||
const onChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newText = e.target.value
|
||||
setText(newText)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!document.querySelector('.topview-fullscreen-container')) {
|
||||
@@ -444,57 +260,137 @@ const AgentSessionInputbarInner: FC<InnerProps> = ({ assistant, agentId, session
|
||||
}
|
||||
}, [focusTextarea])
|
||||
|
||||
const supportedExts = useMemo(() => {
|
||||
if (canAddImageFile && canAddTextFile) {
|
||||
return [...imageExts, ...documentExts, ...textExts]
|
||||
useEffect(() => {
|
||||
const onFocus = () => {
|
||||
if (document.activeElement?.closest('.ant-modal')) {
|
||||
return
|
||||
}
|
||||
|
||||
const lastFocusedComponent = PasteService.getLastFocusedComponent()
|
||||
|
||||
if (!lastFocusedComponent || lastFocusedComponent === 'inputbar') {
|
||||
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]
|
||||
)
|
||||
window.addEventListener('focus', onFocus)
|
||||
return () => window.removeEventListener('focus', onFocus)
|
||||
}, [focusTextarea])
|
||||
|
||||
return (
|
||||
<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
|
||||
/>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -502,4 +398,26 @@ 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
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
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, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useImperativeHandle, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export interface AttachmentButtonRef {
|
||||
openQuickPanel: () => void
|
||||
openFileSelectDialog: () => void
|
||||
}
|
||||
|
||||
interface Props {
|
||||
quickPanel: ToolQuickPanelApi
|
||||
ref?: React.RefObject<AttachmentButtonRef | null>
|
||||
couldAddImageFile: boolean
|
||||
extensions: string[]
|
||||
files: FileType[]
|
||||
@@ -20,9 +24,9 @@ interface Props {
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const AttachmentButton: FC<Props> = ({ quickPanel, couldAddImageFile, extensions, files, setFiles, disabled }) => {
|
||||
const AttachmentButton: FC<Props> = ({ ref, couldAddImageFile, extensions, files, setFiles, disabled }) => {
|
||||
const { t } = useTranslation()
|
||||
const quickPanelHook = useQuickPanel()
|
||||
const quickPanel = useQuickPanel()
|
||||
const { bases: knowledgeBases } = useKnowledgeBases()
|
||||
const [selecting, setSelecting] = useState<boolean>(false)
|
||||
|
||||
@@ -67,7 +71,7 @@ const AttachmentButton: FC<Props> = ({ quickPanel, couldAddImageFile, extensions
|
||||
|
||||
const openKnowledgeFileList = useCallback(
|
||||
(base: KnowledgeBase) => {
|
||||
quickPanelHook.open({
|
||||
quickPanel.open({
|
||||
title: base.name,
|
||||
list: base.items
|
||||
.filter((file): file is KnowledgeItem => ['file'].includes(file.type))
|
||||
@@ -98,7 +102,7 @@ const AttachmentButton: FC<Props> = ({ quickPanel, couldAddImageFile, extensions
|
||||
multiple: true
|
||||
})
|
||||
},
|
||||
[files, quickPanelHook, setFiles]
|
||||
[files, quickPanel, setFiles]
|
||||
)
|
||||
|
||||
const items = useMemo(() => {
|
||||
@@ -126,31 +130,17 @@ const AttachmentButton: FC<Props> = ({ quickPanel, couldAddImageFile, extensions
|
||||
}, [knowledgeBases, openFileSelectDialog, openKnowledgeFileList, t])
|
||||
|
||||
const openQuickPanel = useCallback(() => {
|
||||
quickPanelHook.open({
|
||||
quickPanel.open({
|
||||
title: t('chat.input.upload.attachment'),
|
||||
list: items,
|
||||
symbol: QuickPanelReservedSymbol.File
|
||||
})
|
||||
}, [items, quickPanelHook, t])
|
||||
}, [items, quickPanel, t])
|
||||
|
||||
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])
|
||||
useImperativeHandle(ref, () => ({
|
||||
openQuickPanel,
|
||||
openFileSelectDialog
|
||||
}))
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,241 +1,205 @@
|
||||
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 type { QuickPanelListItem, QuickPanelReservedSymbol } from '@renderer/components/QuickPanel'
|
||||
import { useQuickPanel } from '@renderer/components/QuickPanel'
|
||||
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 { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
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 { useShortcutDisplay } from '@renderer/hooks/useShortcuts'
|
||||
import { useSidebarIconShow } from '@renderer/hooks/useSidebarIcon'
|
||||
import { getProviderByModel } from '@renderer/services/AssistantService'
|
||||
import { getModelUniqId } from '@renderer/services/ModelService'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { selectToolOrderForScope, setIsCollapsed, setToolOrder } from '@renderer/store/inputTools'
|
||||
import { setIsCollapsed, setToolOrder } from '@renderer/store/inputTools'
|
||||
import type { FileType, KnowledgeBase, Model } from '@renderer/types'
|
||||
import { FileTypes } from '@renderer/types'
|
||||
import type { InputBarToolType } from '@renderer/types/chat'
|
||||
import { classNames } from '@renderer/utils'
|
||||
import { Divider, Dropdown } from 'antd'
|
||||
import { isPromptToolUse, isSupportedToolUse } from '@renderer/utils/mcp-tools'
|
||||
import { Divider, Dropdown, Tooltip } from 'antd'
|
||||
import type { ItemType } from 'antd/es/menu/interface'
|
||||
import { Check, CircleChevronRight } from 'lucide-react'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
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 { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
export interface InputbarToolsNewProps {
|
||||
scope: InputbarScope
|
||||
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 {
|
||||
assistantId: string
|
||||
// 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 }>
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
interface ToolConfig {
|
||||
interface ToolButtonConfig {
|
||||
key: InputBarToolType
|
||||
label: string
|
||||
tool: ToolDefinition
|
||||
visible: boolean
|
||||
component: ReactNode
|
||||
condition?: boolean
|
||||
visible?: boolean
|
||||
label?: string
|
||||
icon?: ReactNode
|
||||
}
|
||||
|
||||
const DraggablePortal = ({ children, isDragging }: { children: React.ReactNode; isDragging: boolean }) => {
|
||||
const DraggablePortal = ({ children, isDragging }) => {
|
||||
return isDragging ? createPortal(children, document.body) : children
|
||||
}
|
||||
|
||||
const InputbarTools = ({ scope, assistantId, session }: InputbarToolsNewProps) => {
|
||||
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 { t } = useTranslation()
|
||||
const dispatch = useAppDispatch()
|
||||
const { assistant, model } = useAssistant(assistantId)
|
||||
const toolsContext = useInputbarTools()
|
||||
const quickPanelContext = useQuickPanel()
|
||||
const quickPanelApiCacheRef = useRef(new Map<string, ToolQuickPanelApi>())
|
||||
const { assistant, updateAssistant } = useAssistant(assistantId)
|
||||
|
||||
const getQuickPanelApiForTool = useCallback(
|
||||
(toolKey: string): ToolQuickPanelApi => {
|
||||
const cache = quickPanelApiCacheRef.current
|
||||
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)
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
return cache.get(toolKey)!
|
||||
},
|
||||
[toolsContext.toolsRegistry]
|
||||
)
|
||||
|
||||
const reduxToolOrder = useAppSelector((state) => selectToolOrderForScope(state, scope))
|
||||
const toolOrder = useAppSelector((state) => state.inputTools.toolOrder)
|
||||
const isCollapse = useAppSelector((state) => state.inputTools.isCollapsed)
|
||||
const [targetTool, setTargetTool] = useState<ToolConfig | null>(null)
|
||||
|
||||
// Get tools for current scope
|
||||
const availableTools = useMemo(() => {
|
||||
return getToolsForScope(scope, { assistant, model, session })
|
||||
}, [scope, assistant, model, session])
|
||||
const [targetTool, setTargetTool] = useState<ToolButtonConfig | null>(null)
|
||||
|
||||
// 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]
|
||||
const showThinkingButton = useMemo(
|
||||
() => isSupportedThinkingTokenModel(model) || isSupportedReasoningEffortModel(model),
|
||||
[model]
|
||||
)
|
||||
|
||||
// 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])
|
||||
const showMcpServerButton = useMemo(() => isSupportedToolUse(assistant) || isPromptToolUse(assistant), [assistant])
|
||||
|
||||
// Declarative tools registration (for tools with quickPanel config)
|
||||
// This handles pure menu contributors and trigger handlers
|
||||
useEffect(() => {
|
||||
const disposeCallbacks: Array<() => void> = []
|
||||
const knowledgeSidebarEnabled = useSidebarIconShow('knowledge')
|
||||
const showKnowledgeBaseButton = knowledgeSidebarEnabled && showMcpServerButton
|
||||
|
||||
for (const tool of availableTools) {
|
||||
if (!tool.quickPanel) continue
|
||||
const handleKnowledgeBaseSelect = useCallback(
|
||||
(bases?: KnowledgeBase[]) => {
|
||||
updateAssistant({ knowledge_bases: bases })
|
||||
setSelectedKnowledgeBases(bases ?? [])
|
||||
},
|
||||
[setSelectedKnowledgeBases, updateAssistant]
|
||||
)
|
||||
|
||||
const context = buildRenderContext(tool)
|
||||
// 仅允许在不含图片文件时mention非视觉模型
|
||||
const couldMentionNotVisionModel = useMemo(() => {
|
||||
return !files.some((file) => file.type === FileTypes.IMAGE)
|
||||
}, [files])
|
||||
|
||||
// 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)
|
||||
const onMentionModel = useCallback(
|
||||
(model: Model) => {
|
||||
// 我想应该没有模型是只支持视觉而不支持文本的?
|
||||
if (isVisionModel(model) || couldMentionNotVisionModel) {
|
||||
setMentionedModels((prev) => {
|
||||
const modelId = getModelUniqId(model)
|
||||
const exists = prev.some((m) => getModelUniqId(m) === modelId)
|
||||
return exists ? prev.filter((m) => getModelUniqId(m) !== modelId) : [...prev, model]
|
||||
})
|
||||
} else {
|
||||
logger.error('Cannot add non-vision model when images are uploaded')
|
||||
}
|
||||
},
|
||||
[couldMentionNotVisionModel, setMentionedModels]
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
const onClearMentionModels = useCallback(() => setMentionedModels([]), [setMentionedModels])
|
||||
|
||||
return () => {
|
||||
disposeCallbacks.forEach((dispose) => dispose())
|
||||
}
|
||||
}, [availableTools, buildRenderContext, toolsContext.toolsRegistry])
|
||||
const onEnableGenerateImage = useCallback(() => {
|
||||
updateAssistant({ enableGenerateImage: !assistant.enableGenerateImage })
|
||||
}, [assistant.enableGenerateImage, updateAssistant])
|
||||
|
||||
// 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 newTopicShortcut = useShortcutDisplay('new_topic')
|
||||
const clearTopicShortcut = useShortcutDisplay('clear_topic')
|
||||
|
||||
const toggleToolVisibility = useCallback(
|
||||
(toolKey: InputBarToolType, isVisible: boolean | undefined) => {
|
||||
const newToolOrder: ToolOrderConfig = {
|
||||
const newToolOrder = {
|
||||
visible: [...toolOrder.visible],
|
||||
hidden: [...toolOrder.hidden]
|
||||
}
|
||||
@@ -248,20 +212,129 @@ const InputbarTools = ({ scope, assistantId, session }: InputbarToolsNewProps) =
|
||||
newToolOrder.visible.push(toolKey)
|
||||
}
|
||||
|
||||
dispatch(setToolOrder({ scope, toolOrder: newToolOrder }))
|
||||
dispatch(setToolOrder(newToolOrder))
|
||||
setTargetTool(null)
|
||||
},
|
||||
[dispatch, scope, toolOrder]
|
||||
[dispatch, toolOrder.hidden, toolOrder.visible]
|
||||
)
|
||||
|
||||
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: ToolOrderConfig = {
|
||||
const newToolOrder = {
|
||||
visible: [...toolOrder.visible],
|
||||
hidden: [...toolOrder.hidden]
|
||||
}
|
||||
@@ -279,9 +352,216 @@ const InputbarTools = ({ scope, assistantId, session }: InputbarToolsNewProps) =
|
||||
newToolOrder[destArray].splice(destination.index, 0, removed)
|
||||
}
|
||||
|
||||
dispatch(setToolOrder({ scope, toolOrder: newToolOrder }))
|
||||
dispatch(setToolOrder(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,
|
||||
@@ -291,88 +571,87 @@ const InputbarTools = ({ scope, assistantId, session }: InputbarToolsNewProps) =
|
||||
{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((toolConfig, index) => {
|
||||
const context = buildRenderContext(toolConfig.tool)
|
||||
return (
|
||||
<Draggable key={toolConfig.key} draggableId={toolConfig.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(
|
||||
(tool, index) =>
|
||||
(tool.condition ?? true) && (
|
||||
<Draggable key={tool.key} draggableId={tool.key} index={index}>
|
||||
{(provided, snapshot) => (
|
||||
<DraggablePortal isDragging={snapshot.isDragging}>
|
||||
<ToolWrapper
|
||||
data-key={toolConfig.key}
|
||||
onContextMenu={() => setTargetTool(toolConfig)}
|
||||
data-key={tool.key}
|
||||
onContextMenu={() => setTargetTool(tool)}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
style={provided.draggableProps.style}>
|
||||
{toolConfig.tool.render?.(context)}
|
||||
style={{
|
||||
...provided.draggableProps.style
|
||||
}}>
|
||||
{tool.component}
|
||||
</ToolWrapper>
|
||||
</DraggablePortal>
|
||||
)}
|
||||
</Draggable>
|
||||
)
|
||||
})}
|
||||
{provided.placeholder}
|
||||
</VisibleTools>
|
||||
)}
|
||||
</Droppable>
|
||||
)}
|
||||
|
||||
{showDivider && <Divider type="vertical" style={{ margin: '0 4px' }} />}
|
||||
{provided.placeholder}
|
||||
</VisibleTools>
|
||||
)}
|
||||
</Droppable>
|
||||
|
||||
<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}>
|
||||
{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}>
|
||||
{(provided, snapshot) => (
|
||||
<DraggablePortal isDragging={snapshot.isDragging}>
|
||||
<ToolWrapper
|
||||
data-key={toolConfig.key}
|
||||
className={classNames({ 'is-collapsed': isCollapse })}
|
||||
onContextMenu={() => setTargetTool(toolConfig)}
|
||||
data-key={tool.key}
|
||||
className={classNames({
|
||||
'is-collapsed': isCollapse
|
||||
})}
|
||||
onContextMenu={() => setTargetTool(tool)}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
@@ -380,35 +659,39 @@ const InputbarTools = ({ scope, assistantId, session }: InputbarToolsNewProps) =
|
||||
...provided.draggableProps.style,
|
||||
transitionDelay: `${index * 0.02}s`
|
||||
}}>
|
||||
{toolConfig.tool.render?.(context)}
|
||||
{tool.component}
|
||||
</ToolWrapper>
|
||||
</DraggablePortal>
|
||||
)}
|
||||
</Draggable>
|
||||
)
|
||||
})}
|
||||
{provided.placeholder}
|
||||
</HiddenTools>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
)}
|
||||
{provided.placeholder}
|
||||
</HiddenTools>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
|
||||
{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)' }} />
|
||||
{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)'
|
||||
}}
|
||||
/>
|
||||
</ActionIconButton>
|
||||
)}
|
||||
</ToolsContainer>
|
||||
</Dropdown>
|
||||
{managerElements}
|
||||
</>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ToolsContainer>
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
|
||||
InputbarTools.displayName = 'InputbarTools'
|
||||
|
||||
const ToolsContainer = styled.div`
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
|
||||
@@ -1,27 +1,30 @@
|
||||
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, useMemo, useRef } from 'react'
|
||||
import { memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router'
|
||||
|
||||
export interface KnowledgeBaseButtonRef {
|
||||
openQuickPanel: () => void
|
||||
}
|
||||
|
||||
interface Props {
|
||||
quickPanel: ToolQuickPanelApi
|
||||
ref?: React.RefObject<KnowledgeBaseButtonRef | null>
|
||||
selectedBases?: KnowledgeBase[]
|
||||
onSelect: (bases: KnowledgeBase[]) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const KnowledgeBaseButton: FC<Props> = ({ quickPanel, selectedBases, onSelect, disabled }) => {
|
||||
const KnowledgeBaseButton: FC<Props> = ({ ref, selectedBases, onSelect, disabled }) => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const quickPanelHook = useQuickPanel()
|
||||
const quickPanel = useQuickPanel()
|
||||
const knowledgeState = useAppSelector((state) => state.knowledge)
|
||||
const selectedBasesRef = useRef(selectedBases)
|
||||
|
||||
@@ -73,7 +76,7 @@ const KnowledgeBaseButton: FC<Props> = ({ quickPanel, selectedBases, onSelect, d
|
||||
}, [knowledgeState.bases, t, selectedBases, handleBaseSelect, navigate, onSelect])
|
||||
|
||||
const openQuickPanel = useCallback(() => {
|
||||
quickPanelHook.open({
|
||||
quickPanel.open({
|
||||
title: t('chat.input.knowledge_base'),
|
||||
list: baseItems,
|
||||
symbol: QuickPanelReservedSymbol.KnowledgeBase,
|
||||
@@ -82,42 +85,27 @@ const KnowledgeBaseButton: FC<Props> = ({ quickPanel, selectedBases, onSelect, d
|
||||
item.isSelected = !item.isSelected
|
||||
}
|
||||
})
|
||||
}, [baseItems, quickPanelHook, t])
|
||||
}, [baseItems, quickPanel, t])
|
||||
|
||||
const handleOpenQuickPanel = useCallback(() => {
|
||||
if (quickPanelHook.isVisible && quickPanelHook.symbol === QuickPanelReservedSymbol.KnowledgeBase) {
|
||||
quickPanelHook.close()
|
||||
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.KnowledgeBase) {
|
||||
quickPanel.close()
|
||||
} else {
|
||||
openQuickPanel()
|
||||
}
|
||||
}, [openQuickPanel, quickPanelHook])
|
||||
}, [openQuickPanel, quickPanel])
|
||||
|
||||
// 监听 selectedBases 变化,动态更新已打开的 QuickPanel 列表状态
|
||||
useEffect(() => {
|
||||
if (quickPanelHook.isVisible && quickPanelHook.symbol === QuickPanelReservedSymbol.KnowledgeBase) {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.KnowledgeBase) {
|
||||
// 直接使用重新计算的 baseItems,因为它已经包含了最新的 isSelected 状态
|
||||
quickPanelHook.updateList(baseItems)
|
||||
quickPanel.updateList(baseItems)
|
||||
}
|
||||
}, [selectedBases, quickPanelHook, baseItems])
|
||||
}, [selectedBases, quickPanel, baseItems])
|
||||
|
||||
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])
|
||||
useImperativeHandle(ref, () => ({
|
||||
openQuickPanel
|
||||
}))
|
||||
|
||||
return (
|
||||
<Tooltip placement="top" title={t('chat.input.knowledge_base')} mouseLeaveDelay={0} arrow>
|
||||
@@ -6,7 +6,6 @@ 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'
|
||||
@@ -14,13 +13,19 @@ 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, useMemo, useRef, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useImperativeHandle, 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
|
||||
quickPanel: ToolQuickPanelApi
|
||||
ref?: React.RefObject<MCPToolsButtonRef | null>
|
||||
setInputValue: React.Dispatch<React.SetStateAction<string>>
|
||||
resizeTextArea: () => void
|
||||
}
|
||||
@@ -110,10 +115,10 @@ const extractPromptContent = (response: any): string | null => {
|
||||
return null
|
||||
}
|
||||
|
||||
const MCPToolsButton: FC<Props> = ({ quickPanel, setInputValue, resizeTextArea, assistantId }) => {
|
||||
const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, assistantId }) => {
|
||||
const { activedMcpServers } = useMCPServers()
|
||||
const { t } = useTranslation()
|
||||
const quickPanelHook = useQuickPanel()
|
||||
const quickPanel = useQuickPanel()
|
||||
const navigate = useNavigate()
|
||||
const [form] = Form.useForm()
|
||||
|
||||
@@ -214,15 +219,15 @@ const MCPToolsButton: FC<Props> = ({ quickPanel, setInputValue, resizeTextArea,
|
||||
isSelected: false,
|
||||
action: () => {
|
||||
updateMcpEnabled(false)
|
||||
quickPanelHook.close()
|
||||
quickPanel.close()
|
||||
}
|
||||
})
|
||||
|
||||
return newList
|
||||
}, [activedMcpServers, t, assistantMcpServers, navigate, updateMcpEnabled, quickPanelHook])
|
||||
}, [activedMcpServers, t, assistantMcpServers, navigate, updateMcpEnabled, quickPanel])
|
||||
|
||||
const openQuickPanel = useCallback(() => {
|
||||
quickPanelHook.open({
|
||||
quickPanel.open({
|
||||
title: t('settings.mcp.title'),
|
||||
list: menuItems,
|
||||
symbol: QuickPanelReservedSymbol.Mcp,
|
||||
@@ -231,7 +236,7 @@ const MCPToolsButton: FC<Props> = ({ quickPanel, setInputValue, resizeTextArea,
|
||||
item.isSelected = !item.isSelected
|
||||
}
|
||||
})
|
||||
}, [menuItems, quickPanelHook, t])
|
||||
}, [menuItems, quickPanel, t])
|
||||
|
||||
// 使用 useCallback 优化 insertPromptIntoTextArea
|
||||
const insertPromptIntoTextArea = useCallback(
|
||||
@@ -371,13 +376,13 @@ const MCPToolsButton: FC<Props> = ({ quickPanel, setInputValue, resizeTextArea,
|
||||
|
||||
const openPromptList = useCallback(async () => {
|
||||
const prompts = await promptList
|
||||
quickPanelHook.open({
|
||||
quickPanel.open({
|
||||
title: t('settings.mcp.title'),
|
||||
list: prompts,
|
||||
symbol: QuickPanelReservedSymbol.McpPrompt,
|
||||
multiple: true
|
||||
})
|
||||
}, [promptList, quickPanelHook, t])
|
||||
}, [promptList, quickPanel, t])
|
||||
|
||||
const handleResourceSelect = useCallback(
|
||||
(resource: MCPResource) => {
|
||||
@@ -459,60 +464,27 @@ const MCPToolsButton: FC<Props> = ({ quickPanel, setInputValue, resizeTextArea,
|
||||
}, [activedMcpServers])
|
||||
|
||||
const openResourcesList = useCallback(async () => {
|
||||
quickPanelHook.open({
|
||||
quickPanel.open({
|
||||
title: t('settings.mcp.title'),
|
||||
list: resourcesList,
|
||||
symbol: QuickPanelReservedSymbol.McpResource,
|
||||
multiple: true
|
||||
})
|
||||
}, [resourcesList, quickPanelHook, t])
|
||||
}, [resourcesList, quickPanel, t])
|
||||
|
||||
const handleOpenQuickPanel = useCallback(() => {
|
||||
if (quickPanelHook.isVisible && quickPanelHook.symbol === QuickPanelReservedSymbol.Mcp) {
|
||||
quickPanelHook.close()
|
||||
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.Mcp) {
|
||||
quickPanel.close()
|
||||
} else {
|
||||
openQuickPanel()
|
||||
}
|
||||
}, [openQuickPanel, quickPanelHook])
|
||||
}, [openQuickPanel, quickPanel])
|
||||
|
||||
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])
|
||||
useImperativeHandle(ref, () => ({
|
||||
openQuickPanel,
|
||||
openPromptList,
|
||||
openResourcesList
|
||||
}))
|
||||
|
||||
return (
|
||||
<Tooltip placement="top" title={t('settings.mcp.title')} mouseLeaveDelay={0} arrow>
|
||||
318
src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx
Normal file
318
src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
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)
|
||||
@@ -2,39 +2,38 @@ import { ActionIconButton } from '@renderer/components/Buttons'
|
||||
import {
|
||||
type QuickPanelListItem,
|
||||
type QuickPanelOpenOptions,
|
||||
QuickPanelReservedSymbol,
|
||||
type QuickPanelTriggerInfo
|
||||
QuickPanelReservedSymbol
|
||||
} 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, useMemo, useRef, useState } from 'react'
|
||||
import { memo, useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
export interface QuickPhrasesButtonRef {
|
||||
openQuickPanel: () => void
|
||||
}
|
||||
|
||||
interface Props {
|
||||
quickPanel: ToolQuickPanelApi
|
||||
ref?: React.RefObject<QuickPhrasesButtonRef | null>
|
||||
setInputValue: React.Dispatch<React.SetStateAction<string>>
|
||||
resizeTextArea: () => void
|
||||
assistantId: string
|
||||
}
|
||||
|
||||
const QuickPhrasesButton = ({ quickPanel, setInputValue, resizeTextArea, assistantId }: Props) => {
|
||||
const QuickPhrasesButton = ({ ref, 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 quickPanelHook = useQuickPanel()
|
||||
const quickPanel = 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[] = []) => {
|
||||
@@ -59,60 +58,21 @@ const QuickPhrasesButton = ({ quickPanel, setInputValue, resizeTextArea, assista
|
||||
'handlePhraseSelect_1',
|
||||
() => {
|
||||
setInputValue((prev) => {
|
||||
const triggerInfo = triggerInfoRef.current
|
||||
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement | null
|
||||
|
||||
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 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)
|
||||
triggerInfoRef.current = undefined
|
||||
focusAndSelect(cursorPosition)
|
||||
|
||||
setTimeoutTimer(
|
||||
'handlePhraseSelect_2',
|
||||
() => {
|
||||
textArea.focus()
|
||||
textArea.setSelectionRange(selectionStart, selectionEndPosition)
|
||||
resizeTextArea()
|
||||
},
|
||||
10
|
||||
)
|
||||
return newText
|
||||
})
|
||||
},
|
||||
@@ -178,74 +138,21 @@ const QuickPhrasesButton = ({ quickPanel, setInputValue, resizeTextArea, assista
|
||||
[phraseItems, t]
|
||||
)
|
||||
|
||||
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 openQuickPanel = useCallback(() => {
|
||||
quickPanel.open(quickPanelOpenOptions)
|
||||
}, [quickPanel, quickPanelOpenOptions])
|
||||
|
||||
const handleOpenQuickPanel = useCallback(() => {
|
||||
if (quickPanelHook.isVisible && quickPanelHook.symbol === QuickPanelReservedSymbol.QuickPhrases) {
|
||||
quickPanelHook.close()
|
||||
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.QuickPhrases) {
|
||||
quickPanel.close()
|
||||
} else {
|
||||
openQuickPanel()
|
||||
}
|
||||
}, [openQuickPanel, quickPanelHook])
|
||||
}, [openQuickPanel, quickPanel])
|
||||
|
||||
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])
|
||||
useImperativeHandle(ref, () => ({
|
||||
openQuickPanel
|
||||
}))
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -17,22 +17,25 @@ 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, useEffect, useMemo } from 'react'
|
||||
import { useCallback, useImperativeHandle, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export interface ThinkingButtonRef {
|
||||
openQuickPanel: () => void
|
||||
}
|
||||
|
||||
interface Props {
|
||||
quickPanel: ToolQuickPanelApi
|
||||
ref?: React.RefObject<ThinkingButtonRef | null>
|
||||
model: Model
|
||||
assistantId: string
|
||||
}
|
||||
|
||||
const ThinkingButton: FC<Props> = ({ quickPanel, model, assistantId }): ReactElement => {
|
||||
const ThinkingButton: FC<Props> = ({ ref, model, assistantId }): ReactElement => {
|
||||
const { t } = useTranslation()
|
||||
const quickPanelHook = useQuickPanel()
|
||||
const quickPanel = useQuickPanel()
|
||||
const { assistant, updateAssistantSettings } = useAssistant(assistantId)
|
||||
|
||||
const currentReasoningEffort = useMemo(() => {
|
||||
@@ -103,16 +106,16 @@ const ThinkingButton: FC<Props> = ({ quickPanel, model, assistantId }): ReactEle
|
||||
}, [onThinkingChange])
|
||||
|
||||
const openQuickPanel = useCallback(() => {
|
||||
quickPanelHook.open({
|
||||
quickPanel.open({
|
||||
title: t('assistants.settings.reasoning_effort.label'),
|
||||
list: panelItems,
|
||||
symbol: QuickPanelReservedSymbol.Thinking
|
||||
})
|
||||
}, [quickPanelHook, panelItems, t])
|
||||
}, [quickPanel, panelItems, t])
|
||||
|
||||
const handleOpenQuickPanel = useCallback(() => {
|
||||
if (quickPanelHook.isVisible && quickPanelHook.symbol === QuickPanelReservedSymbol.Thinking) {
|
||||
quickPanelHook.close()
|
||||
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.Thinking) {
|
||||
quickPanel.close()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -121,26 +124,11 @@ const ThinkingButton: FC<Props> = ({ quickPanel, model, assistantId }): ReactEle
|
||||
return
|
||||
}
|
||||
openQuickPanel()
|
||||
}, [openQuickPanel, quickPanelHook, isThinkingEnabled, supportedOptions, disableThinking])
|
||||
}, [openQuickPanel, quickPanel, isThinkingEnabled, supportedOptions, disableThinking])
|
||||
|
||||
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])
|
||||
useImperativeHandle(ref, () => ({
|
||||
openQuickPanel
|
||||
}))
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
@@ -1,8 +1,9 @@
|
||||
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 } from '@renderer/components/QuickPanel'
|
||||
import { QuickPanelReservedSymbol, useQuickPanel } from '@renderer/components/QuickPanel'
|
||||
import {
|
||||
isGeminiModel,
|
||||
isGPT5SeriesReasoningModel,
|
||||
@@ -13,57 +14,66 @@ 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 { useCallback, useEffect, useMemo } from 'react'
|
||||
import type { FC } from 'react'
|
||||
import { memo, useCallback, useImperativeHandle, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
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 }} />
|
||||
}
|
||||
export interface WebSearchButtonRef {
|
||||
openQuickPanel: () => void
|
||||
}
|
||||
|
||||
export const useWebSearchPanelController = (assistantId: string, quickPanelController: ToolQuickPanelController) => {
|
||||
interface Props {
|
||||
ref?: React.RefObject<WebSearchButtonRef | null>
|
||||
assistantId: string
|
||||
}
|
||||
|
||||
const logger = loggerService.withContext('WebSearchButton')
|
||||
|
||||
const WebSearchButton: FC<Props> = ({ ref, assistantId }) => {
|
||||
const { t } = useTranslation()
|
||||
const { assistant, updateAssistant } = useAssistant(assistantId)
|
||||
const quickPanel = useQuickPanel()
|
||||
const { providers } = useWebSearchProviders()
|
||||
const { assistant, updateAssistant } = useAssistant(assistantId)
|
||||
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', () => {
|
||||
@@ -126,6 +136,7 @@ export const useWebSearchPanelController = (assistantId: string, quickPanelContr
|
||||
|
||||
const providerItems = useMemo<QuickPanelListItem[]>(() => {
|
||||
const isWebSearchModelEnabled = assistant.model && isWebSearchModel(assistant.model)
|
||||
|
||||
const items: QuickPanelListItem[] = providers
|
||||
.map((p) => ({
|
||||
label: p.name,
|
||||
@@ -134,12 +145,12 @@ export const useWebSearchPanelController = (assistantId: string, quickPanelContr
|
||||
? t('settings.tool.websearch.apikey')
|
||||
: t('settings.tool.websearch.free')
|
||||
: t('chat.input.web_search.enable_content'),
|
||||
icon: <WebSearchProviderIcon size={13} pid={p.id} />,
|
||||
icon: <WebSearchIcon size={13} pid={p.id} />,
|
||||
isSelected: p.id === assistant?.webSearchProviderId,
|
||||
disabled: !WebSearchService.isWebSearchEnabled(p.id),
|
||||
action: () => updateQuickPanelItem(p.id)
|
||||
}))
|
||||
.filter((item) => !item.disabled)
|
||||
.filter((o) => !o.disabled)
|
||||
|
||||
if (isWebSearchModelEnabled) {
|
||||
items.unshift({
|
||||
@@ -156,6 +167,7 @@ export const useWebSearchPanelController = (assistantId: string, quickPanelContr
|
||||
|
||||
return items
|
||||
}, [
|
||||
WebSearchIcon,
|
||||
assistant.enableWebSearch,
|
||||
assistant.model,
|
||||
assistant?.webSearchProviderId,
|
||||
@@ -166,69 +178,45 @@ export const useWebSearchPanelController = (assistantId: string, quickPanelContr
|
||||
])
|
||||
|
||||
const openQuickPanel = useCallback(() => {
|
||||
quickPanelController.open({
|
||||
quickPanel.open({
|
||||
title: t('chat.input.web_search.label'),
|
||||
list: providerItems,
|
||||
symbol: QuickPanelReservedSymbol.WebSearch,
|
||||
pageSize: 9
|
||||
})
|
||||
}, [providerItems, quickPanelController, t])
|
||||
}, [quickPanel, t, providerItems])
|
||||
|
||||
const toggleQuickPanel = useCallback(() => {
|
||||
if (quickPanelController.isVisible && quickPanelController.symbol === QuickPanelReservedSymbol.WebSearch) {
|
||||
quickPanelController.close()
|
||||
const handleOpenQuickPanel = useCallback(() => {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.WebSearch) {
|
||||
quickPanel.close()
|
||||
} else {
|
||||
openQuickPanel()
|
||||
}
|
||||
}, [openQuickPanel, quickPanelController])
|
||||
}, [openQuickPanel, quickPanel])
|
||||
|
||||
return {
|
||||
enableWebSearch,
|
||||
providerItems,
|
||||
openQuickPanel,
|
||||
toggleQuickPanel,
|
||||
updateWebSearchProvider,
|
||||
updateToModelBuiltinWebSearch,
|
||||
selectedProviderId: assistant.webSearchProviderId
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
const onClick = useCallback(() => {
|
||||
if (enableWebSearch) {
|
||||
updateWebSearchProvider(undefined)
|
||||
} else {
|
||||
handleOpenQuickPanel()
|
||||
}
|
||||
}, [isVisible, providerItems, symbol, updateList])
|
||||
}, [enableWebSearch, handleOpenQuickPanel, updateWebSearchProvider])
|
||||
|
||||
useEffect(() => {
|
||||
const disposeMenu = registerRootMenu([
|
||||
{
|
||||
label: t('chat.input.web_search.label'),
|
||||
description: '',
|
||||
icon: <Globe size={18} />,
|
||||
isMenu: true,
|
||||
action: () => openQuickPanel()
|
||||
}
|
||||
])
|
||||
useImperativeHandle(ref, () => ({
|
||||
openQuickPanel
|
||||
}))
|
||||
|
||||
const disposeTrigger = registerTrigger(QuickPanelReservedSymbol.WebSearch, () => openQuickPanel())
|
||||
|
||||
return () => {
|
||||
disposeMenu()
|
||||
disposeTrigger()
|
||||
}
|
||||
}, [openQuickPanel, registerRootMenu, registerTrigger, t])
|
||||
|
||||
return null
|
||||
return (
|
||||
<Tooltip
|
||||
placement="top"
|
||||
title={enableWebSearch ? t('common.close') : t('chat.input.web_search.label')}
|
||||
mouseLeaveDelay={0}
|
||||
arrow>
|
||||
<ActionIconButton onClick={onClick} active={!!enableWebSearch}>
|
||||
<WebSearchIcon pid={assistant.webSearchProviderId} />
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default WebSearchQuickPanelManager
|
||||
export default memo(WebSearchButton)
|
||||
@@ -1,803 +0,0 @@
|
||||
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;
|
||||
`
|
||||
@@ -1,347 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
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 }
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
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)!
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
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
|
||||
@@ -1,33 +0,0 @@
|
||||
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
|
||||
@@ -1,34 +0,0 @@
|
||||
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
|
||||
@@ -1,41 +0,0 @@
|
||||
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)
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { ToolActionKey, ToolRenderContext, ToolStateKey } from '@renderer/pages/home/Inputbar/types'
|
||||
import type React from 'react'
|
||||
|
||||
import { useActivityDirectoryPanel } from './useActivityDirectoryPanel'
|
||||
|
||||
interface ManagerProps {
|
||||
context: ToolRenderContext<readonly ToolStateKey[], readonly ToolActionKey[]>
|
||||
}
|
||||
|
||||
const ActivityDirectoryQuickPanelManager = ({ context }: ManagerProps) => {
|
||||
const {
|
||||
quickPanel,
|
||||
quickPanelController,
|
||||
actions: { onTextChange },
|
||||
session
|
||||
} = context
|
||||
|
||||
// Get accessible paths from session data
|
||||
const accessiblePaths = session?.accessiblePaths ?? []
|
||||
|
||||
// Always call hooks unconditionally (React rules)
|
||||
useActivityDirectoryPanel(
|
||||
{
|
||||
quickPanel,
|
||||
quickPanelController,
|
||||
accessiblePaths,
|
||||
setText: onTextChange as React.Dispatch<React.SetStateAction<string>>
|
||||
},
|
||||
'manager'
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default ActivityDirectoryQuickPanelManager
|
||||
@@ -1,56 +0,0 @@
|
||||
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)
|
||||
@@ -1,35 +0,0 @@
|
||||
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
|
||||
@@ -1,47 +0,0 @@
|
||||
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
|
||||
@@ -1,41 +0,0 @@
|
||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||
import type { ToolQuickPanelController } from '@renderer/pages/home/Inputbar/types'
|
||||
import { Tooltip } from 'antd'
|
||||
import type { FC } from 'react'
|
||||
import { memo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useWebSearchPanelController, WebSearchProviderIcon } from './WebSearchQuickPanelManager'
|
||||
|
||||
interface Props {
|
||||
quickPanelController: ToolQuickPanelController
|
||||
assistantId: string
|
||||
}
|
||||
|
||||
const WebSearchButton: FC<Props> = ({ quickPanelController, assistantId }) => {
|
||||
const { t } = useTranslation()
|
||||
const { enableWebSearch, toggleQuickPanel, updateWebSearchProvider, selectedProviderId } =
|
||||
useWebSearchPanelController(assistantId, quickPanelController)
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
if (enableWebSearch) {
|
||||
updateWebSearchProvider(undefined)
|
||||
} else {
|
||||
toggleQuickPanel()
|
||||
}
|
||||
}, [enableWebSearch, toggleQuickPanel, updateWebSearchProvider])
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
placement="top"
|
||||
title={enableWebSearch ? t('common.close') : t('chat.input.web_search.label')}
|
||||
mouseLeaveDelay={0}
|
||||
arrow>
|
||||
<ActionIconButton onClick={onClick} active={!!enableWebSearch}>
|
||||
<WebSearchProviderIcon pid={selectedProviderId} />
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(WebSearchButton)
|
||||
@@ -1,457 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,324 +0,0 @@
|
||||
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;
|
||||
`
|
||||
@@ -1,57 +0,0 @@
|
||||
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
|
||||
@@ -1,28 +0,0 @@
|
||||
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
|
||||
@@ -1,23 +0,0 @@
|
||||
// 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'
|
||||
@@ -1,61 +0,0 @@
|
||||
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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user