Merge branch 'main' of github.com:CherryHQ/cherry-studio into v2

This commit is contained in:
fullex 2025-10-29 14:28:03 +08:00
commit f3a279d8de
65 changed files with 5619 additions and 126 deletions

View File

@ -29,8 +29,10 @@ jobs:
days-before-close: 0 # Close immediately after stale
stale-issue-label: 'inactive'
close-issue-label: 'closed:no-response'
exempt-all-milestones: true
exempt-all-assignees: true
stale-issue-message: |
This issue has been labeled as needing more information and has been inactive for ${{ env.daysBeforeStale }} days.
This issue has been labeled as needing more information and has been inactive for ${{ env.daysBeforeStale }} days.
It will be closed now due to lack of additional information.
该问题被标记为"需要更多信息"且已经 ${{ env.daysBeforeStale }} 天没有任何活动,将立即关闭。
@ -46,6 +48,8 @@ jobs:
days-before-stale: ${{ env.daysBeforeStale }}
days-before-close: ${{ env.daysBeforeClose }}
stale-issue-label: 'inactive'
exempt-all-milestones: true
exempt-all-assignees: true
stale-issue-message: |
This issue has been inactive for a prolonged period and will be closed automatically in ${{ env.daysBeforeClose }} days.
该问题已长时间处于闲置状态,${{ env.daysBeforeClose }} 天后将自动关闭。

View File

@ -1,24 +1,24 @@
diff --git a/sdk.mjs b/sdk.mjs
index 461e9a2ba246778261108a682762ffcf26f7224e..44bd667d9f591969d36a105ba5eb8b478c738dd8 100644
index 10162e5b1624f8ce667768943347a6e41089ad2f..32568ae08946590e382270c88d85fba81187568e 100755
--- a/sdk.mjs
+++ b/sdk.mjs
@@ -6215,7 +6215,7 @@ function createAbortController(maxListeners = DEFAULT_MAX_LISTENERS) {
@@ -6213,7 +6213,7 @@ function createAbortController(maxListeners = DEFAULT_MAX_LISTENERS) {
}
// ../src/transport/ProcessTransport.ts
-import { spawn } from "child_process";
+import { fork } from "child_process";
import { createInterface } from "readline";
// ../src/utils/fsOperations.ts
@@ -6473,14 +6473,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);
}
- const isNative = isNativeBinary(pathToClaudeCodeExecutable);
- const spawnCommand = isNative ? pathToClaudeCodeExecutable : executable;
- const spawnArgs = isNative ? args : [...executableArgs, pathToClaudeCodeExecutable, ...args];
- this.logForDebugging(isNative ? `Spawning Claude Code native binary: ${pathToClaudeCodeExecutable} ${args.join(" ")}` : `Spawning Claude Code process: ${executable} ${[...executableArgs, pathToClaudeCodeExecutable, ...args].join(" ")}`);
- const spawnArgs = isNative ? [...executableArgs, ...args] : [...executableArgs, pathToClaudeCodeExecutable, ...args];
- this.logForDebugging(isNative ? `Spawning Claude Code native binary: ${spawnCommand} ${spawnArgs.join(" ")}` : `Spawning Claude Code process: ${spawnCommand} ${spawnArgs.join(" ")}`);
+ this.logForDebugging(`Forking Claude Code Node.js process: ${pathToClaudeCodeExecutable} ${args.join(" ")}`);
const stderrMode = env.DEBUG || stderr ? "pipe" : "ignore";
- this.child = spawn(spawnCommand, spawnArgs, {

View File

@ -10,8 +10,9 @@ This file provides guidance to AI coding assistants when working with code in th
- **Build with HeroUI**: Use HeroUI for every new UI component; never add `antd` or `styled-components`.
- **Log centrally**: Route all logging through `loggerService` with the right context—no `console.log`.
- **Research via subagent**: Lean on `subagent` for external docs, APIs, news, and references.
- **Seek review**: Ask a human developer to review substantial changes before merging.
- **Commit in rhythm**: Keep commits small, conventional, and emoji-tagged.
- **Always propose before executing**: Before making any changes, clearly explain your planned approach and wait for explicit user approval to ensure alignment and prevent unwanted modifications.
- **Write conventional commits with emoji**: Commit small, focused changes using emoji-prefixed Conventional Commit messages (e.g., `✨ feat:`, `🐛 fix:`, `♻️ refactor:`, `
📝 docs:`).
## Development Commands

View File

@ -21,7 +21,11 @@
"quoteStyle": "single"
}
},
"files": { "ignoreUnknown": false },
"files": {
"ignoreUnknown": false,
"includes": ["**"],
"maxSize": 2097152
},
"formatter": {
"attributePosition": "auto",
"bracketSameLine": false,

View File

@ -67,6 +67,10 @@ asarUnpack:
extraResources:
- from: "migrations/sqlite-drizzle"
to: "migrations/sqlite-drizzle"
# copy from node_modules/claude-code-plugins/plugins to resources/data/claude-code-pluginso
- from: "./node_modules/claude-code-plugins/plugins/"
to: "claude-code-plugins"
win:
executableName: Cherry Studio
artifactName: ${productName}-${version}-${arch}-setup.${ext}

View File

@ -81,7 +81,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.1#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.1-d937b73fed.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",
@ -89,6 +89,8 @@
"express": "^5.1.0",
"font-list": "^2.0.0",
"graceful-fs": "^4.2.11",
"gray-matter": "^4.0.3",
"js-yaml": "^4.1.0",
"jsdom": "26.1.0",
"node-stream-zip": "^1.15.0",
"officeparser": "^4.2.0",
@ -198,6 +200,7 @@
"@types/fs-extra": "^11",
"@types/he": "^1",
"@types/html-to-text": "^9",
"@types/js-yaml": "^4.0.9",
"@types/lodash": "^4.17.5",
"@types/markdown-it": "^14",
"@types/md5": "^2.3.5",
@ -237,6 +240,7 @@
"check-disk-space": "3.4.0",
"cheerio": "^1.1.2",
"chokidar": "^4.0.3",
"claude-code-plugins": "1.0.1",
"cli-progress": "^3.12.0",
"clsx": "^2.1.1",
"code-inspector-plugin": "^0.20.14",

View File

@ -96,6 +96,10 @@ export enum IpcChannel {
AgentMessage_PersistExchange = 'agent-message:persist-exchange',
AgentMessage_GetHistory = 'agent-message:get-history',
AgentToolPermission_Request = 'agent-tool-permission:request',
AgentToolPermission_Response = 'agent-tool-permission:response',
AgentToolPermission_Result = 'agent-tool-permission:result',
//copilot
Copilot_GetAuthMessage = 'copilot:get-auth-message',
Copilot_GetCopilotToken = 'copilot:get-copilot-token',
@ -382,5 +386,14 @@ export enum IpcChannel {
Ovms_StopOVMS = 'ovms:stop-ovms',
// CherryAI
Cherryai_GetSignature = 'cherryai:get-signature'
Cherryai_GetSignature = 'cherryai:get-signature',
// Claude Code Plugins
ClaudeCodePlugin_ListAvailable = 'claudeCodePlugin:list-available',
ClaudeCodePlugin_Install = 'claudeCodePlugin:install',
ClaudeCodePlugin_Uninstall = 'claudeCodePlugin:uninstall',
ClaudeCodePlugin_ListInstalled = 'claudeCodePlugin:list-installed',
ClaudeCodePlugin_InvalidateCache = 'claudeCodePlugin:invalidate-cache',
ClaudeCodePlugin_ReadContent = 'claudeCodePlugin:read-content',
ClaudeCodePlugin_WriteContent = 'claudeCodePlugin:write-content'
}

View File

@ -19,6 +19,7 @@ import type {
FileMetadata,
Notification,
OcrProvider,
PluginError,
Provider,
Shortcut,
SupportedOcrFile
@ -49,6 +50,7 @@ import * as NutstoreService from './services/NutstoreService'
import ObsidianVaultService from './services/ObsidianVaultService'
import { ocrService } from './services/ocr/OcrService'
import OvmsManager from './services/OvmsManager'
import { PluginService } from './services/PluginService'
import { proxyManager } from './services/ProxyManager'
import { pythonService } from './services/PythonService'
import { FileServiceManager } from './services/remotefile/FileServiceManager'
@ -95,6 +97,18 @@ const vertexAIService = VertexAIService.getInstance()
const memoryService = MemoryService.getInstance()
const dxtService = new DxtService()
const ovmsManager = new OvmsManager()
const pluginService = PluginService.getInstance()
function normalizeError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error))
}
function extractPluginError(error: unknown): PluginError | null {
if (error && typeof error === 'object' && 'type' in error && typeof (error as { type: unknown }).type === 'string') {
return error as PluginError
}
return null
}
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
const appUpdater = new AppUpdater()
@ -894,6 +908,119 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
// CherryAI
ipcMain.handle(IpcChannel.Cherryai_GetSignature, (_, params) => generateSignature(params))
// Claude Code Plugins
ipcMain.handle(IpcChannel.ClaudeCodePlugin_ListAvailable, async () => {
try {
const data = await pluginService.listAvailable()
return { success: true, data }
} catch (error) {
const pluginError = extractPluginError(error)
if (pluginError) {
logger.error('Failed to list available plugins', pluginError)
return { success: false, error: pluginError }
}
const err = normalizeError(error)
logger.error('Failed to list available plugins', err)
return {
success: false,
error: {
type: 'TRANSACTION_FAILED',
operation: 'list-available',
reason: err.message
}
}
}
})
ipcMain.handle(IpcChannel.ClaudeCodePlugin_Install, async (_, options) => {
try {
const data = await pluginService.install(options)
return { success: true, data }
} catch (error) {
logger.error('Failed to install plugin', { options, error })
return { success: false, error }
}
})
ipcMain.handle(IpcChannel.ClaudeCodePlugin_Uninstall, async (_, options) => {
try {
await pluginService.uninstall(options)
return { success: true, data: undefined }
} catch (error) {
logger.error('Failed to uninstall plugin', { options, error })
return { success: false, error }
}
})
ipcMain.handle(IpcChannel.ClaudeCodePlugin_ListInstalled, async (_, agentId: string) => {
try {
const data = await pluginService.listInstalled(agentId)
return { success: true, data }
} catch (error) {
const pluginError = extractPluginError(error)
if (pluginError) {
logger.error('Failed to list installed plugins', { agentId, error: pluginError })
return { success: false, error: pluginError }
}
const err = normalizeError(error)
logger.error('Failed to list installed plugins', { agentId, error: err })
return {
success: false,
error: {
type: 'TRANSACTION_FAILED',
operation: 'list-installed',
reason: err.message
}
}
}
})
ipcMain.handle(IpcChannel.ClaudeCodePlugin_InvalidateCache, async () => {
try {
pluginService.invalidateCache()
return { success: true, data: undefined }
} catch (error) {
const pluginError = extractPluginError(error)
if (pluginError) {
logger.error('Failed to invalidate plugin cache', pluginError)
return { success: false, error: pluginError }
}
const err = normalizeError(error)
logger.error('Failed to invalidate plugin cache', err)
return {
success: false,
error: {
type: 'TRANSACTION_FAILED',
operation: 'invalidate-cache',
reason: err.message
}
}
}
})
ipcMain.handle(IpcChannel.ClaudeCodePlugin_ReadContent, async (_, sourcePath: string) => {
try {
const data = await pluginService.readContent(sourcePath)
return { success: true, data }
} catch (error) {
logger.error('Failed to read plugin content', { sourcePath, error })
return { success: false, error }
}
})
ipcMain.handle(IpcChannel.ClaudeCodePlugin_WriteContent, async (_, options) => {
try {
await pluginService.writeContent(options.agentId, options.filename, options.type, options.content)
return { success: true, data: undefined }
} catch (error) {
logger.error('Failed to write plugin content', { options, error })
return { success: false, error }
}
})
// Preference handlers
PreferenceService.registerIpcHandler()
}

View File

@ -0,0 +1,199 @@
import fs from 'node:fs'
import path from 'node:path'
import { loggerService } from '@logger'
import { fileStorage } from '@main/services/FileStorage'
import type { FileMetadata, PreprocessProvider } from '@types'
import AdmZip from 'adm-zip'
import { net } from 'electron'
import FormData from 'form-data'
import BasePreprocessProvider from './BasePreprocessProvider'
const logger = loggerService.withContext('MineruPreprocessProvider')
export default class OpenMineruPreprocessProvider extends BasePreprocessProvider {
constructor(provider: PreprocessProvider, userId?: string) {
super(provider, userId)
}
public async parseFile(
sourceId: string,
file: FileMetadata
): Promise<{ processedFile: FileMetadata; quota: number }> {
try {
const filePath = fileStorage.getFilePathById(file)
logger.info(`Open MinerU preprocess processing started: ${filePath}`)
await this.validateFile(filePath)
// 1. Update progress
await this.sendPreprocessProgress(sourceId, 50)
logger.info(`File ${file.name} is starting processing...`)
// 2. Upload file and extract
const { path: outputPath } = await this.uploadFileAndExtract(file)
// 3. Check quota
const quota = await this.checkQuota()
// 4. Create processed file info
return {
processedFile: this.createProcessedFileInfo(file, outputPath),
quota
}
} catch (error) {
logger.error(`Open MinerU preprocess processing failed for:`, error as Error)
throw error
}
}
public async checkQuota() {
// self-hosted version always has enough quota
return Infinity
}
private async validateFile(filePath: string): Promise<void> {
const pdfBuffer = await fs.promises.readFile(filePath)
const doc = await this.readPdf(pdfBuffer)
// File page count must be less than 600 pages
if (doc.numPages >= 600) {
throw new Error(`PDF page count (${doc.numPages}) exceeds the limit of 600 pages`)
}
// File size must be less than 200MB
if (pdfBuffer.length >= 200 * 1024 * 1024) {
const fileSizeMB = Math.round(pdfBuffer.length / (1024 * 1024))
throw new Error(`PDF file size (${fileSizeMB}MB) exceeds the limit of 200MB`)
}
}
private createProcessedFileInfo(file: FileMetadata, outputPath: string): FileMetadata {
// Find the main file after extraction
let finalPath = ''
let finalName = file.origin_name.replace('.pdf', '.md')
// Find the corresponding folder by file name
outputPath = path.join(outputPath, `${file.origin_name.replace('.pdf', '')}`)
try {
const files = fs.readdirSync(outputPath)
const mdFile = files.find((f) => f.endsWith('.md'))
if (mdFile) {
const originalMdPath = path.join(outputPath, mdFile)
const newMdPath = path.join(outputPath, finalName)
// Rename file to original file name
try {
fs.renameSync(originalMdPath, newMdPath)
finalPath = newMdPath
logger.info(`Renamed markdown file from ${mdFile} to ${finalName}`)
} catch (renameError) {
logger.warn(`Failed to rename file ${mdFile} to ${finalName}: ${renameError}`)
// If rename fails, use the original file
finalPath = originalMdPath
finalName = mdFile
}
}
} catch (error) {
logger.warn(`Failed to read output directory ${outputPath}:`, error as Error)
finalPath = path.join(outputPath, `${file.id}.md`)
}
return {
...file,
name: finalName,
path: finalPath,
ext: '.md',
size: fs.existsSync(finalPath) ? fs.statSync(finalPath).size : 0
}
}
private async uploadFileAndExtract(
file: FileMetadata,
maxRetries: number = 5,
intervalMs: number = 5000
): Promise<{ path: string }> {
let retries = 0
const endpoint = `${this.provider.apiHost}/file_parse`
// Get file stream
const filePath = fileStorage.getFilePathById(file)
const fileBuffer = await fs.promises.readFile(filePath)
const formData = new FormData()
formData.append('return_md', 'true')
formData.append('response_format_zip', 'true')
formData.append('files', fileBuffer, {
filename: file.origin_name
})
while (retries < maxRetries) {
let zipPath: string | undefined
try {
const response = await net.fetch(endpoint, {
method: 'POST',
headers: {
token: this.userId ?? '',
...(this.provider.apiKey ? { Authorization: `Bearer ${this.provider.apiKey}` } : {}),
...formData.getHeaders()
},
body: formData.getBuffer()
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
// Check if response header is application/zip
if (response.headers.get('content-type') !== 'application/zip') {
throw new Error(`Downloaded ZIP file has unexpected content-type: ${response.headers.get('content-type')}`)
}
const dirPath = this.storageDir
zipPath = path.join(dirPath, `${file.id}.zip`)
const extractPath = path.join(dirPath, `${file.id}`)
const arrayBuffer = await response.arrayBuffer()
fs.writeFileSync(zipPath, Buffer.from(arrayBuffer))
logger.info(`Downloaded ZIP file: ${zipPath}`)
// Ensure extraction directory exists
if (!fs.existsSync(extractPath)) {
fs.mkdirSync(extractPath, { recursive: true })
}
// Extract files
const zip = new AdmZip(zipPath)
zip.extractAllTo(extractPath, true)
logger.info(`Extracted files to: ${extractPath}`)
return { path: extractPath }
} catch (error) {
logger.warn(
`Failed to upload and extract file: ${(error as Error).message}, retry ${retries + 1}/${maxRetries}`
)
if (retries === maxRetries - 1) {
throw error
}
} finally {
// Delete temporary ZIP file
if (zipPath && fs.existsSync(zipPath)) {
try {
fs.unlinkSync(zipPath)
logger.info(`Deleted temporary ZIP file: ${zipPath}`)
} catch (deleteError) {
logger.warn(`Failed to delete temporary ZIP file ${zipPath}:`, deleteError as Error)
}
}
}
retries++
await new Promise((resolve) => setTimeout(resolve, intervalMs))
}
throw new Error(`Processing timeout for file: ${file.id}`)
}
}

View File

@ -5,6 +5,7 @@ import DefaultPreprocessProvider from './DefaultPreprocessProvider'
import Doc2xPreprocessProvider from './Doc2xPreprocessProvider'
import MineruPreprocessProvider from './MineruPreprocessProvider'
import MistralPreprocessProvider from './MistralPreprocessProvider'
import OpenMineruPreprocessProvider from './OpenMineruPreprocessProvider'
export default class PreprocessProviderFactory {
static create(provider: PreprocessProvider, userId?: string): BasePreprocessProvider {
switch (provider.id) {
@ -14,6 +15,8 @@ export default class PreprocessProviderFactory {
return new MistralPreprocessProvider(provider)
case 'mineru':
return new MineruPreprocessProvider(provider, userId)
case 'open-mineru':
return new OpenMineruPreprocessProvider(provider, userId)
default:
return new DefaultPreprocessProvider(provider)
}

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@
import { EventEmitter } from 'node:events'
import { createRequire } from 'node:module'
import type { McpHttpServerConfig, Options, SDKMessage } from '@anthropic-ai/claude-agent-sdk'
import type { CanUseTool, McpHttpServerConfig, Options, SDKMessage } from '@anthropic-ai/claude-agent-sdk'
import { query } from '@anthropic-ai/claude-agent-sdk'
import { loggerService } from '@logger'
import { config as apiConfigService } from '@main/apiServer/config'
@ -12,10 +12,23 @@ import { app } from 'electron'
import type { GetAgentSessionResponse } from '../..'
import type { AgentServiceInterface, AgentStream, AgentStreamEvent } from '../../interfaces/AgentStreamInterface'
import { promptForToolApproval } from './tool-permissions'
import { ClaudeStreamState, transformSDKMessageToStreamParts } from './transform'
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'
type UserInputMessage = {
type: 'user'
parent_tool_use_id: string | null
session_id: string
message: {
role: 'user'
content: string
}
}
class ClaudeCodeStream extends EventEmitter implements AgentStream {
declare emit: (event: 'data', data: AgentStreamEvent) => boolean
@ -100,6 +113,41 @@ class ClaudeCodeService implements AgentServiceInterface {
const errorChunks: string[] = []
const sessionAllowedTools = new Set<string>(session.allowed_tools ?? [])
const autoAllowTools = new Set<string>([...DEFAULT_AUTO_ALLOW_TOOLS, ...sessionAllowedTools])
const normalizeToolName = (name: string) => (name.startsWith('builtin_') ? name.slice('builtin_'.length) : name)
const canUseTool: CanUseTool = async (toolName, input, options) => {
logger.info('Handling tool permission check', {
toolName,
suggestionCount: options.suggestions?.length ?? 0
})
if (shouldAutoApproveTools) {
logger.debug('Auto-approving tool due to CHERRY_AUTO_ALLOW_TOOLS flag', { toolName })
return { behavior: 'allow', updatedInput: input }
}
if (options.signal.aborted) {
logger.debug('Permission request signal already aborted; denying tool', { toolName })
return {
behavior: 'deny',
message: 'Tool request was cancelled before prompting the user'
}
}
const normalizedToolName = normalizeToolName(toolName)
if (autoAllowTools.has(toolName) || autoAllowTools.has(normalizedToolName)) {
logger.debug('Auto-allowing tool from allowed list', {
toolName,
normalizedToolName
})
return { behavior: 'allow', updatedInput: input }
}
return promptForToolApproval(toolName, input, options)
}
// Build SDK options from parameters
const options: Options = {
abortController,
@ -122,7 +170,8 @@ class ClaudeCodeService implements AgentServiceInterface {
includePartialMessages: true,
permissionMode: session.configuration?.permission_mode,
maxTurns: session.configuration?.max_turns,
allowedTools: session.allowed_tools
allowedTools: session.allowed_tools,
canUseTool
}
if (session.accessible_paths.length > 1) {
@ -161,9 +210,14 @@ class ClaudeCodeService implements AgentServiceInterface {
resume: options.resume
})
const { stream: userInputStream, close: closeUserStream } = this.createUserMessageStream(
prompt,
abortController.signal
)
// Start async processing on the next tick so listeners can subscribe first
setImmediate(() => {
this.processSDKQuery(prompt, options, aiStream, errorChunks).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)
})
@ -177,17 +231,90 @@ class ClaudeCodeService implements AgentServiceInterface {
return aiStream
}
private async *userMessages(prompt: string) {
{
yield {
type: 'user' as const,
parent_tool_use_id: null,
session_id: '',
message: {
role: 'user' as const,
content: prompt
private createUserMessageStream(initialPrompt: string, abortSignal: AbortSignal) {
const queue: Array<UserInputMessage | null> = []
const waiters: Array<(value: UserInputMessage | null) => void> = []
let closed = false
const flushWaiters = (value: UserInputMessage | null) => {
const resolve = waiters.shift()
if (resolve) {
resolve(value)
return true
}
return false
}
const enqueue = (value: UserInputMessage | null) => {
if (closed) return
if (value === null) {
closed = true
}
if (!flushWaiters(value)) {
queue.push(value)
}
}
const close = () => {
if (closed) return
enqueue(null)
}
const onAbort = () => {
close()
}
if (abortSignal.aborted) {
close()
} else {
abortSignal.addEventListener('abort', onAbort, { once: true })
}
const iterator = (async function* () {
try {
while (true) {
let value: UserInputMessage | null
if (queue.length > 0) {
value = queue.shift() ?? null
} else if (closed) {
break
} else {
// Wait for next message or close signal
value = await new Promise<UserInputMessage | null>((resolve) => {
waiters.push(resolve)
})
}
if (value === null) {
break
}
yield value
}
} finally {
closed = true
abortSignal.removeEventListener('abort', onAbort)
while (waiters.length > 0) {
const resolve = waiters.shift()
resolve?.(null)
}
}
})()
enqueue({
type: 'user',
parent_tool_use_id: null,
session_id: '',
message: {
role: 'user',
content: initialPrompt
}
})
return {
stream: iterator,
enqueue,
close
}
}
@ -195,7 +322,8 @@ class ClaudeCodeService implements AgentServiceInterface {
* Process SDK query and emit stream events
*/
private async processSDKQuery(
prompt: string,
promptStream: AsyncIterable<UserInputMessage>,
closePromptStream: () => void,
options: Options,
stream: ClaudeCodeStream,
errorChunks: string[]
@ -203,14 +331,10 @@ class ClaudeCodeService implements AgentServiceInterface {
const jsonOutput: SDKMessage[] = []
let hasCompleted = false
const startTime = Date.now()
const streamState = new ClaudeStreamState()
try {
// Process streaming responses using SDK query
for await (const message of query({
prompt: this.userMessages(prompt),
options
})) {
for await (const message of query({ prompt: promptStream, options })) {
if (hasCompleted) break
jsonOutput.push(message)
@ -221,10 +345,10 @@ class ClaudeCodeService implements AgentServiceInterface {
content: JSON.stringify(message.message.content)
})
} else if (message.type === 'stream_event') {
logger.silly('Claude stream event', {
message,
event: JSON.stringify(message.event)
})
// logger.silly('Claude stream event', {
// message,
// event: JSON.stringify(message.event)
// })
} else {
logger.silly('Claude response', {
message,
@ -232,7 +356,6 @@ class ClaudeCodeService implements AgentServiceInterface {
})
}
// Transform SDKMessage to UIMessageChunks
const chunks = transformSDKMessageToStreamParts(message, streamState)
for (const chunk of chunks) {
stream.emit('data', {
@ -242,7 +365,6 @@ class ClaudeCodeService implements AgentServiceInterface {
}
}
// Successfully completed
hasCompleted = true
const duration = Date.now() - startTime
@ -251,7 +373,6 @@ class ClaudeCodeService implements AgentServiceInterface {
messageCount: jsonOutput.length
})
// Emit completion event
stream.emit('data', {
type: 'complete'
})
@ -260,8 +381,6 @@ class ClaudeCodeService implements AgentServiceInterface {
hasCompleted = true
const duration = Date.now() - startTime
// Check if this is an abort error
const errorObj = error as any
const isAborted =
errorObj?.name === 'AbortError' ||
@ -270,7 +389,6 @@ class ClaudeCodeService implements AgentServiceInterface {
if (isAborted) {
logger.info('SDK query aborted by client disconnect', { duration })
// Simply cleanup and return - don't emit error events
stream.emit('data', {
type: 'cancelled',
error: new Error('Request aborted by client')
@ -285,11 +403,13 @@ class ClaudeCodeService implements AgentServiceInterface {
error: errorObj instanceof Error ? { name: errorObj.name, message: errorObj.message } : String(errorObj),
stderr: errorChunks
})
// Emit error event
stream.emit('data', {
type: 'error',
error: new Error(errorMessage)
})
} finally {
closePromptStream()
}
}
}

View File

@ -0,0 +1,323 @@
import { randomUUID } from 'node:crypto'
import type { PermissionResult, PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'
import { loggerService } from '@logger'
import { IpcChannel } from '@shared/IpcChannel'
import { ipcMain } from 'electron'
import { windowService } from '../../../WindowService'
import { builtinTools } from './tools'
const logger = loggerService.withContext('ClaudeCodeService')
const TOOL_APPROVAL_TIMEOUT_MS = 30_000
const MAX_PREVIEW_LENGTH = 2_000
const shouldAutoApproveTools = process.env.CHERRY_AUTO_ALLOW_TOOLS === '1'
type ToolPermissionBehavior = 'allow' | 'deny'
type ToolPermissionResponsePayload = {
requestId: string
behavior: ToolPermissionBehavior
updatedInput?: unknown
message?: string
updatedPermissions?: PermissionUpdate[]
}
type PendingPermissionRequest = {
fulfill: (update: PermissionResult) => void
timeout: NodeJS.Timeout
signal?: AbortSignal
abortListener?: () => void
originalInput: Record<string, unknown>
toolName: string
}
type RendererPermissionRequestPayload = {
requestId: string
toolName: string
toolId: string
description?: string
requiresPermissions: boolean
input: Record<string, unknown>
inputPreview: string
createdAt: number
expiresAt: number
suggestions: PermissionUpdate[]
}
type RendererPermissionResultPayload = {
requestId: string
behavior: ToolPermissionBehavior
message?: string
reason: 'response' | 'timeout' | 'aborted' | 'no-window'
}
const pendingRequests = new Map<string, PendingPermissionRequest>()
let ipcHandlersInitialized = false
const jsonReplacer = (_key: string, value: unknown) => {
if (typeof value === 'bigint') return value.toString()
if (value instanceof Map) return Object.fromEntries(value.entries())
if (value instanceof Set) return Array.from(value.values())
if (value instanceof Date) return value.toISOString()
if (typeof value === 'function') return undefined
if (value === undefined) return undefined
return value
}
const sanitizeStructuredData = <T>(value: T): T => {
try {
return JSON.parse(JSON.stringify(value, jsonReplacer)) as T
} catch (error) {
logger.warn('Failed to sanitize structured data for tool permission payload', {
error: error instanceof Error ? { name: error.name, message: error.message } : String(error)
})
return value
}
}
const buildInputPreview = (value: unknown): string => {
let preview: string
try {
preview = JSON.stringify(value, null, 2)
} catch (error) {
preview = typeof value === 'string' ? value : String(value)
}
if (preview.length > MAX_PREVIEW_LENGTH) {
preview = `${preview.slice(0, MAX_PREVIEW_LENGTH)}...`
}
return preview
}
const broadcastToRenderer = (
channel: IpcChannel,
payload: RendererPermissionRequestPayload | RendererPermissionResultPayload
): boolean => {
const mainWindow = windowService.getMainWindow()
if (!mainWindow) {
logger.warn('Unable to send agent tool permission payload main window unavailable', {
channel,
requestId: 'requestId' in payload ? payload.requestId : undefined
})
return false
}
mainWindow.webContents.send(channel, payload)
return true
}
const finalizeRequest = (
requestId: string,
update: PermissionResult,
reason: RendererPermissionResultPayload['reason']
) => {
const pending = pendingRequests.get(requestId)
if (!pending) {
logger.debug('Attempted to finalize unknown tool permission request', { requestId, reason })
return false
}
logger.debug('Finalizing tool permission request', {
requestId,
toolName: pending.toolName,
behavior: update.behavior,
reason
})
pendingRequests.delete(requestId)
clearTimeout(pending.timeout)
if (pending.signal && pending.abortListener) {
pending.signal.removeEventListener('abort', pending.abortListener)
}
pending.fulfill(update)
const resultPayload: RendererPermissionResultPayload = {
requestId,
behavior: update.behavior,
message: update.behavior === 'deny' ? update.message : undefined,
reason
}
const dispatched = broadcastToRenderer(IpcChannel.AgentToolPermission_Result, resultPayload)
logger.debug('Sent tool permission result to renderer', {
requestId,
dispatched
})
return true
}
const ensureIpcHandlersRegistered = () => {
if (ipcHandlersInitialized) return
ipcHandlersInitialized = true
ipcMain.handle(IpcChannel.AgentToolPermission_Response, async (_event, payload: ToolPermissionResponsePayload) => {
logger.debug('main received AgentToolPermission_Response', payload)
const { requestId, behavior, updatedInput, message } = payload
const pending = pendingRequests.get(requestId)
if (!pending) {
logger.warn('Received renderer tool permission response for unknown request', { requestId })
return { success: false, error: 'unknown-request' }
}
logger.debug('Received renderer response for tool permission', {
requestId,
toolName: pending.toolName,
behavior,
hasUpdatedPermissions: Array.isArray(payload.updatedPermissions) && payload.updatedPermissions.length > 0
})
const maybeUpdatedInput =
updatedInput && typeof updatedInput === 'object' && !Array.isArray(updatedInput)
? (updatedInput as Record<string, unknown>)
: pending.originalInput
const sanitizedUpdatedPermissions = Array.isArray(payload.updatedPermissions)
? payload.updatedPermissions.map((perm) => sanitizeStructuredData(perm))
: undefined
const finalUpdate: PermissionResult =
behavior === 'allow'
? {
behavior: 'allow',
updatedInput: sanitizeStructuredData(maybeUpdatedInput),
updatedPermissions: sanitizedUpdatedPermissions
}
: {
behavior: 'deny',
message: message ?? 'User denied permission for this tool'
}
finalizeRequest(requestId, finalUpdate, 'response')
return { success: true }
})
}
export async function promptForToolApproval(
toolName: string,
input: Record<string, unknown>,
options?: { signal: AbortSignal; suggestions?: PermissionUpdate[] }
): Promise<PermissionResult> {
if (shouldAutoApproveTools) {
logger.debug('promptForToolApproval auto-approving tool for test', {
toolName
})
return { behavior: 'allow', updatedInput: input }
}
ensureIpcHandlersRegistered()
if (options?.signal?.aborted) {
logger.info('Skipping tool approval prompt because request signal is already aborted', { toolName })
return { behavior: 'deny', message: 'Tool request was cancelled before prompting the user' }
}
const mainWindow = windowService.getMainWindow()
if (!mainWindow) {
logger.warn('Denying tool usage because no renderer window is available to obtain approval', { toolName })
return { behavior: 'deny', message: 'Unable to request approval renderer not ready' }
}
const toolMetadata = builtinTools.find((tool) => tool.name === toolName || tool.id === toolName)
const sanitizedInput = sanitizeStructuredData(input)
const inputPreview = buildInputPreview(sanitizedInput)
const sanitizedSuggestions = (options?.suggestions ?? []).map((suggestion) => sanitizeStructuredData(suggestion))
const requestId = randomUUID()
const createdAt = Date.now()
const expiresAt = createdAt + TOOL_APPROVAL_TIMEOUT_MS
logger.info('Requesting user approval for tool usage', {
requestId,
toolName,
description: toolMetadata?.description
})
const requestPayload: RendererPermissionRequestPayload = {
requestId,
toolName,
toolId: toolMetadata?.id ?? toolName,
description: toolMetadata?.description,
requiresPermissions: toolMetadata?.requirePermissions ?? false,
input: sanitizedInput,
inputPreview,
createdAt,
expiresAt,
suggestions: sanitizedSuggestions
}
const defaultDenyUpdate: PermissionResult = { behavior: 'deny', message: 'Tool request aborted before user decision' }
logger.debug('Registering tool permission request', {
requestId,
toolName,
requiresPermissions: requestPayload.requiresPermissions,
timeoutMs: TOOL_APPROVAL_TIMEOUT_MS,
suggestionCount: sanitizedSuggestions.length
})
return new Promise<PermissionResult>((resolve) => {
const timeout = setTimeout(() => {
logger.info('User tool permission request timed out', { requestId, toolName })
finalizeRequest(requestId, { behavior: 'deny', message: 'Timed out waiting for approval' }, 'timeout')
}, TOOL_APPROVAL_TIMEOUT_MS)
const pending: PendingPermissionRequest = {
fulfill: resolve,
timeout,
originalInput: sanitizedInput,
toolName,
signal: options?.signal
}
if (options?.signal) {
const abortListener = () => {
logger.info('Tool permission request aborted before user responded', { requestId, toolName })
finalizeRequest(requestId, defaultDenyUpdate, 'aborted')
}
pending.abortListener = abortListener
options.signal.addEventListener('abort', abortListener, { once: true })
}
pendingRequests.set(requestId, pending)
logger.debug('Pending tool permission request count', {
count: pendingRequests.size
})
const sent = broadcastToRenderer(IpcChannel.AgentToolPermission_Request, requestPayload)
logger.debug('Broadcasted tool permission request to renderer', {
requestId,
toolName,
sent
})
if (!sent) {
finalizeRequest(
requestId,
{
behavior: 'deny',
message: 'Unable to request approval because the renderer window is unavailable'
},
'no-window'
)
}
})
}

View File

@ -0,0 +1,223 @@
import * as fs from 'node:fs'
import * as path from 'node:path'
import { loggerService } from '@logger'
import { isPathInside } from './file'
const logger = loggerService.withContext('Utils:FileOperations')
const MAX_RECURSION_DEPTH = 1000
/**
* Recursively copy a directory and all its contents
* @param source - Source directory path (must be absolute)
* @param destination - Destination directory path (must be absolute)
* @param options - Copy options
* @param depth - Current recursion depth (internal use)
* @throws If copy operation fails or paths are invalid
*/
export async function copyDirectoryRecursive(
source: string,
destination: string,
options?: { allowedBasePath?: string },
depth = 0
): Promise<void> {
// Input validation
if (!source || !destination) {
throw new TypeError('Source and destination paths are required')
}
if (!path.isAbsolute(source) || !path.isAbsolute(destination)) {
throw new Error('Source and destination paths must be absolute')
}
// Depth limit to prevent stack overflow
if (depth > MAX_RECURSION_DEPTH) {
throw new Error(`Maximum recursion depth exceeded: ${MAX_RECURSION_DEPTH}`)
}
// Path validation - ensure operations stay within allowed boundaries
if (options?.allowedBasePath) {
if (!isPathInside(source, options.allowedBasePath)) {
throw new Error(`Source path is outside allowed directory: ${source}`)
}
if (!isPathInside(destination, options.allowedBasePath)) {
throw new Error(`Destination path is outside allowed directory: ${destination}`)
}
}
try {
// Verify source exists and is a directory
const sourceStats = await fs.promises.lstat(source)
if (!sourceStats.isDirectory()) {
throw new Error(`Source is not a directory: ${source}`)
}
// Create destination directory
await fs.promises.mkdir(destination, { recursive: true })
logger.debug('Created destination directory', { destination })
// Read source directory
const entries = await fs.promises.readdir(source, { withFileTypes: true })
// Copy each entry
for (const entry of entries) {
const sourcePath = path.join(source, entry.name)
const destPath = path.join(destination, entry.name)
// Use lstat to detect symlinks and prevent following them
const entryStats = await fs.promises.lstat(sourcePath)
if (entryStats.isSymbolicLink()) {
logger.warn('Skipping symlink for security', { path: sourcePath })
continue
}
if (entryStats.isDirectory()) {
// Recursively copy subdirectory
await copyDirectoryRecursive(sourcePath, destPath, options, depth + 1)
} else if (entryStats.isFile()) {
// Copy file with error handling for race conditions
try {
await fs.promises.copyFile(sourcePath, destPath)
// Preserve file permissions
await fs.promises.chmod(destPath, entryStats.mode)
logger.debug('Copied file', { from: sourcePath, to: destPath })
} catch (error) {
// Handle race condition where file was deleted during copy
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
logger.warn('File disappeared during copy', { sourcePath })
continue
}
throw error
}
} else {
// Skip special files (pipes, sockets, devices, etc.)
logger.debug('Skipping special file', { path: sourcePath })
}
}
logger.info('Directory copied successfully', { from: source, to: destination, depth })
} catch (error) {
logger.error('Failed to copy directory', { source, destination, depth, error })
throw error
}
}
/**
* Recursively delete a directory and all its contents
* @param dirPath - Directory path to delete (must be absolute)
* @param options - Delete options
* @throws If deletion fails or path is invalid
*/
export async function deleteDirectoryRecursive(dirPath: string, options?: { allowedBasePath?: string }): Promise<void> {
// Input validation
if (!dirPath) {
throw new TypeError('Directory path is required')
}
if (!path.isAbsolute(dirPath)) {
throw new Error('Directory path must be absolute')
}
// Path validation - ensure operations stay within allowed boundaries
if (options?.allowedBasePath) {
if (!isPathInside(dirPath, options.allowedBasePath)) {
throw new Error(`Path is outside allowed directory: ${dirPath}`)
}
}
try {
// Verify path exists before attempting deletion
try {
const stats = await fs.promises.lstat(dirPath)
if (!stats.isDirectory()) {
throw new Error(`Path is not a directory: ${dirPath}`)
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
logger.warn('Directory already deleted', { dirPath })
return
}
throw error
}
// Node.js 14.14+ has fs.rm with recursive option
await fs.promises.rm(dirPath, { recursive: true, force: true })
logger.info('Directory deleted successfully', { dirPath })
} catch (error) {
logger.error('Failed to delete directory', { dirPath, error })
throw error
}
}
/**
* Get total size of a directory (in bytes)
* @param dirPath - Directory path (must be absolute)
* @param options - Size calculation options
* @param depth - Current recursion depth (internal use)
* @returns Total size in bytes
* @throws If size calculation fails or path is invalid
*/
export async function getDirectorySize(
dirPath: string,
options?: { allowedBasePath?: string },
depth = 0
): Promise<number> {
// Input validation
if (!dirPath) {
throw new TypeError('Directory path is required')
}
if (!path.isAbsolute(dirPath)) {
throw new Error('Directory path must be absolute')
}
// Depth limit to prevent stack overflow
if (depth > MAX_RECURSION_DEPTH) {
throw new Error(`Maximum recursion depth exceeded: ${MAX_RECURSION_DEPTH}`)
}
// Path validation - ensure operations stay within allowed boundaries
if (options?.allowedBasePath) {
if (!isPathInside(dirPath, options.allowedBasePath)) {
throw new Error(`Path is outside allowed directory: ${dirPath}`)
}
}
let totalSize = 0
try {
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true })
for (const entry of entries) {
const entryPath = path.join(dirPath, entry.name)
// Use lstat to detect symlinks and prevent following them
const entryStats = await fs.promises.lstat(entryPath)
if (entryStats.isSymbolicLink()) {
logger.debug('Skipping symlink in size calculation', { path: entryPath })
continue
}
if (entryStats.isDirectory()) {
// Recursively get size of subdirectory
totalSize += await getDirectorySize(entryPath, options, depth + 1)
} else if (entryStats.isFile()) {
// Get file size from lstat (already have it)
totalSize += entryStats.size
} else {
// Skip special files
logger.debug('Skipping special file in size calculation', { path: entryPath })
}
}
logger.debug('Calculated directory size', { dirPath, size: totalSize, depth })
return totalSize
} catch (error) {
logger.error('Failed to calculate directory size', { dirPath, depth, error })
throw error
}
}

View File

@ -0,0 +1,309 @@
import { loggerService } from '@logger'
import type { PluginError, PluginMetadata } from '@types'
import * as crypto from 'crypto'
import * as fs from 'fs'
import matter from 'gray-matter'
import * as yaml from 'js-yaml'
import * as path from 'path'
import { getDirectorySize } from './fileOperations'
const logger = loggerService.withContext('Utils:MarkdownParser')
/**
* Parse plugin metadata from a markdown file with frontmatter
* @param filePath Absolute path to the markdown file
* @param sourcePath Relative source path from plugins directory
* @param category Category name derived from parent folder
* @param type Plugin type (agent or command)
* @returns PluginMetadata object with parsed frontmatter and file info
*/
export async function parsePluginMetadata(
filePath: string,
sourcePath: string,
category: string,
type: 'agent' | 'command'
): Promise<PluginMetadata> {
const content = await fs.promises.readFile(filePath, 'utf8')
const stats = await fs.promises.stat(filePath)
// Parse frontmatter safely with FAILSAFE_SCHEMA to prevent deserialization attacks
const { data } = matter(content, {
engines: {
yaml: (s) => yaml.load(s, { schema: yaml.FAILSAFE_SCHEMA }) as object
}
})
// Calculate content hash for integrity checking
const contentHash = crypto.createHash('sha256').update(content).digest('hex')
// Extract filename
const filename = path.basename(filePath)
// Parse allowed_tools - handle both array and comma-separated string
let allowedTools: string[] | undefined
if (data['allowed-tools'] || data.allowed_tools) {
const toolsData = data['allowed-tools'] || data.allowed_tools
if (Array.isArray(toolsData)) {
allowedTools = toolsData
} else if (typeof toolsData === 'string') {
allowedTools = toolsData
.split(',')
.map((t) => t.trim())
.filter(Boolean)
}
}
// Parse tools - similar handling
let tools: string[] | undefined
if (data.tools) {
if (Array.isArray(data.tools)) {
tools = data.tools
} else if (typeof data.tools === 'string') {
tools = data.tools
.split(',')
.map((t) => t.trim())
.filter(Boolean)
}
}
// Parse tags
let tags: string[] | undefined
if (data.tags) {
if (Array.isArray(data.tags)) {
tags = data.tags
} else if (typeof data.tags === 'string') {
tags = data.tags
.split(',')
.map((t) => t.trim())
.filter(Boolean)
}
}
return {
sourcePath,
filename,
name: data.name || filename.replace(/\.md$/, ''),
description: data.description,
allowed_tools: allowedTools,
tools,
category,
type,
tags,
version: data.version,
author: data.author,
size: stats.size,
contentHash
}
}
/**
* Recursively find all directories containing SKILL.md
*
* @param dirPath - Directory to search in
* @param basePath - Base path for calculating relative source paths
* @param maxDepth - Maximum depth to search (default: 10 to prevent infinite loops)
* @param currentDepth - Current search depth (used internally)
* @returns Array of objects with absolute folder path and relative source path
*/
export async function findAllSkillDirectories(
dirPath: string,
basePath: string,
maxDepth = 10,
currentDepth = 0
): Promise<Array<{ folderPath: string; sourcePath: string }>> {
const results: Array<{ folderPath: string; sourcePath: string }> = []
// Prevent excessive recursion
if (currentDepth > maxDepth) {
return results
}
// Check if current directory contains SKILL.md
const skillMdPath = path.join(dirPath, 'SKILL.md')
try {
await fs.promises.stat(skillMdPath)
// Found SKILL.md in this directory
const relativePath = path.relative(basePath, dirPath)
results.push({
folderPath: dirPath,
sourcePath: relativePath
})
return results
} catch {
// SKILL.md not in current directory
}
// Only search subdirectories if current directory doesn't have SKILL.md
try {
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true })
for (const entry of entries) {
if (entry.isDirectory()) {
const subDirPath = path.join(dirPath, entry.name)
const subResults = await findAllSkillDirectories(subDirPath, basePath, maxDepth, currentDepth + 1)
results.push(...subResults)
}
}
} catch (error: any) {
// Ignore errors when reading subdirectories (e.g., permission denied)
logger.debug('Failed to read subdirectory during skill search', {
dirPath,
error: error.message
})
}
return results
}
/**
* Parse metadata from SKILL.md within a skill folder
*
* @param skillFolderPath - Absolute path to skill folder (must be absolute and contain SKILL.md)
* @param sourcePath - Relative path from plugins base (e.g., "skills/my-skill")
* @param category - Category name (typically "skills" for flat structure)
* @returns PluginMetadata with folder name as filename (no extension)
* @throws PluginError if SKILL.md not found or parsing fails
*/
export async function parseSkillMetadata(
skillFolderPath: string,
sourcePath: string,
category: string
): Promise<PluginMetadata> {
// Input validation
if (!skillFolderPath || !path.isAbsolute(skillFolderPath)) {
throw {
type: 'INVALID_METADATA',
reason: 'Skill folder path must be absolute',
path: skillFolderPath
} as PluginError
}
// Look for SKILL.md directly in this folder (no recursion)
const skillMdPath = path.join(skillFolderPath, 'SKILL.md')
// Check if SKILL.md exists
try {
await fs.promises.stat(skillMdPath)
} catch (error: any) {
if (error.code === 'ENOENT') {
logger.error('SKILL.md not found in skill folder', { skillMdPath })
throw {
type: 'FILE_NOT_FOUND',
path: skillMdPath,
message: 'SKILL.md not found in skill folder'
} as PluginError
}
throw error
}
// Read SKILL.md content
let content: string
try {
content = await fs.promises.readFile(skillMdPath, 'utf8')
} catch (error: any) {
logger.error('Failed to read SKILL.md', { skillMdPath, error })
throw {
type: 'READ_FAILED',
path: skillMdPath,
reason: error.message || 'Unknown error'
} as PluginError
}
// Parse frontmatter safely with FAILSAFE_SCHEMA to prevent deserialization attacks
let data: any
try {
const parsed = matter(content, {
engines: {
yaml: (s) => yaml.load(s, { schema: yaml.FAILSAFE_SCHEMA }) as object
}
})
data = parsed.data
} catch (error: any) {
logger.error('Failed to parse SKILL.md frontmatter', { skillMdPath, error })
throw {
type: 'INVALID_METADATA',
reason: `Failed to parse frontmatter: ${error.message}`,
path: skillMdPath
} as PluginError
}
// Calculate hash of SKILL.md only (not entire folder)
// Note: This means changes to other files in the skill won't trigger cache invalidation
// This is intentional - only SKILL.md metadata changes should trigger updates
const contentHash = crypto.createHash('sha256').update(content).digest('hex')
// Get folder name as identifier (NO EXTENSION)
const folderName = path.basename(skillFolderPath)
// Get total folder size
let folderSize: number
try {
folderSize = await getDirectorySize(skillFolderPath)
} catch (error: any) {
logger.error('Failed to calculate skill folder size', { skillFolderPath, error })
// Use 0 as fallback instead of failing completely
folderSize = 0
}
// Parse tools (skills use 'tools', not 'allowed_tools')
let tools: string[] | undefined
if (data.tools) {
if (Array.isArray(data.tools)) {
// Validate all elements are strings
tools = data.tools.filter((t) => typeof t === 'string')
} else if (typeof data.tools === 'string') {
tools = data.tools
.split(',')
.map((t) => t.trim())
.filter(Boolean)
}
}
// Parse tags
let tags: string[] | undefined
if (data.tags) {
if (Array.isArray(data.tags)) {
// Validate all elements are strings
tags = data.tags.filter((t) => typeof t === 'string')
} else if (typeof data.tags === 'string') {
tags = data.tags
.split(',')
.map((t) => t.trim())
.filter(Boolean)
}
}
// Validate and sanitize name
const name = typeof data.name === 'string' && data.name.trim() ? data.name.trim() : folderName
// Validate and sanitize description
const description =
typeof data.description === 'string' && data.description.trim() ? data.description.trim() : undefined
// Validate version and author
const version = typeof data.version === 'string' ? data.version : undefined
const author = typeof data.author === 'string' ? data.author : undefined
logger.debug('Successfully parsed skill metadata', {
skillFolderPath,
folderName,
size: folderSize
})
return {
sourcePath, // e.g., "skills/my-skill"
filename: folderName, // e.g., "my-skill" (folder name, NO .md extension)
name,
description,
tools,
category, // "skills" for flat structure
type: 'skill',
tags,
version,
author,
size: folderSize,
contentHash // Hash of SKILL.md content only
}
}

View File

@ -1,3 +1,4 @@
import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'
import { electronAPI } from '@electron-toolkit/preload'
import type { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
import type { SpanContext } from '@opentelemetry/api'
@ -42,6 +43,16 @@ import type { OpenDialogOptions } from 'electron'
import { contextBridge, ipcRenderer, shell, webUtils } from 'electron'
import type { CreateDirectoryOptions } from 'webdav'
import type {
InstalledPlugin,
InstallPluginOptions,
ListAvailablePluginsResult,
PluginMetadata,
PluginResult,
UninstallPluginOptions,
WritePluginContentOptions
} from '../renderer/src/types/plugin'
export function tracedInvoke(channel: string, spanContext: SpanContext | undefined, ...args: any[]) {
if (spanContext) {
const data = { type: 'trace', context: spanContext }
@ -426,6 +437,15 @@ const api = {
minimizeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowMinimize),
pinActionWindow: (isPinned: boolean) => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowPin, isPinned)
},
agentTools: {
respondToPermission: (payload: {
requestId: string
behavior: 'allow' | 'deny'
updatedInput?: Record<string, unknown>
message?: string
updatedPermissions?: PermissionUpdate[]
}) => ipcRenderer.invoke(IpcChannel.AgentToolPermission_Response, payload)
},
quoteToMainWindow: (text: string) => ipcRenderer.invoke(IpcChannel.App_QuoteToMain, text),
// setDisableHardwareAcceleration: (isDisable: boolean) =>
// ipcRenderer.invoke(IpcChannel.App_SetDisableHardwareAcceleration, isDisable),
@ -548,6 +568,21 @@ const api = {
start: (): Promise<StartApiServerStatusResult> => ipcRenderer.invoke(IpcChannel.ApiServer_Start),
restart: (): Promise<RestartApiServerStatusResult> => ipcRenderer.invoke(IpcChannel.ApiServer_Restart),
stop: (): Promise<StopApiServerStatusResult> => ipcRenderer.invoke(IpcChannel.ApiServer_Stop)
},
claudeCodePlugin: {
listAvailable: (): Promise<PluginResult<ListAvailablePluginsResult>> =>
ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_ListAvailable),
install: (options: InstallPluginOptions): Promise<PluginResult<PluginMetadata>> =>
ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_Install, options),
uninstall: (options: UninstallPluginOptions): Promise<PluginResult<void>> =>
ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_Uninstall, options),
listInstalled: (agentId: string): Promise<PluginResult<InstalledPlugin[]>> =>
ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_ListInstalled, agentId),
invalidateCache: (): Promise<PluginResult<void>> => ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_InvalidateCache),
readContent: (sourcePath: string): Promise<PluginResult<string>> =>
ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_ReadContent, sourcePath),
writeContent: (options: WritePluginContentOptions): Promise<PluginResult<void>> =>
ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_WriteContent, options)
}
}

View File

@ -98,7 +98,8 @@ export default class ModernAiProvider {
// 提前构建中间件
const middlewares = buildAiSdkMiddlewares({
...config,
provider: this.actualProvider
provider: this.actualProvider,
assistant: config.assistant
})
logger.debug('Built middlewares in completions', {
middlewareCount: middlewares.length,

View File

@ -1,13 +1,18 @@
import type { WebSearchPluginConfig } from '@cherrystudio/ai-core/built-in/plugins'
import { loggerService } from '@logger'
import { type MCPTool, type Message, type Model, type Provider } from '@renderer/types'
import { isSupportedThinkingTokenQwenModel } from '@renderer/config/models'
import { isSupportEnableThinkingProvider } from '@renderer/config/providers'
import type { MCPTool } from '@renderer/types'
import { type Assistant, type Message, type Model, type Provider } from '@renderer/types'
import type { Chunk } from '@renderer/types/chunk'
import type { LanguageModelMiddleware } from 'ai'
import { extractReasoningMiddleware, simulateStreamingMiddleware } from 'ai'
import { isEmpty } from 'lodash'
import { isOpenRouterGeminiGenerateImageModel } from '../utils/image'
import { noThinkMiddleware } from './noThinkMiddleware'
import { openrouterGenerateImageMiddleware } from './openrouterGenerateImageMiddleware'
import { qwenThinkingMiddleware } from './qwenThinkingMiddleware'
import { toolChoiceMiddleware } from './toolChoiceMiddleware'
const logger = loggerService.withContext('AiSdkMiddlewareBuilder')
@ -20,6 +25,7 @@ export interface AiSdkMiddlewareConfig {
onChunk?: (chunk: Chunk) => void
model?: Model
provider?: Provider
assistant?: Assistant
enableReasoning: boolean
// 是否开启提示词工具调用
isPromptToolUse: boolean
@ -128,7 +134,7 @@ export function buildAiSdkMiddlewares(config: AiSdkMiddlewareConfig): LanguageMo
const builder = new AiSdkMiddlewareBuilder()
// 0. 知识库强制调用中间件(必须在最前面,确保第一轮强制调用知识库)
if (config.knowledgeRecognition === 'off') {
if (!isEmpty(config.assistant?.knowledge_bases?.map((base) => base.id)) && config.knowledgeRecognition !== 'on') {
builder.add({
name: 'force-knowledge-first',
middleware: toolChoiceMiddleware('builtin_knowledge_search')
@ -219,6 +225,21 @@ function addProviderSpecificMiddlewares(builder: AiSdkMiddlewareBuilder, config:
function addModelSpecificMiddlewares(builder: AiSdkMiddlewareBuilder, config: AiSdkMiddlewareConfig): void {
if (!config.model || !config.provider) return
// Qwen models on providers that don't support enable_thinking parameter (like Ollama, LM Studio, NVIDIA)
// Use /think or /no_think suffix to control thinking mode
if (
config.provider &&
isSupportedThinkingTokenQwenModel(config.model) &&
!isSupportEnableThinkingProvider(config.provider)
) {
const enableThinking = config.assistant?.settings?.reasoning_effort !== undefined
builder.add({
name: 'qwen-thinking-control',
middleware: qwenThinkingMiddleware(enableThinking)
})
logger.debug(`Added Qwen thinking middleware with thinking ${enableThinking ? 'enabled' : 'disabled'}`)
}
// 可以根据模型ID或特性添加特定中间件
// 例如:图像生成模型、多模态模型等
if (isOpenRouterGeminiGenerateImageModel(config.model, config.provider)) {

View File

@ -0,0 +1,39 @@
import type { LanguageModelMiddleware } from 'ai'
/**
* Qwen Thinking Middleware
* Controls thinking mode for Qwen models on providers that don't support enable_thinking parameter (like Ollama)
* Appends '/think' or '/no_think' suffix to user messages based on reasoning_effort setting
* @param enableThinking - Whether thinking mode is enabled (based on reasoning_effort !== undefined)
* @returns LanguageModelMiddleware
*/
export function qwenThinkingMiddleware(enableThinking: boolean): LanguageModelMiddleware {
const suffix = enableThinking ? ' /think' : ' /no_think'
return {
middlewareVersion: 'v2',
transformParams: async ({ params }) => {
const transformedParams = { ...params }
// Process messages in prompt
if (transformedParams.prompt && Array.isArray(transformedParams.prompt)) {
transformedParams.prompt = transformedParams.prompt.map((message) => {
// Only process user messages
if (message.role === 'user') {
// Process content array
if (Array.isArray(message.content)) {
for (const part of message.content) {
if (part.type === 'text' && !part.text.endsWith('/think') && !part.text.endsWith('/no_think')) {
part.text += suffix
}
}
}
}
return message
})
}
return transformedParams
}
}
}

View File

@ -2,7 +2,7 @@ import { DeleteOutlined, ExclamationCircleOutlined, ReloadOutlined } from '@ant-
import { Button, Flex, Tooltip } from '@cherrystudio/ui'
import { restoreFromLocal } from '@renderer/services/BackupService'
import { formatFileSize } from '@renderer/utils'
import { Modal, Table } from 'antd'
import { Modal, Space, Table } from 'antd'
import dayjs from 'dayjs'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -221,6 +221,26 @@ export function LocalBackupManager({ visible, onClose, localBackupDir, restoreMe
}
}
const footerContent = (
<Space align="center">
<Button key="refresh" onClick={fetchBackupFiles} disabled={loading}>
<ReloadOutlined />
{t('settings.data.local.backup.manager.refresh')}
</Button>
<Button
key="delete"
variant="destructive"
onClick={handleDeleteSelected}
disabled={selectedRowKeys.length === 0 || deleting}>
<DeleteOutlined />
{t('settings.data.local.backup.manager.delete.selected')} ({selectedRowKeys.length})
</Button>
<Button key="close" onClick={onClose}>
{t('common.close')}
</Button>
</Space>
)
return (
<Modal
title={t('settings.data.local.backup.manager.title')}
@ -229,24 +249,7 @@ export function LocalBackupManager({ visible, onClose, localBackupDir, restoreMe
width={800}
centered
transitionName="animation-move-down"
classNames={{ footer: 'flex justify-end gap-1' }}
footer={[
<Button key="refresh" onClick={fetchBackupFiles} disabled={loading}>
<ReloadOutlined />
{t('settings.data.local.backup.manager.refresh')}
</Button>,
<Button
key="delete"
variant="destructive"
onClick={handleDeleteSelected}
disabled={selectedRowKeys.length === 0 || deleting}>
<DeleteOutlined />
{t('settings.data.local.backup.manager.delete.selected')} ({selectedRowKeys.length})
</Button>,
<Button key="close" onClick={onClose}>
{t('common.close')}
</Button>
]}>
footer={footerContent}>
<Table
rowKey="fileName"
columns={columns}

View File

@ -4,8 +4,8 @@ exports[`ImageToolButton > should match snapshot 1`] = `
<DocumentFragment>
<button
aria-label="Test tooltip"
class="rounded-full"
data-testid="button"
radius="full"
type="button"
>
<span

View File

@ -3,7 +3,7 @@ import { Button, Tooltip } from '@cherrystudio/ui'
import { restoreFromS3 } from '@renderer/services/BackupService'
import type { S3Config } from '@renderer/types'
import { formatFileSize } from '@renderer/utils'
import { Modal, Space, Table } from 'antd'
import { Modal, Table } from 'antd'
import dayjs from 'dayjs'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'

View File

@ -65,7 +65,7 @@ const NavbarContainer = styled.div<{ $isFullScreen: boolean }>`
min-width: 100%;
display: flex;
flex-direction: row;
min-height: ${isMac ? 'env(titlebar-area-height)' : 'var(--navbar-height)'};
min-height: ${({ $isFullScreen }) => (!$isFullScreen && isMac ? 'env(titlebar-area-height)' : 'var(--navbar-height)')};
max-height: var(--navbar-height);
margin-left: ${isMac ? 'calc(var(--sidebar-width) * -1 + 2px)' : 0};
padding-left: ${({ $isFullScreen }) =>

View File

@ -11,6 +11,8 @@ export function getPreprocessProviderLogo(providerId: PreprocessProviderId) {
return MistralLogo
case 'mineru':
return MinerULogo
case 'open-mineru':
return MinerULogo
default:
return undefined
}
@ -36,5 +38,11 @@ export const PREPROCESS_PROVIDER_CONFIG: Record<PreprocessProviderId, Preprocess
official: 'https://mineru.net/',
apiKey: 'https://mineru.net/apiManage'
}
},
'open-mineru': {
websites: {
official: 'https://github.com/opendatalab/MinerU/',
apiKey: 'https://github.com/opendatalab/MinerU/'
}
}
}

View File

@ -412,7 +412,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
type: 'openai',
apiKey: '',
apiHost: 'https://dashscope.aliyuncs.com/compatible-mode/v1/',
anthropicApiHost: 'https://dashscope.aliyuncs.com/api/v2/apps/claude-code-proxy',
anthropicApiHost: 'https://dashscope.aliyuncs.com/apps/anthropic',
models: SYSTEM_MODELS.dashscope,
isSystem: true,
enabled: false

View File

@ -1,5 +1,6 @@
/// <reference types="vite/client" />
import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'
import type { ToastUtilities } from '@cherrystudio/ui'
import type { HookAPI } from 'antd/es/modal/useModal'
import type { NavigateFunction } from 'react-router-dom'
@ -19,5 +20,14 @@ declare global {
store: any
navigate: NavigateFunction
toast: ToastUtilities
agentTools: {
respondToPermission: (payload: {
requestId: string
behavior: 'allow' | 'deny'
updatedInput?: Record<string, unknown>
message?: string
updatedPermissions?: PermissionUpdate[]
}) => Promise<{ success: boolean }>
}
}
}

View File

@ -0,0 +1,49 @@
import { useAgent } from '@renderer/hooks/agents/useAgent'
import { useSessions } from '@renderer/hooks/agents/useSessions'
import { useAppDispatch } from '@renderer/store'
import { setActiveSessionIdAction, setActiveTopicOrSessionAction } from '@renderer/store/runtime'
import type { CreateSessionForm } from '@renderer/types'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
/**
* Returns a stable callback that creates a default agent session and updates UI state.
*/
export const useCreateDefaultSession = (agentId: string | null) => {
const { agent } = useAgent(agentId)
const { createSession } = useSessions(agentId)
const dispatch = useAppDispatch()
const { t } = useTranslation()
const [creatingSession, setCreatingSession] = useState(false)
const createDefaultSession = useCallback(async () => {
if (!agentId || !agent || creatingSession) {
return null
}
setCreatingSession(true)
try {
const session = {
...agent,
id: undefined,
name: t('common.unnamed')
} satisfies CreateSessionForm
const created = await createSession(session)
if (created) {
dispatch(setActiveSessionIdAction({ agentId, sessionId: created.id }))
dispatch(setActiveTopicOrSessionAction('session'))
}
return created
} finally {
setCreatingSession(false)
}
}, [agentId, agent, createSession, creatingSession, dispatch, t])
return {
createDefaultSession,
creatingSession
}
}

View File

@ -13,12 +13,18 @@ import { useAppDispatch } from '@renderer/store'
import { useAppSelector } from '@renderer/store'
import { handleSaveData } from '@renderer/store'
import { selectMemoryConfig } from '@renderer/store/memory'
import {
type ToolPermissionRequestPayload,
type ToolPermissionResultPayload,
toolPermissionsActions
} from '@renderer/store/toolPermissions'
import { delay, runAsyncFunction } from '@renderer/utils'
import { checkDataLimit } from '@renderer/utils'
import { defaultLanguage } from '@shared/config/constant'
import { IpcChannel } from '@shared/IpcChannel'
import { useLiveQuery } from 'dexie-react-hooks'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useDefaultModel } from './useAssistant'
import useFullScreenNotice from './useFullScreenNotice'
@ -27,6 +33,7 @@ import { useNavbarPosition } from './useNavbar'
const logger = loggerService.withContext('useAppInit')
export function useAppInit() {
const { t } = useTranslation()
const dispatch = useAppDispatch()
const [language] = usePreference('app.language')
const [windowStyle] = usePreference('ui.window_style')
@ -148,6 +155,64 @@ export function useAppInit() {
}
}, [customCss])
useEffect(() => {
if (!window.electron?.ipcRenderer) return
const requestListener = (_event: Electron.IpcRendererEvent, payload: ToolPermissionRequestPayload) => {
logger.debug('Renderer received tool permission request', {
requestId: payload.requestId,
toolName: payload.toolName,
expiresAt: payload.expiresAt,
suggestionCount: payload.suggestions.length
})
dispatch(toolPermissionsActions.requestReceived(payload))
}
const resultListener = (_event: Electron.IpcRendererEvent, payload: ToolPermissionResultPayload) => {
logger.debug('Renderer received tool permission result', {
requestId: payload.requestId,
behavior: payload.behavior,
reason: payload.reason
})
dispatch(toolPermissionsActions.requestResolved(payload))
if (payload.behavior === 'deny') {
const message =
payload.reason === 'timeout'
? (payload.message ?? t('agent.toolPermission.toast.timeout'))
: (payload.message ?? t('agent.toolPermission.toast.denied'))
if (payload.reason === 'no-window') {
logger.debug('Displaying deny toast for tool permission', {
requestId: payload.requestId,
behavior: payload.behavior,
reason: payload.reason
})
window.toast?.error?.(message)
} else if (payload.reason === 'timeout') {
logger.debug('Displaying timeout toast for tool permission', {
requestId: payload.requestId
})
window.toast?.warning?.(message)
} else {
logger.debug('Displaying info toast for tool permission deny', {
requestId: payload.requestId,
reason: payload.reason
})
window.toast?.info?.(message)
}
}
}
window.electron.ipcRenderer.on(IpcChannel.AgentToolPermission_Request, requestListener)
window.electron.ipcRenderer.on(IpcChannel.AgentToolPermission_Result, resultListener)
return () => {
window.electron?.ipcRenderer.removeListener(IpcChannel.AgentToolPermission_Request, requestListener)
window.electron?.ipcRenderer.removeListener(IpcChannel.AgentToolPermission_Result, resultListener)
}
}, [dispatch, t])
useEffect(() => {
// TODO: init data collection
}, [enableDataCollection])

View File

@ -57,7 +57,7 @@ export const useKnowledgeBaseForm = (base?: KnowledgeBase) => {
label: t('settings.tool.preprocess.provider'),
title: t('settings.tool.preprocess.provider'),
options: preprocessProviders
.filter((p) => p.apiKey !== '' || p.id === 'mineru')
.filter((p) => p.apiKey !== '' || ['mineru', 'open-mineru'].includes(p.id))
.map((p) => ({ value: p.id, label: p.name }))
}
return [preprocessOptions]

View File

@ -0,0 +1,163 @@
import type { InstalledPlugin, PluginError, PluginMetadata } from '@renderer/types/plugin'
import { useCallback, useEffect, useState } from 'react'
/**
* Helper to extract error message from PluginError union type
*/
function getPluginErrorMessage(error: PluginError, defaultMessage: string): string {
if ('message' in error && error.message) return error.message
if ('reason' in error) return error.reason
if ('path' in error) return `Error with file: ${error.path}`
return defaultMessage
}
/**
* Hook to fetch and cache available plugins from the resources directory
* @returns Object containing available agents, commands, skills, loading state, and error
*/
export function useAvailablePlugins() {
const [agents, setAgents] = useState<PluginMetadata[]>([])
const [commands, setCommands] = useState<PluginMetadata[]>([])
const [skills, setSkills] = useState<PluginMetadata[]>([])
const [loading, setLoading] = useState<boolean>(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const fetchAvailablePlugins = async () => {
setLoading(true)
setError(null)
try {
const result = await window.api.claudeCodePlugin.listAvailable()
if (result.success) {
setAgents(result.data.agents)
setCommands(result.data.commands)
setSkills(result.data.skills)
} else {
setError(getPluginErrorMessage(result.error, 'Failed to load available plugins'))
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error occurred')
} finally {
setLoading(false)
}
}
fetchAvailablePlugins()
}, [])
return { agents, commands, skills, loading, error }
}
/**
* Hook to fetch installed plugins for a specific agent
* @param agentId - The ID of the agent to fetch plugins for
* @returns Object containing installed plugins, loading state, error, and refresh function
*/
export function useInstalledPlugins(agentId: string | undefined) {
const [plugins, setPlugins] = useState<InstalledPlugin[]>([])
const [loading, setLoading] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
const refresh = useCallback(async () => {
if (!agentId) {
setPlugins([])
setLoading(false)
setError(null)
return
}
setLoading(true)
setError(null)
try {
const result = await window.api.claudeCodePlugin.listInstalled(agentId)
if (result.success) {
setPlugins(result.data)
} else {
setError(getPluginErrorMessage(result.error, 'Failed to load installed plugins'))
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error occurred')
} finally {
setLoading(false)
}
}, [agentId])
useEffect(() => {
refresh()
}, [refresh])
return { plugins, loading, error, refresh }
}
/**
* Hook to provide install and uninstall actions for plugins
* @param agentId - The ID of the agent to perform actions for
* @param onSuccess - Optional callback to be called on successful operations
* @returns Object containing install, uninstall functions and their loading states
*/
export function usePluginActions(agentId: string, onSuccess?: () => void) {
const [installing, setInstalling] = useState<boolean>(false)
const [uninstalling, setUninstalling] = useState<boolean>(false)
const install = useCallback(
async (sourcePath: string, type: 'agent' | 'command' | 'skill') => {
setInstalling(true)
try {
const result = await window.api.claudeCodePlugin.install({
agentId,
sourcePath,
type
})
if (result.success) {
onSuccess?.()
return { success: true as const, data: result.data }
} else {
const errorMessage = getPluginErrorMessage(result.error, 'Failed to install plugin')
return { success: false as const, error: errorMessage }
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'
return { success: false as const, error: errorMessage }
} finally {
setInstalling(false)
}
},
[agentId, onSuccess]
)
const uninstall = useCallback(
async (filename: string, type: 'agent' | 'command' | 'skill') => {
setUninstalling(true)
try {
const result = await window.api.claudeCodePlugin.uninstall({
agentId,
filename,
type
})
if (result.success) {
onSuccess?.()
return { success: true as const }
} else {
const errorMessage = getPluginErrorMessage(result.error, 'Failed to uninstall plugin')
return { success: false as const, error: errorMessage }
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'
return { success: false as const, error: errorMessage }
} finally {
setUninstalling(false)
}
},
[agentId, onSuccess]
)
return { install, uninstall, installing, uninstalling }
}

View File

@ -107,6 +107,50 @@
"title": "Advanced Settings"
},
"essential": "Essential Settings",
"plugins": {
"available": {
"title": "Available Plugins"
},
"confirm": {
"uninstall": "Are you sure you want to uninstall this plugin?"
},
"empty": {
"available": "No plugins found matching your filters. Try adjusting your search or category filters."
},
"error": {
"install": "Failed to install plugin",
"load": "Failed to load plugins",
"uninstall": "Failed to uninstall plugin"
},
"filter": {
"all": "All Categories"
},
"install": "Install",
"installed": {
"empty": "No plugins installed yet. Browse available plugins to get started.",
"title": "Installed Plugins"
},
"installing": "Installing...",
"results": "{{count}} plugin(s) found",
"search": {
"placeholder": "Search plugins..."
},
"success": {
"install": "Plugin installed successfully",
"uninstall": "Plugin uninstalled successfully"
},
"tab": "Plugins",
"type": {
"agent": "Agent",
"agents": "Agents",
"all": "All",
"command": "Command",
"commands": "Commands",
"skills": "Skills"
},
"uninstall": "Uninstall",
"uninstalling": "Uninstalling..."
},
"prompt": "Prompt Settings",
"tooling": {
"mcp": {
@ -191,6 +235,39 @@
"toggle": "{{defaultValue}}"
}
},
"toolPermission": {
"aria": {
"allowRequest": "Allow tool request",
"denyRequest": "Deny tool request",
"hideDetails": "Hide tool details",
"runWithOptions": "Run with additional options",
"showDetails": "Show tool details"
},
"button": {
"cancel": "Cancel",
"run": "Run"
},
"confirmation": "Are you sure you want to run this Claude tool?",
"defaultDenyMessage": "User denied permission for this tool.",
"defaultDescription": "Executes code or system actions in your environment. Make sure the command looks safe before running it.",
"error": {
"sendFailed": "Failed to send your decision. Please try again."
},
"expired": "Expired",
"inputPreview": "Tool input preview",
"pending": "Pending ({{seconds}}s)",
"permissionExpired": "Permission request expired. Waiting for new instructions...",
"requiresElevatedPermissions": "This tool requires elevated permissions.",
"suggestion": {
"permissionUpdateMultiple": "Approving may update multiple session permissions if you chose to always allow this tool.",
"permissionUpdateSingle": "Approving may update your session permissions if you chose to always allow this tool."
},
"toast": {
"denied": "Tool request was denied.",
"timeout": "Tool request timed out before receiving approval."
},
"waiting": "Waiting for tool permission decision..."
},
"type": {
"label": "Agent Type",
"unknown": "Unknown Type"
@ -2299,6 +2376,32 @@
"seed_tip": "Controls upscaling randomness"
}
},
"plugins": {
"actions": "Actions",
"agents": "Agents",
"all_categories": "All Categories",
"all_types": "All",
"category": "Category",
"commands": "Commands",
"confirm_uninstall": "Are you sure you want to uninstall {{name}}?",
"install": "Install",
"install_plugins_from_browser": "Browse available plugins to get started",
"installing": "Installing...",
"name": "Name",
"no_description": "No description available",
"no_installed_plugins": "No plugins installed yet",
"no_results": "No plugins found",
"search_placeholder": "Search plugins...",
"showing_results": "Showing {{count}} plugin",
"showing_results_one": "Showing {{count}} plugin",
"showing_results_other": "Showing {{count}} plugins",
"showing_results_plural": "Showing {{count}} plugins",
"skills": "Skills",
"try_different_search": "Try adjusting your search or category filters",
"type": "Type",
"uninstall": "Uninstall",
"uninstalling": "Uninstalling..."
},
"preview": {
"copy": {
"image": "Copy as image"

View File

@ -107,6 +107,50 @@
"title": "高级设置"
},
"essential": "基础设置",
"plugins": {
"available": {
"title": "可用插件"
},
"confirm": {
"uninstall": "确定要卸载此插件吗?"
},
"empty": {
"available": "未找到匹配的插件。请尝试调整搜索或类别筛选。"
},
"error": {
"install": "安装插件失败",
"load": "加载插件失败",
"uninstall": "卸载插件失败"
},
"filter": {
"all": "所有类别"
},
"install": "安装",
"installed": {
"empty": "尚未安装任何插件。浏览可用插件以开始使用。",
"title": "已安装插件"
},
"installing": "安装中...",
"results": "找到 {{count}} 个插件",
"search": {
"placeholder": "搜索插件..."
},
"success": {
"install": "插件安装成功",
"uninstall": "插件卸载成功"
},
"tab": "插件",
"type": {
"agent": "代理",
"agents": "代理",
"all": "全部",
"command": "命令",
"commands": "命令",
"skills": "技能"
},
"uninstall": "卸载",
"uninstalling": "卸载中..."
},
"prompt": "提示词设置",
"tooling": {
"mcp": {
@ -191,6 +235,39 @@
"toggle": "{{defaultValue}}"
}
},
"toolPermission": {
"aria": {
"allowRequest": "允许工具请求",
"denyRequest": "拒绝工具请求",
"hideDetails": "隐藏工具详情",
"runWithOptions": "带选项运行",
"showDetails": "显示工具详情"
},
"button": {
"cancel": "取消",
"run": "运行"
},
"confirmation": "确定要运行此 Claude 工具吗?",
"defaultDenyMessage": "用户拒绝了该工具的权限。",
"defaultDescription": "在您的环境中执行代码或系统操作。运行前请确保命令安全。",
"error": {
"sendFailed": "发送您的决定失败,请重试。"
},
"expired": "已过期",
"inputPreview": "工具输入预览",
"pending": "等待中 ({{seconds}}秒)",
"permissionExpired": "权限请求已过期。等待新指令...",
"requiresElevatedPermissions": "此工具需要更高权限。",
"suggestion": {
"permissionUpdateMultiple": "如果您选择总是允许此工具,批准可能会更新多个会话权限。",
"permissionUpdateSingle": "如果您选择总是允许此工具,批准可能会更新您的会话权限。"
},
"toast": {
"denied": "工具请求已被拒绝。",
"timeout": "工具请求在收到批准前超时。"
},
"waiting": "等待工具权限决定..."
},
"type": {
"label": "智能体类型",
"unknown": "未知类型"
@ -2299,6 +2376,32 @@
"seed_tip": "控制放大结果的随机性"
}
},
"plugins": {
"actions": "操作",
"agents": "代理",
"all_categories": "所有类别",
"all_types": "全部",
"category": "类别",
"commands": "命令",
"confirm_uninstall": "确定要卸载 {{name}} 吗?",
"install": "安装",
"install_plugins_from_browser": "浏览可用插件以开始使用",
"installing": "安装中...",
"name": "名称",
"no_description": "无描述",
"no_installed_plugins": "尚未安装任何插件",
"no_results": "未找到插件",
"search_placeholder": "搜索插件...",
"showing_results": "显示 {{count}} 个插件",
"showing_results_one": "显示 {{count}} 个插件",
"showing_results_other": "显示 {{count}} 个插件",
"showing_results_plural": "显示 {{count}} 个插件",
"skills": "技能",
"try_different_search": "请尝试调整搜索或类别筛选",
"type": "类型",
"uninstall": "卸载",
"uninstalling": "卸载中..."
},
"preview": {
"copy": {
"image": "复制为图片"

View File

@ -107,6 +107,50 @@
"title": "進階設定"
},
"essential": "必要設定",
"plugins": {
"available": {
"title": "可用外掛"
},
"confirm": {
"uninstall": "確定要解除安裝此外掛嗎?"
},
"empty": {
"available": "未找到符合的外掛。請嘗試調整搜尋或類別篩選。"
},
"error": {
"install": "安裝外掛失敗",
"load": "載入外掛失敗",
"uninstall": "解除安裝外掛失敗"
},
"filter": {
"all": "所有類別"
},
"install": "安裝",
"installed": {
"empty": "尚未安裝任何外掛。瀏覽可用外掛以開始使用。",
"title": "已安裝外掛"
},
"installing": "安裝中...",
"results": "找到 {{count}} 個外掛",
"search": {
"placeholder": "搜尋外掛..."
},
"success": {
"install": "外掛安裝成功",
"uninstall": "外掛解除安裝成功"
},
"tab": "外掛",
"type": {
"agent": "代理",
"agents": "代理",
"all": "全部",
"command": "指令",
"commands": "指令",
"skills": "技能"
},
"uninstall": "解除安裝",
"uninstalling": "解除安裝中..."
},
"prompt": "提示設定",
"tooling": {
"mcp": {
@ -191,6 +235,39 @@
"toggle": "{{defaultValue}}"
}
},
"toolPermission": {
"aria": {
"allowRequest": "允許工具請求",
"denyRequest": "拒絕工具請求",
"hideDetails": "隱藏工具詳情",
"runWithOptions": "帶選項執行",
"showDetails": "顯示工具詳情"
},
"button": {
"cancel": "取消",
"run": "執行"
},
"confirmation": "確定要執行此 Claude 工具嗎?",
"defaultDenyMessage": "使用者拒絕了該工具的權限。",
"defaultDescription": "在您的環境中執行程式碼或系統操作。執行前請確保指令安全。",
"error": {
"sendFailed": "傳送您的決定失敗,請重試。"
},
"expired": "已過期",
"inputPreview": "工具輸入預覽",
"pending": "等待中 ({{seconds}}秒)",
"permissionExpired": "權限請求已過期。等待新指令...",
"requiresElevatedPermissions": "此工具需要提升的權限。",
"suggestion": {
"permissionUpdateMultiple": "如果您選擇總是允許此工具,核准可能會更新多個工作階段權限。",
"permissionUpdateSingle": "如果您選擇總是允許此工具,核准可能會更新您的工作階段權限。"
},
"toast": {
"denied": "工具請求已被拒絕。",
"timeout": "工具請求在收到核准前逾時。"
},
"waiting": "等待工具權限決定..."
},
"type": {
"label": "代理類型",
"unknown": "未知類型"
@ -2299,6 +2376,32 @@
"seed_tip": "控制放大結果的隨機性"
}
},
"plugins": {
"actions": "操作",
"agents": "代理",
"all_categories": "所有類別",
"all_types": "全部",
"category": "類別",
"commands": "指令",
"confirm_uninstall": "確定要解除安裝 {{name}} 嗎?",
"install": "安裝",
"install_plugins_from_browser": "瀏覽可用外掛以開始使用",
"installing": "安裝中...",
"name": "名稱",
"no_description": "無描述",
"no_installed_plugins": "尚未安裝任何外掛",
"no_results": "未找到外掛",
"search_placeholder": "搜尋外掛...",
"showing_results": "顯示 {{count}} 個外掛",
"showing_results_one": "顯示 {{count}} 個外掛",
"showing_results_other": "顯示 {{count}} 個外掛",
"showing_results_plural": "顯示 {{count}} 個外掛",
"skills": "技能",
"try_different_search": "請嘗試調整搜尋或類別篩選",
"type": "類型",
"uninstall": "解除安裝",
"uninstalling": "解除安裝中..."
},
"preview": {
"copy": {
"image": "複製為圖片"

View File

@ -107,6 +107,50 @@
"title": "Erweiterte Einstellungen"
},
"essential": "Grundeinstellungen",
"plugins": {
"available": {
"title": "Verfügbare Plugins"
},
"confirm": {
"uninstall": "Sind Sie sicher, dass Sie dieses Plugin deinstallieren möchten?"
},
"empty": {
"available": "Keine Plugins gefunden, die deinen Filtern entsprechen. Versuche, deine Such- oder Kategoriefilter anzupassen."
},
"error": {
"install": "Fehler beim Installieren des Plugins",
"load": "Fehler beim Laden der Plugins",
"uninstall": "Fehler beim Deinstallieren des Plugins"
},
"filter": {
"all": "Alle Kategorien"
},
"install": "Installieren",
"installed": {
"empty": "Noch keine Plugins installiert. Durchsuche verfügbare Plugins, um loszulegen.",
"title": "Installierte Plugins"
},
"installing": "Wird installiert...",
"results": "{{count}} Plugin(s) gefunden",
"search": {
"placeholder": "Such-Plugins..."
},
"success": {
"install": "Plugin erfolgreich installiert",
"uninstall": "Plugin erfolgreich deinstalliert"
},
"tab": "Plugins",
"type": {
"agent": "Agent",
"agents": "Agenten",
"all": "Alle",
"command": "Befehl",
"commands": "Befehle",
"skills": "Fähigkeiten"
},
"uninstall": "Deinstallieren",
"uninstalling": "Deinstallation läuft..."
},
"prompt": "Prompt-Einstellungen",
"tooling": {
"mcp": {
@ -191,6 +235,39 @@
"toggle": "{{defaultValue}}"
}
},
"toolPermission": {
"aria": {
"allowRequest": "Werkzeuganfrage zulassen",
"denyRequest": "Werkzeuganfrage ablehnen",
"hideDetails": "Werkzeugdetails ausblenden",
"runWithOptions": "Mit zusätzlichen Optionen ausführen",
"showDetails": "Zeige Werkzeugdetails"
},
"button": {
"cancel": "Abbrechen",
"run": "Laufen"
},
"confirmation": "Bist du sicher, dass du dieses Claude-Tool ausführen möchtest?",
"defaultDenyMessage": "Der Benutzer hat die Berechtigung für dieses Tool verweigert.",
"defaultDescription": "Führt Code oder Systemaktionen in Ihrer Umgebung aus. Vergewissern Sie sich, dass der Befehl sicher aussieht, bevor Sie ihn ausführen.",
"error": {
"sendFailed": "Ihre Entscheidung konnte nicht gesendet werden. Bitte versuchen Sie es erneut."
},
"expired": "Abgelaufen",
"inputPreview": "Vorschau der Werkzeugeingabe",
"pending": "Ausstehend ({{seconds}}s)",
"permissionExpired": "Berechtigungsanfrage abgelaufen. Warte auf neue Anweisungen...",
"requiresElevatedPermissions": "Dieses Tool erfordert erhöhte Berechtigungen.",
"suggestion": {
"permissionUpdateMultiple": "Das Genehmigen kann mehrere Sitzungsberechtigungen aktualisieren, wenn Sie sich entschieden haben, dieses Tool immer zuzulassen.",
"permissionUpdateSingle": "Das Genehmigen kann Ihre Sitzungsberechtigungen aktualisieren, wenn Sie sich entschieden haben, dieses Tool immer zuzulassen."
},
"toast": {
"denied": "Tool-Anfrage wurde abgelehnt.",
"timeout": "Tool-Anfrage ist abgelaufen, bevor eine Genehmigung eingegangen ist."
},
"waiting": "Warten auf Entscheidung über Tool-Berechtigung..."
},
"type": {
"label": "Agent-Typ",
"unknown": "Unbekannter Typ"
@ -2299,6 +2376,32 @@
"seed_tip": "Kontrolle der Zufälligkeit des Upscale-Ergebnisses"
}
},
"plugins": {
"actions": "Aktionen",
"agents": "Agenten",
"all_categories": "Alle Kategorien",
"all_types": "Alle",
"category": "Kategorie",
"commands": "Befehle",
"confirm_uninstall": "Sind Sie sicher, dass Sie {{name}} deinstallieren möchten?",
"install": "Installieren",
"install_plugins_from_browser": "Durchsuche verfügbare Plugins, um loszulegen",
"installing": "Installiere…",
"name": "Name",
"no_description": "Keine Beschreibung verfügbar",
"no_installed_plugins": "Noch keine Plugins installiert",
"no_results": "Keine Plugins gefunden",
"search_placeholder": "Such-Plugins...",
"showing_results": "{{count}} Plugin anzeigen",
"showing_results_one": "{{count}} Plugin anzeigen",
"showing_results_other": "Zeige {{count}} Plugins",
"showing_results_plural": "{{count}} Plugins anzeigen",
"skills": "Fähigkeiten",
"try_different_search": "Versuchen Sie, Ihre Suche oder die Kategoriefilter anzupassen.",
"type": "Typ",
"uninstall": "Deinstallieren",
"uninstalling": "Deinstallation läuft..."
},
"preview": {
"copy": {
"image": "Als Bild kopieren"

View File

@ -107,6 +107,50 @@
"title": "Ρυθμίσεις για προχωρημένους"
},
"essential": "Βασικές Ρυθμίσεις",
"plugins": {
"available": {
"title": "Διαθέσιμα πρόσθετα"
},
"confirm": {
"uninstall": "Είστε βέβαιοι ότι θέλετε να απεγκαταστήσετε αυτό το πρόσθετο;"
},
"empty": {
"available": "Δεν βρέθηκε συμβατό πρόσθετο. Δοκιμάστε να προσαρμόσετε την αναζήτηση ή το φίλτρο κατηγοριών."
},
"error": {
"install": "Η εγκατάσταση του πρόσθετου απέτυχε",
"load": "Η φόρτωση του πρόσθετου απέτυχε",
"uninstall": "Η απεγκατάσταση του πρόσθετου απέτυχε"
},
"filter": {
"all": "Όλες οι κατηγορίες"
},
"install": "εγκατάσταση",
"installed": {
"empty": "Δεν έχει εγκατασταθεί κανένα πρόσθετο. Περιηγηθείτε στα διαθέσιμα πρόσθετα για να ξεκινήσετε.",
"title": "Έχει εγκατασταθεί το πρόσθετο"
},
"installing": "Εγκατάσταση...",
"results": "Βρέθηκαν {{count}} πρόσθετα",
"search": {
"placeholder": "Αναζήτηση πρόσθετου..."
},
"success": {
"install": "Η εγκατάσταση του πρόσθετου ολοκληρώθηκε με επιτυχία",
"uninstall": "Η απεγκατάσταση του πρόσθετου ολοκληρώθηκε με επιτυχία"
},
"tab": "Πρόσθετο",
"type": {
"agent": "αντιπρόσωπος",
"agents": "αντιπρόσωπος",
"all": "όλα",
"command": "εντολή",
"commands": "εντολή",
"skills": "δεξιότητα"
},
"uninstall": "απεγκατάσταση",
"uninstalling": "Απεγκατάσταση..."
},
"prompt": "Ρυθμίσεις Προτροπής",
"tooling": {
"mcp": {
@ -191,6 +235,39 @@
"toggle": "{{defaultValue}}"
}
},
"toolPermission": {
"aria": {
"allowRequest": "Επίτρεψη αίτησης εργαλείου",
"denyRequest": "Απόρριψη αιτήματος εργαλείου",
"hideDetails": "Απόκρυψη λεπτομερειών εργαλείου",
"runWithOptions": "Εκτέλεση με επιπλέον επιλογές",
"showDetails": "Εμφάνιση λεπτομερειών εργαλείου"
},
"button": {
"cancel": "Ακύρωση",
"run": "Τρέξε"
},
"confirmation": "Είσαι σίγουρος ότι θέλεις να εκτελέσεις αυτό το εργαλείο Claude;",
"defaultDenyMessage": "Ο χρήστης αρνήθηκε την άδεια για αυτό το εργαλείο.",
"defaultDescription": "Εκτελεί κώδικα ή ενέργειες συστήματος στο περιβάλλον σας. Βεβαιωθείτε ότι η εντολή φαίνεται ασφαλής πριν την εκτελέσετε.",
"error": {
"sendFailed": "Αποτυχία αποστολής της απόφασής σας. Προσπαθήστε ξανά."
},
"expired": "Ληγμένο",
"inputPreview": "Προεπισκόπηση εισόδου εργαλείου",
"pending": "Εκκρεμεί ({{seconds}}δ)",
"permissionExpired": "Το αίτημα άδειας έληξε. Αναμονή για νέες οδηγίες...",
"requiresElevatedPermissions": "Αυτό το εργαλείο απαιτεί αυξημένα δικαιώματα.",
"suggestion": {
"permissionUpdateMultiple": "Η έγκριση μπορεί να ενημερώσει πολλές άδειες συνεδρίας αν επιλέξατε να επιτρέπετε πάντα αυτό το εργαλείο.",
"permissionUpdateSingle": "Η έγκριση ενδέχεται να ενημερώσει τα δικαιώματα περιόδου σύνδεσής σας, εάν επιλέξατε να επιτρέπετε πάντα αυτό το εργαλείο."
},
"toast": {
"denied": "Το αίτημα για εργαλείο απορρίφθηκε.",
"timeout": "Το αίτημα για το εργαλείο έληξε πριν λάβει έγκριση."
},
"waiting": "Αναμονή για απόφαση άδειας εργαλείου..."
},
"type": {
"label": "Τύπος Πράκτορα",
"unknown": "Άγνωστος Τύπος"
@ -2299,6 +2376,32 @@
"seed_tip": "Ελέγχει την τυχαιότητα του αποτελέσματος μεγέθυνσης"
}
},
"plugins": {
"actions": "Λειτουργία",
"agents": "αντιπρόσωπος",
"all_categories": "Όλες οι κατηγορίες",
"all_types": "ολόκληρο",
"category": "Κατηγορία",
"commands": "εντολή",
"confirm_uninstall": "Είστε σίγουροι ότι θέλετε να απεγκαταστήσετε το {{name}};",
"install": "εγκατάσταση",
"install_plugins_from_browser": "Περιηγηθείτε στα διαθέσιμα πρόσθετα για να ξεκινήσετε",
"installing": "Εγκατάσταση...",
"name": "Όνομα",
"no_description": "Χωρίς περιγραφή",
"no_installed_plugins": "Δεν έχει εγκατασταθεί κανένα πρόσθετο",
"no_results": "Δεν βρέθηκε πρόσθετο",
"search_placeholder": "Πρόσθετο αναζήτησης...",
"showing_results": "Εμφάνιση {{count}} προσθέτων",
"showing_results_one": "Εμφάνιση {{count}} προσθέτων",
"showing_results_other": "Εμφάνιση {{count}} προσθέτων",
"showing_results_plural": "Εμφάνιση {{count}} πρόσθετων",
"skills": "δεξιότητα",
"try_different_search": "Προσπαθήστε να προσαρμόσετε την αναζήτηση ή το φίλτρο κατηγοριών",
"type": "τύπος",
"uninstall": "κατάργηση εγκατάστασης",
"uninstalling": "Απεγκατάσταση..."
},
"preview": {
"copy": {
"image": "Αντιγραφή ως εικόνα"

View File

@ -107,6 +107,50 @@
"title": "Configuración avanzada"
},
"essential": "Configuraciones esenciales",
"plugins": {
"available": {
"title": "Complementos disponibles"
},
"confirm": {
"uninstall": "¿Estás seguro de que quieres desinstalar este complemento?"
},
"empty": {
"available": "No se encontró ningún complemento que coincida. Intenta ajustar la búsqueda o los filtros de categoría."
},
"error": {
"install": "Error al instalar el complemento",
"load": "Error al cargar el complemento",
"uninstall": "Error al desinstalar el complemento"
},
"filter": {
"all": "Todas las categorías"
},
"install": "instalación",
"installed": {
"empty": "Aún no se ha instalado ningún complemento. Explora los complementos disponibles para comenzar.",
"title": "Complemento instalado"
},
"installing": "Instalando...",
"results": "Encontrados {{count}} complementos",
"search": {
"placeholder": "Buscar complemento..."
},
"success": {
"install": "Complemento instalado con éxito",
"uninstall": "Complemento desinstalado correctamente"
},
"tab": "complemento",
"type": {
"agent": "agente",
"agents": "Agente",
"all": "todo",
"command": "comando",
"commands": "comando",
"skills": "habilidad"
},
"uninstall": "Desinstalar",
"uninstalling": "Desinstalando..."
},
"prompt": "Configuración de indicaciones",
"tooling": {
"mcp": {
@ -191,6 +235,39 @@
"toggle": "{{defaultValue}}"
}
},
"toolPermission": {
"aria": {
"allowRequest": "Permitir solicitud de herramienta",
"denyRequest": "Denegar solicitud de herramienta",
"hideDetails": "Ocultar detalles de la herramienta",
"runWithOptions": "Ejecutar con opciones adicionales",
"showDetails": "Mostrar detalles de la herramienta"
},
"button": {
"cancel": "Cancelar",
"run": "Correr"
},
"confirmation": "¿Estás seguro de que quieres ejecutar esta herramienta de Claude?",
"defaultDenyMessage": "El usuario denegó el permiso para esta herramienta.",
"defaultDescription": "Ejecuta código o acciones del sistema en tu entorno. Asegúrate de que el comando parezca seguro antes de ejecutarlo.",
"error": {
"sendFailed": "No se pudo enviar tu decisión. Por favor, inténtalo de nuevo."
},
"expired": "Caducado",
"inputPreview": "Vista previa de entrada de herramienta",
"pending": "Pendiente ({{seconds}}s)",
"permissionExpired": "Solicitud de permiso expirada. Esperando nuevas instrucciones...",
"requiresElevatedPermissions": "Esta herramienta requiere permisos elevados.",
"suggestion": {
"permissionUpdateMultiple": "Aprobar puede actualizar varios permisos de sesión si elegiste permitir siempre esta herramienta.",
"permissionUpdateSingle": "Aprobar puede actualizar los permisos de tu sesión si elegiste permitir siempre esta herramienta."
},
"toast": {
"denied": "La solicitud de herramienta fue denegada.",
"timeout": "La solicitud de herramienta expiró antes de recibir la aprobación."
},
"waiting": "Esperando la decisión de permiso de la herramienta..."
},
"type": {
"label": "Tipo de Agente",
"unknown": "Tipo desconocido"
@ -2299,6 +2376,32 @@
"seed_tip": "Controla la aleatoriedad del resultado de la ampliación"
}
},
"plugins": {
"actions": "Operación",
"agents": "Agente",
"all_categories": "Todas las categorías",
"all_types": "todo",
"category": "Categoría",
"commands": "comando",
"confirm_uninstall": "¿Estás seguro de que quieres desinstalar {{name}}?",
"install": "instalación",
"install_plugins_from_browser": "Explora los complementos disponibles para empezar a usar",
"installing": "Instalando...",
"name": "Nombre",
"no_description": "Sin descripción",
"no_installed_plugins": "Aún no se ha instalado ningún complemento",
"no_results": "No se encontró el complemento",
"search_placeholder": "Buscar complemento...",
"showing_results": "Mostrar {{count}} complementos",
"showing_results_one": "Mostrar {{count}} complementos",
"showing_results_other": "Mostrar {{count}} complementos",
"showing_results_plural": "Mostrar {{count}} complementos",
"skills": "habilidad",
"try_different_search": "Por favor, intenta ajustar la búsqueda o los filtros de categoría.",
"type": "tipo",
"uninstall": "Desinstalar",
"uninstalling": "Desinstalando..."
},
"preview": {
"copy": {
"image": "Copiar como imagen"

View File

@ -107,6 +107,50 @@
"title": "Paramètres avancés"
},
"essential": "Paramètres essentiels",
"plugins": {
"available": {
"title": "Plugins disponibles"
},
"confirm": {
"uninstall": "Êtes-vous sûr de vouloir désinstaller ce plugin ?"
},
"empty": {
"available": "Aucun plugin correspondant trouvé. Veuillez essayer dajuster la recherche ou les filtres de catégorie."
},
"error": {
"install": "Échec de l'installation du plugin",
"load": "Échec du chargement du plugin",
"uninstall": "Échec de la désinstallation du plugin"
},
"filter": {
"all": "Toutes les catégories"
},
"install": "Installation",
"installed": {
"empty": "Aucun plugin n'est encore installé. Parcourez les plugins disponibles pour commencer.",
"title": "Extension installée"
},
"installing": "Installation en cours...",
"results": "{{count}} modules complémentaires trouvés",
"search": {
"placeholder": "Recherche de plug-ins..."
},
"success": {
"install": "Installation du plugin réussie",
"uninstall": "Désinstallation du plugin réussie"
},
"tab": "Module d'extension",
"type": {
"agent": "mandataire",
"agents": "mandataire",
"all": "Tout",
"command": "commande",
"commands": "commande",
"skills": "compétence"
},
"uninstall": "Désinstaller",
"uninstalling": "Désinstallation en cours..."
},
"prompt": "Paramètres de l'invite",
"tooling": {
"mcp": {
@ -191,6 +235,39 @@
"toggle": "{{defaultValue}}"
}
},
"toolPermission": {
"aria": {
"allowRequest": "Autoriser la demande d'outil",
"denyRequest": "Refuser la demande d'outil",
"hideDetails": "Masquer les détails de l'outil",
"runWithOptions": "Exécuter avec des options supplémentaires",
"showDetails": "Afficher les détails de l'outil"
},
"button": {
"cancel": "Annuler",
"run": "Courir"
},
"confirmation": "Êtes-vous sûr de vouloir exécuter cet outil Claude ?",
"defaultDenyMessage": "L'utilisateur a refusé l'autorisation pour cet outil.",
"defaultDescription": "Exécute du code ou des actions système dans votre environnement. Assurez-vous que la commande semble sûre avant de lexécuter.",
"error": {
"sendFailed": "Échec de l'envoi de votre décision. Veuillez réessayer."
},
"expired": "Expiré",
"inputPreview": "Aperçu de l'entrée de l'outil",
"pending": "En attente ({{seconds}}s)",
"permissionExpired": "Demande de permission expirée. En attente de nouvelles instructions...",
"requiresElevatedPermissions": "Cet outil nécessite des autorisations élevées.",
"suggestion": {
"permissionUpdateMultiple": "Approuver peut mettre à jour plusieurs autorisations de session si vous avez choisi de toujours autoriser cet outil.",
"permissionUpdateSingle": "Approuver peut mettre à jour vos permissions de session si vous avez choisi de toujours autoriser cet outil."
},
"toast": {
"denied": "La demande d'outil a été refusée.",
"timeout": "La demande d'outil a expiré avant d'obtenir l'approbation."
},
"waiting": "En attente de la décision d'autorisation de l'outil..."
},
"type": {
"label": "Type d'agent",
"unknown": "Type inconnu"
@ -2299,6 +2376,32 @@
"seed_tip": "Contrôle la randomisation du résultat d'agrandissement"
}
},
"plugins": {
"actions": "Opération",
"agents": "mandataire",
"all_categories": "Toutes les catégories",
"all_types": "Tout",
"category": "Catégorie",
"commands": "commande",
"confirm_uninstall": "Êtes-vous sûr de vouloir désinstaller {{name}} ?",
"install": "Installation",
"install_plugins_from_browser": "Parcourir les plugins disponibles pour commencer",
"installing": "Installation en cours...",
"name": "Nom",
"no_description": "Sans description",
"no_installed_plugins": "Aucun plugin nest encore installé",
"no_results": "Aucun plugin trouvé",
"search_placeholder": "Rechercher des modules d'extension...",
"showing_results": "Afficher {{count}} extensions",
"showing_results_one": "Afficher {{count}} modules dextension",
"showing_results_other": "Afficher {{count}} modules d'extension",
"showing_results_plural": "Afficher {{count}} modules d'extension",
"skills": "compétence",
"try_different_search": "Veuillez essayer dajuster la recherche ou le filtre de catégorie.",
"type": "type",
"uninstall": "Désinstaller",
"uninstalling": "Désinstallation en cours..."
},
"preview": {
"copy": {
"image": "Copier en tant qu'image"

View File

@ -107,6 +107,50 @@
"title": "高級設定"
},
"essential": "必須設定",
"plugins": {
"available": {
"title": "利用可能なプラグイン"
},
"confirm": {
"uninstall": "このプラグインをアンインストールしてもよろしいですか?"
},
"empty": {
"available": "一致するプラグインが見つかりませんでした。検索キーワードやカテゴリフィルターを調整してみてください。"
},
"error": {
"install": "プラグインのインストールに失敗しました",
"load": "プラグインの読み込みに失敗しました",
"uninstall": "プラグインのアンインストールに失敗しました"
},
"filter": {
"all": "すべてのカテゴリー"
},
"install": "インストール",
"installed": {
"empty": "まだプラグインがインストールされていません。利用可能なプラグインを見てみましょう。",
"title": "インストール済みプラグイン"
},
"installing": "インストール中...",
"results": "{{count}} 個のプラグインが見つかりました",
"search": {
"placeholder": "検索プラグイン..."
},
"success": {
"install": "プラグインのインストールが成功しました",
"uninstall": "プラグインのアンインストールが成功しました"
},
"tab": "プラグイン",
"type": {
"agent": "代理",
"agents": "代理",
"all": "全部",
"command": "命令",
"commands": "命令",
"skills": "技能"
},
"uninstall": "アンインストール",
"uninstalling": "アンインストール中..."
},
"prompt": "プロンプト設定",
"tooling": {
"mcp": {
@ -191,6 +235,39 @@
"toggle": "{{defaultValue}}"
}
},
"toolPermission": {
"aria": {
"allowRequest": "ツールリクエストを許可",
"denyRequest": "ツールリクエストを拒否",
"hideDetails": "ツールの詳細を非表示",
"runWithOptions": "追加オプションで実行",
"showDetails": "ツールの詳細を表示"
},
"button": {
"cancel": "キャンセル",
"run": "走る"
},
"confirmation": "このClaudeツールを実行してもよろしいですか",
"defaultDenyMessage": "ユーザーはこのツールの使用を拒否しました。",
"defaultDescription": "環境内でコードまたはシステムアクションを実行します。実行前にコマンドが安全であることを確認してください。",
"error": {
"sendFailed": "決定の送信に失敗しました。もう一度お試しください。"
},
"expired": "期限切れ",
"inputPreview": "ツール入力プレビュー",
"pending": "保留中({{seconds}}秒)",
"permissionExpired": "許可リクエストの期限が切れました。新しい指示を待っています...",
"requiresElevatedPermissions": "このツールは昇格した権限が必要です。",
"suggestion": {
"permissionUpdateMultiple": "承認すると、このツールを常に許可することを選択した場合、複数のセッション権限が更新されることがあります。",
"permissionUpdateSingle": "承認すると、このツールを常に許可することを選択した場合、セッションの権限が更新されることがあります。"
},
"toast": {
"denied": "ツールリクエストは拒否されました。",
"timeout": "ツールリクエストは承認を受ける前にタイムアウトしました。"
},
"waiting": "ツールの許可決定を待っています..."
},
"type": {
"label": "エージェントタイプ",
"unknown": "不明なタイプ"
@ -2299,6 +2376,32 @@
"seed_tip": "拡大結果のランダム性を制御します"
}
},
"plugins": {
"actions": "操作",
"agents": "代理",
"all_categories": "すべてのカテゴリー",
"all_types": "全部",
"category": "カテゴリー",
"commands": "命令",
"confirm_uninstall": "{{name}}をアンインストールしてもよろしいですか?",
"install": "インストール",
"install_plugins_from_browser": "利用可能なプラグインを閲覧して、使用を開始してください",
"installing": "インストール中...",
"name": "名称",
"no_description": "説明なし",
"no_installed_plugins": "まだプラグインがインストールされていません",
"no_results": "プラグインが見つかりません",
"search_placeholder": "検索プラグイン...",
"showing_results": "{{count}} 個のプラグインを表示",
"showing_results_one": "{{count}} 個のプラグインを表示",
"showing_results_other": "{{count}} 個のプラグインを表示",
"showing_results_plural": "{{count}} 個のプラグインを表示",
"skills": "スキル",
"try_different_search": "検索またはカテゴリフィルターを調整してみてください",
"type": "タイプ",
"uninstall": "アンインストール",
"uninstalling": "アンインストール中..."
},
"preview": {
"copy": {
"image": "画像としてコピー"

View File

@ -107,6 +107,50 @@
"title": "Configurações avançadas"
},
"essential": "Configurações Essenciais",
"plugins": {
"available": {
"title": "Plugins disponíveis"
},
"confirm": {
"uninstall": "Tem certeza de que deseja desinstalar este plugin?"
},
"empty": {
"available": "Nenhum plugin correspondente encontrado. Tente ajustar a pesquisa ou os filtros de categoria."
},
"error": {
"install": "Falha na instalação do plugin",
"load": "Falha ao carregar o plugin",
"uninstall": "Falha ao desinstalar o plug-in"
},
"filter": {
"all": "Todas as categorias"
},
"install": "Instalação",
"installed": {
"empty": "Nenhum plugin foi instalado ainda. Explore os plugins disponíveis para começar.",
"title": "Plugin instalado"
},
"installing": "Instalando...",
"results": "Encontrados {{count}} plugins",
"search": {
"placeholder": "Pesquisar extensão..."
},
"success": {
"install": "Plugin instalado com sucesso",
"uninstall": "插件 desinstalado com sucesso"
},
"tab": "plug-in",
"type": {
"agent": "agente",
"agents": "agente",
"all": "tudo",
"command": "comando",
"commands": "comando",
"skills": "habilidade"
},
"uninstall": "desinstalar",
"uninstalling": "Desinstalando..."
},
"prompt": "Configurações de Prompt",
"tooling": {
"mcp": {
@ -191,6 +235,39 @@
"toggle": "{{defaultValue}}"
}
},
"toolPermission": {
"aria": {
"allowRequest": "Permitir solicitação de ferramenta",
"denyRequest": "Negar solicitação de ferramenta",
"hideDetails": "Ocultar detalhes da ferramenta",
"runWithOptions": "Executar com opções adicionais",
"showDetails": "Mostrar detalhes da ferramenta"
},
"button": {
"cancel": "Cancelar",
"run": "Correr"
},
"confirmation": "Tem certeza de que quer executar esta ferramenta Claude?",
"defaultDenyMessage": "Usuário negou permissão para esta ferramenta.",
"defaultDescription": "Executa código ou ações do sistema no seu ambiente. Certifique-se de que o comando parece seguro antes de executá-lo.",
"error": {
"sendFailed": "Falha ao enviar sua decisão. Por favor, tente novamente."
},
"expired": "Expirado",
"inputPreview": "Pré-visualização da entrada da ferramenta",
"pending": "Pendente ({{seconds}}s)",
"permissionExpired": "Solicitação de permissão expirou. Aguardando novas instruções...",
"requiresElevatedPermissions": "Esta ferramenta requer permissões elevadas.",
"suggestion": {
"permissionUpdateMultiple": "Aprovar pode atualizar várias permissões de sessão se você escolheu sempre permitir esta ferramenta.",
"permissionUpdateSingle": "Aprovar pode atualizar as permissões da sua sessão se você escolheu sempre permitir esta ferramenta."
},
"toast": {
"denied": "Solicitação de ferramenta foi negada.",
"timeout": "A solicitação da ferramenta expirou antes de receber aprovação."
},
"waiting": "Aguardando decisão de permissão da ferramenta..."
},
"type": {
"label": "Tipo de Agente",
"unknown": "Tipo Desconhecido"
@ -2299,6 +2376,32 @@
"seed_tip": "Controla a aleatoriedade do resultado de ampliação"
}
},
"plugins": {
"actions": "Operação",
"agents": "agente",
"all_categories": "Todas as categorias",
"all_types": "Tudo",
"category": "categoria",
"commands": "comando",
"confirm_uninstall": "Tem certeza de que deseja desinstalar {{name}}?",
"install": "Instalação",
"install_plugins_from_browser": "Navegue pelos plugins disponíveis para começar a usar",
"installing": "Instalando...",
"name": "Nome",
"no_description": "Sem descrição",
"no_installed_plugins": "Nenhum plugin foi instalado ainda",
"no_results": "Plugin não encontrado",
"search_placeholder": "Pesquisar plugin...",
"showing_results": "Exibir {{count}} extensões",
"showing_results_one": "Mostrar {{count}} extensões",
"showing_results_other": "Exibir {{count}} extensões",
"showing_results_plural": "Exibir {{count}} extensões",
"skills": "habilidade",
"try_different_search": "Por favor, tente ajustar a pesquisa ou os filtros de categoria.",
"type": "tipo",
"uninstall": "Desinstalar",
"uninstalling": "Desinstalando..."
},
"preview": {
"copy": {
"image": "Copiar como imagem"

View File

@ -107,6 +107,50 @@
"title": "Расширенные настройки"
},
"essential": "Основные настройки",
"plugins": {
"available": {
"title": "Доступные плагины"
},
"confirm": {
"uninstall": "Вы уверены, что хотите удалить этот плагин?"
},
"empty": {
"available": "Совпадающие плагины не найдены. Попробуйте изменить поиск или фильтр категорий."
},
"error": {
"install": "Ошибка установки плагина",
"load": "Ошибка загрузки плагина",
"uninstall": "Не удалось удалить плагин"
},
"filter": {
"all": "Все категории"
},
"install": "установка",
"installed": {
"empty": "Плагины ещё не установлены. Просмотрите доступные плагины, чтобы начать.",
"title": "Установленный плагин"
},
"installing": "Установка...",
"results": "Найдено {{count}} плагинов",
"search": {
"placeholder": "Поиск плагинов..."
},
"success": {
"install": "Плагин успешно установлен",
"uninstall": "Плагин успешно удалён"
},
"tab": "плагин",
"type": {
"agent": "агент",
"agents": "Прокси",
"all": "всё",
"command": "команда",
"commands": "команда",
"skills": "навык"
},
"uninstall": "Удаление",
"uninstalling": "Удаление..."
},
"prompt": "Настройки подсказки",
"tooling": {
"mcp": {
@ -191,6 +235,39 @@
"toggle": "{{defaultValue}}"
}
},
"toolPermission": {
"aria": {
"allowRequest": "Разрешить запрос инструмента",
"denyRequest": "Отклонить запрос на инструмент",
"hideDetails": "Скрыть сведения об инструменте",
"runWithOptions": "Запустить с дополнительными параметрами",
"showDetails": "Показать сведения об инструменте"
},
"button": {
"cancel": "Отмена",
"run": "Беги"
},
"confirmation": "Вы уверены, что хотите запустить этот инструмент Claude?",
"defaultDenyMessage": "Пользователь отказал в разрешении на использование этого инструмента.",
"defaultDescription": "Выполняет код или системные действия в вашей среде. Убедитесь, что команда выглядит безопасно, прежде чем запускать её.",
"error": {
"sendFailed": "Не удалось отправить ваше решение. Попробуйте ещё раз."
},
"expired": "Истёк",
"inputPreview": "Предварительный просмотр ввода инструмента",
"pending": "Ожидание ({{seconds}}с)",
"permissionExpired": "Срок действия запроса на разрешение истёк. Ожидание новых инструкций...",
"requiresElevatedPermissions": "Этому инструменту требуются повышенные разрешения.",
"suggestion": {
"permissionUpdateMultiple": "Одобрение может обновить разрешения для нескольких сеансов, если вы выбрали всегда разрешать использование этого инструмента.",
"permissionUpdateSingle": "Одобрение может обновить разрешения вашей сессии, если вы выбрали всегда разрешать использование этого инструмента."
},
"toast": {
"denied": "Запрос на инструмент был отклонён.",
"timeout": "Запрос на инструмент превысил время ожидания до получения подтверждения."
},
"waiting": "Ожидание решения о разрешении на использование инструмента..."
},
"type": {
"label": "Тип агента",
"unknown": "Неизвестный тип"
@ -2299,6 +2376,32 @@
"seed_tip": "Контролирует случайный характер увеличения изображений для воспроизводимых результатов"
}
},
"plugins": {
"actions": "Операция",
"agents": "агент",
"all_categories": "Все категории",
"all_types": "всё",
"category": "категория",
"commands": "команда",
"confirm_uninstall": "Вы уверены, что хотите удалить {{name}}?",
"install": "установка",
"install_plugins_from_browser": "Просмотрите доступные плагины, чтобы начать работу",
"installing": "Установка...",
"name": "название",
"no_description": "Без описания",
"no_installed_plugins": "Плагины ещё не установлены",
"no_results": "Плагин не найден",
"search_placeholder": "Поиск плагинов...",
"showing_results": "Отображено {{count}} плагинов",
"showing_results_one": "Отображено {{count}} плагинов",
"showing_results_other": "Отображено {{count}} плагинов",
"showing_results_plural": "Отображение {{count}} плагинов",
"skills": "навык",
"try_different_search": "Пожалуйста, попробуйте изменить поиск или фильтры категорий",
"type": "тип",
"uninstall": "Удаление",
"uninstalling": "Удаление..."
},
"preview": {
"copy": {
"image": "Скопировать как изображение"

View File

@ -72,7 +72,7 @@ export const getCodeToolsApiBaseUrl = (model: Model, type: EndpointType) => {
},
dashscope: {
anthropic: {
api_base_url: 'https://dashscope.aliyuncs.com/api/v2/apps/claude-code-proxy'
api_base_url: 'https://dashscope.aliyuncs.com/apps/anthropic'
}
},
modelscope: {

View File

@ -7,6 +7,7 @@ import { ContentSearch } from '@renderer/components/ContentSearch'
import MultiSelectActionPopup from '@renderer/components/Popups/MultiSelectionPopup'
import PromptPopup from '@renderer/components/Popups/PromptPopup'
import { QuickPanelProvider } from '@renderer/components/QuickPanel'
import { useCreateDefaultSession } from '@renderer/hooks/agents/useCreateDefaultSession'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useChatContext } from '@renderer/hooks/useChatContext'
import { useNavbarPosition } from '@renderer/hooks/useNavbar'
@ -56,6 +57,8 @@ const Chat: FC<Props> = (props) => {
const { chat } = useRuntime()
const { activeTopicOrSession, activeAgentId, activeSessionIdMap } = chat
const activeSessionId = activeAgentId ? activeSessionIdMap[activeAgentId] : null
const sessionAgentId = activeTopicOrSession === 'session' ? activeAgentId : null
const { createDefaultSession } = useCreateDefaultSession(sessionAgentId)
const mainRef = React.useRef<HTMLDivElement>(null)
const contentSearchRef = React.useRef<ContentSearchRef>(null)
@ -94,6 +97,21 @@ const Chat: FC<Props> = (props) => {
}
})
useShortcut(
'new_topic',
() => {
if (activeTopicOrSession !== 'session' || !activeAgentId) {
return
}
void createDefaultSession()
},
{
enabled: activeTopicOrSession === 'session',
preventDefault: true,
enableOnFormTags: true
}
)
const contentSearchFilter: NodeFilter = {
acceptNode(node) {
const container = node.parentElement?.closest('.message-content-container')

View File

@ -3,10 +3,12 @@ import { loggerService } from '@logger'
import { ActionIconButton } from '@renderer/components/Buttons'
import { QuickPanelView } from '@renderer/components/QuickPanel'
import { useAgent } from '@renderer/hooks/agents/useAgent'
import { useCreateDefaultSession } from '@renderer/hooks/agents/useCreateDefaultSession'
import { useSession } from '@renderer/hooks/agents/useSession'
import { selectNewTopicLoading } from '@renderer/hooks/useMessageOperations'
import { getModel } from '@renderer/hooks/useModel'
import { useSettings } from '@renderer/hooks/useSettings'
import { useShortcutDisplay } from '@renderer/hooks/useShortcuts'
import { useTimer } from '@renderer/hooks/useTimer'
import PasteService from '@renderer/services/PasteService'
import { pauseTrace } from '@renderer/services/SpanManagerService'
@ -22,7 +24,7 @@ import { getSendMessageShortcutLabel, isSendMessageKeyPressed } from '@renderer/
import { createMainTextBlock, createMessage } from '@renderer/utils/messageUtils/create'
import TextArea, { type TextAreaRef } from 'antd/es/input/TextArea'
import { isEmpty } from 'lodash'
import { CirclePause } from 'lucide-react'
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'
@ -47,6 +49,8 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
const { session } = useSession(agentId, sessionId)
const { agent } = useAgent(agentId)
const { apiServer } = useSettings()
const { createDefaultSession, creatingSession } = useCreateDefaultSession(agentId)
const newTopicShortcut = useShortcutDisplay('new_topic')
const { sendMessageShortcut, fontSize, enableSpellCheck } = useSettings()
const textareaRef = useRef<TextAreaRef>(null)
@ -88,6 +92,22 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
}, [topicMessages])
const canAbort = loading && streamingAskIds.length > 0
const createSessionDisabled = creatingSession || !apiServer.enabled
const handleCreateSession = useCallback(async () => {
if (createSessionDisabled) {
return
}
try {
const created = await createDefaultSession()
if (created) {
focusTextarea()
}
} catch (error) {
logger.warn('Failed to create agent session via toolbar:', error as Error)
}
}, [createDefaultSession, createSessionDisabled, focusTextarea])
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
//to check if the SendMessage key is pressed
@ -287,8 +307,16 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
}}
onBlur={() => setInputFocus(false)}
/>
<div className="flex justify-end px-1">
<div className="flex items-center gap-1">
<Toolbar>
<ToolbarGroup>
<Tooltip placement="top" content={t('chat.input.new_topic', { Command: newTopicShortcut })} delay={0}>
<ActionIconButton
onClick={handleCreateSession}
disabled={createSessionDisabled}
icon={<MessageSquareDiff size={19} />}></ActionIconButton>
</Tooltip>
</ToolbarGroup>
<ToolbarGroup>
<SendMessageButton sendMessage={sendMessage} disabled={sendDisabled} />
{canAbort && (
<Tooltip placement="top" content={t('chat.input.pause')}>
@ -299,8 +327,8 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
/>
</Tooltip>
)}
</div>
</div>
</ToolbarGroup>
</Toolbar>
</InputBarContainer>
</Container>
</NarrowLayout>
@ -346,6 +374,25 @@ const InputBarContainer = styled.div`
}
`
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;
align-items: center;
gap: 6px;
`
const TextareaStyle: CSSProperties = {
paddingLeft: 0,
padding: '6px 15px 0px' // 减小顶部padding

View File

@ -6,6 +6,7 @@ import type { NormalToolResponse } from '@renderer/types'
export * from './types'
// 导入所有渲染器
import ToolPermissionRequestCard from '../ToolPermissionRequestCard'
import { BashOutputTool } from './BashOutputTool'
import { BashTool } from './BashTool'
import { EditTool } from './EditTool'
@ -78,12 +79,16 @@ function renderToolContent(toolName: AgentToolsType, input: ToolInput, output?:
// 统一的组件渲染入口
export function MessageAgentTools({ toolResponse }: { toolResponse: NormalToolResponse }) {
const { arguments: args, response, tool } = toolResponse
const { arguments: args, response, tool, status } = toolResponse
logger.info('Rendering agent tool response', {
tool: tool,
arguments: args,
response
})
if (status === 'pending') {
return <ToolPermissionRequestCard toolResponse={toolResponse} />
}
return renderToolContent(tool.name as AgentToolsType, args as ToolInput, response as ToolOutput)
}

View File

@ -0,0 +1,235 @@
import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'
import { Button, ButtonGroup, Chip, ScrollShadow } from '@heroui/react'
import { loggerService } from '@logger'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { selectPendingPermissionByToolName, toolPermissionsActions } from '@renderer/store/toolPermissions'
import type { NormalToolResponse } from '@renderer/types'
import { ChevronDown, CirclePlay, CircleX } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
const logger = loggerService.withContext('ToolPermissionRequestCard')
interface Props {
toolResponse: NormalToolResponse
}
export function ToolPermissionRequestCard({ toolResponse }: Props) {
const { t } = useTranslation()
const dispatch = useAppDispatch()
const request = useAppSelector((state) =>
selectPendingPermissionByToolName(state.toolPermissions, toolResponse.tool.name)
)
const [now, setNow] = useState(() => Date.now())
const [showDetails, setShowDetails] = useState(false)
useEffect(() => {
if (!request) return
logger.debug('Rendering inline tool permission card', {
requestId: request.requestId,
toolName: request.toolName,
expiresAt: request.expiresAt
})
setNow(Date.now())
const interval = window.setInterval(() => {
setNow(Date.now())
}, 500)
return () => {
window.clearInterval(interval)
}
}, [request])
const remainingMs = useMemo(() => {
if (!request) return 0
return Math.max(0, request.expiresAt - now)
}, [request, now])
const remainingSeconds = useMemo(() => Math.ceil(remainingMs / 1000), [remainingMs])
const isExpired = remainingMs <= 0
const isSubmittingAllow = request?.status === 'submitting-allow'
const isSubmittingDeny = request?.status === 'submitting-deny'
const isSubmitting = isSubmittingAllow || isSubmittingDeny
const hasSuggestions = (request?.suggestions?.length ?? 0) > 0
const handleDecision = useCallback(
async (
behavior: 'allow' | 'deny',
extra?: {
updatedInput?: Record<string, unknown>
updatedPermissions?: PermissionUpdate[]
message?: string
}
) => {
if (!request) return
logger.debug('Submitting inline tool permission decision', {
requestId: request.requestId,
toolName: request.toolName,
behavior
})
dispatch(toolPermissionsActions.submissionSent({ requestId: request.requestId, behavior }))
try {
const payload = {
requestId: request.requestId,
behavior,
...(behavior === 'allow'
? {
updatedInput: extra?.updatedInput ?? request.input,
updatedPermissions: extra?.updatedPermissions
}
: {
message: extra?.message ?? t('agent.toolPermission.defaultDenyMessage')
})
}
const response = await window.api.agentTools.respondToPermission(payload)
if (!response?.success) {
throw new Error('Renderer response rejected by main process')
}
logger.debug('Tool permission decision acknowledged by main process', {
requestId: request.requestId,
behavior
})
} catch (error) {
logger.error('Failed to send tool permission response', error as Error)
window.toast?.error?.(t('agent.toolPermission.error.sendFailed'))
dispatch(toolPermissionsActions.submissionFailed({ requestId: request.requestId }))
}
},
[dispatch, request, t]
)
if (!request) {
return (
<div className="rounded-xl border border-default-200 bg-default-100 px-4 py-3 text-default-500 text-sm">
{t('agent.toolPermission.waiting')}
</div>
)
}
return (
<div className="w-full max-w-xl rounded-xl border border-default-200 bg-default-100 px-4 py-3 shadow-sm">
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-1">
<div className="font-semibold text-default-700 text-sm">{request.toolName}</div>
<div className="text-default-500 text-xs">
{request.description?.trim() || t('agent.toolPermission.defaultDescription')}
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
<Chip color={isExpired ? 'danger' : 'warning'} size="sm" variant="flat">
{isExpired
? t('agent.toolPermission.expired')
: t('agent.toolPermission.pending', { seconds: remainingSeconds })}
</Chip>
<div className="flex items-center gap-1">
<Button
aria-label={t('agent.toolPermission.aria.denyRequest')}
className="h-8"
color="danger"
isDisabled={isSubmitting || isExpired}
isLoading={isSubmittingDeny}
onPress={() => handleDecision('deny')}
startContent={<CircleX size={16} />}
variant="bordered">
{t('agent.toolPermission.button.cancel')}
</Button>
{hasSuggestions ? (
<ButtonGroup className="h-8">
<Button
className="h-8 px-3"
color="success"
isDisabled={isSubmitting || isExpired}
isLoading={isSubmittingAllow}
onPress={() => handleDecision('allow')}
startContent={<CirclePlay size={16} />}>
{t('agent.toolPermission.button.run')}
</Button>
<Button
aria-label={t('agent.toolPermission.aria.runWithOptions')}
className="h-8 rounded-l-none"
color="success"
isDisabled={isSubmitting || isExpired}
isIconOnly
variant="solid"></Button>
</ButtonGroup>
) : (
<Button
aria-label={t('agent.toolPermission.aria.allowRequest')}
className="h-8 px-3"
color="success"
isDisabled={isSubmitting || isExpired}
isLoading={isSubmittingAllow}
onPress={() => handleDecision('allow')}
startContent={<CirclePlay size={16} />}>
{t('agent.toolPermission.button.run')}
</Button>
)}
<Button
aria-label={
showDetails ? t('agent.toolPermission.aria.hideDetails') : t('agent.toolPermission.aria.showDetails')
}
className="h-8"
isIconOnly
onPress={() => setShowDetails((value) => !value)}
variant="light">
<ChevronDown className={`transition-transform ${showDetails ? 'rotate-180' : ''}`} size={16} />
</Button>
</div>
</div>
</div>
{showDetails && (
<div className="flex flex-col gap-3 border-default-200 border-t pt-3">
<div className="rounded-lg bg-default-200/60 px-3 py-2 text-default-600 text-sm">
{t('agent.toolPermission.confirmation')}
</div>
<div className="rounded-md border border-default-200 bg-default-100 p-3">
<p className="mb-2 font-medium text-default-400 text-xs uppercase tracking-wide">
{t('agent.toolPermission.inputPreview')}
</p>
<ScrollShadow className="max-h-48 font-mono text-xs" hideScrollBar>
<pre className="whitespace-pre-wrap break-all text-left">{request.inputPreview}</pre>
</ScrollShadow>
</div>
{request.requiresPermissions && (
<div className="rounded-md border border-warning-300 bg-warning-50 p-3 text-warning-700 text-xs">
{t('agent.toolPermission.requiresElevatedPermissions')}
</div>
)}
{request.suggestions.length > 0 && (
<div className="rounded-md border border-default-200 bg-default-50 p-3 text-default-500 text-xs">
{request.suggestions.length === 1
? t('agent.toolPermission.suggestion.permissionUpdateSingle')
: t('agent.toolPermission.suggestion.permissionUpdateMultiple')}
</div>
)}
</div>
)}
{isExpired && !isSubmitting && (
<div className="text-center text-danger-500 text-xs">{t('agent.toolPermission.permissionExpired')}</div>
)}
</div>
</div>
)
}
export default ToolPermissionRequestCard

View File

@ -1,6 +1,6 @@
import { Alert, Spinner } from '@heroui/react'
import { DynamicVirtualList } from '@renderer/components/VirtualList'
import { useAgent } from '@renderer/hooks/agents/useAgent'
import { useCreateDefaultSession } from '@renderer/hooks/agents/useCreateDefaultSession'
import { useSessions } from '@renderer/hooks/agents/useSessions'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useAppDispatch } from '@renderer/store'
@ -10,7 +10,6 @@ import {
setActiveTopicOrSessionAction,
setSessionWaitingAction
} from '@renderer/store/runtime'
import type { CreateSessionForm } from '@renderer/types'
import { buildAgentSessionTopicId } from '@renderer/utils/agentSession'
import { motion } from 'framer-motion'
import { memo, useCallback, useEffect } from 'react'
@ -27,11 +26,11 @@ interface SessionsProps {
const Sessions: React.FC<SessionsProps> = ({ agentId }) => {
const { t } = useTranslation()
const { agent } = useAgent(agentId)
const { sessions, isLoading, error, deleteSession, createSession } = useSessions(agentId)
const { sessions, isLoading, error, deleteSession } = useSessions(agentId)
const { chat } = useRuntime()
const { activeSessionIdMap } = chat
const dispatch = useAppDispatch()
const { createDefaultSession, creatingSession } = useCreateDefaultSession(agentId)
const setActiveSessionId = useCallback(
(agentId: string, sessionId: string | null) => {
@ -41,19 +40,6 @@ const Sessions: React.FC<SessionsProps> = ({ agentId }) => {
[dispatch]
)
const handleCreateSession = useCallback(async () => {
if (!agent) return
const session = {
...agent,
id: undefined,
name: t('common.unnamed')
} satisfies CreateSessionForm
const created = await createSession(session)
if (created) {
dispatch(setActiveSessionIdAction({ agentId, sessionId: created.id }))
}
}, [agent, agentId, createSession, dispatch, t])
const handleDeleteSession = useCallback(
async (id: string) => {
if (sessions.length === 1) {
@ -110,7 +96,7 @@ const Sessions: React.FC<SessionsProps> = ({ agentId }) => {
return (
<div className="sessions-tab flex h-full w-full flex-col p-2">
<AddButton onClick={handleCreateSession} className="mb-2">
<AddButton onClick={createDefaultSession} className="mb-2" disabled={creatingSession}>
{t('agent.session.add.title')}
</AddButton>
{/* h-9 */}

View File

@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next'
import AdvancedSettings from './AdvancedSettings'
import EssentialSettings from './EssentialSettings'
import PluginSettings from './PluginSettings'
import PromptSettings from './PromptSettings'
import { AgentLabel, LeftMenu, Settings, StyledMenu, StyledModal } from './shared'
import ToolingSettings from './ToolingSettings'
@ -20,7 +21,7 @@ interface AgentSettingPopupParams extends AgentSettingPopupShowParams {
resolve: () => void
}
type AgentSettingPopupTab = 'essential' | 'prompt' | 'tooling' | 'advanced' | 'session-mcps'
type AgentSettingPopupTab = 'essential' | 'prompt' | 'tooling' | 'advanced' | 'plugins' | 'session-mcps'
const AgentSettingPopupContainer: React.FC<AgentSettingPopupParams> = ({ tab, agentId, resolve }) => {
const [open, setOpen] = useState(true)
@ -56,6 +57,10 @@ const AgentSettingPopupContainer: React.FC<AgentSettingPopupParams> = ({ tab, ag
key: 'tooling',
label: t('agent.settings.tooling.tab', 'Tooling & permissions')
},
{
key: 'plugins',
label: t('agent.settings.plugins.tab', 'Plugins')
},
{
key: 'advanced',
label: t('agent.settings.advance.title', 'Advanced Settings')
@ -75,6 +80,9 @@ const AgentSettingPopupContainer: React.FC<AgentSettingPopupParams> = ({ tab, ag
</div>
)
}
if (!agent) {
return null
}
return (
<div className="flex w-full flex-1">
<LeftMenu>
@ -90,6 +98,7 @@ const AgentSettingPopupContainer: React.FC<AgentSettingPopupParams> = ({ tab, ag
{menu === 'essential' && <EssentialSettings agentBase={agent} update={updateAgent} />}
{menu === 'prompt' && <PromptSettings agentBase={agent} update={updateAgent} />}
{menu === 'tooling' && <ToolingSettings agentBase={agent} update={updateAgent} />}
{menu === 'plugins' && <PluginSettings agentBase={agent} update={updateAgent} />}
{menu === 'advanced' && <AdvancedSettings agentBase={agent} update={updateAgent} />}
</Settings>
</div>

View File

@ -1,6 +1,6 @@
import { EmojiAvatarWithPicker } from '@renderer/components/Avatar/EmojiAvatarWithPicker'
import type { AgentEntity, UpdateAgentForm } from '@renderer/types'
import { isAgentType } from '@renderer/types'
import { AgentConfigurationSchema, isAgentType } from '@renderer/types'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -20,13 +20,11 @@ export const AvatarSetting: React.FC<AvatarSettingsProps> = ({ agent, update })
const updateAvatar = useCallback(
(avatar: string) => {
const parsedConfiguration = AgentConfigurationSchema.parse(agent.configuration ?? {})
const payload = {
id: agent.id,
// hard-encoded default values. better to implement incremental update for configuration
configuration: {
...agent.configuration,
permission_mode: agent.configuration?.permission_mode ?? 'default',
max_turns: agent.configuration?.max_turns ?? 100,
...parsedConfiguration,
avatar
}
} satisfies UpdateAgentForm

View File

@ -0,0 +1,115 @@
import { Card, CardBody, Tab, Tabs } from '@heroui/react'
import { useAvailablePlugins, useInstalledPlugins, usePluginActions } from '@renderer/hooks/usePlugins'
import type { GetAgentResponse, GetAgentSessionResponse, UpdateAgentBaseForm } from '@renderer/types/agent'
import type { FC } from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { InstalledPluginsList } from './components/InstalledPluginsList'
import { PluginBrowser } from './components/PluginBrowser'
import { SettingsContainer } from './shared'
interface PluginSettingsProps {
agentBase: GetAgentResponse | GetAgentSessionResponse
update: (partial: UpdateAgentBaseForm) => Promise<void>
}
const PluginSettings: FC<PluginSettingsProps> = ({ agentBase }) => {
const { t } = useTranslation()
// Fetch available plugins
const { agents, commands, skills, loading: loadingAvailable, error: errorAvailable } = useAvailablePlugins()
// Fetch installed plugins
const { plugins, loading: loadingInstalled, error: errorInstalled, refresh } = useInstalledPlugins(agentBase.id)
// Plugin actions
const { install, uninstall, installing, uninstalling } = usePluginActions(agentBase.id, refresh)
// Handle install action
const handleInstall = useCallback(
async (sourcePath: string, type: 'agent' | 'command' | 'skill') => {
const result = await install(sourcePath, type)
if (result.success) {
window.toast.success(t('agent.settings.plugins.success.install'))
} else {
window.toast.error(t('agent.settings.plugins.error.install') + (result.error ? ': ' + result.error : ''))
}
},
[install, t]
)
// Handle uninstall action
const handleUninstall = useCallback(
async (filename: string, type: 'agent' | 'command' | 'skill') => {
const result = await uninstall(filename, type)
if (result.success) {
window.toast.success(t('agent.settings.plugins.success.uninstall'))
} else {
window.toast.error(t('agent.settings.plugins.error.uninstall') + (result.error ? ': ' + result.error : ''))
}
},
[uninstall, t]
)
return (
<SettingsContainer>
<Tabs
aria-label="Plugin settings tabs"
classNames={{
base: 'w-full',
tabList: 'w-full',
panel: 'w-full flex-1 overflow-hidden'
}}>
<Tab key="available" title={t('agent.settings.plugins.available.title')}>
<div className="flex h-full flex-col overflow-y-auto pt-4">
{errorAvailable ? (
<Card className="bg-danger-50 dark:bg-danger-900/20">
<CardBody>
<p className="text-danger">
{t('agent.settings.plugins.error.load')}: {errorAvailable}
</p>
</CardBody>
</Card>
) : (
<PluginBrowser
agentId={agentBase.id}
agents={agents}
commands={commands}
skills={skills}
installedPlugins={plugins}
onInstall={handleInstall}
onUninstall={handleUninstall}
loading={loadingAvailable || installing || uninstalling}
/>
)}
</div>
</Tab>
<Tab key="installed" title={t('agent.settings.plugins.installed.title')}>
<div className="flex h-full flex-col overflow-y-auto pt-4">
{errorInstalled ? (
<Card className="bg-danger-50 dark:bg-danger-900/20">
<CardBody>
<p className="text-danger">
{t('agent.settings.plugins.error.load')}: {errorInstalled}
</p>
</CardBody>
</Card>
) : (
<InstalledPluginsList
plugins={plugins}
onUninstall={handleUninstall}
loading={loadingInstalled || uninstalling}
/>
)}
</div>
</Tab>
</Tabs>
</SettingsContainer>
)
}
export default PluginSettings

View File

@ -0,0 +1,53 @@
import { Chip } from '@heroui/react'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
export interface CategoryFilterProps {
categories: string[]
selectedCategories: string[]
onChange: (categories: string[]) => void
}
export const CategoryFilter: FC<CategoryFilterProps> = ({ categories, selectedCategories, onChange }) => {
const { t } = useTranslation()
const isAllSelected = selectedCategories.length === 0
const handleCategoryClick = (category: string) => {
if (selectedCategories.includes(category)) {
onChange(selectedCategories.filter((c) => c !== category))
} else {
onChange([...selectedCategories, category])
}
}
const handleAllClick = () => {
onChange([])
}
return (
<div className="flex max-h-24 flex-wrap gap-2 overflow-y-auto">
<Chip
variant={isAllSelected ? 'solid' : 'bordered'}
color={isAllSelected ? 'primary' : 'default'}
onClick={handleAllClick}
className="cursor-pointer">
{t('plugins.all_categories')}
</Chip>
{categories.map((category) => {
const isSelected = selectedCategories.includes(category)
return (
<Chip
key={category}
variant={isSelected ? 'solid' : 'bordered'}
color={isSelected ? 'primary' : 'default'}
onClick={() => handleCategoryClick(category)}
className="cursor-pointer">
{category}
</Chip>
)
})}
</div>
)
}

View File

@ -0,0 +1,99 @@
import { Button, Chip, Skeleton, Table, TableBody, TableCell, TableColumn, TableHeader, TableRow } from '@heroui/react'
import type { InstalledPlugin } from '@renderer/types/plugin'
import { Trash2 } from 'lucide-react'
import type { FC } from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
export interface InstalledPluginsListProps {
plugins: InstalledPlugin[]
onUninstall: (filename: string, type: 'agent' | 'command' | 'skill') => void
loading: boolean
}
export const InstalledPluginsList: FC<InstalledPluginsListProps> = ({ plugins, onUninstall, loading }) => {
const { t } = useTranslation()
const [uninstallingPlugin, setUninstallingPlugin] = useState<string | null>(null)
const handleUninstall = useCallback(
(plugin: InstalledPlugin) => {
const confirmed = window.confirm(
t('plugins.confirm_uninstall', { name: plugin.metadata.name || plugin.filename })
)
if (confirmed) {
setUninstallingPlugin(plugin.filename)
onUninstall(plugin.filename, plugin.type)
// Reset after a delay to allow the operation to complete
setTimeout(() => setUninstallingPlugin(null), 2000)
}
},
[onUninstall, t]
)
if (loading) {
return (
<div className="space-y-2">
<Skeleton className="h-12 w-full rounded-lg" />
<Skeleton className="h-12 w-full rounded-lg" />
<Skeleton className="h-12 w-full rounded-lg" />
</div>
)
}
if (plugins.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-8 text-center">
<p className="text-default-400">{t('plugins.no_installed_plugins')}</p>
<p className="text-default-300 text-small">{t('plugins.install_plugins_from_browser')}</p>
</div>
)
}
return (
<Table aria-label="Installed plugins table" removeWrapper>
<TableHeader>
<TableColumn>{t('plugins.name')}</TableColumn>
<TableColumn>{t('plugins.type')}</TableColumn>
<TableColumn>{t('plugins.category')}</TableColumn>
<TableColumn width={100}>{t('plugins.actions')}</TableColumn>
</TableHeader>
<TableBody>
{plugins.map((plugin) => (
<TableRow key={plugin.filename}>
<TableCell>
<div className="flex flex-col">
<span className="font-semibold text-small">{plugin.metadata.name}</span>
{plugin.metadata.description && (
<span className="line-clamp-1 text-default-400 text-tiny">{plugin.metadata.description}</span>
)}
</div>
</TableCell>
<TableCell>
<Chip size="sm" variant="flat" color={plugin.type === 'agent' ? 'primary' : 'secondary'}>
{plugin.type}
</Chip>
</TableCell>
<TableCell>
<Chip size="sm" variant="dot">
{plugin.metadata.category}
</Chip>
</TableCell>
<TableCell>
<Button
size="sm"
color="danger"
variant="light"
isIconOnly
onPress={() => handleUninstall(plugin)}
isLoading={uninstallingPlugin === plugin.filename}
isDisabled={loading}>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}

View File

@ -0,0 +1,228 @@
import { Input, Pagination, Tab, Tabs } from '@heroui/react'
import type { InstalledPlugin, PluginMetadata } from '@renderer/types/plugin'
import { Search } from 'lucide-react'
import type { FC } from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { CategoryFilter } from './CategoryFilter'
import { PluginCard } from './PluginCard'
import { PluginDetailModal } from './PluginDetailModal'
export interface PluginBrowserProps {
agentId: string
agents: PluginMetadata[]
commands: PluginMetadata[]
skills: PluginMetadata[]
installedPlugins: InstalledPlugin[]
onInstall: (sourcePath: string, type: 'agent' | 'command' | 'skill') => void
onUninstall: (filename: string, type: 'agent' | 'command' | 'skill') => void
loading: boolean
}
type PluginType = 'all' | 'agent' | 'command' | 'skill'
const ITEMS_PER_PAGE = 12
export const PluginBrowser: FC<PluginBrowserProps> = ({
agentId,
agents,
commands,
skills,
installedPlugins,
onInstall,
onUninstall,
loading
}) => {
const { t } = useTranslation()
const [searchQuery, setSearchQuery] = useState('')
const [selectedCategories, setSelectedCategories] = useState<string[]>([])
const [activeType, setActiveType] = useState<PluginType>('all')
const [currentPage, setCurrentPage] = useState(1)
const [actioningPlugin, setActioningPlugin] = useState<string | null>(null)
const [selectedPlugin, setSelectedPlugin] = useState<PluginMetadata | null>(null)
const [isModalOpen, setIsModalOpen] = useState(false)
// Combine all plugins based on active type
const allPlugins = useMemo(() => {
switch (activeType) {
case 'agent':
return agents
case 'command':
return commands
case 'skill':
return skills
case 'all':
default:
return [...agents, ...commands, ...skills]
}
}, [agents, commands, skills, activeType])
// Extract all unique categories
const allCategories = useMemo(() => {
const categories = new Set<string>()
allPlugins.forEach((plugin) => {
if (plugin.category) {
categories.add(plugin.category)
}
})
return Array.from(categories).sort()
}, [allPlugins])
// Filter plugins based on search query and selected categories
const filteredPlugins = useMemo(() => {
return allPlugins.filter((plugin) => {
// Filter by search query
const searchLower = searchQuery.toLowerCase()
const matchesSearch =
!searchQuery ||
plugin.name.toLowerCase().includes(searchLower) ||
plugin.description?.toLowerCase().includes(searchLower) ||
plugin.tags?.some((tag) => tag.toLowerCase().includes(searchLower))
// Filter by selected categories
const matchesCategory = selectedCategories.length === 0 || selectedCategories.includes(plugin.category)
return matchesSearch && matchesCategory
})
}, [allPlugins, searchQuery, selectedCategories])
// Paginate filtered plugins
const paginatedPlugins = useMemo(() => {
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE
const endIndex = startIndex + ITEMS_PER_PAGE
return filteredPlugins.slice(startIndex, endIndex)
}, [filteredPlugins, currentPage])
const totalPages = Math.ceil(filteredPlugins.length / ITEMS_PER_PAGE)
// Check if a plugin is installed
const isPluginInstalled = (plugin: PluginMetadata): boolean => {
return installedPlugins.some(
(installed) => installed.filename === plugin.filename && installed.type === plugin.type
)
}
// Handle install with loading state
const handleInstall = async (plugin: PluginMetadata) => {
setActioningPlugin(plugin.sourcePath)
await onInstall(plugin.sourcePath, plugin.type)
setActioningPlugin(null)
}
// Handle uninstall with loading state
const handleUninstall = async (plugin: PluginMetadata) => {
setActioningPlugin(plugin.sourcePath)
await onUninstall(plugin.filename, plugin.type)
setActioningPlugin(null)
}
// Reset to first page when filters change
const handleSearchChange = (value: string) => {
setSearchQuery(value)
setCurrentPage(1)
}
const handleCategoryChange = (categories: string[]) => {
setSelectedCategories(categories)
setCurrentPage(1)
}
const handleTypeChange = (type: string | number) => {
setActiveType(type as PluginType)
setCurrentPage(1)
}
const handlePluginClick = (plugin: PluginMetadata) => {
setSelectedPlugin(plugin)
setIsModalOpen(true)
}
const handleModalClose = () => {
setIsModalOpen(false)
setSelectedPlugin(null)
}
return (
<div className="flex flex-col gap-4">
{/* Search Input */}
<Input
placeholder={t('plugins.search_placeholder')}
value={searchQuery}
onValueChange={handleSearchChange}
startContent={<Search className="h-4 w-4 text-default-400" />}
isClearable
classNames={{
input: 'text-small',
inputWrapper: 'h-10'
}}
/>
{/* Category Filter */}
<CategoryFilter
categories={allCategories}
selectedCategories={selectedCategories}
onChange={handleCategoryChange}
/>
{/* Type Tabs */}
<Tabs selectedKey={activeType} onSelectionChange={handleTypeChange} variant="underlined">
<Tab key="all" title={t('plugins.all_types')} />
<Tab key="agent" title={t('plugins.agents')} />
<Tab key="command" title={t('plugins.commands')} />
<Tab key="skill" title={t('plugins.skills')} />
</Tabs>
{/* Result Count */}
<div className="flex items-center justify-between">
<p className="text-default-500 text-small">{t('plugins.showing_results', { count: filteredPlugins.length })}</p>
</div>
{/* Plugin Grid */}
{paginatedPlugins.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<p className="text-default-400">{t('plugins.no_results')}</p>
<p className="text-default-300 text-small">{t('plugins.try_different_search')}</p>
</div>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{paginatedPlugins.map((plugin) => {
const installed = isPluginInstalled(plugin)
const isActioning = actioningPlugin === plugin.sourcePath
return (
<PluginCard
key={`${plugin.type}-${plugin.sourcePath}`}
plugin={plugin}
installed={installed}
onInstall={() => handleInstall(plugin)}
onUninstall={() => handleUninstall(plugin)}
loading={loading || isActioning}
onClick={() => handlePluginClick(plugin)}
/>
)
})}
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex justify-center">
<Pagination total={totalPages} page={currentPage} onChange={setCurrentPage} showControls />
</div>
)}
{/* Plugin Detail Modal */}
<PluginDetailModal
agentId={agentId}
plugin={selectedPlugin}
isOpen={isModalOpen}
onClose={handleModalClose}
installed={selectedPlugin ? isPluginInstalled(selectedPlugin) : false}
onInstall={() => selectedPlugin && handleInstall(selectedPlugin)}
onUninstall={() => selectedPlugin && handleUninstall(selectedPlugin)}
loading={selectedPlugin ? actioningPlugin === selectedPlugin.sourcePath : false}
/>
</div>
)
}

View File

@ -0,0 +1,83 @@
import { Button, Card, CardBody, CardFooter, CardHeader, Chip, Spinner } from '@heroui/react'
import type { PluginMetadata } from '@renderer/types/plugin'
import { Download, Trash2 } from 'lucide-react'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
export interface PluginCardProps {
plugin: PluginMetadata
installed: boolean
onInstall: () => void
onUninstall: () => void
loading: boolean
onClick: () => void
}
export const PluginCard: FC<PluginCardProps> = ({ plugin, installed, onInstall, onUninstall, loading, onClick }) => {
const { t } = useTranslation()
return (
<Card className="w-full cursor-pointer transition-shadow hover:shadow-md" isPressable onPress={onClick}>
<CardHeader className="flex flex-col items-start gap-2 pb-2">
<div className="flex w-full items-center justify-between">
<h3 className="font-semibold text-medium">{plugin.name}</h3>
<Chip
size="sm"
variant="solid"
color={plugin.type === 'agent' ? 'primary' : plugin.type === 'skill' ? 'success' : 'secondary'}>
{plugin.type}
</Chip>
</div>
<Chip size="sm" variant="dot" color="default">
{plugin.category}
</Chip>
</CardHeader>
<CardBody className="py-2">
<p className="line-clamp-3 text-default-500 text-small">{plugin.description || t('plugins.no_description')}</p>
{plugin.tags && plugin.tags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{plugin.tags.map((tag) => (
<Chip key={tag} size="sm" variant="bordered" className="text-tiny">
{tag}
</Chip>
))}
</div>
)}
</CardBody>
<CardFooter className="pt-2">
{installed ? (
<Button
color="danger"
variant="flat"
size="sm"
startContent={loading ? <Spinner size="sm" color="current" /> : <Trash2 className="h-4 w-4" />}
onClick={(e) => {
e.stopPropagation()
onUninstall()
}}
isDisabled={loading}
fullWidth>
{loading ? t('plugins.uninstalling') : t('plugins.uninstall')}
</Button>
) : (
<Button
color="primary"
variant="flat"
size="sm"
startContent={loading ? <Spinner size="sm" color="current" /> : <Download className="h-4 w-4" />}
onClick={(e) => {
e.stopPropagation()
onInstall()
}}
isDisabled={loading}
fullWidth>
{loading ? t('plugins.installing') : t('plugins.install')}
</Button>
)}
</CardFooter>
</Card>
)
}

View File

@ -0,0 +1,321 @@
import {
Button,
Chip,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Spinner,
Textarea
} from '@heroui/react'
import type { PluginMetadata } from '@renderer/types/plugin'
import { Download, Edit, Save, Trash2, X } from 'lucide-react'
import type { FC } from 'react'
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
export interface PluginDetailModalProps {
agentId: string
plugin: PluginMetadata | null
isOpen: boolean
onClose: () => void
installed: boolean
onInstall: () => void
onUninstall: () => void
loading: boolean
}
export const PluginDetailModal: FC<PluginDetailModalProps> = ({
agentId,
plugin,
isOpen,
onClose,
installed,
onInstall,
onUninstall,
loading
}) => {
const { t } = useTranslation()
const [content, setContent] = useState<string>('')
const [contentLoading, setContentLoading] = useState(false)
const [contentError, setContentError] = useState<string | null>(null)
const [isEditing, setIsEditing] = useState(false)
const [editedContent, setEditedContent] = useState<string>('')
const [saving, setSaving] = useState(false)
// Fetch plugin content when modal opens or plugin changes
useEffect(() => {
if (!isOpen || !plugin) {
setContent('')
setContentError(null)
setIsEditing(false)
setEditedContent('')
return
}
const fetchContent = async () => {
setContentLoading(true)
setContentError(null)
setIsEditing(false)
setEditedContent('')
try {
let sourcePath = plugin.sourcePath
if (plugin.type === 'skill') {
sourcePath = sourcePath + '/' + 'SKILL.md'
}
const result = await window.api.claudeCodePlugin.readContent(sourcePath)
if (result.success) {
setContent(result.data)
} else {
setContentError(`Failed to load content: ${result.error.type}`)
}
} catch (error) {
setContentError(`Error loading content: ${error instanceof Error ? error.message : String(error)}`)
} finally {
setContentLoading(false)
}
}
fetchContent()
}, [isOpen, plugin])
const handleEdit = () => {
setEditedContent(content)
setIsEditing(true)
}
const handleCancelEdit = () => {
setIsEditing(false)
setEditedContent('')
}
const handleSave = async () => {
if (!plugin) return
setSaving(true)
try {
const result = await window.api.claudeCodePlugin.writeContent({
agentId,
filename: plugin.filename,
type: plugin.type,
content: editedContent
})
if (result.success) {
setContent(editedContent)
setIsEditing(false)
window.toast?.success('Plugin content saved successfully')
} else {
window.toast?.error(`Failed to save: ${result.error.type}`)
}
} catch (error) {
window.toast?.error(`Error saving: ${error instanceof Error ? error.message : String(error)}`)
} finally {
setSaving(false)
}
}
if (!plugin) return null
const modalContent = (
<Modal
isOpen={isOpen}
onClose={onClose}
size="2xl"
scrollBehavior="inside"
classNames={{
wrapper: 'z-[9999]'
}}>
<ModalContent>
<ModalHeader className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<h2 className="font-bold text-xl">{plugin.name}</h2>
<Chip size="sm" variant="solid" color={plugin.type === 'agent' ? 'primary' : 'secondary'}>
{plugin.type}
</Chip>
</div>
<div className="flex items-center gap-2">
<Chip size="sm" variant="dot" color="default">
{plugin.category}
</Chip>
{plugin.version && (
<Chip size="sm" variant="bordered">
v{plugin.version}
</Chip>
)}
</div>
</ModalHeader>
<ModalBody>
{/* Description */}
{plugin.description && (
<div className="mb-4">
<h3 className="mb-2 font-semibold text-small">Description</h3>
<p className="text-default-600 text-small">{plugin.description}</p>
</div>
)}
{/* Author */}
{plugin.author && (
<div className="mb-4">
<h3 className="mb-2 font-semibold text-small">Author</h3>
<p className="text-default-600 text-small">{plugin.author}</p>
</div>
)}
{/* Tools (for agents) */}
{plugin.tools && plugin.tools.length > 0 && (
<div className="mb-4">
<h3 className="mb-2 font-semibold text-small">Tools</h3>
<div className="flex flex-wrap gap-1">
{plugin.tools.map((tool) => (
<Chip key={tool} size="sm" variant="flat">
{tool}
</Chip>
))}
</div>
</div>
)}
{/* Allowed Tools (for commands) */}
{plugin.allowed_tools && plugin.allowed_tools.length > 0 && (
<div className="mb-4">
<h3 className="mb-2 font-semibold text-small">Allowed Tools</h3>
<div className="flex flex-wrap gap-1">
{plugin.allowed_tools.map((tool) => (
<Chip key={tool} size="sm" variant="flat">
{tool}
</Chip>
))}
</div>
</div>
)}
{/* Tags */}
{plugin.tags && plugin.tags.length > 0 && (
<div className="mb-4">
<h3 className="mb-2 font-semibold text-small">Tags</h3>
<div className="flex flex-wrap gap-1">
{plugin.tags.map((tag) => (
<Chip key={tag} size="sm" variant="bordered">
{tag}
</Chip>
))}
</div>
</div>
)}
{/* Metadata */}
<div className="mb-4">
<h3 className="mb-2 font-semibold text-small">Metadata</h3>
<div className="space-y-1 text-small">
<div className="flex justify-between">
<span className="text-default-500">File:</span>
<span className="font-mono text-default-600 text-tiny">{plugin.filename}</span>
</div>
<div className="flex justify-between">
<span className="text-default-500">Size:</span>
<span className="text-default-600">{(plugin.size / 1024).toFixed(2)} KB</span>
</div>
<div className="flex justify-between">
<span className="text-default-500">Source:</span>
<span className="font-mono text-default-600 text-tiny">{plugin.sourcePath}</span>
</div>
{plugin.installedAt && (
<div className="flex justify-between">
<span className="text-default-500">Installed:</span>
<span className="text-default-600">{new Date(plugin.installedAt).toLocaleString()}</span>
</div>
)}
</div>
</div>
{/* Content */}
<div className="mb-4">
<div className="mb-2 flex items-center justify-between">
<h3 className="font-semibold text-small">Content</h3>
{installed && !contentLoading && !contentError && (
<div className="flex gap-2">
{isEditing ? (
<>
<Button
size="sm"
variant="flat"
color="danger"
startContent={<X className="h-3 w-3" />}
onPress={handleCancelEdit}
isDisabled={saving}>
Cancel
</Button>
<Button
size="sm"
color="primary"
startContent={saving ? <Spinner size="sm" color="current" /> : <Save className="h-3 w-3" />}
onPress={handleSave}
isDisabled={saving}>
{saving ? 'Saving...' : 'Save'}
</Button>
</>
) : (
<Button size="sm" variant="flat" startContent={<Edit className="h-3 w-3" />} onPress={handleEdit}>
Edit
</Button>
)}
</div>
)}
</div>
{contentLoading ? (
<div className="flex items-center justify-center py-4">
<Spinner size="sm" />
</div>
) : contentError ? (
<div className="rounded-md bg-danger-50 p-3 text-danger text-small">{contentError}</div>
) : isEditing ? (
<Textarea
value={editedContent}
onValueChange={setEditedContent}
minRows={20}
classNames={{
input: 'font-mono text-tiny'
}}
/>
) : (
<pre className="max-h-96 overflow-auto whitespace-pre-wrap rounded-md bg-default-100 p-3 font-mono text-tiny">
{content}
</pre>
)}
</div>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
Close
</Button>
{installed ? (
<Button
color="danger"
variant="flat"
startContent={loading ? <Spinner size="sm" color="current" /> : <Trash2 className="h-4 w-4" />}
onPress={onUninstall}
isDisabled={loading}>
{loading ? t('plugins.uninstalling') : t('plugins.uninstall')}
</Button>
) : (
<Button
color="primary"
startContent={loading ? <Spinner size="sm" color="current" /> : <Download className="h-4 w-4" />}
onPress={onInstall}
isDisabled={loading}>
{loading ? t('plugins.installing') : t('plugins.install')}
</Button>
)}
</ModalFooter>
</ModalContent>
</Modal>
)
return createPortal(modalContent, document.body)
}

View File

@ -0,0 +1,10 @@
export type { CategoryFilterProps } from './CategoryFilter'
export { CategoryFilter } from './CategoryFilter'
export type { InstalledPluginsListProps } from './InstalledPluginsList'
export { InstalledPluginsList } from './InstalledPluginsList'
export type { PluginBrowserProps } from './PluginBrowser'
export { PluginBrowser } from './PluginBrowser'
export type { PluginCardProps } from './PluginCard'
export { PluginCard } from './PluginCard'
export type { PluginDetailModalProps } from './PluginDetailModal'
export { PluginDetailModal } from './PluginDetailModal'

View File

@ -77,7 +77,6 @@ const PreprocessProviderSettings: FC<Props> = ({ provider: _provider }) => {
src={getPreprocessProviderLogo(preprocessProvider.id)}
className="h-4 w-4 border-[0.5px] border-[var(--color-border)]"
/>
<ProviderName> {preprocessProvider.name}</ProviderName>
{officialWebsite && preprocessProviderConfig?.websites && (
<Link target="_blank" href={preprocessProviderConfig.websites.official}>

View File

@ -33,6 +33,7 @@ import selectionStore from './selectionStore'
import settings from './settings'
import shortcuts from './shortcuts'
import tabs from './tabs'
import toolPermissions from './toolPermissions'
import translate from './translate'
import websearch from './websearch'
@ -62,15 +63,16 @@ const rootReducer = combineReducers({
inputTools: inputToolsReducer,
translate,
ocr,
note
note,
toolPermissions
})
const persistedReducer = persistReducer(
{
key: 'cherry-studio',
storage,
version: 167,
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'],
version: 168,
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs', 'toolPermissions'],
migrate
},
rootReducer

View File

@ -22,6 +22,7 @@ import {
import db from '@renderer/databases'
import i18n from '@renderer/i18n'
import { DEFAULT_ASSISTANT_SETTINGS } from '@renderer/services/AssistantService'
import { defaultPreprocessProviders } from '@renderer/store/preprocess'
import type {
Assistant,
BuiltinOcrProvider,
@ -205,6 +206,18 @@ function addShortcuts(state: RootState, ids: string[], afterId: string) {
}
}
// add preprocess provider
function addPreprocessProviders(state: RootState, id: string) {
if (state.preprocess && state.preprocess.providers) {
if (!state.preprocess.providers.find((p) => p.id === id)) {
const provider = defaultPreprocessProviders.find((p) => p.id === id)
if (provider) {
state.preprocess.providers.push({ ...provider })
}
}
}
}
const migrateConfig = {
'2': (state: RootState) => {
try {
@ -2719,6 +2732,11 @@ const migrateConfig = {
preset.settings.toolUseMode = DEFAULT_ASSISTANT_SETTINGS.toolUseMode
}
})
// 更新阿里云百炼的 Anthropic API 地址
const dashscopeProvider = state.llm.providers.find((provider) => provider.id === 'dashscope')
if (dashscopeProvider) {
dashscopeProvider.anthropicApiHost = 'https://dashscope.aliyuncs.com/apps/anthropic'
}
return state
} catch (error) {
logger.error('migrate 166 error', error as Error)
@ -2733,6 +2751,15 @@ const migrateConfig = {
logger.error('migrate 167 error', error as Error)
return state
}
},
'168': (state: RootState) => {
try {
addPreprocessProviders(state, 'open-mineru')
return state
} catch (error) {
logger.error('migrate 168 error', error as Error)
return state
}
}
}

View File

@ -27,10 +27,19 @@ const initialState: PreprocessState = {
model: 'mistral-ocr-latest',
apiKey: '',
apiHost: 'https://api.mistral.ai'
},
{
id: 'open-mineru',
name: 'Open MinerU',
apiKey: '',
apiHost: ''
}
],
defaultProvider: 'mineru'
}
export const defaultPreprocessProviders = initialState.providers
const preprocessSlice = createSlice({
name: 'preprocess',
initialState,

View File

@ -0,0 +1,101 @@
import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'
import type { PayloadAction } from '@reduxjs/toolkit'
import { createSlice } from '@reduxjs/toolkit'
export type ToolPermissionRequestPayload = {
requestId: string
toolName: string
toolId: string
description?: string
requiresPermissions: boolean
input: Record<string, unknown>
inputPreview: string
createdAt: number
expiresAt: number
suggestions: PermissionUpdate[]
}
export type ToolPermissionResultPayload = {
requestId: string
behavior: 'allow' | 'deny'
message?: string
reason: 'response' | 'timeout' | 'aborted' | 'no-window'
}
export type ToolPermissionStatus = 'pending' | 'submitting-allow' | 'submitting-deny'
export type ToolPermissionEntry = ToolPermissionRequestPayload & {
status: ToolPermissionStatus
}
export interface ToolPermissionsState {
requests: Record<string, ToolPermissionEntry>
}
const initialState: ToolPermissionsState = {
requests: {}
}
const toolPermissionsSlice = createSlice({
name: 'toolPermissions',
initialState,
reducers: {
requestReceived: (state, action: PayloadAction<ToolPermissionRequestPayload>) => {
const payload = action.payload
state.requests[payload.requestId] = {
...payload,
status: 'pending'
}
},
submissionSent: (state, action: PayloadAction<{ requestId: string; behavior: 'allow' | 'deny' }>) => {
const { requestId, behavior } = action.payload
const entry = state.requests[requestId]
if (!entry) return
entry.status = behavior === 'allow' ? 'submitting-allow' : 'submitting-deny'
},
submissionFailed: (state, action: PayloadAction<{ requestId: string }>) => {
const entry = state.requests[action.payload.requestId]
if (!entry) return
entry.status = 'pending'
},
requestResolved: (state, action: PayloadAction<ToolPermissionResultPayload>) => {
const { requestId } = action.payload
delete state.requests[requestId]
},
clearAll: (state) => {
state.requests = {}
}
}
})
export const toolPermissionsActions = toolPermissionsSlice.actions
export const selectActiveToolPermission = (state: ToolPermissionsState): ToolPermissionEntry | null => {
const activeEntries = Object.values(state.requests).filter(
(entry) => entry.status === 'pending' || entry.status === 'submitting-allow' || entry.status === 'submitting-deny'
)
if (activeEntries.length === 0) return null
activeEntries.sort((a, b) => a.createdAt - b.createdAt)
return activeEntries[0]
}
export const selectPendingPermissionByToolName = (
state: ToolPermissionsState,
toolName: string
): ToolPermissionEntry | undefined => {
const activeEntries = Object.values(state.requests)
.filter((entry) => entry.toolName === toolName)
.filter(
(entry) => entry.status === 'pending' || entry.status === 'submitting-allow' || entry.status === 'submitting-deny'
)
if (activeEntries.length === 0) return undefined
activeEntries.sort((a, b) => a.createdAt - b.createdAt)
return activeEntries[0]
}
export default toolPermissionsSlice.reducer

View File

@ -8,6 +8,7 @@ import type { ModelMessage, TextStreamPart } from 'ai'
import * as z from 'zod'
import type { Message, MessageBlock } from './newMessage'
import { PluginMetadataSchema } from './plugin'
// ------------------ Core enums and helper types ------------------
export const PermissionModeSchema = z.enum(['default', 'acceptEdits', 'bypassPermissions', 'plan'])
@ -57,7 +58,30 @@ export const AgentConfigurationSchema = z
// https://docs.claude.com/en/docs/claude-code/sdk/sdk-permissions#mode-specific-behaviors
permission_mode: PermissionModeSchema.optional().default('default'), // Permission mode, default to 'default'
max_turns: z.number().optional().default(100) // Maximum number of interaction turns, default to 100
max_turns: z.number().optional().default(100), // Maximum number of interaction turns, default to 100
// Plugin metadata
installed_plugins: z
.array(
z.object({
sourcePath: z.string(), // Full source path for re-install/updates
filename: z.string(), // Destination filename (unique)
type: z.enum(['agent', 'command', 'skill']),
name: z.string(),
description: z.string().optional(),
allowed_tools: z.array(z.string()).optional(),
tools: z.array(z.string()).optional(),
category: z.string().optional(),
tags: z.array(z.string()).optional(),
version: z.string().optional(),
author: z.string().optional(),
contentHash: z.string(), // Detect file modifications
installedAt: z.number(), // Track installation time
updatedAt: z.number().optional() // Track updates
})
)
.optional()
.default([])
})
.loose()
@ -265,7 +289,16 @@ export interface UpdateSessionRequest extends Partial<AgentBase> {}
export const GetAgentSessionResponseSchema = AgentSessionEntitySchema.extend({
tools: z.array(ToolSchema).optional(), // All tools available to the session (including built-in and custom)
messages: z.array(AgentSessionMessageEntitySchema).optional(), // Messages in the session
slash_commands: z.array(SlashCommandSchema).optional() // Array of slash commands to trigger the agent
slash_commands: z.array(SlashCommandSchema).optional(), // Array of slash commands to trigger the agent
plugins: z
.array(
z.object({
filename: z.string(),
type: z.enum(['agent', 'command', 'skill']),
metadata: PluginMetadataSchema
})
)
.optional() // Installed plugins from workdir
})
export const CreateAgentSessionResponseSchema = GetAgentSessionResponseSchema

View File

@ -22,6 +22,7 @@ export * from './knowledge'
export * from './mcp'
export * from './notification'
export * from './ocr'
export * from './plugin'
export * from './provider'
export type Assistant = {

View File

@ -107,7 +107,8 @@ export type ProcessingStatus = 'pending' | 'processing' | 'completed' | 'failed'
export const PreprocessProviderIds = {
doc2x: 'doc2x',
mistral: 'mistral',
mineru: 'mineru'
mineru: 'mineru',
'open-mineru': 'open-mineru'
} as const
export type PreprocessProviderId = keyof typeof PreprocessProviderIds

View File

@ -0,0 +1,98 @@
import * as z from 'zod'
// Plugin Type
export type PluginType = 'agent' | 'command' | 'skill'
// Plugin Metadata Type
export const PluginMetadataSchema = z.object({
// Identification
sourcePath: z.string(), // e.g., "agents/ai-specialists/ai-ethics-advisor.md" or "skills/my-skill"
filename: z.string(), // IMPORTANT: Semantics vary by type:
// - For agents/commands: includes .md extension (e.g., "my-agent.md")
// - For skills: folder name only, no extension (e.g., "my-skill")
name: z.string(), // Display name from frontmatter or filename
// Content
description: z.string().optional(),
allowed_tools: z.array(z.string()).optional(), // from frontmatter (for commands)
tools: z.array(z.string()).optional(), // from frontmatter (for agents and skills)
// Organization
category: z.string(), // derived from parent folder name
type: z.enum(['agent', 'command', 'skill']), // UPDATED: now includes 'skill'
tags: z.array(z.string()).optional(),
// Versioning (for future updates)
version: z.string().optional(),
author: z.string().optional(),
// Metadata
size: z.number(), // file size in bytes
contentHash: z.string(), // SHA-256 hash for change detection
installedAt: z.number().optional(), // Unix timestamp (for installed plugins)
updatedAt: z.number().optional() // Unix timestamp (for installed plugins)
})
export type PluginMetadata = z.infer<typeof PluginMetadataSchema>
export const InstalledPluginSchema = z.object({
filename: z.string(),
type: z.enum(['agent', 'command', 'skill']),
metadata: PluginMetadataSchema
})
export type InstalledPlugin = z.infer<typeof InstalledPluginSchema>
// Error handling types
export type PluginError =
| { type: 'PATH_TRAVERSAL'; message: string; path: string }
| { type: 'FILE_NOT_FOUND'; path: string }
| { type: 'PERMISSION_DENIED'; path: string }
| { type: 'INVALID_METADATA'; reason: string; path: string }
| { type: 'FILE_TOO_LARGE'; size: number; max: number }
| { type: 'DUPLICATE_FILENAME'; filename: string }
| { type: 'INVALID_WORKDIR'; workdir: string; agentId: string; message?: string }
| { type: 'INVALID_FILE_TYPE'; extension: string }
| { type: 'WORKDIR_NOT_FOUND'; workdir: string }
| { type: 'DISK_SPACE_ERROR'; required: number; available: number }
| { type: 'TRANSACTION_FAILED'; operation: string; reason: string }
| { type: 'READ_FAILED'; path: string; reason: string }
| { type: 'WRITE_FAILED'; path: string; reason: string }
| { type: 'PLUGIN_NOT_INSTALLED'; filename: string; agentId: string }
export type PluginResult<T> = { success: true; data: T } | { success: false; error: PluginError }
export interface InstallPluginOptions {
agentId: string
sourcePath: string
type: 'agent' | 'command' | 'skill'
}
export interface UninstallPluginOptions {
agentId: string
filename: string
type: 'agent' | 'command' | 'skill'
}
export interface WritePluginContentOptions {
agentId: string
filename: string
type: 'agent' | 'command' | 'skill'
content: string
}
export interface ListAvailablePluginsResult {
agents: PluginMetadata[]
commands: PluginMetadata[]
skills: PluginMetadata[] // NEW: skills plugin type
total: number
}
// IPC Channel Constants
export const CLAUDE_CODE_PLUGIN_IPC_CHANNELS = {
LIST_AVAILABLE: 'claudeCodePlugin:list-available',
INSTALL: 'claudeCodePlugin:install',
UNINSTALL: 'claudeCodePlugin:uninstall',
LIST_INSTALLED: 'claudeCodePlugin:list-installed',
INVALIDATE_CACHE: 'claudeCodePlugin:invalidate-cache'
} as const

122
yarn.lock
View File

@ -483,9 +483,9 @@ __metadata:
languageName: node
linkType: hard
"@anthropic-ai/claude-agent-sdk@npm:0.1.1":
version: 0.1.1
resolution: "@anthropic-ai/claude-agent-sdk@npm:0.1.1"
"@anthropic-ai/claude-agent-sdk@npm:0.1.25":
version: 0.1.25
resolution: "@anthropic-ai/claude-agent-sdk@npm:0.1.25"
dependencies:
"@img/sharp-darwin-arm64": "npm:^0.33.5"
"@img/sharp-darwin-x64": "npm:^0.33.5"
@ -493,6 +493,8 @@ __metadata:
"@img/sharp-linux-arm64": "npm:^0.33.5"
"@img/sharp-linux-x64": "npm:^0.33.5"
"@img/sharp-win32-x64": "npm:^0.33.5"
peerDependencies:
zod: ^3.24.1
dependenciesMeta:
"@img/sharp-darwin-arm64":
optional: true
@ -506,13 +508,13 @@ __metadata:
optional: true
"@img/sharp-win32-x64":
optional: true
checksum: 10c0/6b6e34eb4e871fc5d0120c311054b757831dfb953110f9f9d7af0202f26a16c9059e7d0a1c002dc581afb50ccf20f100670f0b3a6682696f6b4ddeeea1d0d8d0
checksum: 10c0/6954ef056cf22f5d1ea1337ee647bc98934323dd3f81d6288ae683950fe08b62e3b46978d7df3637e263d6993770c5995d6ff44efcc309da070e7dd4f82e71d8
languageName: node
linkType: hard
"@anthropic-ai/claude-agent-sdk@patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.1#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.1-d937b73fed.patch":
version: 0.1.1
resolution: "@anthropic-ai/claude-agent-sdk@patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.1#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.1-d937b73fed.patch::version=0.1.1&hash=f97b6e"
"@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":
version: 0.1.25
resolution: "@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::version=0.1.25&hash=1b10b5"
dependencies:
"@img/sharp-darwin-arm64": "npm:^0.33.5"
"@img/sharp-darwin-x64": "npm:^0.33.5"
@ -520,6 +522,8 @@ __metadata:
"@img/sharp-linux-arm64": "npm:^0.33.5"
"@img/sharp-linux-x64": "npm:^0.33.5"
"@img/sharp-win32-x64": "npm:^0.33.5"
peerDependencies:
zod: ^3.24.1
dependenciesMeta:
"@img/sharp-darwin-arm64":
optional: true
@ -533,7 +537,7 @@ __metadata:
optional: true
"@img/sharp-win32-x64":
optional: true
checksum: 10c0/4312b2cb008a332f52d63b1b005d16482c9cbdb3377729422287506c12e9003e0b376e8b8ef3d127908238c36f799608eda85d9b760a96cd836b3a5f7752104f
checksum: 10c0/01d4759213d55085d6eff0f17e9908fb00a929f71ad9fe6fc1494fff24b8300dc57c7e16122e02f547f634b3a1ba346a1179bad0b82b3fad3268c91c724acb9e
languageName: node
linkType: hard
@ -15707,6 +15711,13 @@ __metadata:
languageName: node
linkType: hard
"@types/js-yaml@npm:^4.0.9":
version: 4.0.9
resolution: "@types/js-yaml@npm:4.0.9"
checksum: 10c0/24de857aa8d61526bbfbbaa383aa538283ad17363fcd5bb5148e2c7f604547db36646440e739d78241ed008702a8920665d1add5618687b6743858fae00da211
languageName: node
linkType: hard
"@types/json-schema@npm:^7.0.15, @types/json-schema@npm:^7.0.6":
version: 7.0.15
resolution: "@types/json-schema@npm:7.0.15"
@ -17171,7 +17182,7 @@ __metadata:
"@ai-sdk/mistral": "npm:^2.0.19"
"@ai-sdk/perplexity": "npm:^2.0.13"
"@ant-design/v5-patch-for-react-19": "npm:^1.0.3"
"@anthropic-ai/claude-agent-sdk": "patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.1#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.1-d937b73fed.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"
"@anthropic-ai/sdk": "npm:^0.41.0"
"@anthropic-ai/vertex-sdk": "patch:@anthropic-ai/vertex-sdk@npm%3A0.11.4#~/.yarn/patches/@anthropic-ai-vertex-sdk-npm-0.11.4-c19cb41edb.patch"
"@aws-sdk/client-bedrock": "npm:^3.840.0"
@ -17264,6 +17275,7 @@ __metadata:
"@types/fs-extra": "npm:^11"
"@types/he": "npm:^1"
"@types/html-to-text": "npm:^9"
"@types/js-yaml": "npm:^4.0.9"
"@types/lodash": "npm:^4.17.5"
"@types/markdown-it": "npm:^14"
"@types/md5": "npm:^2.3.5"
@ -17303,6 +17315,7 @@ __metadata:
check-disk-space: "npm:3.4.0"
cheerio: "npm:^1.1.2"
chokidar: "npm:^4.0.3"
claude-code-plugins: "npm:1.0.1"
cli-progress: "npm:^3.12.0"
clsx: "npm:^2.1.1"
code-inspector-plugin: "npm:^0.20.14"
@ -17346,6 +17359,7 @@ __metadata:
fs-extra: "npm:^11.2.0"
google-auth-library: "npm:^9.15.1"
graceful-fs: "npm:^4.2.11"
gray-matter: "npm:^4.0.3"
he: "npm:^1.2.0"
html-tags: "npm:^5.1.0"
html-to-image: "npm:^1.11.13"
@ -17358,6 +17372,7 @@ __metadata:
isbinaryfile: "npm:5.0.4"
jaison: "npm:^2.0.2"
jest-styled-components: "npm:^7.2.0"
js-yaml: "npm:^4.1.0"
jsdom: "npm:26.1.0"
linguist-languages: "npm:^8.1.0"
lint-staged: "npm:^15.5.0"
@ -18017,14 +18032,7 @@ __metadata:
languageName: node
linkType: hard
"argparse@npm:^2.0.1":
version: 2.0.1
resolution: "argparse@npm:2.0.1"
checksum: 10c0/c5640c2d89045371c7cedd6a70212a04e360fd34d6edeae32f6952c63949e3525ea77dbec0289d8213a99bbaeab5abfa860b5c12cf88a2e6cf8106e90dd27a7e
languageName: node
linkType: hard
"argparse@npm:~1.0.3":
"argparse@npm:^1.0.7, argparse@npm:~1.0.3":
version: 1.0.10
resolution: "argparse@npm:1.0.10"
dependencies:
@ -18033,6 +18041,13 @@ __metadata:
languageName: node
linkType: hard
"argparse@npm:^2.0.1":
version: 2.0.1
resolution: "argparse@npm:2.0.1"
checksum: 10c0/c5640c2d89045371c7cedd6a70212a04e360fd34d6edeae32f6952c63949e3525ea77dbec0289d8213a99bbaeab5abfa860b5c12cf88a2e6cf8106e90dd27a7e
languageName: node
linkType: hard
"aria-hidden@npm:^1.2.4":
version: 1.2.6
resolution: "aria-hidden@npm:1.2.6"
@ -19046,6 +19061,13 @@ __metadata:
languageName: node
linkType: hard
"claude-code-plugins@npm:1.0.1":
version: 1.0.1
resolution: "claude-code-plugins@npm:1.0.1"
checksum: 10c0/13fb614d1b65ea001f774183b8e9ce3deaab5402f2d99fc92f0786de5931db33b30cf975f723186bbfcf694f675c9a9ba182e531e92d25a4350844279e0bd6d5
languageName: node
linkType: hard
"clean-stack@npm:^2.0.0":
version: 2.2.0
resolution: "clean-stack@npm:2.2.0"
@ -21822,7 +21844,7 @@ __metadata:
languageName: node
linkType: hard
"esprima@npm:^4.0.1, esprima@npm:~4.0.0":
"esprima@npm:^4.0.0, esprima@npm:^4.0.1, esprima@npm:~4.0.0":
version: 4.0.1
resolution: "esprima@npm:4.0.1"
bin:
@ -22075,6 +22097,15 @@ __metadata:
languageName: node
linkType: hard
"extend-shallow@npm:^2.0.1":
version: 2.0.1
resolution: "extend-shallow@npm:2.0.1"
dependencies:
is-extendable: "npm:^0.1.0"
checksum: 10c0/ee1cb0a18c9faddb42d791b2d64867bd6cfd0f3affb711782eb6e894dd193e2934a7f529426aac7c8ddb31ac5d38000a00aa2caf08aa3dfc3e1c8ff6ba340bd9
languageName: node
linkType: hard
"extend@npm:^3.0.0, extend@npm:^3.0.2":
version: 3.0.2
resolution: "extend@npm:3.0.2"
@ -23162,6 +23193,18 @@ __metadata:
languageName: node
linkType: hard
"gray-matter@npm:^4.0.3":
version: 4.0.3
resolution: "gray-matter@npm:4.0.3"
dependencies:
js-yaml: "npm:^3.13.1"
kind-of: "npm:^6.0.2"
section-matter: "npm:^1.0.0"
strip-bom-string: "npm:^1.0.0"
checksum: 10c0/e38489906dad4f162ca01e0dcbdbed96d1a53740cef446b9bf76d80bec66fa799af07776a18077aee642346c5e1365ed95e4c91854a12bf40ba0d4fb43a625a6
languageName: node
linkType: hard
"gtoken@npm:^7.0.0":
version: 7.1.0
resolution: "gtoken@npm:7.1.0"
@ -23983,6 +24026,13 @@ __metadata:
languageName: node
linkType: hard
"is-extendable@npm:^0.1.0":
version: 0.1.1
resolution: "is-extendable@npm:0.1.1"
checksum: 10c0/dd5ca3994a28e1740d1e25192e66eed128e0b2ff161a7ea348e87ae4f616554b486854de423877a2a2c171d5f7cd6e8093b91f54533bc88a59ee1c9838c43879
languageName: node
linkType: hard
"is-extglob@npm:^2.1.1":
version: 2.1.1
resolution: "is-extglob@npm:2.1.1"
@ -24358,6 +24408,18 @@ __metadata:
languageName: node
linkType: hard
"js-yaml@npm:^3.13.1":
version: 3.14.1
resolution: "js-yaml@npm:3.14.1"
dependencies:
argparse: "npm:^1.0.7"
esprima: "npm:^4.0.0"
bin:
js-yaml: bin/js-yaml.js
checksum: 10c0/6746baaaeac312c4db8e75fa22331d9a04cccb7792d126ed8ce6a0bbcfef0cedaddd0c5098fade53db067c09fe00aa1c957674b4765610a8b06a5a189e46433b
languageName: node
linkType: hard
"jsbn@npm:1.1.0":
version: 1.1.0
resolution: "jsbn@npm:1.1.0"
@ -24602,6 +24664,13 @@ __metadata:
languageName: node
linkType: hard
"kind-of@npm:^6.0.0, kind-of@npm:^6.0.2":
version: 6.0.3
resolution: "kind-of@npm:6.0.3"
checksum: 10c0/61cdff9623dabf3568b6445e93e31376bee1cdb93f8ba7033d86022c2a9b1791a1d9510e026e6465ebd701a6dd2f7b0808483ad8838341ac52f003f512e0b4c4
languageName: node
linkType: hard
"kolorist@npm:^1.8.0":
version: 1.8.0
resolution: "kolorist@npm:1.8.0"
@ -30769,6 +30838,16 @@ __metadata:
languageName: node
linkType: hard
"section-matter@npm:^1.0.0":
version: 1.0.0
resolution: "section-matter@npm:1.0.0"
dependencies:
extend-shallow: "npm:^2.0.1"
kind-of: "npm:^6.0.0"
checksum: 10c0/8007f91780adc5aaa781a848eaae50b0f680bbf4043b90cf8a96778195b8fab690c87fe7a989e02394ce69890e330811ec8dab22397d384673ce59f7d750641d
languageName: node
linkType: hard
"seek-bzip@npm:^1.0.5":
version: 1.0.6
resolution: "seek-bzip@npm:1.0.6"
@ -31556,6 +31635,13 @@ __metadata:
languageName: node
linkType: hard
"strip-bom-string@npm:^1.0.0":
version: 1.0.0
resolution: "strip-bom-string@npm:1.0.0"
checksum: 10c0/5c5717e2643225aa6a6d659d34176ab2657037f1fe2423ac6fcdb488f135e14fef1022030e426d8b4d0989e09adbd5c3288d5d3b9c632abeefd2358dfc512bca
languageName: node
linkType: hard
"strip-bom@npm:^3.0.0":
version: 3.0.0
resolution: "strip-bom@npm:3.0.0"