mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-02-19 00:54:46 +08:00
Merge remote-tracking branch 'origin/main' into feat/user-guide
This commit is contained in:
commit
95e9c8afb5
@ -347,6 +347,7 @@
|
||||
"oxlint-tsgolint": "^0.2.0",
|
||||
"p-queue": "^8.1.0",
|
||||
"pako": "1.0.11",
|
||||
"partial-json": "0.1.7",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdf-parse": "^1.1.1",
|
||||
"prosemirror-model": "1.25.2",
|
||||
|
||||
22
pnpm-lock.yaml
generated
22
pnpm-lock.yaml
generated
@ -920,6 +920,9 @@ importers:
|
||||
pako:
|
||||
specifier: 1.0.11
|
||||
version: 1.0.11
|
||||
partial-json:
|
||||
specifier: 0.1.7
|
||||
version: 0.1.7
|
||||
pdf-lib:
|
||||
specifier: ^1.17.1
|
||||
version: 1.17.1
|
||||
@ -9748,6 +9751,9 @@ packages:
|
||||
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
partial-json@0.1.7:
|
||||
resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==}
|
||||
|
||||
path-browserify@1.0.1:
|
||||
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
|
||||
|
||||
@ -17680,11 +17686,11 @@ snapshots:
|
||||
- utf-8-validate
|
||||
- vite
|
||||
|
||||
'@vitest/browser@3.2.4(msw@2.12.7(@types/node@24.10.4)(typescript@5.8.3))(playwright@1.57.0)(rolldown-vite@7.3.0(@types/node@22.17.2)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4)':
|
||||
'@vitest/browser@3.2.4(msw@2.12.7(@types/node@24.10.4)(typescript@5.8.3))(playwright@1.57.0)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4)':
|
||||
dependencies:
|
||||
'@testing-library/dom': 10.4.1
|
||||
'@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1)
|
||||
'@vitest/mocker': 3.2.4(msw@2.12.7(@types/node@24.10.4)(typescript@5.8.3))(rolldown-vite@7.3.0(@types/node@22.17.2)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@vitest/mocker': 3.2.4(msw@2.12.7(@types/node@24.10.4)(typescript@5.8.3))(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@vitest/utils': 3.2.4
|
||||
magic-string: 0.30.21
|
||||
sirv: 3.0.2
|
||||
@ -17738,14 +17744,14 @@ snapshots:
|
||||
msw: 2.12.7(@types/node@22.17.2)(typescript@5.8.3)
|
||||
vite: rolldown-vite@7.3.0(@types/node@22.17.2)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||
|
||||
'@vitest/mocker@3.2.4(msw@2.12.7(@types/node@24.10.4)(typescript@5.8.3))(rolldown-vite@7.3.0(@types/node@22.17.2)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
'@vitest/mocker@3.2.4(msw@2.12.7(@types/node@24.10.4)(typescript@5.8.3))(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@vitest/spy': 3.2.4
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
msw: 2.12.7(@types/node@24.10.4)(typescript@5.8.3)
|
||||
vite: rolldown-vite@7.3.0(@types/node@22.17.2)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vite: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||
|
||||
'@vitest/pretty-format@3.2.4':
|
||||
dependencies:
|
||||
@ -17776,7 +17782,7 @@ snapshots:
|
||||
sirv: 3.0.2
|
||||
tinyglobby: 0.2.15
|
||||
tinyrainbow: 2.0.0
|
||||
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.17.2)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(esbuild@0.25.12)(jiti@2.6.1)(jsdom@26.1.0)(msw@2.12.7(@types/node@22.17.2)(typescript@5.8.3))(tsx@4.21.0)(yaml@2.8.2)
|
||||
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(esbuild@0.25.12)(jiti@2.6.1)(jsdom@26.1.0)(msw@2.12.7(@types/node@24.10.4)(typescript@5.8.3))(tsx@4.21.0)(yaml@2.8.2)
|
||||
|
||||
'@vitest/utils@3.2.4':
|
||||
dependencies:
|
||||
@ -22573,6 +22579,8 @@ snapshots:
|
||||
|
||||
parseurl@1.3.3: {}
|
||||
|
||||
partial-json@0.1.7: {}
|
||||
|
||||
path-browserify@1.0.1: {}
|
||||
|
||||
path-data-parser@0.1.0: {}
|
||||
@ -25143,7 +25151,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/expect': 3.2.4
|
||||
'@vitest/mocker': 3.2.4(msw@2.12.7(@types/node@24.10.4)(typescript@5.8.3))(rolldown-vite@7.3.0(@types/node@22.17.2)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@vitest/mocker': 3.2.4(msw@2.12.7(@types/node@24.10.4)(typescript@5.8.3))(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@vitest/pretty-format': 3.2.4
|
||||
'@vitest/runner': 3.2.4
|
||||
'@vitest/snapshot': 3.2.4
|
||||
@ -25167,7 +25175,7 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/debug': 4.1.12
|
||||
'@types/node': 24.10.4
|
||||
'@vitest/browser': 3.2.4(msw@2.12.7(@types/node@24.10.4)(typescript@5.8.3))(playwright@1.57.0)(rolldown-vite@7.3.0(@types/node@22.17.2)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4)
|
||||
'@vitest/browser': 3.2.4(msw@2.12.7(@types/node@24.10.4)(typescript@5.8.3))(playwright@1.57.0)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4)
|
||||
'@vitest/ui': 3.2.4(vitest@3.2.4)
|
||||
jsdom: 26.1.0
|
||||
transitivePeerDependencies:
|
||||
|
||||
@ -4,7 +4,7 @@ import path from 'node:path'
|
||||
import { loggerService } from '@logger'
|
||||
import { windowService } from '@main/services/WindowService'
|
||||
import { getFileExt, getTempDir } from '@main/utils/file'
|
||||
import type { FileMetadata, PreprocessProvider } from '@types'
|
||||
import type { FileMetadata, PreprocessProvider, PreprocessReadPdfResult } from '@types'
|
||||
import { PDFDocument } from 'pdf-lib'
|
||||
|
||||
const logger = loggerService.withContext('BasePreprocessProvider')
|
||||
@ -90,7 +90,7 @@ export default abstract class BasePreprocessProvider {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
public async readPdf(buffer: Buffer) {
|
||||
public async readPdf(buffer: Buffer): Promise<PreprocessReadPdfResult> {
|
||||
const pdfDoc = await PDFDocument.load(buffer, { ignoreEncryption: true })
|
||||
return {
|
||||
numPages: pdfDoc.getPageCount()
|
||||
|
||||
289
src/main/knowledge/preprocess/PaddleocrPreprocessProvider.ts
Normal file
289
src/main/knowledge/preprocess/PaddleocrPreprocessProvider.ts
Normal file
@ -0,0 +1,289 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
import { fileStorage } from '@main/services/FileStorage'
|
||||
import { getFileType } from '@main/utils/file'
|
||||
import { MB } from '@shared/config/constant'
|
||||
import type { FileMetadata, PreprocessProvider, PreprocessReadPdfResult } from '@types'
|
||||
import { net } from 'electron'
|
||||
import * as z from 'zod'
|
||||
|
||||
import BasePreprocessProvider from './BasePreprocessProvider'
|
||||
|
||||
const logger = loggerService.withContext('PaddleocrPreprocessProvider')
|
||||
|
||||
/**
|
||||
* 单个文件大小不超过50MB,为避免处理超时,建议每个文件不超过100页。若超过100页,API只解析前100页,后续页将被忽略。
|
||||
* 来源:PaddleOCR 官方 API 调用说明 https://aistudio.baidu.com/paddleocr
|
||||
*/
|
||||
export const PDF_SIZE_LIMIT_MB = 50
|
||||
export const PDF_PAGE_LIMIT = 100
|
||||
export const PDF_SIZE_LIMIT_BYTES = PDF_SIZE_LIMIT_MB * MB
|
||||
|
||||
enum FileType {
|
||||
PDF = 0,
|
||||
Image = 1
|
||||
}
|
||||
|
||||
const ApiResponseSchema = z.looseObject({
|
||||
result: z
|
||||
.looseObject({
|
||||
layoutParsingResults: z
|
||||
.array(
|
||||
z.looseObject({
|
||||
markdown: z.looseObject({
|
||||
text: z.string().min(1, 'Markdown text cannot be empty')
|
||||
})
|
||||
})
|
||||
)
|
||||
.min(1, 'At least one layout parsing result required')
|
||||
})
|
||||
.optional(),
|
||||
errorCode: z.number().optional(),
|
||||
errorMsg: z.string().optional()
|
||||
})
|
||||
|
||||
type ApiResponse = z.infer<typeof ApiResponseSchema>
|
||||
|
||||
const isApiSuccess = (response: ApiResponse): boolean => {
|
||||
const hasNoError = !response.errorCode || response.errorCode === 0
|
||||
const hasSuccessMsg = !response.errorMsg || /success/i.test(response.errorMsg)
|
||||
return hasNoError && hasSuccessMsg
|
||||
}
|
||||
|
||||
function formatZodError(error: z.ZodError): string {
|
||||
return error.issues
|
||||
.map((issue) => {
|
||||
const path = issue.path.join('.')
|
||||
const code = issue.code
|
||||
const message = issue.message
|
||||
return `[${code}] ${path}: ${message}`
|
||||
})
|
||||
.join('; ')
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof z.ZodError) {
|
||||
return formatZodError(error)
|
||||
} else if (error instanceof Error) {
|
||||
return error.message
|
||||
} else if (typeof error === 'string') {
|
||||
return error
|
||||
} else {
|
||||
return 'Unknown error'
|
||||
}
|
||||
}
|
||||
|
||||
export default class PaddleocrPreprocessProvider extends BasePreprocessProvider {
|
||||
constructor(provider: PreprocessProvider, userId?: string) {
|
||||
super(provider, userId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析文件并通过 PaddleOCR 进行预处理(当前仅支持 PDF 文件)
|
||||
* @param sourceId - 源任务ID,用于进度更新/日志追踪
|
||||
* @param file - 待处理的文件元数据(仅支持 ext 为 .pdf 的文件)
|
||||
* @returns {Promise<{processedFile: FileMetadata; quota: number}>} 处理后的文件元数据 + 配额消耗(当前 PaddleOCR 配额为 0)
|
||||
* @throws {Error} 若传入非 PDF 文件、文件大小超限、页数超限等会抛出异常
|
||||
*/
|
||||
public async parseFile(
|
||||
sourceId: string,
|
||||
file: FileMetadata
|
||||
): Promise<{ processedFile: FileMetadata; quota: number }> {
|
||||
try {
|
||||
const filePath = fileStorage.getFilePathById(file)
|
||||
logger.info(`PaddleOCR preprocess processing started: ${filePath}`)
|
||||
|
||||
const fileBuffer = await this.validateFile(filePath)
|
||||
|
||||
// 进度条
|
||||
await this.sendPreprocessProgress(sourceId, 25)
|
||||
|
||||
// 1.读取pdf文件并编码为base64
|
||||
const fileData = fileBuffer.toString('base64')
|
||||
await this.sendPreprocessProgress(sourceId, 50)
|
||||
|
||||
// 2. 调用PadlleOCR文档处理API
|
||||
const apiResponse = await this.callPaddleOcrApi(fileData, FileType.PDF)
|
||||
logger.info(`PaddleOCR API call completed`)
|
||||
|
||||
await this.sendPreprocessProgress(sourceId, 75)
|
||||
|
||||
// 3. 处理 API 错误场景
|
||||
if (!isApiSuccess(apiResponse)) {
|
||||
const errorCode = apiResponse.errorCode ?? -1
|
||||
const errorMsg = apiResponse.errorMsg || 'Unknown error'
|
||||
const fullErrorMsg = `PaddleOCR API processing failed [${errorCode}]: ${errorMsg}`
|
||||
logger.error(fullErrorMsg)
|
||||
throw new Error(fullErrorMsg)
|
||||
}
|
||||
|
||||
// 4. 保存markdown文本
|
||||
const outputDir = await this.saveResults(apiResponse.result, file)
|
||||
|
||||
await this.sendPreprocessProgress(sourceId, 100)
|
||||
|
||||
const processedFile = await this.createProcessedFileInfo(file, outputDir)
|
||||
|
||||
// 5. 创建处理后数据
|
||||
return {
|
||||
processedFile,
|
||||
quota: 0
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
logger.error(`PaddleOCR preprocess processing failed for:`, error as Error)
|
||||
throw new Error(getErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
public async checkQuota(): Promise<number> {
|
||||
// PaddleOCR doesn't have quota checking, return 0
|
||||
return 0
|
||||
}
|
||||
|
||||
private getMarkdownFileName(file: FileMetadata): string {
|
||||
return file.origin_name.replace(/\.(pdf|jpg|jpeg|png)$/i, '.md')
|
||||
}
|
||||
|
||||
private async validateFile(filePath: string): Promise<Buffer> {
|
||||
// 阶段1:校验文件类型
|
||||
logger.info(`Validating PDF file: ${filePath}`)
|
||||
const ext = path.extname(filePath).toLowerCase()
|
||||
if (ext !== '.pdf') {
|
||||
throw new Error(`File ${filePath} is not a PDF (extension: ${ext.slice(1)})`)
|
||||
}
|
||||
|
||||
// 阶段2:校验文件大小
|
||||
const stats = await fs.promises.stat(filePath)
|
||||
const fileSizeBytes = stats.size
|
||||
if (fileSizeBytes > PDF_SIZE_LIMIT_BYTES) {
|
||||
const fileSizeMB = Math.round(fileSizeBytes / MB)
|
||||
throw new Error(`PDF file size (${fileSizeMB}MB) exceeds the limit of ${PDF_SIZE_LIMIT_MB}MB`)
|
||||
}
|
||||
|
||||
// 阶段3:校验页数(兼容 PDF 解析失败的场景)
|
||||
const pdfBuffer = await fs.promises.readFile(filePath)
|
||||
let doc: PreprocessReadPdfResult | undefined
|
||||
|
||||
try {
|
||||
doc = await this.readPdf(pdfBuffer)
|
||||
} catch (error: unknown) {
|
||||
// PDF 解析失败:抛异常,跳过页数校验
|
||||
const errorMsg = getErrorMessage(error)
|
||||
logger.error(
|
||||
`Failed to parse PDF structure (file may be corrupted or use non-standard format). ` +
|
||||
`Skipping page count validation. Will attempt to process with PaddleOCR API. ` +
|
||||
`Error details: ${errorMsg}. ` +
|
||||
`Suggestion: If processing fails, try repairing the PDF using tools like Adobe Acrobat or online PDF repair services.`
|
||||
)
|
||||
throw error
|
||||
}
|
||||
|
||||
if (doc?.numPages > PDF_PAGE_LIMIT) {
|
||||
throw new Error(`PDF page count (${doc.numPages}) exceeds the limit of ${PDF_PAGE_LIMIT} pages`)
|
||||
}
|
||||
|
||||
logger.info(`PDF validation passed: ${doc.numPages} pages, ${Math.round(fileSizeBytes / MB)}MB`)
|
||||
|
||||
return pdfBuffer
|
||||
}
|
||||
|
||||
private async createProcessedFileInfo(file: FileMetadata, outputDir: string): Promise<FileMetadata> {
|
||||
const finalMdFileName = this.getMarkdownFileName(file)
|
||||
const finalMdPath = path.join(outputDir, finalMdFileName)
|
||||
|
||||
const ext = path.extname(finalMdPath)
|
||||
const type = getFileType(ext)
|
||||
const fileSize = (await fs.promises.stat(finalMdPath)).size
|
||||
|
||||
return {
|
||||
...file,
|
||||
name: finalMdFileName,
|
||||
path: finalMdPath,
|
||||
type: type,
|
||||
ext: ext,
|
||||
size: fileSize
|
||||
}
|
||||
}
|
||||
|
||||
private async callPaddleOcrApi(fileData: string, fileType: number): Promise<ApiResponse> {
|
||||
if (!this.provider.apiHost) {
|
||||
throw new Error('PaddleOCR API host is not configured')
|
||||
}
|
||||
|
||||
const endpoint = this.provider.apiHost
|
||||
const payload = {
|
||||
file: fileData,
|
||||
fileType: fileType,
|
||||
useDocOrientationClassify: false,
|
||||
useDocUnwarping: false,
|
||||
useTextlineOrientation: false,
|
||||
useChartRecognition: false
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await net.fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Client-Platform': 'cherry-studio',
|
||||
Authorization: `token ${this.provider.apiKey}`
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
logger.error(`PaddleOCR API error: HTTP ${response.status} - ${errorText}`)
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const rawData = await response.json()
|
||||
logger.debug('PaddleOCR API response', { data: rawData })
|
||||
|
||||
// Zod 校验响应结构(不合法则直接抛错)
|
||||
const validatedData = ApiResponseSchema.parse(rawData)
|
||||
return validatedData // 返回完整响应
|
||||
} catch (error: unknown) {
|
||||
const errorMsg = getErrorMessage(error)
|
||||
logger.error(`Failed to call PaddleOCR API: ${errorMsg}`, { error })
|
||||
throw new Error(`Failed to call PaddleOCR API: ${errorMsg}`)
|
||||
}
|
||||
}
|
||||
|
||||
private async saveResults(result: ApiResponse['result'], file: FileMetadata): Promise<string> {
|
||||
const outputDir = path.join(this.storageDir, file.id)
|
||||
|
||||
// 确保输出目录存在且为空
|
||||
if (fs.existsSync(outputDir)) {
|
||||
fs.rmSync(outputDir, { recursive: true, force: true })
|
||||
}
|
||||
fs.mkdirSync(outputDir, { recursive: true })
|
||||
|
||||
// 处理 result 为 undefined 的场景(API 无解析结果)
|
||||
if (!result) {
|
||||
const errorMsg = `Parsing failed: No valid parsing result from PaddleOCR API for file [ID: ${file.id}]`
|
||||
// Keep warning log for troubleshooting
|
||||
logger.error(errorMsg)
|
||||
// Throw exception to interrupt function execution (no empty file created)
|
||||
throw new Error(errorMsg)
|
||||
}
|
||||
|
||||
// Zod 保证:result 存在时,layoutParsingResults 必是非空数组
|
||||
const markdownText = result.layoutParsingResults
|
||||
.filter((layoutResult) => layoutResult?.markdown?.text)
|
||||
.map((layoutResult) => layoutResult.markdown.text)
|
||||
.join('\n\n')
|
||||
|
||||
// 直接构造目标文件名
|
||||
const finalMdFileName = this.getMarkdownFileName(file)
|
||||
const finalMdPath = path.join(outputDir, finalMdFileName)
|
||||
|
||||
// 保存 Markdown 文件
|
||||
fs.writeFileSync(finalMdPath, markdownText, 'utf-8')
|
||||
|
||||
logger.info(`Saved markdown file: ${finalMdPath}`)
|
||||
return outputDir
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,8 @@ import Doc2xPreprocessProvider from './Doc2xPreprocessProvider'
|
||||
import MineruPreprocessProvider from './MineruPreprocessProvider'
|
||||
import MistralPreprocessProvider from './MistralPreprocessProvider'
|
||||
import OpenMineruPreprocessProvider from './OpenMineruPreprocessProvider'
|
||||
import PaddleocrPreprocessProvider from './PaddleocrPreprocessProvider'
|
||||
|
||||
export default class PreprocessProviderFactory {
|
||||
static create(provider: PreprocessProvider, userId?: string): BasePreprocessProvider {
|
||||
switch (provider.id) {
|
||||
@ -17,6 +19,8 @@ export default class PreprocessProviderFactory {
|
||||
return new MineruPreprocessProvider(provider, userId)
|
||||
case 'open-mineru':
|
||||
return new OpenMineruPreprocessProvider(provider, userId)
|
||||
case 'paddleocr':
|
||||
return new PaddleocrPreprocessProvider(provider, userId)
|
||||
default:
|
||||
return new DefaultPreprocessProvider(provider)
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ import { loggerService } from '@logger'
|
||||
import { config as apiConfigService } from '@main/apiServer/config'
|
||||
import { validateModelId } from '@main/apiServer/utils'
|
||||
import { isWin } from '@main/constant'
|
||||
import { configManager } from '@main/services/ConfigManager'
|
||||
import { autoDiscoverGitBash } from '@main/utils/process'
|
||||
import getLoginShellEnvironment from '@main/utils/shell-env'
|
||||
import { withoutTrailingApiVersion } from '@shared/utils'
|
||||
@ -34,6 +35,9 @@ const DEFAULT_AUTO_ALLOW_TOOLS = new Set(['Read', 'Glob', 'Grep'])
|
||||
const shouldAutoApproveTools = process.env.CHERRY_AUTO_ALLOW_TOOLS === '1'
|
||||
const NO_RESUME_COMMANDS = ['/clear']
|
||||
|
||||
const getLanguageInstruction = () =>
|
||||
`IMPORTANT: You MUST use ${configManager.getLanguage()} language for ALL your outputs, including: (1) text responses, (2) tool call parameters like "description" fields, and (3) any user-facing content. Never use English unless the content is code, file paths, or technical identifiers.`
|
||||
|
||||
type UserInputMessage = {
|
||||
type: 'user'
|
||||
parent_tool_use_id: string | null
|
||||
@ -255,9 +259,13 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
? {
|
||||
type: 'preset',
|
||||
preset: 'claude_code',
|
||||
append: session.instructions
|
||||
append: `${session.instructions}\n\n${getLanguageInstruction()}`
|
||||
}
|
||||
: { type: 'preset', preset: 'claude_code' },
|
||||
: {
|
||||
type: 'preset',
|
||||
preset: 'claude_code',
|
||||
append: getLanguageInstruction()
|
||||
},
|
||||
settingSources: ['project'],
|
||||
includePartialMessages: true,
|
||||
permissionMode: session.configuration?.permission_mode,
|
||||
|
||||
@ -77,7 +77,10 @@ const generateMessageId = (): string => `msg_${uuidv4().replace(/-/g, '')}`
|
||||
* Removes any local command stdout/stderr XML wrappers that should never surface to the UI.
|
||||
*/
|
||||
export const stripLocalCommandTags = (text: string): string => {
|
||||
return text.replace(/<local-command-(stdout|stderr)>(.*?)<\/local-command-\1>/gs, '$2')
|
||||
return text
|
||||
.replace(/<local-command-(stdout|stderr)>(.*?)<\/local-command-\1>/gs, '$2')
|
||||
.replace('(no content)', '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
@ -321,7 +324,46 @@ function handleUserMessage(
|
||||
const chunks: AgentStreamPart[] = []
|
||||
const providerMetadata = sdkMessageToProviderMetadata(message)
|
||||
const content = message.message.content
|
||||
const isSynthetic = message.isSynthetic ?? false
|
||||
|
||||
// Check if content contains tool_result blocks (synthetic tool result messages)
|
||||
// This handles both SDK-flagged messages and standard tool_result content
|
||||
const contentArray = Array.isArray(content) ? content : []
|
||||
const hasToolResults = contentArray.some((block: any) => block.type === 'tool_result')
|
||||
|
||||
if (hasToolResults || message.tool_use_result || message.parent_tool_use_id) {
|
||||
if (!Array.isArray(content)) {
|
||||
return chunks
|
||||
}
|
||||
for (const block of content) {
|
||||
if (block.type === 'tool_result') {
|
||||
const toolResult = block as ToolResultContent
|
||||
const pendingCall = state.consumePendingToolCall(toolResult.tool_use_id)
|
||||
const toolCallId = pendingCall?.toolCallId ?? state.getNamespacedToolCallId(toolResult.tool_use_id)
|
||||
if (toolResult.is_error) {
|
||||
chunks.push({
|
||||
type: 'tool-error',
|
||||
toolCallId,
|
||||
toolName: pendingCall?.toolName ?? 'unknown',
|
||||
input: pendingCall?.input,
|
||||
error: toolResult.content,
|
||||
providerExecuted: true
|
||||
} as AgentStreamPart)
|
||||
} else {
|
||||
chunks.push({
|
||||
type: 'tool-result',
|
||||
toolCallId,
|
||||
toolName: pendingCall?.toolName ?? 'unknown',
|
||||
input: pendingCall?.input,
|
||||
output: toolResult.content,
|
||||
providerExecuted: true
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
// For non-synthetic messages (user-initiated content), render text content
|
||||
if (typeof content === 'string') {
|
||||
if (!content) {
|
||||
return chunks
|
||||
@ -352,39 +394,12 @@ function handleUserMessage(
|
||||
return chunks
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
return chunks
|
||||
}
|
||||
|
||||
// For non-synthetic array content, render text blocks
|
||||
for (const block of content) {
|
||||
if (block.type === 'tool_result') {
|
||||
const toolResult = block as ToolResultContent
|
||||
const pendingCall = state.consumePendingToolCall(toolResult.tool_use_id)
|
||||
const toolCallId = pendingCall?.toolCallId ?? state.getNamespacedToolCallId(toolResult.tool_use_id)
|
||||
if (toolResult.is_error) {
|
||||
chunks.push({
|
||||
type: 'tool-error',
|
||||
toolCallId,
|
||||
toolName: pendingCall?.toolName ?? 'unknown',
|
||||
input: pendingCall?.input,
|
||||
error: toolResult.content,
|
||||
providerExecuted: true
|
||||
} as AgentStreamPart)
|
||||
} else {
|
||||
chunks.push({
|
||||
type: 'tool-result',
|
||||
toolCallId,
|
||||
toolName: pendingCall?.toolName ?? 'unknown',
|
||||
input: pendingCall?.input,
|
||||
output: toolResult.content,
|
||||
providerExecuted: true
|
||||
})
|
||||
}
|
||||
} else if (block.type === 'text' && !isSynthetic) {
|
||||
if (block.type === 'text') {
|
||||
const rawText = (block as { text: string }).text
|
||||
const filteredText = filterCommandTags(rawText)
|
||||
|
||||
// Only push text chunks if there's content after filtering
|
||||
if (filteredText) {
|
||||
const id = message.uuid?.toString() || generateMessageId()
|
||||
chunks.push({
|
||||
@ -404,8 +419,6 @@ function handleUserMessage(
|
||||
providerMetadata
|
||||
})
|
||||
}
|
||||
} else {
|
||||
logger.warn('Unhandled user content block', { type: (block as any).type })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -67,7 +67,8 @@ export class PpocrService extends OcrBaseService {
|
||||
} satisfies OcrPayload
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json'
|
||||
'Content-Type': 'application/json',
|
||||
'Client-Platform': 'cherry-studio'
|
||||
}
|
||||
|
||||
if (options.accessToken) {
|
||||
|
||||
@ -238,15 +238,16 @@ export class AiSdkToChunkAdapter {
|
||||
|
||||
// === 工具调用相关事件(原始 AI SDK 事件,如果没有被中间件处理) ===
|
||||
|
||||
// case 'tool-input-start':
|
||||
// case 'tool-input-delta':
|
||||
// case 'tool-input-end':
|
||||
// this.toolCallHandler.handleToolCallCreated(chunk)
|
||||
// break
|
||||
case 'tool-input-start':
|
||||
this.toolCallHandler.handleToolInputStart(chunk)
|
||||
break
|
||||
case 'tool-input-delta':
|
||||
this.toolCallHandler.handleToolInputDelta(chunk)
|
||||
break
|
||||
case 'tool-input-end':
|
||||
this.toolCallHandler.handleToolInputEnd(chunk)
|
||||
break
|
||||
|
||||
// case 'tool-input-delta':
|
||||
// this.toolCallHandler.handleToolCallCreated(chunk)
|
||||
// break
|
||||
case 'tool-call':
|
||||
this.toolCallHandler.handleToolCall(chunk)
|
||||
break
|
||||
|
||||
@ -5,18 +5,12 @@
|
||||
*/
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { processKnowledgeReferences } from '@renderer/services/KnowledgeService'
|
||||
import type {
|
||||
BaseTool,
|
||||
MCPCallToolResponse,
|
||||
MCPTool,
|
||||
MCPToolResponse,
|
||||
MCPToolResultContent,
|
||||
NormalToolResponse
|
||||
} from '@renderer/types'
|
||||
import type { BaseTool, MCPTool, MCPToolResponse, NormalToolResponse } from '@renderer/types'
|
||||
import type { Chunk } from '@renderer/types/chunk'
|
||||
import { ChunkType } from '@renderer/types/chunk'
|
||||
import type { ToolSet, TypedToolCall, TypedToolError, TypedToolResult } from 'ai'
|
||||
import type { ProviderMetadata, ToolSet, TypedToolCall, TypedToolError, TypedToolResult } from 'ai'
|
||||
|
||||
const logger = loggerService.withContext('ToolCallChunkHandler')
|
||||
|
||||
@ -26,6 +20,8 @@ export type ToolcallsMap = {
|
||||
args: any
|
||||
// mcpTool 现在可以是 MCPTool 或我们为 Provider 工具创建的通用类型
|
||||
tool: BaseTool
|
||||
// Streaming arguments buffer
|
||||
streamingArgs?: string
|
||||
}
|
||||
/**
|
||||
* 工具调用处理器类
|
||||
@ -71,110 +67,169 @@ export class ToolCallChunkHandler {
|
||||
return ToolCallChunkHandler.addActiveToolCallImpl(toolCallId, map)
|
||||
}
|
||||
|
||||
// /**
|
||||
// * 设置 onChunk 回调
|
||||
// */
|
||||
// public setOnChunk(callback: (chunk: Chunk) => void): void {
|
||||
// this.onChunk = callback
|
||||
// }
|
||||
/**
|
||||
* 根据工具名称确定工具类型
|
||||
*/
|
||||
private determineToolType(toolName: string, toolCallId: string): BaseTool {
|
||||
let mcpTool: MCPTool | undefined
|
||||
if (toolName.startsWith('builtin_')) {
|
||||
return {
|
||||
id: toolCallId,
|
||||
name: toolName,
|
||||
description: toolName,
|
||||
type: 'builtin'
|
||||
} as BaseTool
|
||||
} else if ((mcpTool = this.mcpTools.find((t) => t.id === toolName) as MCPTool)) {
|
||||
return mcpTool
|
||||
} else {
|
||||
return {
|
||||
id: toolCallId,
|
||||
name: toolName,
|
||||
description: toolName,
|
||||
type: 'provider'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleToolCallCreated(
|
||||
// chunk:
|
||||
// | {
|
||||
// type: 'tool-input-start'
|
||||
// id: string
|
||||
// toolName: string
|
||||
// providerMetadata?: ProviderMetadata
|
||||
// providerExecuted?: boolean
|
||||
// }
|
||||
// | {
|
||||
// type: 'tool-input-end'
|
||||
// id: string
|
||||
// providerMetadata?: ProviderMetadata
|
||||
// }
|
||||
// | {
|
||||
// type: 'tool-input-delta'
|
||||
// id: string
|
||||
// delta: string
|
||||
// providerMetadata?: ProviderMetadata
|
||||
// }
|
||||
// ): void {
|
||||
// switch (chunk.type) {
|
||||
// case 'tool-input-start': {
|
||||
// // 能拿到说明是mcpTool
|
||||
// // if (this.activeToolCalls.get(chunk.id)) return
|
||||
/**
|
||||
* 处理工具输入开始事件 - 流式参数开始
|
||||
*/
|
||||
public handleToolInputStart(chunk: {
|
||||
type: 'tool-input-start'
|
||||
id: string
|
||||
toolName: string
|
||||
providerMetadata?: ProviderMetadata
|
||||
providerExecuted?: boolean
|
||||
}): void {
|
||||
const { id: toolCallId, toolName, providerExecuted } = chunk
|
||||
|
||||
// const tool: BaseTool | MCPTool = {
|
||||
// id: chunk.id,
|
||||
// name: chunk.toolName,
|
||||
// description: chunk.toolName,
|
||||
// type: chunk.toolName.startsWith('builtin_') ? 'builtin' : 'provider'
|
||||
// }
|
||||
// this.activeToolCalls.set(chunk.id, {
|
||||
// toolCallId: chunk.id,
|
||||
// toolName: chunk.toolName,
|
||||
// args: '',
|
||||
// tool
|
||||
// })
|
||||
// const toolResponse: MCPToolResponse | NormalToolResponse = {
|
||||
// id: chunk.id,
|
||||
// tool: tool,
|
||||
// arguments: {},
|
||||
// status: 'pending',
|
||||
// toolCallId: chunk.id
|
||||
// }
|
||||
// this.onChunk({
|
||||
// type: ChunkType.MCP_TOOL_PENDING,
|
||||
// responses: [toolResponse]
|
||||
// })
|
||||
// break
|
||||
// }
|
||||
// case 'tool-input-delta': {
|
||||
// const toolCall = this.activeToolCalls.get(chunk.id)
|
||||
// if (!toolCall) {
|
||||
// logger.warn(`🔧 [ToolCallChunkHandler] Tool call not found: ${chunk.id}`)
|
||||
// return
|
||||
// }
|
||||
// toolCall.args += chunk.delta
|
||||
// break
|
||||
// }
|
||||
// case 'tool-input-end': {
|
||||
// const toolCall = this.activeToolCalls.get(chunk.id)
|
||||
// this.activeToolCalls.delete(chunk.id)
|
||||
// if (!toolCall) {
|
||||
// logger.warn(`🔧 [ToolCallChunkHandler] Tool call not found: ${chunk.id}`)
|
||||
// return
|
||||
// }
|
||||
// // const toolResponse: ToolCallResponse = {
|
||||
// // id: toolCall.toolCallId,
|
||||
// // tool: toolCall.tool,
|
||||
// // arguments: toolCall.args,
|
||||
// // status: 'pending',
|
||||
// // toolCallId: toolCall.toolCallId
|
||||
// // }
|
||||
// // logger.debug('toolResponse', toolResponse)
|
||||
// // this.onChunk({
|
||||
// // type: ChunkType.MCP_TOOL_PENDING,
|
||||
// // responses: [toolResponse]
|
||||
// // })
|
||||
// break
|
||||
// }
|
||||
// }
|
||||
// // if (!toolCall) {
|
||||
// // Logger.warn(`🔧 [ToolCallChunkHandler] Tool call not found: ${chunk.id}`)
|
||||
// // return
|
||||
// // }
|
||||
// // this.onChunk({
|
||||
// // type: ChunkType.MCP_TOOL_CREATED,
|
||||
// // tool_calls: [
|
||||
// // {
|
||||
// // id: chunk.id,
|
||||
// // name: chunk.toolName,
|
||||
// // status: 'pending'
|
||||
// // }
|
||||
// // ]
|
||||
// // })
|
||||
// }
|
||||
if (!toolCallId || !toolName) {
|
||||
logger.warn(`🔧 [ToolCallChunkHandler] Invalid tool-input-start chunk: missing id or toolName`)
|
||||
return
|
||||
}
|
||||
|
||||
// 如果已存在,跳过
|
||||
if (this.activeToolCalls.has(toolCallId)) {
|
||||
return
|
||||
}
|
||||
|
||||
let tool: BaseTool
|
||||
if (providerExecuted) {
|
||||
tool = {
|
||||
id: toolCallId,
|
||||
name: toolName,
|
||||
description: toolName,
|
||||
type: 'provider'
|
||||
} as BaseTool
|
||||
} else {
|
||||
tool = this.determineToolType(toolName, toolCallId)
|
||||
}
|
||||
|
||||
// 初始化流式工具调用
|
||||
this.addActiveToolCall(toolCallId, {
|
||||
toolCallId,
|
||||
toolName,
|
||||
args: undefined,
|
||||
tool,
|
||||
streamingArgs: ''
|
||||
})
|
||||
|
||||
logger.info(`🔧 [ToolCallChunkHandler] Tool input streaming started: ${toolName} (${toolCallId})`)
|
||||
|
||||
// 发送初始 streaming chunk
|
||||
const toolResponse: MCPToolResponse | NormalToolResponse = {
|
||||
id: toolCallId,
|
||||
tool: tool,
|
||||
arguments: undefined,
|
||||
status: 'streaming',
|
||||
toolCallId: toolCallId,
|
||||
partialArguments: ''
|
||||
}
|
||||
|
||||
this.onChunk({
|
||||
type: ChunkType.MCP_TOOL_STREAMING,
|
||||
responses: [toolResponse]
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理工具输入增量事件 - 流式参数片段
|
||||
*/
|
||||
public handleToolInputDelta(chunk: {
|
||||
type: 'tool-input-delta'
|
||||
id: string
|
||||
delta: string
|
||||
providerMetadata?: ProviderMetadata
|
||||
}): void {
|
||||
const { id: toolCallId, delta } = chunk
|
||||
|
||||
const toolCall = this.activeToolCalls.get(toolCallId)
|
||||
if (!toolCall) {
|
||||
logger.warn(`🔧 [ToolCallChunkHandler] Tool call not found for delta: ${toolCallId}`)
|
||||
return
|
||||
}
|
||||
|
||||
// 累积流式参数
|
||||
toolCall.streamingArgs = (toolCall.streamingArgs || '') + delta
|
||||
|
||||
// 发送 streaming chunk 更新
|
||||
const toolResponse: MCPToolResponse | NormalToolResponse = {
|
||||
id: toolCallId,
|
||||
tool: toolCall.tool,
|
||||
arguments: undefined,
|
||||
status: 'streaming',
|
||||
toolCallId: toolCallId,
|
||||
partialArguments: toolCall.streamingArgs
|
||||
}
|
||||
|
||||
this.onChunk({
|
||||
type: ChunkType.MCP_TOOL_STREAMING,
|
||||
responses: [toolResponse]
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理工具输入结束事件 - 流式参数完成
|
||||
*/
|
||||
public handleToolInputEnd(chunk: { type: 'tool-input-end'; id: string; providerMetadata?: ProviderMetadata }): void {
|
||||
const { id: toolCallId } = chunk
|
||||
|
||||
const toolCall = this.activeToolCalls.get(toolCallId)
|
||||
if (!toolCall) {
|
||||
logger.warn(`🔧 [ToolCallChunkHandler] Tool call not found for end: ${toolCallId}`)
|
||||
return
|
||||
}
|
||||
|
||||
// 尝试解析完整的 JSON 参数
|
||||
let parsedArgs: any = undefined
|
||||
if (toolCall.streamingArgs) {
|
||||
try {
|
||||
parsedArgs = JSON.parse(toolCall.streamingArgs)
|
||||
toolCall.args = parsedArgs
|
||||
} catch (e) {
|
||||
logger.warn(`🔧 [ToolCallChunkHandler] Failed to parse streaming args for ${toolCallId}:`, e as Error)
|
||||
// 保留原始字符串
|
||||
toolCall.args = toolCall.streamingArgs
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`🔧 [ToolCallChunkHandler] Tool input streaming completed: ${toolCall.toolName} (${toolCallId})`)
|
||||
|
||||
// 发送 streaming 完成 chunk
|
||||
const toolResponse: MCPToolResponse | NormalToolResponse = {
|
||||
id: toolCallId,
|
||||
tool: toolCall.tool,
|
||||
arguments: parsedArgs,
|
||||
status: 'pending',
|
||||
toolCallId: toolCallId,
|
||||
partialArguments: toolCall.streamingArgs
|
||||
}
|
||||
|
||||
this.onChunk({
|
||||
type: ChunkType.MCP_TOOL_STREAMING,
|
||||
responses: [toolResponse]
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理工具调用事件
|
||||
@ -191,6 +246,15 @@ export class ToolCallChunkHandler {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this tool call was already processed via streaming events
|
||||
const existingToolCall = this.activeToolCalls.get(toolCallId)
|
||||
if (existingToolCall?.streamingArgs !== undefined) {
|
||||
// Tool call was already processed via streaming events (tool-input-start/delta/end)
|
||||
// Update args if needed, but don't emit duplicate pending chunk
|
||||
existingToolCall.args = args
|
||||
return
|
||||
}
|
||||
|
||||
let tool: BaseTool
|
||||
let mcpTool: MCPTool | undefined
|
||||
// 根据 providerExecuted 标志区分处理逻辑
|
||||
@ -216,11 +280,6 @@ export class ToolCallChunkHandler {
|
||||
// 如果是客户端执行的 MCP 工具,沿用现有逻辑
|
||||
// toolName is mcpTool.id (registered with id as key in convertMcpToolsToAiSdkTools)
|
||||
logger.info(`[ToolCallChunkHandler] Handling client-side MCP tool: ${toolName}`)
|
||||
// mcpTool = this.mcpTools.find((t) => t.name === toolName) as MCPTool
|
||||
// if (!mcpTool) {
|
||||
// logger.warn(`[ToolCallChunkHandler] MCP tool not found: ${toolName}`)
|
||||
// return
|
||||
// }
|
||||
tool = mcpTool
|
||||
} else {
|
||||
tool = {
|
||||
@ -357,40 +416,20 @@ export class ToolCallChunkHandler {
|
||||
|
||||
export const addActiveToolCall = ToolCallChunkHandler.addActiveToolCall.bind(ToolCallChunkHandler)
|
||||
|
||||
/**
|
||||
* 从工具输出中提取图片(使用 MCP SDK 类型安全验证)
|
||||
*/
|
||||
function extractImagesFromToolOutput(output: unknown): string[] {
|
||||
if (!output) {
|
||||
return []
|
||||
}
|
||||
|
||||
const contents: unknown[] = []
|
||||
|
||||
if (isMcpCallToolResponse(output)) {
|
||||
contents.push(...output.content)
|
||||
} else if (Array.isArray(output)) {
|
||||
contents.push(...output)
|
||||
} else if (hasContentArray(output)) {
|
||||
contents.push(...output.content)
|
||||
const result = CallToolResultSchema.safeParse(output)
|
||||
if (result.success) {
|
||||
return result.data.content
|
||||
.filter((c) => c.type === 'image')
|
||||
.map((content) => `data:${content.mimeType ?? 'image/png'};base64,${content.data}`)
|
||||
}
|
||||
|
||||
return contents
|
||||
.filter(isMcpImageContent)
|
||||
.map((content) => `data:${content.mimeType ?? 'image/png'};base64,${content.data}`)
|
||||
}
|
||||
|
||||
function isMcpCallToolResponse(value: unknown): value is MCPCallToolResponse {
|
||||
return typeof value === 'object' && value !== null && Array.isArray((value as MCPCallToolResponse).content)
|
||||
}
|
||||
|
||||
function hasContentArray(value: unknown): value is { content: unknown[] } {
|
||||
return typeof value === 'object' && value !== null && Array.isArray((value as { content?: unknown }).content)
|
||||
}
|
||||
|
||||
function isMcpImageContent(content: unknown): content is MCPToolResultContent & { data: string } {
|
||||
if (typeof content !== 'object' || content === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
const resultContent = content as MCPToolResultContent
|
||||
|
||||
return resultContent.type === 'image' && typeof resultContent.data === 'string'
|
||||
return []
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ import { createVertexProvider, isVertexAIConfigured } from '@renderer/hooks/useV
|
||||
import { getProviderByModel } from '@renderer/services/AssistantService'
|
||||
import { getProviderById } from '@renderer/services/ProviderService'
|
||||
import store from '@renderer/store'
|
||||
import type { EndpointType } from '@renderer/types'
|
||||
import { isSystemProvider, type Model, type Provider, SystemProviderIds } from '@renderer/types'
|
||||
import type { OpenAICompletionsStreamOptions } from '@renderer/types/aiCoreTypes'
|
||||
import {
|
||||
@ -140,6 +141,48 @@ export function adaptProvider({ provider, model }: { provider: Provider; model?:
|
||||
return adaptedProvider
|
||||
}
|
||||
|
||||
interface BaseExtraOptions {
|
||||
fetch?: typeof fetch
|
||||
endpoint: string
|
||||
mode?: 'responses' | 'chat'
|
||||
headers: Record<string, string>
|
||||
}
|
||||
|
||||
interface AzureOpenAIExtraOptions extends BaseExtraOptions {
|
||||
apiVersion: string
|
||||
useDeploymentBasedUrls: true | undefined
|
||||
}
|
||||
|
||||
interface BedrockApiKeyExtraOptions extends BaseExtraOptions {
|
||||
region: string
|
||||
apiKey: string
|
||||
}
|
||||
|
||||
interface BedrockAccessKeyExtraOptions extends BaseExtraOptions {
|
||||
region: string
|
||||
accessKeyId: string
|
||||
secretAccessKey: string
|
||||
}
|
||||
|
||||
type BedrockExtraOptions = BedrockApiKeyExtraOptions | BedrockAccessKeyExtraOptions
|
||||
|
||||
interface VertexExtraOptions extends BaseExtraOptions {
|
||||
project: string
|
||||
location: string
|
||||
googleCredentials: {
|
||||
privateKey: string
|
||||
clientEmail: string
|
||||
}
|
||||
}
|
||||
|
||||
interface CherryInExtraOptions extends BaseExtraOptions {
|
||||
endpointType?: EndpointType
|
||||
anthropicBaseURL?: string
|
||||
geminiBaseURL?: string
|
||||
}
|
||||
|
||||
type ExtraOptions = BedrockExtraOptions | AzureOpenAIExtraOptions | VertexExtraOptions | CherryInExtraOptions
|
||||
|
||||
/**
|
||||
* 将 Provider 配置转换为新 AI SDK 格式
|
||||
* 简化版:利用新的别名映射系统
|
||||
@ -158,6 +201,8 @@ export function providerToAiSdkConfig(actualProvider: Provider, model: Model): A
|
||||
includeUsage = store.getState().settings.openAI?.streamOptions?.includeUsage
|
||||
}
|
||||
|
||||
// Specially, some providers which need to early return
|
||||
// Copilot
|
||||
const isCopilotProvider = actualProvider.id === SystemProviderIds.copilot
|
||||
if (isCopilotProvider) {
|
||||
const storedHeaders = store.getState().copilot.defaultHeaders ?? {}
|
||||
@ -177,6 +222,7 @@ export function providerToAiSdkConfig(actualProvider: Provider, model: Model): A
|
||||
}
|
||||
}
|
||||
|
||||
// Ollama
|
||||
if (isOllamaProvider(actualProvider)) {
|
||||
return {
|
||||
providerId: 'ollama',
|
||||
@ -190,106 +236,142 @@ export function providerToAiSdkConfig(actualProvider: Provider, model: Model): A
|
||||
}
|
||||
}
|
||||
|
||||
// 处理OpenAI模式
|
||||
const extraOptions: any = {}
|
||||
extraOptions.endpoint = endpoint
|
||||
if (actualProvider.type === 'openai-response' && !isOpenAIChatCompletionOnlyModel(model)) {
|
||||
extraOptions.mode = 'responses'
|
||||
} else if (aiSdkProviderId === 'openai' || (aiSdkProviderId === 'cherryin' && actualProvider.type === 'openai')) {
|
||||
extraOptions.mode = 'chat'
|
||||
// Generally, construct extraOptions according to provider & model
|
||||
// Consider as OpenAI like provider
|
||||
|
||||
// Construct baseExtraOptions first
|
||||
// About mode of azure:
|
||||
// https://learn.microsoft.com/en-us/azure/ai-foundry/openai/latest
|
||||
// https://learn.microsoft.com/en-us/azure/ai-foundry/openai/how-to/responses?tabs=python-key#responses-api
|
||||
let mode: BaseExtraOptions['mode']
|
||||
if (
|
||||
(actualProvider.type === 'openai-response' && !isOpenAIChatCompletionOnlyModel(model)) ||
|
||||
aiSdkProviderId === 'azure-responses'
|
||||
) {
|
||||
mode = 'responses'
|
||||
} else if (
|
||||
aiSdkProviderId === 'openai' ||
|
||||
(aiSdkProviderId === 'cherryin' && actualProvider.type === 'openai') ||
|
||||
aiSdkProviderId === 'azure'
|
||||
) {
|
||||
mode = 'chat'
|
||||
}
|
||||
|
||||
extraOptions.headers = {
|
||||
const headers: BaseExtraOptions['headers'] = {
|
||||
...defaultAppHeaders(),
|
||||
...actualProvider.extra_headers
|
||||
}
|
||||
|
||||
if (aiSdkProviderId === 'openai') {
|
||||
extraOptions.headers['X-Api-Key'] = baseConfig.apiKey
|
||||
}
|
||||
// azure
|
||||
// https://learn.microsoft.com/en-us/azure/ai-foundry/openai/latest
|
||||
// https://learn.microsoft.com/en-us/azure/ai-foundry/openai/how-to/responses?tabs=python-key#responses-api
|
||||
if (aiSdkProviderId === 'azure-responses') {
|
||||
extraOptions.mode = 'responses'
|
||||
} else if (aiSdkProviderId === 'azure') {
|
||||
extraOptions.mode = 'chat'
|
||||
}
|
||||
if (isAzureOpenAIProvider(actualProvider)) {
|
||||
const apiVersion = actualProvider.apiVersion?.trim()
|
||||
if (apiVersion) {
|
||||
extraOptions.apiVersion = apiVersion
|
||||
if (!['preview', 'v1'].includes(apiVersion)) {
|
||||
extraOptions.useDeploymentBasedUrls = true
|
||||
}
|
||||
if (actualProvider.extra_headers?.['X-Api-Key'] === undefined) {
|
||||
headers['X-Api-Key'] = baseConfig.apiKey
|
||||
}
|
||||
}
|
||||
|
||||
// bedrock
|
||||
if (aiSdkProviderId === 'bedrock') {
|
||||
const authType = getAwsBedrockAuthType()
|
||||
extraOptions.region = getAwsBedrockRegion()
|
||||
|
||||
if (authType === 'apiKey') {
|
||||
extraOptions.apiKey = getAwsBedrockApiKey()
|
||||
} else {
|
||||
extraOptions.accessKeyId = getAwsBedrockAccessKeyId()
|
||||
extraOptions.secretAccessKey = getAwsBedrockSecretAccessKey()
|
||||
}
|
||||
}
|
||||
// google-vertex
|
||||
if (aiSdkProviderId === 'google-vertex' || aiSdkProviderId === 'google-vertex-anthropic') {
|
||||
if (!isVertexAIConfigured()) {
|
||||
throw new Error('VertexAI is not configured. Please configure project, location and service account credentials.')
|
||||
}
|
||||
const { project, location, googleCredentials } = createVertexProvider(actualProvider)
|
||||
extraOptions.project = project
|
||||
extraOptions.location = location
|
||||
extraOptions.googleCredentials = {
|
||||
...googleCredentials,
|
||||
privateKey: formatPrivateKey(googleCredentials.privateKey)
|
||||
}
|
||||
baseConfig.baseURL += aiSdkProviderId === 'google-vertex' ? '/publishers/google' : '/publishers/anthropic/models'
|
||||
}
|
||||
|
||||
// cherryin
|
||||
if (aiSdkProviderId === 'cherryin') {
|
||||
if (model.endpoint_type) {
|
||||
extraOptions.endpointType = model.endpoint_type
|
||||
}
|
||||
// CherryIN API Host
|
||||
const cherryinProvider = getProviderById(SystemProviderIds.cherryin)
|
||||
if (cherryinProvider) {
|
||||
extraOptions.anthropicBaseURL = cherryinProvider.anthropicApiHost + '/v1'
|
||||
extraOptions.geminiBaseURL = cherryinProvider.apiHost + '/v1beta/models'
|
||||
}
|
||||
}
|
||||
let _fetch: typeof fetch | undefined
|
||||
|
||||
// Apply developer-to-system role conversion for providers that don't support developer role
|
||||
// bug: https://github.com/vercel/ai/issues/10982
|
||||
// fixPR: https://github.com/vercel/ai/pull/11127
|
||||
// TODO: but the PR don't backport to v5, the code will be removed when upgrading to v6
|
||||
if (!isSupportDeveloperRoleProvider(actualProvider) || !isOpenAIReasoningModel(model)) {
|
||||
extraOptions.fetch = createDeveloperToSystemFetch(extraOptions.fetch)
|
||||
_fetch = createDeveloperToSystemFetch(fetch)
|
||||
}
|
||||
|
||||
const baseExtraOptions = {
|
||||
fetch: _fetch,
|
||||
endpoint,
|
||||
mode,
|
||||
headers
|
||||
} as const satisfies BaseExtraOptions
|
||||
|
||||
// Create specifical fields in extraOptions for different provider
|
||||
let extraOptions: ExtraOptions | undefined
|
||||
if (isAzureOpenAIProvider(actualProvider)) {
|
||||
const apiVersion = actualProvider.apiVersion?.trim()
|
||||
let useDeploymentBasedUrls: true | undefined
|
||||
if (apiVersion) {
|
||||
if (!['preview', 'v1'].includes(apiVersion)) {
|
||||
useDeploymentBasedUrls = true
|
||||
}
|
||||
}
|
||||
extraOptions = {
|
||||
...baseExtraOptions,
|
||||
apiVersion,
|
||||
useDeploymentBasedUrls
|
||||
} satisfies AzureOpenAIExtraOptions
|
||||
} else if (aiSdkProviderId === 'bedrock') {
|
||||
// bedrock
|
||||
const authType = getAwsBedrockAuthType()
|
||||
const region = getAwsBedrockRegion()
|
||||
|
||||
if (authType === 'apiKey') {
|
||||
extraOptions = {
|
||||
...baseExtraOptions,
|
||||
region,
|
||||
apiKey: getAwsBedrockApiKey()
|
||||
} satisfies BedrockApiKeyExtraOptions
|
||||
} else {
|
||||
extraOptions = {
|
||||
...baseExtraOptions,
|
||||
region,
|
||||
accessKeyId: getAwsBedrockAccessKeyId(),
|
||||
secretAccessKey: getAwsBedrockSecretAccessKey()
|
||||
} satisfies BedrockAccessKeyExtraOptions
|
||||
}
|
||||
} else if (aiSdkProviderId === 'google-vertex' || aiSdkProviderId === 'google-vertex-anthropic') {
|
||||
// google-vertex
|
||||
if (!isVertexAIConfigured()) {
|
||||
throw new Error('VertexAI is not configured. Please configure project, location and service account credentials.')
|
||||
}
|
||||
const { project, location, googleCredentials } = createVertexProvider(actualProvider)
|
||||
extraOptions = {
|
||||
...baseExtraOptions,
|
||||
project,
|
||||
location,
|
||||
googleCredentials: {
|
||||
...googleCredentials,
|
||||
privateKey: formatPrivateKey(googleCredentials.privateKey)
|
||||
}
|
||||
} satisfies VertexExtraOptions
|
||||
baseConfig.baseURL += aiSdkProviderId === 'google-vertex' ? '/publishers/google' : '/publishers/anthropic/models'
|
||||
} else if (aiSdkProviderId === 'cherryin') {
|
||||
// CherryIN API Host
|
||||
const cherryinProvider = getProviderById(SystemProviderIds.cherryin)
|
||||
const endpointType: EndpointType | undefined = model.endpoint_type
|
||||
let anthropicBaseURL: string | undefined
|
||||
let geminiBaseURL: string | undefined
|
||||
if (cherryinProvider) {
|
||||
anthropicBaseURL = cherryinProvider.anthropicApiHost + '/v1'
|
||||
geminiBaseURL = cherryinProvider.apiHost + '/v1beta/models'
|
||||
}
|
||||
extraOptions = {
|
||||
...baseExtraOptions,
|
||||
endpointType,
|
||||
anthropicBaseURL,
|
||||
geminiBaseURL
|
||||
} satisfies CherryInExtraOptions
|
||||
} else {
|
||||
extraOptions = baseExtraOptions
|
||||
}
|
||||
|
||||
if (hasProviderConfig(aiSdkProviderId) && aiSdkProviderId !== 'openai-compatible') {
|
||||
// if the provider has a specific aisdk provider
|
||||
const options = ProviderConfigFactory.fromProvider(aiSdkProviderId, baseConfig, extraOptions)
|
||||
return {
|
||||
providerId: aiSdkProviderId,
|
||||
options
|
||||
}
|
||||
}
|
||||
|
||||
// 否则fallback到openai-compatible
|
||||
const options = ProviderConfigFactory.createOpenAICompatible(baseConfig.baseURL, baseConfig.apiKey)
|
||||
return {
|
||||
providerId: 'openai-compatible',
|
||||
options: {
|
||||
...options,
|
||||
name: actualProvider.id,
|
||||
...extraOptions,
|
||||
includeUsage
|
||||
} else {
|
||||
// otherwise, fallback to openai-compatible
|
||||
const options = ProviderConfigFactory.createOpenAICompatible(baseConfig.baseURL, baseConfig.apiKey)
|
||||
return {
|
||||
providerId: 'openai-compatible',
|
||||
options: {
|
||||
...options,
|
||||
name: actualProvider.id,
|
||||
...extraOptions,
|
||||
includeUsage
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import Doc2xLogo from '@renderer/assets/images/ocr/doc2x.png'
|
||||
import MinerULogo from '@renderer/assets/images/ocr/mineru.jpg'
|
||||
import MistralLogo from '@renderer/assets/images/providers/mistral.png'
|
||||
import PaddleocrLogo from '@renderer/assets/images/providers/paddleocr.png'
|
||||
import type { PreprocessProviderId } from '@renderer/types'
|
||||
|
||||
export function getPreprocessProviderLogo(providerId: PreprocessProviderId) {
|
||||
@ -13,6 +14,8 @@ export function getPreprocessProviderLogo(providerId: PreprocessProviderId) {
|
||||
return MinerULogo
|
||||
case 'open-mineru':
|
||||
return MinerULogo
|
||||
case 'paddleocr':
|
||||
return PaddleocrLogo
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
@ -44,5 +47,11 @@ export const PREPROCESS_PROVIDER_CONFIG: Record<PreprocessProviderId, Preprocess
|
||||
official: 'https://github.com/opendatalab/MinerU/',
|
||||
apiKey: 'https://github.com/opendatalab/MinerU/'
|
||||
}
|
||||
},
|
||||
paddleocr: {
|
||||
websites: {
|
||||
official: 'https://aistudio.baidu.com/paddleocr/',
|
||||
apiKey: 'https://aistudio.baidu.com/paddleocr/'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -932,7 +932,7 @@ export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = {
|
||||
official: 'https://www.together.ai/',
|
||||
apiKey: 'https://api.together.ai/settings/api-keys',
|
||||
docs: 'https://docs.together.ai/docs/introduction',
|
||||
models: 'https://docs.together.ai/docs/chat-models'
|
||||
models: 'https://docs.together.ai/docs/serverless-models'
|
||||
}
|
||||
},
|
||||
dmxapi: {
|
||||
|
||||
@ -185,17 +185,13 @@ export function useAppInit() {
|
||||
suggestionCount: payload.suggestions.length,
|
||||
autoApprove: payload.autoApprove
|
||||
})
|
||||
dispatch(toolPermissionsActions.requestReceived(payload))
|
||||
|
||||
// Auto-approve if requested
|
||||
if (payload.autoApprove) {
|
||||
logger.debug('Auto-approving tool permission request', {
|
||||
requestId: payload.requestId,
|
||||
toolName: payload.toolName
|
||||
})
|
||||
|
||||
dispatch(toolPermissionsActions.submissionSent({ requestId: payload.requestId, behavior: 'allow' }))
|
||||
|
||||
try {
|
||||
const response = await window.api.agentTools.respondToPermission({
|
||||
requestId: payload.requestId,
|
||||
@ -214,9 +210,13 @@ export function useAppInit() {
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to send auto-approval response', error as Error)
|
||||
dispatch(toolPermissionsActions.submissionFailed({ requestId: payload.requestId }))
|
||||
// Fall through to add to store for manual approval
|
||||
dispatch(toolPermissionsActions.requestReceived(payload))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
dispatch(toolPermissionsActions.requestReceived(payload))
|
||||
}
|
||||
|
||||
const resultListener = (_event: Electron.IpcRendererEvent, payload: ToolPermissionResultPayload) => {
|
||||
|
||||
@ -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 !== '' || ['open-mineru'].includes(p.id))
|
||||
.filter((p) => p.apiKey !== '' || ['mineru', 'open-mineru', 'paddleocr'].includes(p.id))
|
||||
.map((p) => ({ value: p.id, label: p.name }))
|
||||
}
|
||||
return [preprocessOptions]
|
||||
|
||||
@ -4992,10 +4992,8 @@
|
||||
"image_provider": "OCR service provider",
|
||||
"paddleocr": {
|
||||
"aistudio_access_token": "Access token of AI Studio Community",
|
||||
"aistudio_url_label": "AI Studio Community",
|
||||
"api_url": "API URL",
|
||||
"serving_doc_url_label": "PaddleOCR Serving Documentation",
|
||||
"tip": "You can refer to the official PaddleOCR documentation to deploy a local service, or deploy a cloud service on the PaddlePaddle AI Studio Community. For the latter case, please provide the access token of the AI Studio Community."
|
||||
"api_url_label": "Obtain Access Token and API URL"
|
||||
},
|
||||
"system": {
|
||||
"win": {
|
||||
@ -5008,6 +5006,12 @@
|
||||
"title": "OCR service"
|
||||
},
|
||||
"preprocess": {
|
||||
"paddleocr": {
|
||||
"aistudio_access_token": "Access token of AI Studio Community",
|
||||
"api_url": "API URL",
|
||||
"api_url_label": "Obtain Access Token and API URL",
|
||||
"paddleocr_url_label": "PaddleOCR Official Website"
|
||||
},
|
||||
"provider": "Document Processing Provider",
|
||||
"provider_placeholder": "Choose a document processing provider",
|
||||
"title": "Document Processing",
|
||||
|
||||
@ -4992,10 +4992,8 @@
|
||||
"image_provider": "OCR 服务提供商",
|
||||
"paddleocr": {
|
||||
"aistudio_access_token": "星河社区访问令牌",
|
||||
"aistudio_url_label": "星河社区",
|
||||
"api_url": "API URL",
|
||||
"serving_doc_url_label": "PaddleOCR 服务化部署文档",
|
||||
"tip": "您可以参考 PaddleOCR 官方文档部署本地服务,或者在飞桨星河社区部署云服务。对于后一种情况,请填写星河社区访问令牌。"
|
||||
"api_url_label": "获取访问令牌及API URL"
|
||||
},
|
||||
"system": {
|
||||
"win": {
|
||||
@ -5008,6 +5006,12 @@
|
||||
"title": "OCR 服务"
|
||||
},
|
||||
"preprocess": {
|
||||
"paddleocr": {
|
||||
"aistudio_access_token": "星河社区访问令牌",
|
||||
"api_url": "API URL",
|
||||
"api_url_label": "获取访问令牌及API URL",
|
||||
"paddleocr_url_label": "PaddleOCR 官网"
|
||||
},
|
||||
"provider": "文档处理服务商",
|
||||
"provider_placeholder": "选择一个文档处理服务商",
|
||||
"title": "文档处理",
|
||||
|
||||
@ -4991,11 +4991,9 @@
|
||||
},
|
||||
"image_provider": "OCR 服務供應商",
|
||||
"paddleocr": {
|
||||
"aistudio_access_token": "星河社群存取權杖",
|
||||
"aistudio_url_label": "星河社群",
|
||||
"aistudio_access_token": "星河社區訪問令牌",
|
||||
"api_url": "API 網址",
|
||||
"serving_doc_url_label": "PaddleOCR 服務化部署文件",
|
||||
"tip": "您可以參考 PaddleOCR 官方文件來部署本機服務,或是在飛槳星河社群部署雲端服務。對於後者,請提供星河社群的存取權杖。"
|
||||
"api_url_label": "取得訪問令牌及 API 網址"
|
||||
},
|
||||
"system": {
|
||||
"win": {
|
||||
@ -5008,6 +5006,12 @@
|
||||
"title": "OCR 服務"
|
||||
},
|
||||
"preprocess": {
|
||||
"paddleocr": {
|
||||
"aistudio_access_token": "星河社區訪問令牌",
|
||||
"api_url": "API 網址",
|
||||
"api_url_label": "取得訪問令牌及 API 網址",
|
||||
"paddleocr_url_label": "PaddleOCR 官方網站"
|
||||
},
|
||||
"provider": "文件處理供應商",
|
||||
"provider_placeholder": "選擇一個文件處理供應商",
|
||||
"title": "文件處理",
|
||||
|
||||
@ -4992,10 +4992,8 @@
|
||||
"image_provider": "OCR-Anbieter",
|
||||
"paddleocr": {
|
||||
"aistudio_access_token": "StarRiver Community Access Token",
|
||||
"aistudio_url_label": "StarRiver Community",
|
||||
"api_url": "API-URL",
|
||||
"serving_doc_url_label": "PaddleOCR-Dokumentation für die Bereitstellung",
|
||||
"tip": "Sie können die offizielle PaddleOCR-Dokumentation als Referenz für die lokale Bereitstellung verwenden oder in der StarRiver-Community Cloud-Dienste bereitstellen. Für letztere Option geben Sie bitte den StarRiver-Community-Zugriffstoken ein."
|
||||
"api_url_label": "Zugriffstoken und API-URL abrufen"
|
||||
},
|
||||
"system": {
|
||||
"win": {
|
||||
@ -5008,6 +5006,12 @@
|
||||
"title": "OCR-Dienst"
|
||||
},
|
||||
"preprocess": {
|
||||
"paddleocr": {
|
||||
"aistudio_access_token": "Zugriffstoken der AI Studio Community",
|
||||
"api_url": "API-URL",
|
||||
"api_url_label": "Zugriffstoken und API-URL abrufen",
|
||||
"paddleocr_url_label": "Offizielle Website von PaddleOCR"
|
||||
},
|
||||
"provider": "Dokumentverarbeitungsanbieter",
|
||||
"provider_placeholder": "Einen Dokumentverarbeitungsanbieter auswählen",
|
||||
"title": "Dokumentverarbeitung",
|
||||
|
||||
@ -4992,10 +4992,8 @@
|
||||
"image_provider": "Πάροχοι υπηρεσιών OCR",
|
||||
"paddleocr": {
|
||||
"aistudio_access_token": "Διακριτικό πρόσβασης της κοινότητας AI Studio",
|
||||
"aistudio_url_label": "Κοινότητα AI Studio",
|
||||
"api_url": "Διεύθυνση URL API",
|
||||
"serving_doc_url_label": "Τεκμηρίωση PaddleOCR Serving",
|
||||
"tip": "Μπορείτε να ανατρέξετε στην επίσημη τεκμηρίωση του PaddleOCR για να αναπτύξετε μια τοπική υπηρεσία, ή να αναπτύξετε μια υπηρεσία στο cloud στην Κοινότητα PaddlePaddle AI Studio. Στη δεύτερη περίπτωση, παρακαλώ παρέχετε το διακριτικό πρόσβασης (access token) της Κοινότητας AI Studio."
|
||||
"api_url_label": "Λήψη Διακριτικού Πρόσβασης και URL του API"
|
||||
},
|
||||
"system": {
|
||||
"win": {
|
||||
@ -5008,6 +5006,12 @@
|
||||
"title": "Υπηρεσία OCR"
|
||||
},
|
||||
"preprocess": {
|
||||
"paddleocr": {
|
||||
"aistudio_access_token": "Δείκτης πρόσβασης της κοινότητας AI Studio",
|
||||
"api_url": "Διεύθυνση URL του API",
|
||||
"api_url_label": "Λήψη Διακριτικού Πρόσβασης και URL API",
|
||||
"paddleocr_url_label": "Επίσημη ιστοσελίδα του PaddleOCR"
|
||||
},
|
||||
"provider": "πάροχος υπηρεσιών προεπεξεργασίας εγγράφων",
|
||||
"provider_placeholder": "Επιλέξτε έναν πάροχο υπηρεσιών προεπεξεργασίας εγγράφων",
|
||||
"title": "Προεπεξεργασία εγγράφων",
|
||||
|
||||
@ -4992,10 +4992,8 @@
|
||||
"image_provider": "Proveedor de servicios OCR",
|
||||
"paddleocr": {
|
||||
"aistudio_access_token": "Token de acceso de la comunidad de AI Studio",
|
||||
"aistudio_url_label": "Comunidad de AI Studio",
|
||||
"api_url": "URL de la API",
|
||||
"serving_doc_url_label": "Documentación de PaddleOCR Serving",
|
||||
"tip": "Puede consultar la documentación oficial de PaddleOCR para implementar un servicio local, o implementar un servicio en la nube en la Comunidad de PaddlePaddle AI Studio. En este último caso, proporcione el token de acceso de la Comunidad de AI Studio."
|
||||
"api_url_label": "Obtener el token de acceso y la URL de la API"
|
||||
},
|
||||
"system": {
|
||||
"win": {
|
||||
@ -5008,6 +5006,12 @@
|
||||
"title": "Servicio OCR"
|
||||
},
|
||||
"preprocess": {
|
||||
"paddleocr": {
|
||||
"aistudio_access_token": "Token de acceso de la Comunidad AI Studio",
|
||||
"api_url": "URL de la API",
|
||||
"api_url_label": "Obtener el token de acceso y la URL de la API",
|
||||
"paddleocr_url_label": "Sitio web oficial de PaddleOCR"
|
||||
},
|
||||
"provider": "Proveedor de servicios de preprocesamiento de documentos",
|
||||
"provider_placeholder": "Seleccionar un proveedor de servicios de preprocesamiento de documentos",
|
||||
"title": "Preprocesamiento de documentos",
|
||||
|
||||
@ -4992,10 +4992,8 @@
|
||||
"image_provider": "Fournisseur de service OCR",
|
||||
"paddleocr": {
|
||||
"aistudio_access_token": "Jeton d’accès de la communauté AI Studio",
|
||||
"aistudio_url_label": "Communauté AI Studio",
|
||||
"api_url": "URL de l’API",
|
||||
"serving_doc_url_label": "Documentation de PaddleOCR Serving",
|
||||
"tip": "Vous pouvez consulter la documentation officielle de PaddleOCR pour déployer un service local, ou déployer un service cloud sur la Communauté PaddlePaddle AI Studio. Dans ce dernier cas, veuillez fournir le jeton d’accès de la Communauté AI Studio."
|
||||
"api_url_label": "Obtenir le jeton d’accès et l’URL de l’API"
|
||||
},
|
||||
"system": {
|
||||
"win": {
|
||||
@ -5008,6 +5006,12 @@
|
||||
"title": "Service OCR"
|
||||
},
|
||||
"preprocess": {
|
||||
"paddleocr": {
|
||||
"aistudio_access_token": "Jeton d’accès à la communauté AI Studio",
|
||||
"api_url": "URL de l’API",
|
||||
"api_url_label": "Obtenir le jeton d’accès et l’URL de l’API",
|
||||
"paddleocr_url_label": "Site officiel de PaddleOCR"
|
||||
},
|
||||
"provider": "fournisseur de services de prétraitement de documents",
|
||||
"provider_placeholder": "Choisissez un prestataire de traitement de documents",
|
||||
"title": "Prétraitement des documents",
|
||||
|
||||
@ -4992,10 +4992,8 @@
|
||||
"image_provider": "OCRサービスプロバイダー",
|
||||
"paddleocr": {
|
||||
"aistudio_access_token": "AI Studio Community のアクセス・トークン",
|
||||
"aistudio_url_label": "AI Studio Community",
|
||||
"api_url": "API URL",
|
||||
"serving_doc_url_label": "PaddleOCR サービング ドキュメント",
|
||||
"tip": "ローカルサービスをデプロイするには、公式の PaddleOCR ドキュメントを参照するか、PaddlePaddle AI Studio コミュニティ上でクラウドサービスをデプロイすることができます。後者の場合は、AI Studio コミュニティのアクセストークンを提供してください。"
|
||||
"api_url_label": "アクセストークンとAPI URLを取得する"
|
||||
},
|
||||
"system": {
|
||||
"win": {
|
||||
@ -5008,6 +5006,12 @@
|
||||
"title": "OCRサービス"
|
||||
},
|
||||
"preprocess": {
|
||||
"paddleocr": {
|
||||
"aistudio_access_token": "AI Studioコミュニティのアクセストークン",
|
||||
"api_url": "API URL",
|
||||
"api_url_label": "アクセストークンとAPI URLを取得する",
|
||||
"paddleocr_url_label": "PaddleOCR 公式ウェブサイト"
|
||||
},
|
||||
"provider": "プレプロセスプロバイダー",
|
||||
"provider_placeholder": "前処理プロバイダーを選択してください",
|
||||
"title": "前処理",
|
||||
|
||||
@ -4992,10 +4992,8 @@
|
||||
"image_provider": "Provedor de serviços OCR",
|
||||
"paddleocr": {
|
||||
"aistudio_access_token": "Token de acesso da comunidade AI Studio",
|
||||
"aistudio_url_label": "Comunidade AI Studio",
|
||||
"api_url": "URL da API",
|
||||
"serving_doc_url_label": "Documentação do PaddleOCR Serving",
|
||||
"tip": "Você pode consultar a documentação oficial do PaddleOCR para implantar um serviço local ou implantar um serviço na nuvem na Comunidade PaddlePaddle AI Studio. No último caso, forneça o token de acesso da Comunidade AI Studio."
|
||||
"api_url_label": "Obter Token de Acesso e URL da API"
|
||||
},
|
||||
"system": {
|
||||
"win": {
|
||||
@ -5008,6 +5006,12 @@
|
||||
"title": "Serviço OCR"
|
||||
},
|
||||
"preprocess": {
|
||||
"paddleocr": {
|
||||
"aistudio_access_token": "Token de acesso da Comunidade AI Studio",
|
||||
"api_url": "URL da API",
|
||||
"api_url_label": "Obter Token de Acesso e URL da API",
|
||||
"paddleocr_url_label": "Site Oficial do PaddleOCR"
|
||||
},
|
||||
"provider": "prestador de serviços de pré-processamento de documentos",
|
||||
"provider_placeholder": "Escolha um fornecedor de pré-processamento de documentos",
|
||||
"title": "Pré-processamento de documentos",
|
||||
|
||||
@ -4992,10 +4992,8 @@
|
||||
"image_provider": "Furnizor serviciu OCR",
|
||||
"paddleocr": {
|
||||
"aistudio_access_token": "Token de acces Comunitatea AI Studio",
|
||||
"aistudio_url_label": "Comunitatea AI Studio",
|
||||
"api_url": "URL API",
|
||||
"serving_doc_url_label": "Documentație servire PaddleOCR",
|
||||
"tip": "Poți consulta documentația oficială PaddleOCR pentru a implementa un serviciu local sau poți implementa un serviciu cloud pe Comunitatea PaddlePaddle AI Studio. Pentru ultimul caz, te rugăm să furnizezi tokenul de acces al Comunității AI Studio."
|
||||
"api_url_label": "Obțineți token-ul de acces și URL-ul API"
|
||||
},
|
||||
"system": {
|
||||
"win": {
|
||||
@ -5008,6 +5006,12 @@
|
||||
"title": "Serviciu OCR"
|
||||
},
|
||||
"preprocess": {
|
||||
"paddleocr": {
|
||||
"aistudio_access_token": "Token de acces al Comunității AI Studio",
|
||||
"api_url": "URL API",
|
||||
"api_url_label": "Obțineți token-ul de acces și URL-ul API",
|
||||
"paddleocr_url_label": "Site-ul oficial PaddleOCR"
|
||||
},
|
||||
"provider": "Furnizor procesare documente",
|
||||
"provider_placeholder": "Alege un furnizor de procesare documente",
|
||||
"title": "Procesare documente",
|
||||
|
||||
@ -4992,10 +4992,8 @@
|
||||
"image_provider": "Поставщик услуг OCR",
|
||||
"paddleocr": {
|
||||
"aistudio_access_token": "Токен доступа сообщества AI Studio",
|
||||
"aistudio_url_label": "Сообщество AI Studio",
|
||||
"api_url": "URL API",
|
||||
"serving_doc_url_label": "Документация по PaddleOCR Serving",
|
||||
"tip": "Вы можете обратиться к официальной документации PaddleOCR, чтобы развернуть локальный сервис, либо развернуть облачный сервис в сообществе PaddlePaddle AI Studio. В последнем случае, пожалуйста, предоставьте токен доступа сообщества AI Studio."
|
||||
"api_url_label": "Получение токена доступа и URL API"
|
||||
},
|
||||
"system": {
|
||||
"win": {
|
||||
@ -5008,6 +5006,12 @@
|
||||
"title": "OCR-сервис"
|
||||
},
|
||||
"preprocess": {
|
||||
"paddleocr": {
|
||||
"aistudio_access_token": "Токен доступа к сообществу AI Studio",
|
||||
"api_url": "URL API",
|
||||
"api_url_label": "Получение токена доступа и URL API",
|
||||
"paddleocr_url_label": "Официальный сайт PaddleOCR"
|
||||
},
|
||||
"provider": "Поставщик обработки документов",
|
||||
"provider_placeholder": "Выберите поставщика услуг обработки документов",
|
||||
"title": "Обработка документов",
|
||||
|
||||
292
src/renderer/src/pages/home/Messages/Blocks/ToolBlockGroup.tsx
Normal file
292
src/renderer/src/pages/home/Messages/Blocks/ToolBlockGroup.tsx
Normal file
@ -0,0 +1,292 @@
|
||||
import type { MCPToolResponseStatus } from '@renderer/types'
|
||||
import type { ToolMessageBlock } from '@renderer/types/newMessage'
|
||||
import { Collapse, type CollapseProps } from 'antd'
|
||||
import { Wrench } from 'lucide-react'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { useToolApproval } from '../Tools/hooks/useToolApproval'
|
||||
import MessageTools from '../Tools/MessageTools'
|
||||
import ToolApprovalActionsComponent from '../Tools/ToolApprovalActions'
|
||||
import ToolHeader from '../Tools/ToolHeader'
|
||||
|
||||
// ============ Styled Components ============
|
||||
|
||||
const Container = styled.div`
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
|
||||
/* Only style the direct group collapse, not nested tool collapses */
|
||||
> .ant-collapse {
|
||||
background: transparent;
|
||||
border: none;
|
||||
|
||||
> .ant-collapse-item {
|
||||
border: none !important;
|
||||
|
||||
> .ant-collapse-header {
|
||||
padding: 8px 12px !important;
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.75rem !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.ant-collapse-expand-icon {
|
||||
padding: 0 !important;
|
||||
margin-left: 8px;
|
||||
height: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
> .ant-collapse-content {
|
||||
border: none;
|
||||
background: transparent;
|
||||
|
||||
> .ant-collapse-content-box {
|
||||
padding: 4px 0 0 0 !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const GroupHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
|
||||
.tool-icon {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.tool-count {
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
`
|
||||
|
||||
const ScrollableToolList = styled.div`
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
`
|
||||
|
||||
const ToolItem = styled.div<{ $isCompleted: boolean }>`
|
||||
opacity: ${(props) => (props.$isCompleted ? 0.7 : 1)};
|
||||
transition: opacity 0.2s;
|
||||
`
|
||||
|
||||
const AnimatedHeaderWrapper = styled(motion.div)`
|
||||
display: inline-block;
|
||||
`
|
||||
|
||||
const HeaderWithActions = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
`
|
||||
|
||||
// ============ Types & Helpers ============
|
||||
|
||||
interface Props {
|
||||
blocks: ToolMessageBlock[]
|
||||
}
|
||||
|
||||
function isCompletedStatus(status: MCPToolResponseStatus | undefined): boolean {
|
||||
return status === 'done' || status === 'error' || status === 'cancelled'
|
||||
}
|
||||
|
||||
function isWaitingStatus(status: MCPToolResponseStatus | undefined): boolean {
|
||||
return status === 'pending'
|
||||
}
|
||||
|
||||
// Animation variants for smooth header transitions
|
||||
const headerVariants = {
|
||||
enter: { x: 20, opacity: 0 },
|
||||
center: { x: 0, opacity: 1, transition: { duration: 0.2, ease: 'easeOut' as const } },
|
||||
exit: { x: -20, opacity: 0, transition: { duration: 0.15 } }
|
||||
}
|
||||
|
||||
// ============ Sub-Components ============
|
||||
|
||||
// Component for rendering a block with approval actions
|
||||
interface WaitingToolHeaderProps {
|
||||
block: ToolMessageBlock
|
||||
}
|
||||
|
||||
const WaitingToolHeader = React.memo(({ block }: WaitingToolHeaderProps) => {
|
||||
const approval = useToolApproval(block)
|
||||
|
||||
return (
|
||||
<HeaderWithActions>
|
||||
<ToolHeader block={block} variant="collapse-label" showStatus={false} />
|
||||
{(approval.isWaiting || approval.isExecuting) && <ToolApprovalActionsComponent {...approval} compact />}
|
||||
</HeaderWithActions>
|
||||
)
|
||||
})
|
||||
WaitingToolHeader.displayName = 'WaitingToolHeader'
|
||||
|
||||
interface GroupHeaderContentProps {
|
||||
blocks: ToolMessageBlock[]
|
||||
allCompleted: boolean
|
||||
}
|
||||
|
||||
const GroupHeaderContent = React.memo(({ blocks, allCompleted }: GroupHeaderContentProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (allCompleted) {
|
||||
return (
|
||||
<GroupHeader>
|
||||
<Wrench size={14} className="tool-icon" />
|
||||
<span className="tool-count">{t('message.tools.groupHeader', { count: blocks.length })}</span>
|
||||
</GroupHeader>
|
||||
)
|
||||
}
|
||||
|
||||
// Find blocks needing approval (pending status)
|
||||
const waitingBlocks = blocks.filter((block) => {
|
||||
const status = block.metadata?.rawMcpToolResponse?.status
|
||||
return isWaitingStatus(status)
|
||||
})
|
||||
|
||||
// Prioritize showing waiting blocks that need approval
|
||||
const lastWaitingBlock = waitingBlocks[waitingBlocks.length - 1]
|
||||
if (lastWaitingBlock) {
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
<AnimatedHeaderWrapper
|
||||
key={lastWaitingBlock.id}
|
||||
variants={headerVariants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit">
|
||||
<WaitingToolHeader block={lastWaitingBlock} />
|
||||
</AnimatedHeaderWrapper>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
const runningBlocks = blocks.filter((block) => {
|
||||
const status = block.metadata?.rawMcpToolResponse?.status
|
||||
return !isCompletedStatus(status) && !isWaitingStatus(status)
|
||||
})
|
||||
|
||||
// Get the last running block (most recent) and render with animation
|
||||
const lastRunningBlock = runningBlocks[runningBlocks.length - 1]
|
||||
if (lastRunningBlock) {
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
<AnimatedHeaderWrapper
|
||||
key={lastRunningBlock.id}
|
||||
variants={headerVariants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit">
|
||||
<ToolHeader block={lastRunningBlock} variant="collapse-label" />
|
||||
</AnimatedHeaderWrapper>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return (
|
||||
<GroupHeader>
|
||||
<Wrench size={14} className="tool-icon" />
|
||||
<span className="tool-count">{t('message.tools.groupHeader', { count: blocks.length })}</span>
|
||||
</GroupHeader>
|
||||
)
|
||||
})
|
||||
GroupHeaderContent.displayName = 'GroupHeaderContent'
|
||||
|
||||
// Component for tool list content with auto-scroll
|
||||
interface ToolListContentProps {
|
||||
blocks: ToolMessageBlock[]
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>
|
||||
}
|
||||
|
||||
const ToolListContent = React.memo(({ blocks, scrollRef }: ToolListContentProps) => (
|
||||
<ScrollableToolList ref={scrollRef}>
|
||||
{blocks.map((block) => {
|
||||
const status = block.metadata?.rawMcpToolResponse?.status
|
||||
const isCompleted = isCompletedStatus(status)
|
||||
return (
|
||||
<ToolItem key={block.id} data-block-id={block.id} $isCompleted={isCompleted}>
|
||||
<MessageTools block={block} />
|
||||
</ToolItem>
|
||||
)
|
||||
})}
|
||||
</ScrollableToolList>
|
||||
))
|
||||
ToolListContent.displayName = 'ToolListContent'
|
||||
|
||||
// ============ Main Component ============
|
||||
|
||||
const ToolBlockGroup: React.FC<Props> = ({ blocks }) => {
|
||||
const [activeKey, setActiveKey] = useState<string[]>([])
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const userExpandedRef = useRef(false)
|
||||
|
||||
const allCompleted = useMemo(() => {
|
||||
return blocks.every((block) => {
|
||||
const status = block.metadata?.rawMcpToolResponse?.status
|
||||
return isCompletedStatus(status)
|
||||
})
|
||||
}, [blocks])
|
||||
|
||||
const currentRunningBlock = useMemo(() => {
|
||||
return blocks.find((block) => {
|
||||
const status = block.metadata?.rawMcpToolResponse?.status
|
||||
return !isCompletedStatus(status)
|
||||
})
|
||||
}, [blocks])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeKey.includes('tool-group') && currentRunningBlock && scrollRef.current) {
|
||||
const element = scrollRef.current.querySelector(`[data-block-id="${currentRunningBlock.id}"]`)
|
||||
element?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
}, [activeKey, currentRunningBlock])
|
||||
|
||||
const handleChange = (keys: string | string[]) => {
|
||||
const keyArray = Array.isArray(keys) ? keys : [keys]
|
||||
const isExpanding = keyArray.includes('tool-group')
|
||||
userExpandedRef.current = isExpanding
|
||||
setActiveKey(keyArray)
|
||||
}
|
||||
|
||||
const items: CollapseProps['items'] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
key: 'tool-group',
|
||||
label: <GroupHeaderContent blocks={blocks} allCompleted={allCompleted} />,
|
||||
children: <ToolListContent blocks={blocks} scrollRef={scrollRef} />
|
||||
}
|
||||
]
|
||||
}, [blocks, allCompleted])
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Collapse
|
||||
ghost
|
||||
size="small"
|
||||
expandIconPosition="end"
|
||||
activeKey={activeKey}
|
||||
onChange={handleChange}
|
||||
items={items}
|
||||
/>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(ToolBlockGroup)
|
||||
@ -3,7 +3,7 @@ import type { RootState } from '@renderer/store'
|
||||
import { messageBlocksSelectors } from '@renderer/store/messageBlock'
|
||||
import type { ImageMessageBlock, Message, MessageBlock } from '@renderer/types/newMessage'
|
||||
import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
|
||||
import { isMainTextBlock, isMessageProcessing, isVideoBlock } from '@renderer/utils/messageUtils/is'
|
||||
import { isMainTextBlock, isMessageProcessing, isToolBlock, isVideoBlock } from '@renderer/utils/messageUtils/is'
|
||||
import { AnimatePresence, motion, type Variants } from 'motion/react'
|
||||
import React, { useMemo } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
@ -18,6 +18,7 @@ import MainTextBlock from './MainTextBlock'
|
||||
import PlaceholderBlock from './PlaceholderBlock'
|
||||
import ThinkingBlock from './ThinkingBlock'
|
||||
import ToolBlock from './ToolBlock'
|
||||
import ToolBlockGroup from './ToolBlockGroup'
|
||||
import TranslationBlock from './TranslationBlock'
|
||||
import VideoBlock from './VideoBlock'
|
||||
|
||||
@ -94,6 +95,14 @@ const groupSimilarBlocks = (blocks: MessageBlock[]): (MessageBlock[] | MessageBl
|
||||
} else {
|
||||
acc.push([currentBlock])
|
||||
}
|
||||
} else if (currentBlock.type === MessageBlockType.TOOL) {
|
||||
// 对于TOOL类型,按连续分组
|
||||
const prevGroup = acc[acc.length - 1]
|
||||
if (Array.isArray(prevGroup) && prevGroup[0].type === MessageBlockType.TOOL) {
|
||||
prevGroup.push(currentBlock)
|
||||
} else {
|
||||
acc.push([currentBlock])
|
||||
}
|
||||
} else {
|
||||
acc.push(currentBlock)
|
||||
}
|
||||
@ -147,6 +156,29 @@ const MessageBlockRenderer: React.FC<Props> = ({ blocks, message }) => {
|
||||
<VideoBlock key={firstVideoBlock.id} block={firstVideoBlock} />
|
||||
</AnimatedBlockWrapper>
|
||||
)
|
||||
} else if (block[0].type === MessageBlockType.TOOL) {
|
||||
// 对于连续的TOOL,使用分组显示
|
||||
if (block.length === 1) {
|
||||
// 单个工具调用,直接渲染
|
||||
if (!isToolBlock(block[0])) {
|
||||
logger.warn('Expected tool block but got different type', block[0])
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<AnimatedBlockWrapper key={groupKey} enableAnimation={message.status.includes('ing')}>
|
||||
<ToolBlock key={block[0].id} block={block[0]} />
|
||||
</AnimatedBlockWrapper>
|
||||
)
|
||||
}
|
||||
// 多个工具调用,使用分组组件
|
||||
const toolBlocks = block.filter(isToolBlock)
|
||||
// Use first block ID as stable key to prevent remounting when new blocks are added
|
||||
const stableGroupKey = `tool-group-${toolBlocks[0].id}`
|
||||
return (
|
||||
<AnimatedBlockWrapper key={stableGroupKey} enableAnimation={message.status.includes('ing')}>
|
||||
<ToolBlockGroup blocks={toolBlocks} />
|
||||
</AnimatedBlockWrapper>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import type { CollapseProps } from 'antd'
|
||||
import { Tag } from 'antd'
|
||||
import { CheckCircle, Terminal, XCircle } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { ToolTitle } from './GenericTools'
|
||||
import { truncateOutput } from '../shared/truncateOutput'
|
||||
import { ToolHeader, TruncatedIndicator } from './GenericTools'
|
||||
import type { BashOutputToolInput, BashOutputToolOutput } from './types'
|
||||
import { AgentToolsType } from './types'
|
||||
|
||||
@ -44,34 +46,6 @@ const parseBashOutput = (output?: BashOutputToolOutput): ParsedBashOutput | null
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusConfig = (parsedOutput: ParsedBashOutput | null) => {
|
||||
if (!parsedOutput) return null
|
||||
|
||||
if (parsedOutput.tool_use_error) {
|
||||
return {
|
||||
color: 'danger',
|
||||
icon: <XCircle className="h-3.5 w-3.5" />,
|
||||
text: 'Error'
|
||||
} as const
|
||||
}
|
||||
|
||||
const isCompleted = parsedOutput.status === 'completed'
|
||||
const isSuccess = parsedOutput.exit_code === 0
|
||||
|
||||
return {
|
||||
color: isCompleted && isSuccess ? 'success' : isCompleted && !isSuccess ? 'danger' : 'warning',
|
||||
icon:
|
||||
isCompleted && isSuccess ? (
|
||||
<CheckCircle className="h-3.5 w-3.5" />
|
||||
) : isCompleted && !isSuccess ? (
|
||||
<XCircle className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
),
|
||||
text: isCompleted ? (isSuccess ? 'Success' : 'Failed') : 'Running'
|
||||
} as const
|
||||
}
|
||||
|
||||
export function BashOutputTool({
|
||||
input,
|
||||
output
|
||||
@ -79,15 +53,62 @@ export function BashOutputTool({
|
||||
input?: BashOutputToolInput
|
||||
output?: BashOutputToolOutput
|
||||
}): NonNullable<CollapseProps['items']>[number] {
|
||||
const { t } = useTranslation()
|
||||
const parsedOutput = parseBashOutput(output)
|
||||
|
||||
const getStatusConfig = (parsed: ParsedBashOutput | null) => {
|
||||
if (!parsed) return null
|
||||
|
||||
if (parsed.tool_use_error) {
|
||||
return {
|
||||
color: 'danger',
|
||||
icon: <XCircle className="h-3.5 w-3.5" />,
|
||||
text: t('message.tools.status.error')
|
||||
} as const
|
||||
}
|
||||
|
||||
const isCompleted = parsed.status === 'completed'
|
||||
const isSuccess = parsed.exit_code === 0
|
||||
|
||||
if (isCompleted && isSuccess) {
|
||||
return {
|
||||
color: 'success',
|
||||
icon: <CheckCircle className="h-3.5 w-3.5" />,
|
||||
text: t('message.tools.status.success')
|
||||
} as const
|
||||
}
|
||||
|
||||
if (isCompleted) {
|
||||
return {
|
||||
color: 'danger',
|
||||
icon: <XCircle className="h-3.5 w-3.5" />,
|
||||
text: t('message.tools.status.failed')
|
||||
} as const
|
||||
}
|
||||
|
||||
return {
|
||||
color: 'warning',
|
||||
icon: <Terminal className="h-3.5 w-3.5" />,
|
||||
text: t('message.tools.status.running')
|
||||
} as const
|
||||
}
|
||||
|
||||
const statusConfig = getStatusConfig(parsedOutput)
|
||||
|
||||
// Truncate stdout and stderr separately
|
||||
const truncatedStdout = truncateOutput(parsedOutput?.stdout)
|
||||
const truncatedStderr = truncateOutput(parsedOutput?.stderr)
|
||||
const truncatedError = truncateOutput(parsedOutput?.tool_use_error)
|
||||
const truncatedRawOutput = truncateOutput(output)
|
||||
|
||||
const children = parsedOutput ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Status Info */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{parsedOutput.exit_code !== undefined && (
|
||||
<Tag color={parsedOutput.exit_code === 0 ? 'success' : 'danger'}>Exit Code: {parsedOutput.exit_code}</Tag>
|
||||
<Tag color={parsedOutput.exit_code === 0 ? 'success' : 'danger'}>
|
||||
{t('message.tools.sections.exitCode')}: {parsedOutput.exit_code}
|
||||
</Tag>
|
||||
)}
|
||||
{parsedOutput.timestamp && (
|
||||
<Tag className="py-0 font-mono text-xs">{new Date(parsedOutput.timestamp).toLocaleString()}</Tag>
|
||||
@ -95,73 +116,78 @@ export function BashOutputTool({
|
||||
</div>
|
||||
|
||||
{/* Standard Output */}
|
||||
{parsedOutput.stdout && (
|
||||
{truncatedStdout.data && (
|
||||
<div>
|
||||
<div className="mb-2 font-medium text-default-600 text-xs">stdout:</div>
|
||||
<div className="mb-2 font-medium text-default-600 text-xs">{t('message.tools.sections.stdout')}:</div>
|
||||
<pre className="whitespace-pre-wrap font-mono text-default-700 text-xs dark:text-default-300">
|
||||
{parsedOutput.stdout}
|
||||
{truncatedStdout.data}
|
||||
</pre>
|
||||
{truncatedStdout.isTruncated && <TruncatedIndicator originalLength={truncatedStdout.originalLength} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Standard Error */}
|
||||
{parsedOutput.stderr && (
|
||||
{truncatedStderr.data && (
|
||||
<div className="border border-danger-200">
|
||||
<div className="mb-2 font-medium text-danger-600 text-xs">stderr:</div>
|
||||
<div className="mb-2 font-medium text-danger-600 text-xs">{t('message.tools.sections.stderr')}:</div>
|
||||
<pre className="whitespace-pre-wrap font-mono text-danger-600 text-xs dark:text-danger-400">
|
||||
{parsedOutput.stderr}
|
||||
{truncatedStderr.data}
|
||||
</pre>
|
||||
{truncatedStderr.isTruncated && <TruncatedIndicator originalLength={truncatedStderr.originalLength} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tool Use Error */}
|
||||
{parsedOutput.tool_use_error && (
|
||||
{truncatedError.data && (
|
||||
<div className="border border-danger-200">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<XCircle className="h-4 w-4 text-danger" />
|
||||
<span className="font-medium text-danger-600 text-xs">Error:</span>
|
||||
<span className="font-medium text-danger-600 text-xs">{t('message.tools.status.error')}:</span>
|
||||
</div>
|
||||
<pre className="whitespace-pre-wrap font-mono text-danger-600 text-xs dark:text-danger-400">
|
||||
{parsedOutput.tool_use_error}
|
||||
{truncatedError.data}
|
||||
</pre>
|
||||
{truncatedError.isTruncated && <TruncatedIndicator originalLength={truncatedError.originalLength} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// 原始输出(如果解析失败或非 XML 格式)
|
||||
output && (
|
||||
truncatedRawOutput.data && (
|
||||
<div>
|
||||
<pre className="whitespace-pre-wrap font-mono text-default-700 text-xs dark:text-default-300">{output}</pre>
|
||||
<pre className="whitespace-pre-wrap font-mono text-default-700 text-xs dark:text-default-300">
|
||||
{truncatedRawOutput.data}
|
||||
</pre>
|
||||
{truncatedRawOutput.isTruncated && <TruncatedIndicator originalLength={truncatedRawOutput.originalLength} />}
|
||||
</div>
|
||||
)
|
||||
)
|
||||
return {
|
||||
key: AgentToolsType.BashOutput,
|
||||
label: (
|
||||
<>
|
||||
<ToolTitle
|
||||
icon={<Terminal className="h-4 w-4" />}
|
||||
label="Bash Output"
|
||||
params={
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag className="py-0 font-mono text-xs">{input?.bash_id}</Tag>
|
||||
{statusConfig && (
|
||||
<Tag
|
||||
color={statusConfig.color}
|
||||
icon={statusConfig.icon}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: '2px'
|
||||
}}>
|
||||
{statusConfig.text}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
<ToolHeader
|
||||
toolName={AgentToolsType.BashOutput}
|
||||
params={
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag className="py-0 font-mono text-xs">{input?.bash_id}</Tag>
|
||||
{statusConfig && (
|
||||
<Tag
|
||||
color={statusConfig.color}
|
||||
icon={statusConfig.icon}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: '2px'
|
||||
}}>
|
||||
{statusConfig.text}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
variant="collapse-label"
|
||||
showStatus={false}
|
||||
/>
|
||||
),
|
||||
|
||||
children: children
|
||||
|
||||
@ -1,9 +1,13 @@
|
||||
import type { CollapseProps } from 'antd'
|
||||
import { Popover, Tag } from 'antd'
|
||||
import { Terminal } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { ToolTitle } from './GenericTools'
|
||||
import type { BashToolInput as BashToolInputType, BashToolOutput as BashToolOutputType } from './types'
|
||||
import { truncateOutput } from '../shared/truncateOutput'
|
||||
import { SkeletonValue, ToolHeader, TruncatedIndicator } from './GenericTools'
|
||||
import {
|
||||
AgentToolsType,
|
||||
type BashToolInput as BashToolInputType,
|
||||
type BashToolOutput as BashToolOutputType
|
||||
} from './types'
|
||||
|
||||
export function BashTool({
|
||||
input,
|
||||
@ -12,33 +16,45 @@ export function BashTool({
|
||||
input?: BashToolInputType
|
||||
output?: BashToolOutputType
|
||||
}): NonNullable<CollapseProps['items']>[number] {
|
||||
// 如果有输出,计算输出行数
|
||||
const outputLines = output ? output.split('\n').length : 0
|
||||
|
||||
// 处理命令字符串,添加空值检查
|
||||
const command = input?.command ?? ''
|
||||
|
||||
const tagContent = <Tag className="!m-0 max-w-full truncate font-mono">{command}</Tag>
|
||||
const { t } = useTranslation()
|
||||
const command = input?.command
|
||||
const { data: truncatedOutput, isTruncated, originalLength } = truncateOutput(output)
|
||||
|
||||
return {
|
||||
key: 'tool',
|
||||
label: (
|
||||
<>
|
||||
<ToolTitle
|
||||
icon={<Terminal className="h-4 w-4" />}
|
||||
label="Bash"
|
||||
params={input?.description}
|
||||
stats={output ? `${outputLines} ${outputLines === 1 ? 'line' : 'lines'}` : undefined}
|
||||
/>
|
||||
<div className="mt-1 max-w-full">
|
||||
<Popover
|
||||
content={<div className="max-w-xl whitespace-pre-wrap break-all font-mono text-xs">{command}</div>}
|
||||
trigger="hover">
|
||||
{tagContent}
|
||||
</Popover>
|
||||
</div>
|
||||
</>
|
||||
<ToolHeader
|
||||
toolName={AgentToolsType.Bash}
|
||||
params={<SkeletonValue value={input?.description} width="150px" />}
|
||||
variant="collapse-label"
|
||||
showStatus={false}
|
||||
/>
|
||||
),
|
||||
children: <div className="whitespace-pre-line">{output}</div>
|
||||
children: (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Command 输入区域 */}
|
||||
{command && (
|
||||
<div>
|
||||
<div className="mb-1 font-medium text-muted-foreground text-xs">{t('message.tools.sections.command')}</div>
|
||||
<div className="max-h-40 overflow-y-auto rounded-md bg-muted/50 p-2">
|
||||
<code className="whitespace-pre-wrap break-all font-mono text-xs">{command}</code>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Output 输出区域 */}
|
||||
{truncatedOutput ? (
|
||||
<div>
|
||||
<div className="mb-1 font-medium text-muted-foreground text-xs">{t('message.tools.sections.output')}</div>
|
||||
<div className="max-h-60 overflow-y-auto rounded-md bg-muted/30 p-2">
|
||||
<pre className="whitespace-pre-wrap font-mono text-xs">{truncatedOutput}</pre>
|
||||
</div>
|
||||
{isTruncated && <TruncatedIndicator originalLength={originalLength} />}
|
||||
</div>
|
||||
) : (
|
||||
<SkeletonValue value={null} width="100%" fallback={null} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import type { CollapseProps } from 'antd'
|
||||
import { FileEdit } from 'lucide-react'
|
||||
|
||||
import { ToolTitle } from './GenericTools'
|
||||
import { ToolHeader } from './GenericTools'
|
||||
import type { EditToolInput, EditToolOutput } from './types'
|
||||
import { AgentToolsType } from './types'
|
||||
|
||||
@ -37,7 +36,14 @@ export function EditTool({
|
||||
}): NonNullable<CollapseProps['items']>[number] {
|
||||
return {
|
||||
key: AgentToolsType.Edit,
|
||||
label: <ToolTitle icon={<FileEdit className="h-4 w-4" />} label="Edit" params={input?.file_path} />,
|
||||
label: (
|
||||
<ToolHeader
|
||||
toolName={AgentToolsType.Edit}
|
||||
params={input?.file_path}
|
||||
variant="collapse-label"
|
||||
showStatus={false}
|
||||
/>
|
||||
),
|
||||
children: (
|
||||
<>
|
||||
{/* Diff View */}
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import type { CollapseProps } from 'antd'
|
||||
import { DoorOpen } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
|
||||
import { ToolTitle } from './GenericTools'
|
||||
import { truncateOutput } from '../shared/truncateOutput'
|
||||
import { ToolHeader, TruncatedIndicator } from './GenericTools'
|
||||
import type { ExitPlanModeToolInput, ExitPlanModeToolOutput } from './types'
|
||||
import { AgentToolsType } from './types'
|
||||
|
||||
@ -13,16 +14,27 @@ export function ExitPlanModeTool({
|
||||
input?: ExitPlanModeToolInput
|
||||
output?: ExitPlanModeToolOutput
|
||||
}): NonNullable<CollapseProps['items']>[number] {
|
||||
const { t } = useTranslation()
|
||||
const plan = input?.plan ?? ''
|
||||
const combinedContent = plan + '\n\n' + (output ?? '')
|
||||
const { data: truncatedContent, isTruncated, originalLength } = truncateOutput(combinedContent)
|
||||
const planCount = plan.split('\n\n').length
|
||||
|
||||
return {
|
||||
key: AgentToolsType.ExitPlanMode,
|
||||
label: (
|
||||
<ToolTitle
|
||||
icon={<DoorOpen className="h-4 w-4" />}
|
||||
label="ExitPlanMode"
|
||||
stats={`${plan.split('\n\n').length} plans`}
|
||||
<ToolHeader
|
||||
toolName={AgentToolsType.ExitPlanMode}
|
||||
stats={`${planCount} ${t(planCount === 1 ? 'message.tools.units.plan' : 'message.tools.units.plans')}`}
|
||||
variant="collapse-label"
|
||||
showStatus={false}
|
||||
/>
|
||||
),
|
||||
children: <ReactMarkdown>{plan + '\n\n' + (output ?? '')}</ReactMarkdown>
|
||||
children: (
|
||||
<div>
|
||||
<ReactMarkdown>{truncatedContent}</ReactMarkdown>
|
||||
{isTruncated && <TruncatedIndicator originalLength={originalLength} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,31 +1,60 @@
|
||||
// 通用工具组件 - 减少重复代码
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { LoadingIcon } from '@renderer/components/Icons'
|
||||
import type { MCPToolResponseStatus } from '@renderer/types'
|
||||
import { formatFileSize } from '@renderer/utils/file'
|
||||
import { Skeleton } from 'antd'
|
||||
import { Check, Ellipsis, TriangleAlert, X } from 'lucide-react'
|
||||
import { createContext, type ReactNode, use } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
// 生成 AccordionItem 的标题
|
||||
export function ToolTitle({
|
||||
icon,
|
||||
label,
|
||||
params,
|
||||
stats,
|
||||
className = 'text-sm'
|
||||
}: {
|
||||
icon?: ReactNode
|
||||
label: string
|
||||
params?: string | ReactNode
|
||||
stats?: string | ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
export { default as ToolHeader, type ToolHeaderProps } from '../ToolHeader'
|
||||
|
||||
// Streaming context - 用于传递流式状态给子组件
|
||||
export const StreamingContext = createContext<boolean>(false)
|
||||
export const useIsStreaming = () => use(StreamingContext)
|
||||
|
||||
export function SkeletonSpan({ width = '60px' }: { width?: string }) {
|
||||
return (
|
||||
<div className={`flex items-center gap-1 ${className}`}>
|
||||
{icon && <span className="flex flex-shrink-0">{icon}</span>}
|
||||
{label && <span className="flex-shrink-0 font-medium text-sm">{label}</span>}
|
||||
{params && <span className="min-w-0 truncate text-muted-foreground text-xs">{params}</span>}
|
||||
{stats && <span className="flex-shrink-0 text-muted-foreground text-xs">{stats}</span>}
|
||||
</div>
|
||||
<Skeleton.Input
|
||||
active
|
||||
size="small"
|
||||
style={{
|
||||
width,
|
||||
minWidth: width,
|
||||
height: '1em',
|
||||
verticalAlign: 'middle'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* SkeletonValue - 流式时显示 skeleton,否则显示值
|
||||
*/
|
||||
export function SkeletonValue({
|
||||
value,
|
||||
width = '60px',
|
||||
fallback
|
||||
}: {
|
||||
value: ReactNode
|
||||
width?: string
|
||||
fallback?: ReactNode
|
||||
}) {
|
||||
const isStreaming = useIsStreaming()
|
||||
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
return <>{value}</>
|
||||
}
|
||||
|
||||
if (isStreaming) {
|
||||
return <SkeletonSpan width={width} />
|
||||
}
|
||||
|
||||
return <>{fallback ?? ''}</>
|
||||
}
|
||||
|
||||
// 纯字符串输入工具 (Task, Bash, Search)
|
||||
export function StringInputTool({
|
||||
input,
|
||||
@ -93,3 +122,112 @@ export function StringOutputTool({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ToolStatus extends MCPToolResponseStatus with UI-derived statuses
|
||||
// 'waiting' is a UI status derived from 'pending' + needs approval
|
||||
export type ToolStatus = MCPToolResponseStatus | 'waiting'
|
||||
|
||||
/**
|
||||
* Convert raw data layer status to UI display status
|
||||
* @param status - Raw status from MCPToolResponseStatus
|
||||
* @param isWaiting - Whether the tool is waiting for user approval
|
||||
* @returns The effective UI status
|
||||
*/
|
||||
export function getEffectiveStatus(status: MCPToolResponseStatus | undefined, isWaiting: boolean): ToolStatus {
|
||||
if (status === 'pending') {
|
||||
return isWaiting ? 'waiting' : 'invoking'
|
||||
}
|
||||
return status ?? 'pending'
|
||||
}
|
||||
|
||||
// 工具状态指示器 - 显示在 Collapse 标题右侧
|
||||
export function ToolStatusIndicator({ status, hasError = false }: { status: ToolStatus; hasError?: boolean }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const getStatusInfo = (): { label: string; icon: ReactNode; color: StatusColor } | null => {
|
||||
switch (status) {
|
||||
case 'streaming':
|
||||
return { label: t('message.tools.streaming', 'Streaming'), icon: <LoadingIcon />, color: 'primary' }
|
||||
case 'waiting':
|
||||
return { label: t('message.tools.pending', 'Awaiting Approval'), icon: <LoadingIcon />, color: 'warning' }
|
||||
case 'pending':
|
||||
case 'invoking':
|
||||
return { label: t('message.tools.invoking'), icon: <LoadingIcon />, color: 'primary' }
|
||||
case 'cancelled':
|
||||
return {
|
||||
label: t('message.tools.cancelled'),
|
||||
icon: <X size={13} className="lucide-custom" />,
|
||||
color: 'error'
|
||||
}
|
||||
case 'done':
|
||||
return hasError
|
||||
? {
|
||||
label: t('message.tools.error'),
|
||||
icon: <TriangleAlert size={13} className="lucide-custom" />,
|
||||
color: 'error'
|
||||
}
|
||||
: {
|
||||
label: t('message.tools.completed'),
|
||||
icon: <Check size={13} className="lucide-custom" />,
|
||||
color: 'success'
|
||||
}
|
||||
case 'error':
|
||||
return {
|
||||
label: t('message.tools.error'),
|
||||
icon: <TriangleAlert size={13} className="lucide-custom" />,
|
||||
color: 'error'
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const info = getStatusInfo()
|
||||
if (!info) return null
|
||||
|
||||
return (
|
||||
<StatusIndicatorContainer $color={info.color}>
|
||||
{info.label}
|
||||
{info.icon}
|
||||
</StatusIndicatorContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export type StatusColor = 'primary' | 'success' | 'warning' | 'error'
|
||||
|
||||
function getStatusColor(color: StatusColor): string {
|
||||
switch (color) {
|
||||
case 'primary':
|
||||
case 'success':
|
||||
return 'var(--color-primary)'
|
||||
case 'warning':
|
||||
return 'var(--color-status-warning, #faad14)'
|
||||
case 'error':
|
||||
return 'var(--color-status-error, #ff4d4f)'
|
||||
default:
|
||||
return 'var(--color-text)'
|
||||
}
|
||||
}
|
||||
|
||||
export const StatusIndicatorContainer = styled.span<{ $color: StatusColor }>`
|
||||
font-size: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
opacity: 0.85;
|
||||
color: ${(props) => getStatusColor(props.$color)};
|
||||
`
|
||||
|
||||
export function TruncatedIndicator({ originalLength }: { originalLength: number }) {
|
||||
const { t } = useTranslation()
|
||||
const sizeStr = formatFileSize(originalLength)
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex items-center gap-1 text-muted-foreground text-xs">
|
||||
<Ellipsis size={14} />
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 font-mono">
|
||||
{t('message.tools.truncated', { defaultValue: sizeStr, size: sizeStr })}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,8 +1,13 @@
|
||||
import type { CollapseProps } from 'antd'
|
||||
import { FolderSearch } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { ToolTitle } from './GenericTools'
|
||||
import type { GlobToolInput as GlobToolInputType, GlobToolOutput as GlobToolOutputType } from './types'
|
||||
import { countLines, truncateOutput } from '../shared/truncateOutput'
|
||||
import { ToolHeader, TruncatedIndicator } from './GenericTools'
|
||||
import {
|
||||
AgentToolsType,
|
||||
type GlobToolInput as GlobToolInputType,
|
||||
type GlobToolOutput as GlobToolOutputType
|
||||
} from './types'
|
||||
|
||||
export function GlobTool({
|
||||
input,
|
||||
@ -11,19 +16,31 @@ export function GlobTool({
|
||||
input?: GlobToolInputType
|
||||
output?: GlobToolOutputType
|
||||
}): NonNullable<CollapseProps['items']>[number] {
|
||||
const { t } = useTranslation()
|
||||
// 如果有输出,计算文件数量
|
||||
const lineCount = output ? output.split('\n').filter((line) => line.trim()).length : 0
|
||||
const lineCount = countLines(output)
|
||||
const { data: truncatedOutput, isTruncated, originalLength } = truncateOutput(output)
|
||||
|
||||
return {
|
||||
key: 'tool',
|
||||
label: (
|
||||
<ToolTitle
|
||||
icon={<FolderSearch className="h-4 w-4" />}
|
||||
label="Glob"
|
||||
<ToolHeader
|
||||
toolName={AgentToolsType.Glob}
|
||||
params={input?.pattern}
|
||||
stats={output ? `${lineCount} ${lineCount === 1 ? 'file' : 'files'}` : undefined}
|
||||
stats={
|
||||
output
|
||||
? `${lineCount} ${t(lineCount === 1 ? 'message.tools.units.file' : 'message.tools.units.files')}`
|
||||
: undefined
|
||||
}
|
||||
variant="collapse-label"
|
||||
showStatus={false}
|
||||
/>
|
||||
),
|
||||
children: <div>{output}</div>
|
||||
children: (
|
||||
<div>
|
||||
<div>{truncatedOutput}</div>
|
||||
{isTruncated && <TruncatedIndicator originalLength={originalLength} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import type { CollapseProps } from 'antd'
|
||||
import { FileSearch } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { ToolTitle } from './GenericTools'
|
||||
import type { GrepToolInput, GrepToolOutput } from './types'
|
||||
import { countLines, truncateOutput } from '../shared/truncateOutput'
|
||||
import { ToolHeader, TruncatedIndicator } from './GenericTools'
|
||||
import { AgentToolsType, type GrepToolInput, type GrepToolOutput } from './types'
|
||||
|
||||
export function GrepTool({
|
||||
input,
|
||||
@ -11,24 +12,36 @@ export function GrepTool({
|
||||
input?: GrepToolInput
|
||||
output?: GrepToolOutput
|
||||
}): NonNullable<CollapseProps['items']>[number] {
|
||||
const { t } = useTranslation()
|
||||
// 如果有输出,计算结果行数
|
||||
const resultLines = output ? output.split('\n').filter((line) => line.trim()).length : 0
|
||||
const resultLines = countLines(output)
|
||||
const { data: truncatedOutput, isTruncated, originalLength } = truncateOutput(output)
|
||||
|
||||
return {
|
||||
key: 'tool',
|
||||
label: (
|
||||
<ToolTitle
|
||||
icon={<FileSearch className="h-4 w-4" />}
|
||||
label="Grep"
|
||||
<ToolHeader
|
||||
toolName={AgentToolsType.Grep}
|
||||
params={
|
||||
<>
|
||||
{input?.pattern}
|
||||
{input?.output_mode && <span className="ml-1">({input.output_mode})</span>}
|
||||
</>
|
||||
}
|
||||
stats={output ? `${resultLines} ${resultLines === 1 ? 'line' : 'lines'}` : undefined}
|
||||
stats={
|
||||
output
|
||||
? `${resultLines} ${t(resultLines === 1 ? 'message.tools.units.line' : 'message.tools.units.lines')}`
|
||||
: undefined
|
||||
}
|
||||
variant="collapse-label"
|
||||
showStatus={false}
|
||||
/>
|
||||
),
|
||||
children: <div>{output}</div>
|
||||
children: (
|
||||
<div>
|
||||
<div>{truncatedOutput}</div>
|
||||
{isTruncated && <TruncatedIndicator originalLength={originalLength} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
import type { CollapseProps } from 'antd'
|
||||
import { FileText } from 'lucide-react'
|
||||
|
||||
import { renderCodeBlock } from './EditTool'
|
||||
import { ToolTitle } from './GenericTools'
|
||||
import { ToolHeader } from './GenericTools'
|
||||
import type { MultiEditToolInput, MultiEditToolOutput } from './types'
|
||||
import { AgentToolsType } from './types'
|
||||
|
||||
@ -15,7 +14,14 @@ export function MultiEditTool({
|
||||
const edits = Array.isArray(input?.edits) ? input.edits : []
|
||||
return {
|
||||
key: AgentToolsType.MultiEdit,
|
||||
label: <ToolTitle icon={<FileText className="h-4 w-4" />} label="MultiEdit" params={input?.file_path} />,
|
||||
label: (
|
||||
<ToolHeader
|
||||
toolName={AgentToolsType.MultiEdit}
|
||||
params={input?.file_path}
|
||||
variant="collapse-label"
|
||||
showStatus={false}
|
||||
/>
|
||||
),
|
||||
children: (
|
||||
<div>
|
||||
{edits.map((edit, index) => (
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import type { CollapseProps } from 'antd'
|
||||
import { Tag } from 'antd'
|
||||
import { FileText } from 'lucide-react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
|
||||
import { ToolTitle } from './GenericTools'
|
||||
import { truncateOutput } from '../shared/truncateOutput'
|
||||
import { ToolHeader, TruncatedIndicator } from './GenericTools'
|
||||
import type { NotebookEditToolInput, NotebookEditToolOutput } from './types'
|
||||
import { AgentToolsType } from './types'
|
||||
|
||||
@ -14,16 +14,21 @@ export function NotebookEditTool({
|
||||
input?: NotebookEditToolInput
|
||||
output?: NotebookEditToolOutput
|
||||
}): NonNullable<CollapseProps['items']>[number] {
|
||||
const { data: truncatedOutput, isTruncated, originalLength } = truncateOutput(output)
|
||||
|
||||
return {
|
||||
key: AgentToolsType.NotebookEdit,
|
||||
label: (
|
||||
<>
|
||||
<ToolTitle icon={<FileText className="h-4 w-4" />} label="NotebookEdit" />
|
||||
<Tag className="mt-1" color="blue">
|
||||
{input?.notebook_path}{' '}
|
||||
</Tag>
|
||||
</>
|
||||
<div className="flex items-center gap-2">
|
||||
<ToolHeader toolName={AgentToolsType.NotebookEdit} variant="collapse-label" showStatus={false} />
|
||||
<Tag color="blue">{input?.notebook_path}</Tag>
|
||||
</div>
|
||||
),
|
||||
children: <ReactMarkdown>{output ?? ''}</ReactMarkdown>
|
||||
children: (
|
||||
<div>
|
||||
<ReactMarkdown>{truncatedOutput}</ReactMarkdown>
|
||||
{isTruncated && <TruncatedIndicator originalLength={originalLength} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import { formatFileSize } from '@renderer/utils/file'
|
||||
import type { CollapseProps } from 'antd'
|
||||
import { FileText } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
|
||||
import { ToolTitle } from './GenericTools'
|
||||
import { truncateOutput } from '../shared/truncateOutput'
|
||||
import { SkeletonValue, ToolHeader, TruncatedIndicator } from './GenericTools'
|
||||
import type { ReadToolInput as ReadToolInputType, ReadToolOutput as ReadToolOutputType, TextOutput } from './types'
|
||||
import { AgentToolsType } from './types'
|
||||
|
||||
@ -28,17 +30,9 @@ const normalizeOutputString = (output?: ReadToolOutputType): string | null => {
|
||||
const getOutputStats = (outputString: string | null) => {
|
||||
if (!outputString) return null
|
||||
|
||||
const bytes = new Blob([outputString]).size
|
||||
const formatSize = (size: number) => {
|
||||
if (size < 1024) return `${size} B`
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
return {
|
||||
lineCount: outputString.split('\n').length,
|
||||
fileSize: bytes,
|
||||
formatSize
|
||||
fileSize: new Blob([outputString]).size
|
||||
}
|
||||
}
|
||||
|
||||
@ -49,19 +43,34 @@ export function ReadTool({
|
||||
input?: ReadToolInputType
|
||||
output?: ReadToolOutputType
|
||||
}): NonNullable<CollapseProps['items']>[number] {
|
||||
const { t } = useTranslation()
|
||||
const outputString = normalizeOutputString(output)
|
||||
const stats = getOutputStats(outputString)
|
||||
const filename = input?.file_path?.split('/').pop()
|
||||
const { data: truncatedOutput, isTruncated, originalLength } = truncateOutput(outputString)
|
||||
|
||||
return {
|
||||
key: AgentToolsType.Read,
|
||||
label: (
|
||||
<ToolTitle
|
||||
icon={<FileText className="h-4 w-4" />}
|
||||
label="Read File"
|
||||
params={input?.file_path?.split('/').pop()}
|
||||
stats={stats ? `${stats.lineCount} lines, ${stats.formatSize(stats.fileSize)}` : undefined}
|
||||
<ToolHeader
|
||||
toolName={AgentToolsType.Read}
|
||||
params={<SkeletonValue value={filename} width="120px" />}
|
||||
stats={
|
||||
stats
|
||||
? `${stats.lineCount} ${t(stats.lineCount === 1 ? 'message.tools.units.line' : 'message.tools.units.lines')}, ${formatFileSize(stats.fileSize)}`
|
||||
: undefined
|
||||
}
|
||||
variant="collapse-label"
|
||||
showStatus={false}
|
||||
/>
|
||||
),
|
||||
children: outputString ? <ReactMarkdown>{outputString}</ReactMarkdown> : null
|
||||
children: truncatedOutput ? (
|
||||
<div>
|
||||
<ReactMarkdown>{truncatedOutput}</ReactMarkdown>
|
||||
{isTruncated && <TruncatedIndicator originalLength={originalLength} />}
|
||||
</div>
|
||||
) : (
|
||||
<SkeletonValue value={null} width="100%" fallback={null} />
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,13 @@
|
||||
import type { CollapseProps } from 'antd'
|
||||
import { Search } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { StringInputTool, StringOutputTool, ToolTitle } from './GenericTools'
|
||||
import type { SearchToolInput as SearchToolInputType, SearchToolOutput as SearchToolOutputType } from './types'
|
||||
import { countLines, truncateOutput } from '../shared/truncateOutput'
|
||||
import { StringInputTool, StringOutputTool, ToolHeader, TruncatedIndicator } from './GenericTools'
|
||||
import {
|
||||
AgentToolsType,
|
||||
type SearchToolInput as SearchToolInputType,
|
||||
type SearchToolOutput as SearchToolOutputType
|
||||
} from './types'
|
||||
|
||||
export function SearchTool({
|
||||
input,
|
||||
@ -11,25 +16,37 @@ export function SearchTool({
|
||||
input?: SearchToolInputType
|
||||
output?: SearchToolOutputType
|
||||
}): NonNullable<CollapseProps['items']>[number] {
|
||||
const { t } = useTranslation()
|
||||
// 如果有输出,计算结果数量
|
||||
const resultCount = output ? output.split('\n').filter((line) => line.trim()).length : 0
|
||||
const resultCount = countLines(output)
|
||||
const { data: truncatedOutput, isTruncated, originalLength } = truncateOutput(output)
|
||||
|
||||
return {
|
||||
key: 'tool',
|
||||
label: (
|
||||
<ToolTitle
|
||||
icon={<Search className="h-4 w-4" />}
|
||||
label="Search"
|
||||
<ToolHeader
|
||||
toolName={AgentToolsType.Search}
|
||||
params={input ? `"${input}"` : undefined}
|
||||
stats={output ? `${resultCount} ${resultCount === 1 ? 'result' : 'results'}` : undefined}
|
||||
stats={
|
||||
output
|
||||
? `${resultCount} ${t(resultCount === 1 ? 'message.tools.units.result' : 'message.tools.units.results')}`
|
||||
: undefined
|
||||
}
|
||||
variant="collapse-label"
|
||||
showStatus={false}
|
||||
/>
|
||||
),
|
||||
children: (
|
||||
<div>
|
||||
{input && <StringInputTool input={input} label="Search Query" />}
|
||||
{output && (
|
||||
{input && <StringInputTool input={input} label={t('message.tools.sections.searchQuery')} />}
|
||||
{truncatedOutput && (
|
||||
<div>
|
||||
<StringOutputTool output={output} label="Search Results" textColor="text-yellow-600 dark:text-yellow-400" />
|
||||
<StringOutputTool
|
||||
output={truncatedOutput}
|
||||
label={t('message.tools.sections.searchResults')}
|
||||
textColor="text-yellow-600 dark:text-yellow-400"
|
||||
/>
|
||||
{isTruncated && <TruncatedIndicator originalLength={originalLength} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import type { CollapseProps } from 'antd'
|
||||
import { PencilRuler } from 'lucide-react'
|
||||
|
||||
import { ToolTitle } from './GenericTools'
|
||||
import type { SkillToolInput, SkillToolOutput } from './types'
|
||||
import { truncateOutput } from '../shared/truncateOutput'
|
||||
import { ToolHeader, TruncatedIndicator } from './GenericTools'
|
||||
import { AgentToolsType, type SkillToolInput, type SkillToolOutput } from './types'
|
||||
|
||||
export function SkillTool({
|
||||
input,
|
||||
@ -11,9 +11,18 @@ export function SkillTool({
|
||||
input?: SkillToolInput
|
||||
output?: SkillToolOutput
|
||||
}): NonNullable<CollapseProps['items']>[number] {
|
||||
const { data: truncatedOutput, isTruncated, originalLength } = truncateOutput(output)
|
||||
|
||||
return {
|
||||
key: 'tool',
|
||||
label: <ToolTitle icon={<PencilRuler className="h-4 w-4" />} label="Skill" params={input?.command} />,
|
||||
children: <div>{output}</div>
|
||||
label: (
|
||||
<ToolHeader toolName={AgentToolsType.Skill} params={input?.command} variant="collapse-label" showStatus={false} />
|
||||
),
|
||||
children: (
|
||||
<div>
|
||||
<div>{truncatedOutput}</div>
|
||||
{isTruncated && <TruncatedIndicator originalLength={originalLength} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,15 @@
|
||||
import type { CollapseProps } from 'antd'
|
||||
import { Bot } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Markdown from 'react-markdown'
|
||||
|
||||
import { ToolTitle } from './GenericTools'
|
||||
import type { TaskToolInput as TaskToolInputType, TaskToolOutput as TaskToolOutputType } from './types'
|
||||
import { truncateOutput } from '../shared/truncateOutput'
|
||||
import { SkeletonValue, ToolHeader, TruncatedIndicator } from './GenericTools'
|
||||
import {
|
||||
AgentToolsType,
|
||||
type TaskToolInput as TaskToolInputType,
|
||||
type TaskToolOutput as TaskToolOutputType
|
||||
} from './types'
|
||||
|
||||
export function TaskTool({
|
||||
input,
|
||||
@ -12,17 +18,51 @@ export function TaskTool({
|
||||
input?: TaskToolInputType
|
||||
output?: TaskToolOutputType
|
||||
}): NonNullable<CollapseProps['items']>[number] {
|
||||
const { t } = useTranslation()
|
||||
const hasOutput = Array.isArray(output) && output.length > 0
|
||||
|
||||
// Combine all text outputs and truncate
|
||||
const { truncatedText, isTruncated, originalLength } = useMemo(() => {
|
||||
if (!hasOutput) return { truncatedText: '', isTruncated: false, originalLength: 0 }
|
||||
const combinedText = output!.map((item) => item.text).join('\n\n')
|
||||
const result = truncateOutput(combinedText)
|
||||
return { truncatedText: result.data, isTruncated: result.isTruncated, originalLength: result.originalLength }
|
||||
}, [output, hasOutput])
|
||||
|
||||
return {
|
||||
key: 'tool',
|
||||
label: <ToolTitle icon={<Bot className="h-4 w-4" />} label="Task" params={input?.description} />,
|
||||
label: (
|
||||
<ToolHeader
|
||||
toolName={AgentToolsType.Task}
|
||||
params={<SkeletonValue value={input?.description} width="150px" />}
|
||||
variant="collapse-label"
|
||||
showStatus={false}
|
||||
/>
|
||||
),
|
||||
children: (
|
||||
<div>
|
||||
{Array.isArray(output) &&
|
||||
output.map((item) => (
|
||||
<div key={item.type}>
|
||||
<div>{item.type === 'text' ? <Markdown>{item.text}</Markdown> : item.text}</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Prompt 输入区域 */}
|
||||
{input?.prompt && (
|
||||
<div>
|
||||
<div className="mb-1 font-medium text-muted-foreground text-xs">{t('message.tools.sections.prompt')}</div>
|
||||
<div className="max-h-40 overflow-y-auto rounded-md bg-muted/50 p-2 text-sm">
|
||||
<Markdown>{input.prompt}</Markdown>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Output 输出区域 */}
|
||||
{hasOutput ? (
|
||||
<div>
|
||||
<div className="mb-1 font-medium text-muted-foreground text-xs">{t('message.tools.sections.output')}</div>
|
||||
<div className="rounded-md bg-muted/30 p-2">
|
||||
<Markdown>{truncatedText}</Markdown>
|
||||
{isTruncated && <TruncatedIndicator originalLength={originalLength} />}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<SkeletonValue value={null} width="100%" fallback={null} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import type { CollapseProps } from 'antd'
|
||||
import { Card } from 'antd'
|
||||
import { CheckCircle, Circle, Clock, ListTodo } from 'lucide-react'
|
||||
import { CheckCircle, Circle, Clock } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { ToolTitle } from './GenericTools'
|
||||
import { ToolHeader } from './GenericTools'
|
||||
import type { TodoItem, TodoWriteToolInput as TodoWriteToolInputType } from './types'
|
||||
import { AgentToolsType } from './types'
|
||||
|
||||
@ -20,12 +21,6 @@ const getStatusConfig = (status: TodoItem['status']) => {
|
||||
opacity: 0.9,
|
||||
icon: <Clock className="h-4 w-4" strokeWidth={2.5} />
|
||||
}
|
||||
case 'pending':
|
||||
return {
|
||||
color: 'var(--color-border)',
|
||||
opacity: 0.4,
|
||||
icon: <Circle className="h-4 w-4" strokeWidth={2.5} />
|
||||
}
|
||||
default:
|
||||
return {
|
||||
color: 'var(--color-border)',
|
||||
@ -40,17 +35,19 @@ export function TodoWriteTool({
|
||||
}: {
|
||||
input?: TodoWriteToolInputType
|
||||
}): NonNullable<CollapseProps['items']>[number] {
|
||||
const { t } = useTranslation()
|
||||
const todos = Array.isArray(input?.todos) ? input.todos : []
|
||||
const doneCount = todos.filter((todo) => todo.status === 'completed').length
|
||||
|
||||
return {
|
||||
key: AgentToolsType.TodoWrite,
|
||||
label: (
|
||||
<ToolTitle
|
||||
icon={<ListTodo className="h-4 w-4" />}
|
||||
label="Todo Write"
|
||||
params={`${doneCount} Done`}
|
||||
stats={`${todos.length} ${todos.length === 1 ? 'item' : 'items'}`}
|
||||
<ToolHeader
|
||||
toolName={AgentToolsType.TodoWrite}
|
||||
params={`${doneCount} ${t('message.tools.status.done')}`}
|
||||
stats={`${todos.length} ${t(todos.length === 1 ? 'message.tools.units.item' : 'message.tools.units.items')}`}
|
||||
variant="collapse-label"
|
||||
showStatus={false}
|
||||
/>
|
||||
),
|
||||
children: (
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||
import type { CollapseProps } from 'antd'
|
||||
import { Wrench } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { ToolTitle } from './GenericTools'
|
||||
import { ToolArgsTable } from '../shared/ArgsTable'
|
||||
import { ToolHeader } from './GenericTools'
|
||||
|
||||
interface UnknownToolProps {
|
||||
toolName: string
|
||||
@ -21,75 +21,54 @@ const getToolDisplayName = (name: string) => {
|
||||
return name
|
||||
}
|
||||
|
||||
const getToolDescription = (toolName: string) => {
|
||||
if (toolName.startsWith('mcp__')) {
|
||||
return 'MCP Server Tool'
|
||||
}
|
||||
return 'Tool'
|
||||
}
|
||||
|
||||
const UnknownToolContent = ({ input, output }: { input?: unknown; output?: unknown }) => {
|
||||
const { highlightCode } = useCodeStyle()
|
||||
const [inputHtml, setInputHtml] = useState<string>('')
|
||||
const [outputHtml, setOutputHtml] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
if (input !== undefined) {
|
||||
const inputStr = JSON.stringify(input, null, 2)
|
||||
highlightCode(inputStr, 'json').then(setInputHtml)
|
||||
}
|
||||
}, [input, highlightCode])
|
||||
|
||||
useEffect(() => {
|
||||
if (output !== undefined) {
|
||||
const outputStr = JSON.stringify(output, null, 2)
|
||||
highlightCode(outputStr, 'json').then(setOutputHtml)
|
||||
}
|
||||
}, [output, highlightCode])
|
||||
|
||||
if (input === undefined && output === undefined) {
|
||||
return <div className="text-foreground-500 text-xs">No data available for this tool</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{input !== undefined && (
|
||||
<div>
|
||||
<div className="mb-1 font-semibold text-foreground-600 text-xs dark:text-foreground-400">Input:</div>
|
||||
<div
|
||||
className="overflow-x-auto rounded bg-gray-50 dark:bg-gray-900"
|
||||
dangerouslySetInnerHTML={{ __html: inputHtml }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{output !== undefined && (
|
||||
<div>
|
||||
<div className="mb-1 font-semibold text-foreground-600 text-xs dark:text-foreground-400">Output:</div>
|
||||
<div
|
||||
className="rounded bg-gray-50 dark:bg-gray-900 [&>*]:whitespace-pre-line"
|
||||
dangerouslySetInnerHTML={{ __html: outputHtml }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback renderer for unknown tool types
|
||||
* Uses shared ArgsTable for consistent styling with MCP tools
|
||||
*/
|
||||
export function UnknownToolRenderer({
|
||||
toolName = '',
|
||||
input,
|
||||
output
|
||||
}: UnknownToolProps): NonNullable<CollapseProps['items']>[number] {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const getToolDescription = (name: string) => {
|
||||
if (name.startsWith('mcp__')) {
|
||||
return t('message.tools.labels.mcpServerTool')
|
||||
}
|
||||
return t('message.tools.labels.tool')
|
||||
}
|
||||
|
||||
// Normalize input/output for table display
|
||||
const normalizeArgs = (value: unknown): Record<string, unknown> | unknown[] | null => {
|
||||
if (value === undefined || value === null) return null
|
||||
if (typeof value === 'object') return value as Record<string, unknown> | unknown[]
|
||||
// Wrap primitive values
|
||||
return { value }
|
||||
}
|
||||
|
||||
const normalizedInput = normalizeArgs(input)
|
||||
const normalizedOutput = normalizeArgs(output)
|
||||
|
||||
return {
|
||||
key: 'unknown-tool',
|
||||
label: (
|
||||
<ToolTitle
|
||||
<ToolHeader
|
||||
toolName={getToolDisplayName(toolName)}
|
||||
icon={<Wrench className="h-4 w-4" />}
|
||||
label={getToolDisplayName(toolName)}
|
||||
params={getToolDescription(toolName)}
|
||||
variant="collapse-label"
|
||||
showStatus={false}
|
||||
/>
|
||||
),
|
||||
children: <UnknownToolContent input={input} output={output} />
|
||||
children: (
|
||||
<div className="space-y-1">
|
||||
{normalizedInput && <ToolArgsTable args={normalizedInput} title={t('message.tools.sections.input')} />}
|
||||
{normalizedOutput && <ToolArgsTable args={normalizedOutput} title={t('message.tools.sections.output')} />}
|
||||
{!normalizedInput && !normalizedOutput && (
|
||||
<div className="p-3 text-foreground-500 text-xs">{t('message.tools.noData')}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import type { CollapseProps } from 'antd'
|
||||
import { Globe } from 'lucide-react'
|
||||
|
||||
import { ToolTitle } from './GenericTools'
|
||||
import type { WebFetchToolInput, WebFetchToolOutput } from './types'
|
||||
import { truncateOutput } from '../shared/truncateOutput'
|
||||
import { ToolHeader, TruncatedIndicator } from './GenericTools'
|
||||
import { AgentToolsType, type WebFetchToolInput, type WebFetchToolOutput } from './types'
|
||||
|
||||
export function WebFetchTool({
|
||||
input,
|
||||
@ -11,9 +11,18 @@ export function WebFetchTool({
|
||||
input?: WebFetchToolInput
|
||||
output?: WebFetchToolOutput
|
||||
}): NonNullable<CollapseProps['items']>[number] {
|
||||
const { data: truncatedOutput, isTruncated, originalLength } = truncateOutput(output)
|
||||
|
||||
return {
|
||||
key: 'tool',
|
||||
label: <ToolTitle icon={<Globe className="h-4 w-4" />} label="Web Fetch" params={input?.url} />,
|
||||
children: <div>{output}</div>
|
||||
label: (
|
||||
<ToolHeader toolName={AgentToolsType.WebFetch} params={input?.url} variant="collapse-label" showStatus={false} />
|
||||
),
|
||||
children: (
|
||||
<div>
|
||||
<div>{truncatedOutput}</div>
|
||||
{isTruncated && <TruncatedIndicator originalLength={originalLength} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import type { CollapseProps } from 'antd'
|
||||
import { Globe } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { ToolTitle } from './GenericTools'
|
||||
import type { WebSearchToolInput, WebSearchToolOutput } from './types'
|
||||
import { countLines, truncateOutput } from '../shared/truncateOutput'
|
||||
import { ToolHeader, TruncatedIndicator } from './GenericTools'
|
||||
import { AgentToolsType, type WebSearchToolInput, type WebSearchToolOutput } from './types'
|
||||
|
||||
export function WebSearchTool({
|
||||
input,
|
||||
@ -11,19 +12,31 @@ export function WebSearchTool({
|
||||
input?: WebSearchToolInput
|
||||
output?: WebSearchToolOutput
|
||||
}): NonNullable<CollapseProps['items']>[number] {
|
||||
const { t } = useTranslation()
|
||||
// 如果有输出,计算结果数量
|
||||
const resultCount = output ? output.split('\n').filter((line) => line.trim()).length : 0
|
||||
const resultCount = countLines(output)
|
||||
const { data: truncatedOutput, isTruncated, originalLength } = truncateOutput(output)
|
||||
|
||||
return {
|
||||
key: 'tool',
|
||||
label: (
|
||||
<ToolTitle
|
||||
icon={<Globe className="h-4 w-4" />}
|
||||
label="Web Search"
|
||||
<ToolHeader
|
||||
toolName={AgentToolsType.WebSearch}
|
||||
params={input?.query}
|
||||
stats={output ? `${resultCount} ${resultCount === 1 ? 'result' : 'results'}` : undefined}
|
||||
stats={
|
||||
output
|
||||
? `${resultCount} ${t(resultCount === 1 ? 'message.tools.units.result' : 'message.tools.units.results')}`
|
||||
: undefined
|
||||
}
|
||||
variant="collapse-label"
|
||||
showStatus={false}
|
||||
/>
|
||||
),
|
||||
children: <div>{output}</div>
|
||||
children: (
|
||||
<div>
|
||||
<div>{truncatedOutput}</div>
|
||||
{isTruncated && <TruncatedIndicator originalLength={originalLength} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
import type { CollapseProps } from 'antd'
|
||||
import { FileText } from 'lucide-react'
|
||||
|
||||
import { ToolTitle } from './GenericTools'
|
||||
import type { WriteToolInput, WriteToolOutput } from './types'
|
||||
import { ToolHeader } from './GenericTools'
|
||||
import { AgentToolsType, type WriteToolInput, type WriteToolOutput } from './types'
|
||||
|
||||
export function WriteTool({
|
||||
input
|
||||
@ -12,7 +11,14 @@ export function WriteTool({
|
||||
}): NonNullable<CollapseProps['items']>[number] {
|
||||
return {
|
||||
key: 'tool',
|
||||
label: <ToolTitle icon={<FileText className="h-4 w-4" />} label="Write" params={input?.file_path} />,
|
||||
label: (
|
||||
<ToolHeader
|
||||
toolName={AgentToolsType.Write}
|
||||
params={input?.file_path}
|
||||
variant="collapse-label"
|
||||
showStatus={false}
|
||||
/>
|
||||
),
|
||||
children: <div>{input?.content}</div>
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { selectPendingPermission } from '@renderer/store/toolPermissions'
|
||||
import type { NormalToolResponse } from '@renderer/types'
|
||||
import type { CollapseProps } from 'antd'
|
||||
import { Collapse, Spin } from 'antd'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Collapse } from 'antd'
|
||||
import { parse as parsePartialJson } from 'partial-json'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
// 导出所有类型
|
||||
export * from './types'
|
||||
@ -15,6 +15,7 @@ import { BashOutputTool } from './BashOutputTool'
|
||||
import { BashTool } from './BashTool'
|
||||
import { EditTool } from './EditTool'
|
||||
import { ExitPlanModeTool } from './ExitPlanModeTool'
|
||||
import { getEffectiveStatus, StreamingContext, type ToolStatus, ToolStatusIndicator } from './GenericTools'
|
||||
import { GlobTool } from './GlobTool'
|
||||
import { GrepTool } from './GrepTool'
|
||||
import { MultiEditTool } from './MultiEditTool'
|
||||
@ -31,9 +32,7 @@ import { WebFetchTool } from './WebFetchTool'
|
||||
import { WebSearchTool } from './WebSearchTool'
|
||||
import { WriteTool } from './WriteTool'
|
||||
|
||||
const logger = loggerService.withContext('MessageAgentTools')
|
||||
|
||||
// 创建工具渲染器映射,这样就实现了完全的类型安全
|
||||
// 创建工具渲染器映射
|
||||
export const toolRenderers = {
|
||||
[AgentToolsType.Read]: ReadTool,
|
||||
[AgentToolsType.Task]: TaskTool,
|
||||
@ -51,76 +50,116 @@ export const toolRenderers = {
|
||||
[AgentToolsType.NotebookEdit]: NotebookEditTool,
|
||||
[AgentToolsType.ExitPlanMode]: ExitPlanModeTool,
|
||||
[AgentToolsType.Skill]: SkillTool
|
||||
} as const
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-safe tool renderer invocation function.
|
||||
* Use this function to call a tool renderer with proper type checking,
|
||||
* avoiding the need for `as any` type assertions at call sites.
|
||||
*
|
||||
* @param toolName - The name of the tool (must be a valid AgentToolsType)
|
||||
* @param input - The input for the tool (accepts various input formats)
|
||||
* @param output - Optional output from the tool
|
||||
* @returns The rendered collapse item
|
||||
*/
|
||||
export function renderTool(
|
||||
toolName: AgentToolsType,
|
||||
input: ToolInput | Record<string, unknown> | string | undefined,
|
||||
output?: ToolOutput | unknown
|
||||
): NonNullable<CollapseProps['items']>[number] {
|
||||
const renderer = toolRenderers[toolName] as (props: {
|
||||
input?: unknown
|
||||
output?: unknown
|
||||
}) => NonNullable<CollapseProps['items']>[number]
|
||||
return renderer({ input, output })
|
||||
}
|
||||
|
||||
// 类型守卫函数
|
||||
export function isValidAgentToolsType(toolName: unknown): toolName is AgentToolsType {
|
||||
return typeof toolName === 'string' && Object.values(AgentToolsType).includes(toolName as AgentToolsType)
|
||||
}
|
||||
|
||||
// 统一的渲染组件
|
||||
function ToolContent({ toolName, input, output }: { toolName: AgentToolsType; input: ToolInput; output?: ToolOutput }) {
|
||||
const Renderer = toolRenderers[toolName]
|
||||
const renderedItem = Renderer
|
||||
? Renderer({ input: input as any, output: output as any })
|
||||
: UnknownToolRenderer({ input: input as any, output: output as any, toolName })
|
||||
function ToolContent({
|
||||
toolName,
|
||||
input,
|
||||
output,
|
||||
isStreaming = false,
|
||||
status,
|
||||
hasError = false
|
||||
}: {
|
||||
toolName?: string
|
||||
input?: ToolInput | Record<string, unknown>
|
||||
output?: ToolOutput | unknown
|
||||
isStreaming?: boolean
|
||||
status?: ToolStatus
|
||||
hasError?: boolean
|
||||
}) {
|
||||
const renderedItem = isValidAgentToolsType(toolName)
|
||||
? renderTool(toolName, (input ?? {}) as Record<string, unknown>, output)
|
||||
: UnknownToolRenderer({ toolName: toolName ?? 'Tool', input, output })
|
||||
|
||||
const toolContentItem: NonNullable<CollapseProps['items']>[number] = {
|
||||
...renderedItem,
|
||||
label: (
|
||||
<div className="flex w-full items-start justify-between gap-2">
|
||||
<div className="min-w-0">{renderedItem.label}</div>
|
||||
{status && (
|
||||
<div className="shrink-0">
|
||||
<ToolStatusIndicator status={status} hasError={hasError} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
classNames: {
|
||||
body: 'bg-foreground-50 p-2 text-foreground-900 dark:bg-foreground-100 max-h-96 p-2 overflow-scroll'
|
||||
body: 'bg-foreground-50 p-2 text-foreground-900 dark:bg-foreground-100 max-h-96 overflow-scroll'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapse
|
||||
className="w-max max-w-full"
|
||||
expandIconPosition="end"
|
||||
size="small"
|
||||
defaultActiveKey={toolName === AgentToolsType.TodoWrite ? [AgentToolsType.TodoWrite] : []}
|
||||
items={[toolContentItem]}
|
||||
/>
|
||||
<StreamingContext value={isStreaming}>
|
||||
<Collapse
|
||||
className="w-max max-w-full"
|
||||
expandIconPosition="end"
|
||||
size="small"
|
||||
defaultActiveKey={toolName === AgentToolsType.TodoWrite ? [AgentToolsType.TodoWrite] : []}
|
||||
items={[toolContentItem]}
|
||||
/>
|
||||
</StreamingContext>
|
||||
)
|
||||
}
|
||||
|
||||
// 统一的组件渲染入口
|
||||
export function MessageAgentTools({ toolResponse }: { toolResponse: NormalToolResponse }) {
|
||||
const { arguments: args, response, tool, status } = toolResponse
|
||||
logger.debug('Rendering agent tool response', {
|
||||
tool: tool,
|
||||
arguments: args,
|
||||
status,
|
||||
response
|
||||
})
|
||||
const { arguments: args, response, tool, status, partialArguments } = toolResponse
|
||||
|
||||
const pendingPermission = useAppSelector((state) =>
|
||||
selectPendingPermission(state.toolPermissions, toolResponse.toolCallId)
|
||||
)
|
||||
|
||||
if (status === 'pending') {
|
||||
if (pendingPermission) {
|
||||
return <ToolPermissionRequestCard toolResponse={toolResponse} />
|
||||
const parsedPartialArgs = useMemo(() => {
|
||||
if (!partialArguments) return undefined
|
||||
try {
|
||||
return parsePartialJson(partialArguments)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
return <ToolPendingIndicator toolName={tool?.name} description={tool?.description} />
|
||||
}, [partialArguments])
|
||||
|
||||
const effectiveStatus = getEffectiveStatus(status, !!pendingPermission)
|
||||
|
||||
if (effectiveStatus === 'waiting') {
|
||||
return <ToolPermissionRequestCard toolResponse={toolResponse} />
|
||||
}
|
||||
|
||||
const isLoading = effectiveStatus === 'streaming' || effectiveStatus === 'invoking'
|
||||
return (
|
||||
<ToolContent toolName={tool.name as AgentToolsType} input={args as ToolInput} output={response as ToolOutput} />
|
||||
)
|
||||
}
|
||||
|
||||
function ToolPendingIndicator({ toolName, description }: { toolName?: string; description?: string }) {
|
||||
const { t } = useTranslation()
|
||||
const label = toolName || t('agent.toolPermission.toolPendingFallback', 'Tool')
|
||||
const detail = description?.trim() || t('agent.toolPermission.executing')
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-xl items-center gap-3 rounded-xl border border-default-200 bg-default-100 px-4 py-3 shadow-sm">
|
||||
<Spin size="small" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-semibold text-default-700 text-sm">{label}</span>
|
||||
<span className="text-default-500 text-xs">{detail}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ToolContent
|
||||
toolName={tool?.name}
|
||||
input={args ?? parsedPartialArgs}
|
||||
output={isLoading ? undefined : response}
|
||||
isStreaming={isLoading}
|
||||
status={effectiveStatus}
|
||||
hasError={status === 'error'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import type { CollapseProps } from 'antd'
|
||||
|
||||
export enum AgentToolsType {
|
||||
Skill = 'Skill',
|
||||
Read = 'Read',
|
||||
@ -386,3 +388,52 @@ export type ToolOutput =
|
||||
export interface ToolRenderer {
|
||||
render: (props: { input: ToolInput; output?: ToolOutput }) => React.ReactElement
|
||||
}
|
||||
|
||||
// 工具类型到输入类型的映射(用于文档和类型提示)
|
||||
export interface ToolInputMap {
|
||||
[AgentToolsType.Skill]: SkillToolInput
|
||||
[AgentToolsType.Read]: ReadToolInput
|
||||
[AgentToolsType.Task]: TaskToolInput
|
||||
[AgentToolsType.Bash]: BashToolInput
|
||||
[AgentToolsType.Search]: SearchToolInput
|
||||
[AgentToolsType.Glob]: GlobToolInput
|
||||
[AgentToolsType.TodoWrite]: TodoWriteToolInput
|
||||
[AgentToolsType.WebSearch]: WebSearchToolInput
|
||||
[AgentToolsType.Grep]: GrepToolInput
|
||||
[AgentToolsType.Write]: WriteToolInput
|
||||
[AgentToolsType.WebFetch]: WebFetchToolInput
|
||||
[AgentToolsType.Edit]: EditToolInput
|
||||
[AgentToolsType.MultiEdit]: MultiEditToolInput
|
||||
[AgentToolsType.BashOutput]: BashOutputToolInput
|
||||
[AgentToolsType.NotebookEdit]: NotebookEditToolInput
|
||||
[AgentToolsType.ExitPlanMode]: ExitPlanModeToolInput
|
||||
}
|
||||
|
||||
// 工具类型到输出类型的映射
|
||||
export interface ToolOutputMap {
|
||||
[AgentToolsType.Skill]: SkillToolOutput
|
||||
[AgentToolsType.Read]: ReadToolOutput
|
||||
[AgentToolsType.Task]: TaskToolOutput
|
||||
[AgentToolsType.Bash]: BashToolOutput
|
||||
[AgentToolsType.Search]: SearchToolOutput
|
||||
[AgentToolsType.Glob]: GlobToolOutput
|
||||
[AgentToolsType.TodoWrite]: TodoWriteToolOutput
|
||||
[AgentToolsType.WebSearch]: WebSearchToolOutput
|
||||
[AgentToolsType.Grep]: GrepToolOutput
|
||||
[AgentToolsType.Write]: WriteToolOutput
|
||||
[AgentToolsType.WebFetch]: WebFetchToolOutput
|
||||
[AgentToolsType.Edit]: EditToolOutput
|
||||
[AgentToolsType.MultiEdit]: MultiEditToolOutput
|
||||
[AgentToolsType.BashOutput]: BashOutputToolOutput
|
||||
[AgentToolsType.NotebookEdit]: NotebookEditToolOutput
|
||||
[AgentToolsType.ExitPlanMode]: ExitPlanModeToolOutput
|
||||
}
|
||||
|
||||
// 通用工具渲染器函数类型 - 接受宽松的输入类型
|
||||
export type ToolRendererFn = (props: {
|
||||
input?: ToolInput | Record<string, unknown> | string
|
||||
output?: ToolOutput | unknown
|
||||
}) => NonNullable<CollapseProps['items']>[number]
|
||||
|
||||
// 工具渲染器映射类型
|
||||
export type ToolRenderersMap = Record<AgentToolsType, ToolRendererFn>
|
||||
|
||||
@ -1,107 +1,66 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { CopyIcon, LoadingIcon } from '@renderer/components/Icons'
|
||||
import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { CopyIcon } from '@renderer/components/Icons'
|
||||
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||
import { useMCPServers } from '@renderer/hooks/useMCPServers'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useTimer } from '@renderer/hooks/useTimer'
|
||||
import type { MCPToolResponse } from '@renderer/types'
|
||||
import type { ToolMessageBlock } from '@renderer/types/newMessage'
|
||||
import { isToolAutoApproved } from '@renderer/utils/mcp-tools'
|
||||
import { cancelToolAction, confirmToolAction } from '@renderer/utils/userConfirmation'
|
||||
import type { MCPProgressEvent } from '@shared/config/types'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import {
|
||||
Button,
|
||||
Collapse,
|
||||
ConfigProvider,
|
||||
Dropdown,
|
||||
Flex,
|
||||
message as antdMessage,
|
||||
Modal,
|
||||
Progress,
|
||||
Tabs,
|
||||
Tooltip
|
||||
} from 'antd'
|
||||
import { Collapse, ConfigProvider, Flex, message as antdMessage, Progress, Tooltip } from 'antd'
|
||||
import { message } from 'antd'
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CirclePlay,
|
||||
CircleX,
|
||||
Maximize,
|
||||
PauseCircle,
|
||||
ShieldCheck,
|
||||
TriangleAlert,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import { Check, ChevronRight, ShieldCheck } from 'lucide-react'
|
||||
import { parse as parsePartialJson } from 'partial-json'
|
||||
import type { FC } from 'react'
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { memo, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { useMcpToolApproval } from './hooks/useMcpToolApproval'
|
||||
import {
|
||||
getEffectiveStatus,
|
||||
SkeletonSpan,
|
||||
ToolStatusIndicator,
|
||||
TruncatedIndicator
|
||||
} from './MessageAgentTools/GenericTools'
|
||||
import {
|
||||
ArgKey,
|
||||
ArgsSection,
|
||||
ArgsSectionTitle,
|
||||
ArgsTable,
|
||||
ArgValue,
|
||||
formatArgValue,
|
||||
ResponseSection
|
||||
} from './shared/ArgsTable'
|
||||
import { truncateOutput } from './shared/truncateOutput'
|
||||
import ToolApprovalActionsComponent from './ToolApprovalActions'
|
||||
|
||||
interface Props {
|
||||
block: ToolMessageBlock
|
||||
}
|
||||
|
||||
const logger = loggerService.withContext('MessageTools')
|
||||
|
||||
const COUNTDOWN_TIME = 30
|
||||
|
||||
const MessageMcpTool: FC<Props> = ({ block }) => {
|
||||
const [activeKeys, setActiveKeys] = useState<string[]>([])
|
||||
const [copiedMap, setCopiedMap] = useState<Record<string, boolean>>({})
|
||||
const [countdown, setCountdown] = useState<number>(COUNTDOWN_TIME)
|
||||
const { t } = useTranslation()
|
||||
const { messageFont, fontSize } = useSettings()
|
||||
const { mcpServers, updateMCPServer } = useMCPServers()
|
||||
const [expandedResponse, setExpandedResponse] = useState<{ content: string; title: string } | null>(null)
|
||||
const [progress, setProgress] = useState<number>(0)
|
||||
const { setTimeoutTimer } = useTimer()
|
||||
|
||||
// Use the unified approval hook
|
||||
const approval = useMcpToolApproval(block)
|
||||
|
||||
const toolResponse = block.metadata?.rawMcpToolResponse as MCPToolResponse
|
||||
|
||||
const { id, tool, status, response } = toolResponse as MCPToolResponse
|
||||
const { id, tool, status, response, partialArguments } = toolResponse as MCPToolResponse
|
||||
const isPending = status === 'pending'
|
||||
const isDone = status === 'done'
|
||||
const isError = status === 'error'
|
||||
|
||||
const isAutoApproved = useMemo(
|
||||
() =>
|
||||
isToolAutoApproved(
|
||||
tool,
|
||||
mcpServers.find((s) => s.id === tool.serverId)
|
||||
),
|
||||
[tool, mcpServers]
|
||||
)
|
||||
|
||||
// 增加本地状态来跟踪用户确认
|
||||
const [isConfirmed, setIsConfirmed] = useState(isAutoApproved)
|
||||
|
||||
// 判断不同的UI状态
|
||||
const isWaitingConfirmation = isPending && !isAutoApproved && !isConfirmed
|
||||
const isExecuting = isPending && (isAutoApproved || isConfirmed)
|
||||
|
||||
const timer = useRef<NodeJS.Timeout | null>(null)
|
||||
useEffect(() => {
|
||||
if (!isWaitingConfirmation) return
|
||||
|
||||
if (countdown > 0) {
|
||||
timer.current = setTimeout(() => {
|
||||
logger.debug(`countdown: ${countdown}`)
|
||||
setCountdown((prev) => prev - 1)
|
||||
}, 1000)
|
||||
} else if (countdown === 0) {
|
||||
setIsConfirmed(true)
|
||||
confirmToolAction(id)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timer.current) {
|
||||
clearTimeout(timer.current)
|
||||
}
|
||||
}
|
||||
}, [countdown, id, isWaitingConfirmation])
|
||||
const isStreaming = status === 'streaming'
|
||||
|
||||
useEffect(() => {
|
||||
const removeListener = window.electron.ipcRenderer.on(
|
||||
@ -119,33 +78,16 @@ const MessageMcpTool: FC<Props> = ({ block }) => {
|
||||
}
|
||||
}, [id])
|
||||
|
||||
const cancelCountdown = () => {
|
||||
if (timer.current) {
|
||||
clearTimeout(timer.current)
|
||||
// Auto-expand when streaming, auto-collapse when done
|
||||
useEffect(() => {
|
||||
if (isStreaming) {
|
||||
// Expand when streaming starts
|
||||
setActiveKeys((prev) => (prev.includes(id) ? prev : [...prev, id]))
|
||||
} else if (isDone || isError) {
|
||||
// Collapse when streaming ends
|
||||
setActiveKeys((prev) => prev.filter((key) => key !== id))
|
||||
}
|
||||
}
|
||||
|
||||
const argsString = useMemo(() => {
|
||||
if (toolResponse?.arguments) {
|
||||
return JSON.stringify(toolResponse.arguments, null, 2)
|
||||
}
|
||||
return 'No arguments'
|
||||
}, [toolResponse])
|
||||
|
||||
const resultString = useMemo(() => {
|
||||
try {
|
||||
return JSON.stringify(
|
||||
{
|
||||
params: toolResponse?.arguments,
|
||||
response: toolResponse?.response
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
} catch (e) {
|
||||
return 'Invalid Result'
|
||||
}
|
||||
}, [toolResponse])
|
||||
}, [isStreaming, isDone, isError, id])
|
||||
|
||||
if (!toolResponse) {
|
||||
return null
|
||||
@ -162,17 +104,6 @@ const MessageMcpTool: FC<Props> = ({ block }) => {
|
||||
setActiveKeys(Array.isArray(keys) ? keys : [keys])
|
||||
}
|
||||
|
||||
const handleConfirmTool = () => {
|
||||
cancelCountdown()
|
||||
setIsConfirmed(true)
|
||||
confirmToolAction(id)
|
||||
}
|
||||
|
||||
const handleCancelTool = () => {
|
||||
cancelCountdown()
|
||||
cancelToolAction(id)
|
||||
}
|
||||
|
||||
const handleAbortTool = async () => {
|
||||
if (toolResponse?.id) {
|
||||
try {
|
||||
@ -189,75 +120,8 @@ const MessageMcpTool: FC<Props> = ({ block }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleAutoApprove = async () => {
|
||||
cancelCountdown()
|
||||
|
||||
if (!tool || !tool.name) {
|
||||
return
|
||||
}
|
||||
|
||||
const server = mcpServers.find((s) => s.id === tool.serverId)
|
||||
if (!server) {
|
||||
return
|
||||
}
|
||||
|
||||
let disabledAutoApproveTools = [...(server.disabledAutoApproveTools || [])]
|
||||
|
||||
// Remove tool from disabledAutoApproveTools to enable auto-approve
|
||||
disabledAutoApproveTools = disabledAutoApproveTools.filter((name) => name !== tool.name)
|
||||
|
||||
const updatedServer = {
|
||||
...server,
|
||||
disabledAutoApproveTools
|
||||
}
|
||||
|
||||
updateMCPServer(updatedServer)
|
||||
|
||||
// Also confirm the current tool
|
||||
setIsConfirmed(true)
|
||||
confirmToolAction(id)
|
||||
|
||||
window.toast.success(t('message.tools.autoApproveEnabled', 'Auto-approve enabled for this tool'))
|
||||
}
|
||||
|
||||
const renderStatusIndicator = (status: string, hasError: boolean) => {
|
||||
let label = ''
|
||||
let icon: React.ReactNode | null = null
|
||||
|
||||
if (status === 'pending') {
|
||||
if (isWaitingConfirmation) {
|
||||
label = t('message.tools.pending', 'Awaiting Approval')
|
||||
icon = <LoadingIcon style={{ marginLeft: 6, color: 'var(--status-color-warning)' }} />
|
||||
} else if (isExecuting) {
|
||||
label = t('message.tools.invoking')
|
||||
icon = <LoadingIcon style={{ marginLeft: 6 }} />
|
||||
}
|
||||
} else if (status === 'cancelled') {
|
||||
label = t('message.tools.cancelled')
|
||||
icon = <X size={13} style={{ marginLeft: 6 }} className="lucide-custom" />
|
||||
} else if (status === 'done') {
|
||||
if (hasError) {
|
||||
label = t('message.tools.error')
|
||||
icon = <TriangleAlert size={13} style={{ marginLeft: 6 }} className="lucide-custom" />
|
||||
} else {
|
||||
label = t('message.tools.completed')
|
||||
icon = <Check size={13} style={{ marginLeft: 6 }} className="lucide-custom" />
|
||||
}
|
||||
} else if (status === 'error') {
|
||||
label = t('message.tools.error')
|
||||
icon = <TriangleAlert size={13} style={{ marginLeft: 6 }} className="lucide-custom" />
|
||||
}
|
||||
|
||||
return (
|
||||
<StatusIndicator status={status} hasError={hasError}>
|
||||
{label}
|
||||
{icon}
|
||||
</StatusIndicator>
|
||||
)
|
||||
}
|
||||
|
||||
// Format tool responses for collapse items
|
||||
const getCollapseItems = () => {
|
||||
const getCollapseItems = (): { key: string; label: React.ReactNode; children: React.ReactNode }[] => {
|
||||
const items: { key: string; label: React.ReactNode; children: React.ReactNode }[] = []
|
||||
const hasError = response?.isError === true
|
||||
const result = {
|
||||
@ -282,22 +146,8 @@ const MessageMcpTool: FC<Props> = ({ block }) => {
|
||||
{progress > 0 ? (
|
||||
<Progress type="circle" size={14} percent={Number((progress * 100)?.toFixed(0))} />
|
||||
) : (
|
||||
renderStatusIndicator(status, hasError)
|
||||
<ToolStatusIndicator status={getEffectiveStatus(status, approval.isWaiting)} hasError={hasError} />
|
||||
)}
|
||||
<Tooltip title={t('common.expand')} mouseEnterDelay={0.5}>
|
||||
<ActionButton
|
||||
className="message-action-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setExpandedResponse({
|
||||
content: JSON.stringify(response, null, 2),
|
||||
title: tool.name
|
||||
})
|
||||
}}
|
||||
aria-label={t('common.expand')}>
|
||||
<Maximize size={14} />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
{!isPending && (
|
||||
<Tooltip title={t('common.copy')} mouseEnterDelay={0.5}>
|
||||
<ActionButton
|
||||
@ -315,65 +165,25 @@ const MessageMcpTool: FC<Props> = ({ block }) => {
|
||||
</ActionButtonsContainer>
|
||||
</MessageTitleLabel>
|
||||
),
|
||||
children:
|
||||
(isDone || isError) && result ? (
|
||||
<ToolResponseContainer
|
||||
style={{
|
||||
fontFamily: messageFont === 'serif' ? 'var(--font-family-serif)' : 'var(--font-family)',
|
||||
fontSize
|
||||
}}>
|
||||
<CollapsedContent isExpanded={activeKeys.includes(id)} resultString={resultString} />
|
||||
</ToolResponseContainer>
|
||||
) : argsString ? (
|
||||
<>
|
||||
<ToolResponseContainer>
|
||||
<CollapsedContent isExpanded={activeKeys.includes(id)} resultString={argsString} />
|
||||
</ToolResponseContainer>
|
||||
</>
|
||||
) : null
|
||||
children: (
|
||||
<ToolResponseContainer
|
||||
style={{
|
||||
fontFamily: messageFont === 'serif' ? 'var(--font-family-serif)' : 'var(--font-family)',
|
||||
fontSize
|
||||
}}>
|
||||
<ToolResponseContent
|
||||
isExpanded={activeKeys.includes(id)}
|
||||
args={isStreaming ? partialArguments : toolResponse.arguments}
|
||||
isStreaming={!!isStreaming}
|
||||
response={isDone || isError ? toolResponse.response : undefined}
|
||||
/>
|
||||
</ToolResponseContainer>
|
||||
)
|
||||
})
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
const renderPreview = (content: string) => {
|
||||
if (!content) return null
|
||||
|
||||
try {
|
||||
logger.debug(`renderPreview: ${content}`)
|
||||
const parsedResult = JSON.parse(content)
|
||||
switch (parsedResult.content[0]?.type) {
|
||||
case 'text':
|
||||
try {
|
||||
return (
|
||||
<CollapsedContent
|
||||
isExpanded={true}
|
||||
resultString={JSON.stringify(JSON.parse(parsedResult.content[0].text), null, 2)}
|
||||
/>
|
||||
)
|
||||
} catch (e) {
|
||||
return (
|
||||
<CollapsedContent
|
||||
isExpanded={true}
|
||||
resultString={JSON.stringify(parsedResult.content[0].text, null, 2)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
default:
|
||||
return <CollapsedContent isExpanded={true} resultString={JSON.stringify(parsedResult, null, 2)} />
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('failed to render the preview of mcp results:', e as Error)
|
||||
return (
|
||||
<CollapsedContent
|
||||
isExpanded={true}
|
||||
resultString={e instanceof Error ? e.message : JSON.stringify(e, null, 2)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfigProvider
|
||||
@ -401,155 +211,166 @@ const MessageMcpTool: FC<Props> = ({ block }) => {
|
||||
{isPending && (
|
||||
<ActionsBar>
|
||||
<ActionLabel>
|
||||
{isWaitingConfirmation
|
||||
{approval.isWaiting
|
||||
? t('settings.mcp.tools.autoApprove.tooltip.confirm')
|
||||
: t('message.tools.invoking')}
|
||||
</ActionLabel>
|
||||
|
||||
<ActionButtonsGroup>
|
||||
{isWaitingConfirmation && (
|
||||
<Button
|
||||
color="danger"
|
||||
variant="filled"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
handleCancelTool()
|
||||
}}>
|
||||
<CircleX size={15} className="lucide-custom" />
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
{isExecuting && toolResponse?.id ? (
|
||||
<Button
|
||||
size="small"
|
||||
color="danger"
|
||||
variant="solid"
|
||||
className="abort-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleAbortTool()
|
||||
}}>
|
||||
<PauseCircle size={14} className="lucide-custom" />
|
||||
{t('chat.input.pause')}
|
||||
</Button>
|
||||
) : (
|
||||
isWaitingConfirmation && (
|
||||
<StyledDropdownButton
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<ChevronDown size={14} />}
|
||||
onClick={() => {
|
||||
handleConfirmTool()
|
||||
}}
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'autoApprove',
|
||||
label: t('settings.mcp.tools.autoApprove.label'),
|
||||
onClick: () => {
|
||||
handleAutoApprove()
|
||||
}
|
||||
}
|
||||
]
|
||||
}}>
|
||||
<CirclePlay size={15} className="lucide-custom" />
|
||||
<CountdownText>
|
||||
{t('settings.mcp.tools.run', 'Run')} ({countdown}s)
|
||||
</CountdownText>
|
||||
</StyledDropdownButton>
|
||||
)
|
||||
)}
|
||||
</ActionButtonsGroup>
|
||||
<ToolApprovalActionsComponent
|
||||
{...approval}
|
||||
showAbort={approval.isExecuting && !!toolResponse?.id}
|
||||
onAbort={handleAbortTool}
|
||||
/>
|
||||
</ActionsBar>
|
||||
)}
|
||||
</ToolContentWrapper>
|
||||
</ToolContainer>
|
||||
</ConfigProvider>
|
||||
<Modal
|
||||
title={expandedResponse?.title}
|
||||
open={!!expandedResponse}
|
||||
onCancel={() => setExpandedResponse(null)}
|
||||
footer={null}
|
||||
width="80%"
|
||||
centered
|
||||
transitionName="animation-move-down"
|
||||
styles={{ body: { maxHeight: '80vh', overflow: 'auto' } }}>
|
||||
{expandedResponse && (
|
||||
<ExpandedResponseContainer
|
||||
style={{
|
||||
fontFamily: messageFont === 'serif' ? 'var(--font-family-serif)' : 'var(--font-family)',
|
||||
fontSize
|
||||
}}>
|
||||
<Tabs
|
||||
tabBarExtraContent={
|
||||
<ActionButton
|
||||
className="copy-expanded-button"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
typeof expandedResponse.content === 'string'
|
||||
? expandedResponse.content
|
||||
: JSON.stringify(expandedResponse.content, null, 2)
|
||||
)
|
||||
antdMessage.success({ content: t('message.copied'), key: 'copy-expanded' })
|
||||
}}
|
||||
aria-label={t('common.copy')}>
|
||||
<i className="iconfont icon-copy"></i>
|
||||
</ActionButton>
|
||||
}
|
||||
items={[
|
||||
{
|
||||
key: 'preview',
|
||||
label: t('message.tools.preview'),
|
||||
children: renderPreview(expandedResponse.content)
|
||||
},
|
||||
{
|
||||
key: 'raw',
|
||||
label: t('message.tools.raw'),
|
||||
children: (
|
||||
<CollapsedContent
|
||||
isExpanded={true}
|
||||
resultString={
|
||||
typeof expandedResponse.content === 'string'
|
||||
? expandedResponse.content
|
||||
: JSON.stringify(expandedResponse.content, null, 2)
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</ExpandedResponseContainer>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// New component to handle collapsed content
|
||||
const CollapsedContent: FC<{ isExpanded: boolean; resultString: string }> = ({ isExpanded, resultString }) => {
|
||||
const { highlightCode } = useCodeStyle()
|
||||
const [styledResult, setStyledResult] = useState<string>('')
|
||||
/**
|
||||
* Extract preview content from MCP tool response using SDK schema
|
||||
*/
|
||||
const extractPreviewContent = (response: unknown): string => {
|
||||
if (!response) return ''
|
||||
|
||||
useEffect(() => {
|
||||
if (!isExpanded) {
|
||||
return
|
||||
const result = CallToolResultSchema.safeParse(response)
|
||||
if (result.success) {
|
||||
const contents = result.data.content
|
||||
if (contents.length === 0) return ''
|
||||
|
||||
const textParts: string[] = []
|
||||
for (const content of contents) {
|
||||
switch (content.type) {
|
||||
case 'text':
|
||||
if (content.text) {
|
||||
try {
|
||||
const parsed = JSON.parse(content.text)
|
||||
textParts.push(JSON.stringify(parsed, null, 2))
|
||||
} catch {
|
||||
textParts.push(content.text)
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'image':
|
||||
textParts.push(`[Image: ${content.mimeType ?? 'image/png'}]`)
|
||||
break
|
||||
case 'resource':
|
||||
textParts.push(`[Resource: ${content.resource?.uri ?? 'unknown'}]`)
|
||||
break
|
||||
}
|
||||
}
|
||||
return textParts.join('\n\n')
|
||||
}
|
||||
|
||||
// Fallback: return JSON string for unknown format
|
||||
return JSON.stringify(response, null, 2)
|
||||
}
|
||||
|
||||
// Unified tool response content component
|
||||
const ToolResponseContent: FC<{
|
||||
isExpanded: boolean
|
||||
args: string | Record<string, unknown> | Record<string, unknown>[] | undefined
|
||||
isStreaming: boolean
|
||||
response?: unknown
|
||||
}> = ({ isExpanded, args, isStreaming, response }) => {
|
||||
const { highlightCode } = useCodeStyle()
|
||||
const [highlightedResponse, setHighlightedResponse] = useState<string>('')
|
||||
const [isTruncated, setIsTruncated] = useState(false)
|
||||
const [originalLength, setOriginalLength] = useState(0)
|
||||
|
||||
// Parse args if it's a string (streaming partial JSON)
|
||||
const parsedArgs = useMemo(() => {
|
||||
if (!args) return null
|
||||
if (typeof args === 'string') {
|
||||
try {
|
||||
return parsePartialJson(args)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return args
|
||||
}, [args])
|
||||
|
||||
// Extract and highlight response when available
|
||||
useEffect(() => {
|
||||
if (!isExpanded || !response) return
|
||||
|
||||
const highlight = async () => {
|
||||
const result = await highlightCode(resultString, 'json')
|
||||
setStyledResult(result)
|
||||
const previewContent = extractPreviewContent(response)
|
||||
const {
|
||||
data: truncatedContent,
|
||||
isTruncated: wasTruncated,
|
||||
originalLength: origLen
|
||||
} = truncateOutput(previewContent)
|
||||
setIsTruncated(wasTruncated)
|
||||
setOriginalLength(origLen)
|
||||
const result = await highlightCode(truncatedContent, 'json')
|
||||
setHighlightedResponse(result)
|
||||
}
|
||||
|
||||
const timer = setTimeout(highlight, 0)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [isExpanded, resultString, highlightCode])
|
||||
}, [isExpanded, response, highlightCode])
|
||||
|
||||
if (!isExpanded) {
|
||||
return null
|
||||
if (!isExpanded) return null
|
||||
|
||||
// Handle both object and array args - for arrays, show as single entry
|
||||
const getEntries = (): Array<[string, unknown]> => {
|
||||
if (!parsedArgs || typeof parsedArgs !== 'object') return []
|
||||
if (Array.isArray(parsedArgs)) {
|
||||
return [['arguments', parsedArgs]]
|
||||
}
|
||||
return Object.entries(parsedArgs)
|
||||
}
|
||||
const entries = getEntries()
|
||||
|
||||
const renderArgsTable = (): React.ReactNode => {
|
||||
if (entries.length === 0) return null
|
||||
return (
|
||||
<ArgsSection>
|
||||
<ArgsSectionTitle>Arguments</ArgsSectionTitle>
|
||||
<ArgsTable>
|
||||
<tbody>
|
||||
{entries.map(([key, value]) => (
|
||||
<tr key={key}>
|
||||
<ArgKey>{key}</ArgKey>
|
||||
<ArgValue>{formatArgValue(value)}</ArgValue>
|
||||
</tr>
|
||||
))}
|
||||
{isStreaming && (
|
||||
<tr>
|
||||
<ArgKey>
|
||||
<SkeletonSpan width="60px" />
|
||||
</ArgKey>
|
||||
<ArgValue>
|
||||
<SkeletonSpan width="120px" />
|
||||
</ArgValue>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</ArgsTable>
|
||||
</ArgsSection>
|
||||
)
|
||||
}
|
||||
|
||||
return <MarkdownContainer className="markdown" dangerouslySetInnerHTML={{ __html: styledResult }} />
|
||||
return (
|
||||
<div>
|
||||
{/* Arguments Table */}
|
||||
{renderArgsTable()}
|
||||
|
||||
{/* Response */}
|
||||
{response !== undefined && response !== null && highlightedResponse && (
|
||||
<ResponseSection>
|
||||
<ArgsSectionTitle>Response</ArgsSectionTitle>
|
||||
<MarkdownContainer className="markdown" dangerouslySetInnerHTML={{ __html: highlightedResponse }} />
|
||||
{isTruncated && <TruncatedIndicator originalLength={originalLength} />}
|
||||
</ResponseSection>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ToolContentWrapper = styled.div`
|
||||
@ -586,22 +407,6 @@ const ActionLabel = styled.div`
|
||||
white-space: nowrap;
|
||||
`
|
||||
|
||||
const ActionButtonsGroup = styled.div`
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
`
|
||||
|
||||
const CountdownText = styled.span`
|
||||
width: 65px;
|
||||
text-align: left;
|
||||
`
|
||||
|
||||
const StyledDropdownButton = styled(Dropdown.Button)`
|
||||
.ant-btn-group {
|
||||
border-radius: 6px;
|
||||
}
|
||||
`
|
||||
|
||||
const ExpandIcon = styled(ChevronRight)<{ $isActive?: boolean }>`
|
||||
transition: transform 0.2s;
|
||||
transform: ${({ $isActive }) => ($isActive ? 'rotate(90deg)' : 'rotate(0deg)')};
|
||||
@ -670,31 +475,6 @@ const ToolName = styled(Flex)`
|
||||
font-size: 13px;
|
||||
`
|
||||
|
||||
const StatusIndicator = styled.span<{ status: string; hasError?: boolean }>`
|
||||
color: ${(props) => {
|
||||
switch (props.status) {
|
||||
case 'pending':
|
||||
return 'var(--status-color-warning)'
|
||||
case 'invoking':
|
||||
return 'var(--status-color-invoking)'
|
||||
case 'cancelled':
|
||||
return 'var(--status-color-error)'
|
||||
case 'done':
|
||||
return props.hasError ? 'var(--status-color-error)' : 'var(--status-color-success)'
|
||||
case 'error':
|
||||
return 'var(--status-color-error)'
|
||||
default:
|
||||
return 'var(--color-text)'
|
||||
}
|
||||
}};
|
||||
font-size: 11px;
|
||||
font-weight: ${(props) => (props.status === 'pending' ? '600' : '400')};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
opacity: ${(props) => (props.status === 'pending' ? '1' : '0.85')};
|
||||
padding-left: 12px;
|
||||
`
|
||||
|
||||
const ActionButtonsContainer = styled.div`
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
@ -752,27 +532,4 @@ const ToolResponseContainer = styled.div`
|
||||
position: relative;
|
||||
`
|
||||
|
||||
const ExpandedResponseContainer = styled.div`
|
||||
background: var(--color-bg-1);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
position: relative;
|
||||
|
||||
.copy-expanded-button {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background-color: var(--color-bg-2);
|
||||
border-radius: 4px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--color-text);
|
||||
}
|
||||
`
|
||||
|
||||
export default memo(MessageMcpTool)
|
||||
|
||||
@ -0,0 +1,165 @@
|
||||
import { LoadingIcon } from '@renderer/components/Icons'
|
||||
import { Button, Dropdown } from 'antd'
|
||||
import { ChevronDown, CirclePlay, CircleX, ShieldCheck } from 'lucide-react'
|
||||
import type { FC, MouseEvent } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import type { ToolApprovalActions, ToolApprovalState } from './hooks/useToolApproval'
|
||||
|
||||
export interface ToolApprovalActionsProps extends ToolApprovalState, ToolApprovalActions {
|
||||
/** Compact mode for use in headers */
|
||||
compact?: boolean
|
||||
/** Show abort button when executing */
|
||||
showAbort?: boolean
|
||||
/** Abort handler */
|
||||
onAbort?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified tool approval action buttons
|
||||
* Used in both MessageMcpTool and ToolPermissionRequestCard
|
||||
*/
|
||||
export const ToolApprovalActionsComponent: FC<ToolApprovalActionsProps> = ({
|
||||
isWaiting,
|
||||
isExecuting,
|
||||
remainingSeconds,
|
||||
isExpired,
|
||||
isSubmitting,
|
||||
confirm,
|
||||
cancel,
|
||||
autoApprove,
|
||||
compact = false,
|
||||
showAbort = false,
|
||||
onAbort
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
// Stop event propagation to prevent collapse toggle
|
||||
const handleClick = (e: MouseEvent, handler: () => void) => {
|
||||
e.stopPropagation()
|
||||
handler()
|
||||
}
|
||||
|
||||
// Nothing to show if not waiting and not executing
|
||||
if (!isWaiting && !isExecuting) return null
|
||||
|
||||
// Expired state for agent tools
|
||||
if (isExpired && !isExecuting) {
|
||||
return (
|
||||
<ExpiredBadge $compact={compact} onClick={(e) => e.stopPropagation()}>
|
||||
{t('agent.toolPermission.expired')}
|
||||
</ExpiredBadge>
|
||||
)
|
||||
}
|
||||
|
||||
// Executing state - show loading or abort button
|
||||
if (isExecuting) {
|
||||
if (showAbort && onAbort) {
|
||||
return (
|
||||
<ActionsContainer $compact={compact} onClick={(e) => e.stopPropagation()}>
|
||||
<Button size="small" color="danger" variant="solid" onClick={(e) => handleClick(e, onAbort)}>
|
||||
{t('chat.input.pause')}
|
||||
</Button>
|
||||
</ActionsContainer>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<ActionsContainer $compact={compact} onClick={(e) => e.stopPropagation()}>
|
||||
<LoadingIndicator>
|
||||
<LoadingIcon />
|
||||
{!compact && <span>{t('message.tools.invoking')}</span>}
|
||||
</LoadingIndicator>
|
||||
</ActionsContainer>
|
||||
)
|
||||
}
|
||||
|
||||
// Waiting state - show confirm/cancel buttons
|
||||
return (
|
||||
<ActionsContainer $compact={compact} onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
size="small"
|
||||
color="danger"
|
||||
variant={compact ? 'text' : 'outlined'}
|
||||
disabled={isSubmitting}
|
||||
onClick={(e) => handleClick(e, cancel)}>
|
||||
<CircleX size={compact ? 13 : 14} className="lucide-custom" />
|
||||
{!compact && t('common.cancel')}
|
||||
</Button>
|
||||
|
||||
{autoApprove ? (
|
||||
<StyledDropdownButton
|
||||
size="small"
|
||||
type="primary"
|
||||
disabled={isSubmitting}
|
||||
icon={<ChevronDown size={compact ? 12 : 14} />}
|
||||
onClick={(e) => handleClick(e, confirm)}
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'autoApprove',
|
||||
label: t('settings.mcp.tools.autoApprove.label'),
|
||||
icon: <ShieldCheck size={14} />,
|
||||
onClick: () => autoApprove()
|
||||
}
|
||||
]
|
||||
}}>
|
||||
<CirclePlay size={compact ? 13 : 15} className="lucide-custom" />
|
||||
<CountdownText $compact={compact}>
|
||||
{compact ? `${remainingSeconds}s` : `${t('settings.mcp.tools.run', 'Run')} (${remainingSeconds}s)`}
|
||||
</CountdownText>
|
||||
</StyledDropdownButton>
|
||||
) : (
|
||||
<Button size="small" type="primary" disabled={isSubmitting} onClick={(e) => handleClick(e, confirm)}>
|
||||
<CirclePlay size={compact ? 13 : 15} className="lucide-custom" />
|
||||
<CountdownText $compact={compact}>
|
||||
{compact ? `${remainingSeconds}s` : `${t('settings.mcp.tools.run', 'Run')} (${remainingSeconds}s)`}
|
||||
</CountdownText>
|
||||
</Button>
|
||||
)}
|
||||
</ActionsContainer>
|
||||
)
|
||||
}
|
||||
|
||||
// Styled components
|
||||
|
||||
const ActionsContainer = styled.div<{ $compact: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${(props) => (props.$compact ? '4px' : '8px')};
|
||||
|
||||
.ant-btn-sm {
|
||||
height: ${(props) => (props.$compact ? '24px' : '28px')};
|
||||
padding: ${(props) => (props.$compact ? '0 6px' : '0 8px')};
|
||||
font-size: ${(props) => (props.$compact ? '12px' : '13px')};
|
||||
}
|
||||
`
|
||||
|
||||
const ExpiredBadge = styled.span<{ $compact: boolean }>`
|
||||
font-size: ${(props) => (props.$compact ? '11px' : '12px')};
|
||||
color: var(--color-status-error, #ff4d4f);
|
||||
padding: ${(props) => (props.$compact ? '2px 6px' : '4px 8px')};
|
||||
background: var(--color-status-error-bg, rgba(255, 77, 79, 0.1));
|
||||
border-radius: 4px;
|
||||
`
|
||||
|
||||
const LoadingIndicator = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--color-primary);
|
||||
font-size: 12px;
|
||||
`
|
||||
|
||||
const CountdownText = styled.span<{ $compact: boolean }>`
|
||||
min-width: ${(props) => (props.$compact ? '24px' : '65px')};
|
||||
text-align: left;
|
||||
`
|
||||
|
||||
const StyledDropdownButton = styled(Dropdown.Button)`
|
||||
.ant-btn-group {
|
||||
border-radius: 6px;
|
||||
}
|
||||
`
|
||||
|
||||
export default ToolApprovalActionsComponent
|
||||
272
src/renderer/src/pages/home/Messages/Tools/ToolHeader.tsx
Normal file
272
src/renderer/src/pages/home/Messages/Tools/ToolHeader.tsx
Normal file
@ -0,0 +1,272 @@
|
||||
import type { MCPTool, MCPToolResponse, NormalToolResponse } from '@renderer/types'
|
||||
import type { ToolMessageBlock } from '@renderer/types/newMessage'
|
||||
import { isToolAutoApproved } from '@renderer/utils/mcp-tools'
|
||||
import { Flex, Tooltip } from 'antd'
|
||||
import {
|
||||
Bot,
|
||||
DoorOpen,
|
||||
FileEdit,
|
||||
FileSearch,
|
||||
FileText,
|
||||
FolderSearch,
|
||||
Globe,
|
||||
ListTodo,
|
||||
NotebookPen,
|
||||
PencilRuler,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
Terminal,
|
||||
Wrench
|
||||
} from 'lucide-react'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { type ToolStatus, ToolStatusIndicator } from './MessageAgentTools/GenericTools'
|
||||
import { AgentToolsType } from './MessageAgentTools/types'
|
||||
|
||||
export interface ToolHeaderProps {
|
||||
block?: ToolMessageBlock
|
||||
|
||||
toolName?: string
|
||||
icon?: ReactNode
|
||||
params?: ReactNode
|
||||
stats?: ReactNode
|
||||
|
||||
// Common config
|
||||
status?: ToolStatus
|
||||
hasError?: boolean
|
||||
showStatus?: boolean // default true
|
||||
|
||||
// Style variant
|
||||
variant?: 'standalone' | 'collapse-label'
|
||||
}
|
||||
|
||||
const getAgentToolIcon = (toolName: string): ReactNode => {
|
||||
switch (toolName) {
|
||||
case AgentToolsType.Read:
|
||||
return <FileText size={14} />
|
||||
case AgentToolsType.Task:
|
||||
return <Bot size={14} />
|
||||
case AgentToolsType.Bash:
|
||||
case AgentToolsType.BashOutput:
|
||||
return <Terminal size={14} />
|
||||
case AgentToolsType.Search:
|
||||
return <Search size={14} />
|
||||
case AgentToolsType.Glob:
|
||||
return <FolderSearch size={14} />
|
||||
case AgentToolsType.Grep:
|
||||
return <FileSearch size={14} />
|
||||
case AgentToolsType.Write:
|
||||
return <FileText size={14} />
|
||||
case AgentToolsType.Edit:
|
||||
return <FileEdit size={14} />
|
||||
case AgentToolsType.MultiEdit:
|
||||
return <FileText size={14} />
|
||||
case AgentToolsType.WebSearch:
|
||||
case AgentToolsType.WebFetch:
|
||||
return <Globe size={14} />
|
||||
case AgentToolsType.NotebookEdit:
|
||||
return <NotebookPen size={14} />
|
||||
case AgentToolsType.TodoWrite:
|
||||
return <ListTodo size={14} />
|
||||
case AgentToolsType.ExitPlanMode:
|
||||
return <DoorOpen size={14} />
|
||||
case AgentToolsType.Skill:
|
||||
return <PencilRuler size={14} />
|
||||
default:
|
||||
return <Wrench size={14} />
|
||||
}
|
||||
}
|
||||
|
||||
const getAgentToolLabel = (toolName: string, t: (key: string) => string): string => {
|
||||
switch (toolName) {
|
||||
case AgentToolsType.Read:
|
||||
return t('message.tools.labels.readFile')
|
||||
case AgentToolsType.Task:
|
||||
return t('message.tools.labels.task')
|
||||
case AgentToolsType.Bash:
|
||||
return t('message.tools.labels.bash')
|
||||
case AgentToolsType.BashOutput:
|
||||
return t('message.tools.labels.bashOutput')
|
||||
case AgentToolsType.Search:
|
||||
return t('message.tools.labels.search')
|
||||
case AgentToolsType.Glob:
|
||||
return t('message.tools.labels.glob')
|
||||
case AgentToolsType.Grep:
|
||||
return t('message.tools.labels.grep')
|
||||
case AgentToolsType.Write:
|
||||
return t('message.tools.labels.write')
|
||||
case AgentToolsType.Edit:
|
||||
return t('message.tools.labels.edit')
|
||||
case AgentToolsType.MultiEdit:
|
||||
return t('message.tools.labels.multiEdit')
|
||||
case AgentToolsType.WebSearch:
|
||||
return t('message.tools.labels.webSearch')
|
||||
case AgentToolsType.WebFetch:
|
||||
return t('message.tools.labels.webFetch')
|
||||
case AgentToolsType.NotebookEdit:
|
||||
return t('message.tools.labels.notebookEdit')
|
||||
case AgentToolsType.TodoWrite:
|
||||
return t('message.tools.labels.todoWrite')
|
||||
case AgentToolsType.ExitPlanMode:
|
||||
return t('message.tools.labels.exitPlanMode')
|
||||
case AgentToolsType.Skill:
|
||||
return t('message.tools.labels.skill')
|
||||
default:
|
||||
return toolName
|
||||
}
|
||||
}
|
||||
|
||||
const getToolDescription = (toolResponse?: MCPToolResponse | NormalToolResponse): string | undefined => {
|
||||
if (!toolResponse) return undefined
|
||||
const args = toolResponse.arguments
|
||||
if (!args || typeof args !== 'object' || Array.isArray(args)) return undefined
|
||||
|
||||
// Common description fields
|
||||
return (
|
||||
(args as Record<string, unknown>).description ||
|
||||
(args as Record<string, unknown>).file_path ||
|
||||
(args as Record<string, unknown>).pattern ||
|
||||
(args as Record<string, unknown>).query ||
|
||||
(args as Record<string, unknown>).command ||
|
||||
(args as Record<string, unknown>).url
|
||||
)?.toString()
|
||||
}
|
||||
|
||||
// ============ Styled Components ============
|
||||
|
||||
const HeaderContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
padding: 8px 12px;
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.75rem;
|
||||
min-width: 0;
|
||||
`
|
||||
|
||||
// Label variant: no border/padding, for use inside Collapse header
|
||||
const LabelContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 14px;
|
||||
min-width: 0;
|
||||
`
|
||||
|
||||
const ToolName = styled(Flex)`
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
flex-shrink: 0;
|
||||
|
||||
.tool-icon {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.name {
|
||||
white-space: nowrap;
|
||||
}
|
||||
`
|
||||
|
||||
const Description = styled.span`
|
||||
color: var(--color-text-2);
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
max-width: 300px;
|
||||
`
|
||||
|
||||
const Stats = styled.span`
|
||||
color: var(--color-text-2);
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
`
|
||||
|
||||
const StatusWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
`
|
||||
|
||||
// ============ Main Component ============
|
||||
|
||||
const ToolHeader: FC<ToolHeaderProps> = ({
|
||||
block,
|
||||
toolName: propToolName,
|
||||
icon: propIcon,
|
||||
params,
|
||||
stats,
|
||||
status: propStatus,
|
||||
hasError: propHasError,
|
||||
showStatus = true,
|
||||
variant = 'standalone'
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const toolResponse = block?.metadata?.rawMcpToolResponse
|
||||
const tool = toolResponse?.tool
|
||||
|
||||
const toolName = propToolName || tool?.name || 'Tool'
|
||||
|
||||
const status = propStatus || (toolResponse?.status as ToolStatus)
|
||||
const hasError = propHasError ?? toolResponse?.response?.isError === true
|
||||
|
||||
const description = params ?? getToolDescription(toolResponse)
|
||||
|
||||
const Container = variant === 'standalone' ? HeaderContainer : LabelContainer
|
||||
|
||||
if (block && tool?.type === 'mcp') {
|
||||
const mcpTool = tool as MCPTool
|
||||
return (
|
||||
<Container>
|
||||
<ToolName align="center" gap={6}>
|
||||
<Wrench size={14} className="tool-icon" />
|
||||
<span className="name">
|
||||
{mcpTool.serverName} : {mcpTool.name}
|
||||
</span>
|
||||
{isToolAutoApproved(mcpTool) && (
|
||||
<Tooltip title={t('message.tools.autoApproveEnabled')} mouseLeaveDelay={0}>
|
||||
<ShieldCheck size={14} color="var(--color-primary)" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</ToolName>
|
||||
{description && <Description>{description}</Description>}
|
||||
{stats && <Stats>{stats}</Stats>}
|
||||
{showStatus && status && (
|
||||
<StatusWrapper>
|
||||
<ToolStatusIndicator status={status} hasError={hasError} />
|
||||
</StatusWrapper>
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<ToolName align="center" gap={6}>
|
||||
<span className="tool-icon">{propIcon || getAgentToolIcon(toolName)}</span>
|
||||
<span className="name">{getAgentToolLabel(toolName, t)}</span>
|
||||
</ToolName>
|
||||
{description && <Description>{description}</Description>}
|
||||
{stats && <Stats>{stats}</Stats>}
|
||||
{showStatus && status && (
|
||||
<StatusWrapper>
|
||||
<ToolStatusIndicator status={status} hasError={hasError} />
|
||||
</StatusWrapper>
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ToolHeader)
|
||||
@ -1,14 +1,16 @@
|
||||
import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'
|
||||
import { loggerService } from '@logger'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { selectPendingPermission, toolPermissionsActions } from '@renderer/store/toolPermissions'
|
||||
import { LoadingIcon } from '@renderer/components/Icons'
|
||||
import type { NormalToolResponse } from '@renderer/types'
|
||||
import { Button, Spin } from 'antd'
|
||||
import { ChevronDown, CirclePlay, CircleX } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import type { CollapseProps } from 'antd'
|
||||
import { Collapse } from 'antd'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const logger = loggerService.withContext('ToolPermissionRequestCard')
|
||||
import { useAgentToolApproval } from './hooks/useAgentToolApproval'
|
||||
import { type StatusColor, StatusIndicatorContainer, StreamingContext } from './MessageAgentTools/GenericTools'
|
||||
import { isValidAgentToolsType, renderTool } from './MessageAgentTools/index'
|
||||
import { UnknownToolRenderer } from './MessageAgentTools/UnknownToolRenderer'
|
||||
import ToolApprovalActionsComponent from './ToolApprovalActions'
|
||||
|
||||
interface Props {
|
||||
toolResponse: NormalToolResponse
|
||||
@ -16,250 +18,115 @@ interface Props {
|
||||
|
||||
export function ToolPermissionRequestCard({ toolResponse }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const dispatch = useAppDispatch()
|
||||
const request = useAppSelector((state) => selectPendingPermission(state.toolPermissions, toolResponse.toolCallId))
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
const [showDetails, setShowDetails] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!request) return
|
||||
const approval = useAgentToolApproval(null, { toolCallId: toolResponse.toolCallId })
|
||||
|
||||
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)
|
||||
const statusInfo = useMemo((): { color: StatusColor; text: string; showLoading: boolean } => {
|
||||
if (approval.isExecuting) {
|
||||
return { color: 'primary', text: t('message.tools.invoking'), showLoading: true }
|
||||
}
|
||||
}, [request])
|
||||
if (approval.isExpired) {
|
||||
return { color: 'error', text: t('agent.toolPermission.expired'), showLoading: false }
|
||||
}
|
||||
return {
|
||||
color: 'warning',
|
||||
text: t('agent.toolPermission.pending', { seconds: approval.remainingSeconds }),
|
||||
showLoading: true
|
||||
}
|
||||
}, [approval.isExecuting, approval.isExpired, approval.remainingSeconds, t])
|
||||
|
||||
const remainingMs = useMemo(() => {
|
||||
if (!request) return 0
|
||||
return Math.max(0, request.expiresAt - now)
|
||||
}, [request, now])
|
||||
const renderToolContent = useCallback((): React.ReactNode => {
|
||||
const toolName = toolResponse.tool?.name ?? ''
|
||||
const input = (approval.input ?? toolResponse.arguments) as Record<string, unknown> | undefined
|
||||
|
||||
const remainingSeconds = useMemo(() => Math.ceil(remainingMs / 1000), [remainingMs])
|
||||
const isExpired = remainingMs <= 0
|
||||
const renderedItem = isValidAgentToolsType(toolName)
|
||||
? renderTool(toolName, input)
|
||||
: UnknownToolRenderer({ input, toolName })
|
||||
|
||||
const isSubmittingAllow = request?.status === 'submitting-allow'
|
||||
const isSubmittingDeny = request?.status === 'submitting-deny'
|
||||
const isSubmitting = isSubmittingAllow || isSubmittingDeny
|
||||
const isInvoking = request?.status === 'invoking'
|
||||
|
||||
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>
|
||||
const statusIndicator = (
|
||||
<StatusIndicatorContainer $color={statusInfo.color}>
|
||||
{statusInfo.text}
|
||||
{statusInfo.showLoading && <LoadingIcon />}
|
||||
</StatusIndicatorContainer>
|
||||
)
|
||||
}
|
||||
|
||||
if (isInvoking) {
|
||||
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 items-center gap-3">
|
||||
<Spin size="small" />
|
||||
<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">{t('agent.toolPermission.executing')}</div>
|
||||
</div>
|
||||
</div>
|
||||
{request.inputPreview && (
|
||||
<div className="flex items-center justify-end">
|
||||
<Button
|
||||
aria-label={
|
||||
showDetails
|
||||
? t('agent.toolPermission.aria.hideDetails')
|
||||
: t('agent.toolPermission.aria.showDetails')
|
||||
}
|
||||
className="h-8 text-default-600 transition-colors hover:bg-default-200/50 hover:text-default-800"
|
||||
onClick={() => setShowDetails((value) => !value)}
|
||||
icon={<ChevronDown className={`transition-transform ${showDetails ? 'rotate-180' : ''}`} size={16} />}
|
||||
variant="text"
|
||||
style={{ backgroundColor: 'transparent' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showDetails && request.inputPreview && (
|
||||
<div className="flex flex-col gap-3 border-default-200 border-t pt-3">
|
||||
<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>
|
||||
<div className="max-h-[192px] overflow-auto font-mono text-xs">
|
||||
<pre className="whitespace-pre-wrap break-all p-2 text-left">{request.inputPreview}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
const toolContentItem: NonNullable<CollapseProps['items']>[number] = {
|
||||
...renderedItem,
|
||||
label: (
|
||||
<div className="flex w-full items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">{renderedItem.label}</div>
|
||||
<div className="shrink-0 pt-px">{statusIndicator}</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
classNames: {
|
||||
body: 'bg-foreground-50 p-2 text-foreground-900 dark:bg-foreground-100 max-h-60 overflow-auto'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<StreamingContext value={false}>
|
||||
<Collapse
|
||||
className="w-full"
|
||||
expandIconPosition="end"
|
||||
size="small"
|
||||
defaultActiveKey={[String(renderedItem.key ?? toolName)]}
|
||||
items={[toolContentItem]}
|
||||
/>
|
||||
</StreamingContext>
|
||||
)
|
||||
}
|
||||
}, [toolResponse.tool?.name, approval.input, toolResponse.arguments, statusInfo])
|
||||
|
||||
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>
|
||||
<Container>
|
||||
{/* Tool content area with status in header */}
|
||||
{renderToolContent()}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<div
|
||||
className={`rounded px-2 py-0.5 font-medium text-xs ${
|
||||
isExpired ? 'text-[var(--color-error)]' : 'text-[var(--color-status-warning)]'
|
||||
}`}>
|
||||
{isExpired
|
||||
? t('agent.toolPermission.expired')
|
||||
: t('agent.toolPermission.pending', { seconds: remainingSeconds })}
|
||||
</div>
|
||||
{/* Bottom action bar - only show when not invoking */}
|
||||
{!approval.isExecuting && (
|
||||
<ActionsBar>
|
||||
<ToolApprovalActionsComponent {...approval} />
|
||||
</ActionsBar>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
aria-label={t('agent.toolPermission.aria.denyRequest')}
|
||||
className="h-8"
|
||||
color="danger"
|
||||
disabled={isSubmitting || isExpired}
|
||||
loading={isSubmittingDeny}
|
||||
onClick={() => handleDecision('deny')}
|
||||
icon={<CircleX size={16} />}
|
||||
iconPosition={'start'}
|
||||
variant="outlined">
|
||||
{t('agent.toolPermission.button.cancel')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
aria-label={t('agent.toolPermission.aria.allowRequest')}
|
||||
className="h-8 px-3"
|
||||
color="primary"
|
||||
disabled={isSubmitting || isExpired}
|
||||
loading={isSubmittingAllow}
|
||||
onClick={() => handleDecision('allow')}
|
||||
icon={<CirclePlay size={16} />}
|
||||
iconPosition={'start'}
|
||||
variant="solid">
|
||||
{t('agent.toolPermission.button.run')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
aria-label={
|
||||
showDetails ? t('agent.toolPermission.aria.hideDetails') : t('agent.toolPermission.aria.showDetails')
|
||||
}
|
||||
className="h-8 text-default-600 transition-colors hover:bg-default-200/50 hover:text-default-800"
|
||||
onClick={() => setShowDetails((value) => !value)}
|
||||
icon={<ChevronDown className={`transition-transform ${showDetails ? 'rotate-180' : ''}`} size={16} />}
|
||||
variant="text"
|
||||
style={{ backgroundColor: 'transparent' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{approval.isExpired && !approval.isSubmitting && !approval.isExecuting && (
|
||||
<div className="px-3 pb-2 text-center text-danger-500 text-xs">
|
||||
{t('agent.toolPermission.permissionExpired')}
|
||||
</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>
|
||||
<div className="max-h-[192px] overflow-auto font-mono text-xs">
|
||||
<pre className="whitespace-pre-wrap break-all p-2 text-left">{request.inputPreview}</pre>
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
width: 100%;
|
||||
max-width: 36rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
background-color: var(--color-background-soft);
|
||||
overflow: hidden;
|
||||
|
||||
.ant-collapse {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ant-collapse-item {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.ant-collapse-header {
|
||||
padding: 8px 12px !important;
|
||||
}
|
||||
`
|
||||
|
||||
const ActionsBar = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
background-color: var(--color-background);
|
||||
`
|
||||
|
||||
export default ToolPermissionRequestCard
|
||||
|
||||
@ -0,0 +1,376 @@
|
||||
import type { NormalToolResponse } from '@renderer/types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { parse as parsePartialJson } from 'partial-json'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { isValidAgentToolsType, MessageAgentTools } from '../MessageAgentTools'
|
||||
|
||||
vi.mock('@renderer/services/AssistantService', () => ({
|
||||
getDefaultAssistant: vi.fn(() => ({
|
||||
id: 'test-assistant',
|
||||
name: 'Test Assistant',
|
||||
settings: {}
|
||||
})),
|
||||
getDefaultTopic: vi.fn(() => ({
|
||||
id: 'test-topic',
|
||||
assistantId: 'test-assistant',
|
||||
createdAt: new Date().toISOString()
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock dependencies
|
||||
const mockUseAppSelector = vi.fn()
|
||||
const mockUseTranslation = vi.fn()
|
||||
|
||||
vi.mock('@renderer/store', () => ({
|
||||
useAppSelector: (selector: any) => mockUseAppSelector(selector)
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/store/toolPermissions', () => ({
|
||||
selectPendingPermission: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => mockUseTranslation(),
|
||||
initReactI18next: {
|
||||
type: '3rdParty',
|
||||
init: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@logger', () => ({
|
||||
loggerService: {
|
||||
withContext: () => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn()
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock antd components
|
||||
vi.mock('antd', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>
|
||||
return {
|
||||
...actual,
|
||||
Collapse: ({ items, defaultActiveKey, className }: any) => (
|
||||
<div data-testid="collapse" className={className} data-active-key={JSON.stringify(defaultActiveKey)}>
|
||||
{items?.map((item: any) => (
|
||||
<div key={item.key} data-testid={`collapse-item-${item.key}`}>
|
||||
<div data-testid={`collapse-header-${item.key}`}>{item.label}</div>
|
||||
<div data-testid={`collapse-content-${item.key}`}>{item.children}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
Spin: ({ size }: any) => <div data-testid="spin" data-size={size} />,
|
||||
Skeleton: {
|
||||
Input: ({ style }: any) => <span data-testid="skeleton-input" style={style} />
|
||||
},
|
||||
Tag: ({ children, className }: any) => (
|
||||
<span data-testid="tag" className={className}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
Popover: ({ children }: any) => <>{children}</>,
|
||||
Card: ({ children, className }: any) => (
|
||||
<div data-testid="card" className={className}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Button: ({ children, onClick, type, size, icon, disabled }: any) => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="button"
|
||||
onClick={onClick}
|
||||
data-type={type}
|
||||
data-size={size}
|
||||
disabled={disabled}>
|
||||
{icon}
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// Mock lucide-react icons
|
||||
vi.mock('lucide-react', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>
|
||||
return {
|
||||
...actual,
|
||||
Loader2: ({ className }: any) => <span data-testid="loader-icon" className={className} />,
|
||||
FileText: () => <span data-testid="file-icon" />,
|
||||
Terminal: () => <span data-testid="terminal-icon" />,
|
||||
ListTodo: () => <span data-testid="list-icon" />,
|
||||
Circle: () => <span data-testid="circle-icon" />,
|
||||
CheckCircle: () => <span data-testid="check-circle-icon" />,
|
||||
Clock: () => <span data-testid="clock-icon" />,
|
||||
Check: () => <span data-testid="check-icon" />,
|
||||
TriangleAlert: () => <span data-testid="triangle-alert-icon" />,
|
||||
X: () => <span data-testid="x-icon" />,
|
||||
Wrench: () => <span data-testid="wrench-icon" />,
|
||||
ImageIcon: () => <span data-testid="image-icon" />
|
||||
}
|
||||
})
|
||||
|
||||
// Mock LoadingIcon
|
||||
vi.mock('@renderer/components/Icons', () => ({
|
||||
LoadingIcon: () => <span data-testid="loading-icon" />
|
||||
}))
|
||||
|
||||
// Mock ToolPermissionRequestCard
|
||||
vi.mock('../ToolPermissionRequestCard', () => ({
|
||||
default: () => <div data-testid="permission-card">Permission Required</div>
|
||||
}))
|
||||
|
||||
describe('MessageAgentTools', () => {
|
||||
// Mock translations for tools
|
||||
const mockTranslations: Record<string, string> = {
|
||||
'message.tools.labels.bash': 'Bash',
|
||||
'message.tools.labels.readFile': 'Read File',
|
||||
'message.tools.labels.todoWrite': 'Todo Write',
|
||||
'message.tools.labels.edit': 'Edit',
|
||||
'message.tools.labels.write': 'Write',
|
||||
'message.tools.labels.grep': 'Grep',
|
||||
'message.tools.labels.glob': 'Glob',
|
||||
'message.tools.labels.webSearch': 'Web Search',
|
||||
'message.tools.labels.webFetch': 'Web Fetch',
|
||||
'message.tools.labels.skill': 'Skill',
|
||||
'message.tools.labels.task': 'Task',
|
||||
'message.tools.labels.search': 'Search',
|
||||
'message.tools.labels.exitPlanMode': 'ExitPlanMode',
|
||||
'message.tools.labels.multiEdit': 'MultiEdit',
|
||||
'message.tools.labels.notebookEdit': 'NotebookEdit',
|
||||
'message.tools.labels.mcpServerTool': 'MCP Server Tool',
|
||||
'message.tools.labels.tool': 'Tool',
|
||||
'message.tools.sections.command': 'Command',
|
||||
'message.tools.sections.output': 'Output',
|
||||
'message.tools.sections.prompt': 'Prompt',
|
||||
'message.tools.sections.input': 'Input',
|
||||
'message.tools.status.done': 'Done',
|
||||
'message.tools.units.item': 'item',
|
||||
'message.tools.units.items': 'items',
|
||||
'message.tools.units.line': 'line',
|
||||
'message.tools.units.lines': 'lines',
|
||||
'message.tools.units.file': 'file',
|
||||
'message.tools.units.files': 'files',
|
||||
'message.tools.units.result': 'result',
|
||||
'message.tools.units.results': 'results'
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseAppSelector.mockReturnValue(null) // No pending permission
|
||||
mockUseTranslation.mockReturnValue({
|
||||
t: (key: string, fallback?: string) => mockTranslations[key] ?? fallback ?? key
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Helper to create tool response
|
||||
const createToolResponse = (overrides: Partial<NormalToolResponse> = {}): NormalToolResponse => ({
|
||||
id: 'test-tool-1',
|
||||
tool: {
|
||||
id: 'Read',
|
||||
name: 'Read',
|
||||
description: 'Read a file',
|
||||
type: 'provider'
|
||||
},
|
||||
arguments: undefined,
|
||||
status: 'pending',
|
||||
toolCallId: 'call-123',
|
||||
...overrides
|
||||
})
|
||||
|
||||
describe('isValidAgentToolsType', () => {
|
||||
it('should return true for valid tool types', () => {
|
||||
expect(isValidAgentToolsType('Read')).toBe(true)
|
||||
expect(isValidAgentToolsType('Bash')).toBe(true)
|
||||
expect(isValidAgentToolsType('TodoWrite')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for invalid tool types', () => {
|
||||
expect(isValidAgentToolsType('InvalidTool')).toBe(false)
|
||||
expect(isValidAgentToolsType('')).toBe(false)
|
||||
expect(isValidAgentToolsType(null)).toBe(false)
|
||||
expect(isValidAgentToolsType(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('partial-json parsing', () => {
|
||||
it('should parse partial JSON correctly', () => {
|
||||
// Test partial-json library behavior
|
||||
const partialJson = '{"file_path": "/test.ts"'
|
||||
const parsed = parsePartialJson(partialJson)
|
||||
expect(parsed).toEqual({ file_path: '/test.ts' })
|
||||
})
|
||||
|
||||
it('should parse nested partial JSON', () => {
|
||||
const partialJson = '{"todos": [{"content": "Task 1", "status": "pending"'
|
||||
const parsed = parsePartialJson(partialJson)
|
||||
expect(parsed).toEqual({
|
||||
todos: [{ content: 'Task 1', status: 'pending' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle empty partial JSON', () => {
|
||||
const partialJson = '{'
|
||||
const parsed = parsePartialJson(partialJson)
|
||||
expect(parsed).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('streaming tool rendering', () => {
|
||||
it('should render dedicated tool renderer with partial arguments during streaming', () => {
|
||||
const toolResponse = createToolResponse({
|
||||
tool: { id: 'Read', name: 'Read', description: 'Read a file', type: 'provider' },
|
||||
status: 'streaming',
|
||||
partialArguments: '{"file_path": "/test.ts"'
|
||||
})
|
||||
|
||||
render(<MessageAgentTools toolResponse={toolResponse} />)
|
||||
|
||||
// Should render the DEDICATED ReadTool component, not StreamingToolContent
|
||||
// ReadTool uses 'Read File' as label, not just 'Read'
|
||||
expect(screen.getByText('Read File')).toBeInTheDocument()
|
||||
// Should show the filename from partial args
|
||||
expect(screen.getByText('test.ts')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass parsed partial arguments to dedicated tool renderer', () => {
|
||||
const toolResponse = createToolResponse({
|
||||
tool: { id: 'Read', name: 'Read', description: 'Read a file', type: 'provider' },
|
||||
status: 'streaming',
|
||||
partialArguments: '{"file_path": "/path/to/myfile.ts", "offset": 10'
|
||||
})
|
||||
|
||||
render(<MessageAgentTools toolResponse={toolResponse} />)
|
||||
|
||||
// Should use dedicated ReadTool renderer
|
||||
expect(screen.getByText('Read File')).toBeInTheDocument()
|
||||
// Should show the filename extracted by ReadTool
|
||||
expect(screen.getByText('myfile.ts')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update dedicated renderer as more arguments stream in', () => {
|
||||
const initialResponse = createToolResponse({
|
||||
tool: { id: 'Read', name: 'Read', description: 'Read a file', type: 'provider' },
|
||||
status: 'streaming',
|
||||
partialArguments: '{"file_path": "/test/partial'
|
||||
})
|
||||
|
||||
const { rerender } = render(<MessageAgentTools toolResponse={initialResponse} />)
|
||||
|
||||
// Should use dedicated renderer even with partial path
|
||||
expect(screen.getByText('Read File')).toBeInTheDocument()
|
||||
|
||||
// Update with status changed to pending when arguments complete
|
||||
const updatedResponse = createToolResponse({
|
||||
tool: { id: 'Read', name: 'Read', description: 'Read a file', type: 'provider' },
|
||||
status: 'pending',
|
||||
partialArguments: '{"file_path": "/test/complete.ts", "limit": 100}'
|
||||
})
|
||||
|
||||
rerender(<MessageAgentTools toolResponse={updatedResponse} />)
|
||||
|
||||
// When pending with no permission, shows ToolStatusIndicator with loading icon
|
||||
expect(screen.getByTestId('loading-icon')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('completed tool rendering', () => {
|
||||
it('should render tool with full arguments when done', () => {
|
||||
const toolResponse = createToolResponse({
|
||||
tool: { id: 'Read', name: 'Read', description: 'Read a file', type: 'provider' },
|
||||
status: 'done',
|
||||
arguments: { file_path: '/test.ts', limit: 100 },
|
||||
response: 'file content here'
|
||||
})
|
||||
|
||||
render(<MessageAgentTools toolResponse={toolResponse} />)
|
||||
|
||||
// Should render the complete tool with output
|
||||
expect(screen.getByText('Read File')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render error state correctly', () => {
|
||||
const toolResponse = createToolResponse({
|
||||
tool: { id: 'Read', name: 'Read', description: 'Read a file', type: 'provider' },
|
||||
status: 'error',
|
||||
arguments: { file_path: '/nonexistent.ts' },
|
||||
response: 'File not found'
|
||||
})
|
||||
|
||||
render(<MessageAgentTools toolResponse={toolResponse} />)
|
||||
|
||||
// Should still render the tool component
|
||||
expect(screen.getByText('Read File')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pending without streaming', () => {
|
||||
it('should show permission card when pending permission exists', () => {
|
||||
mockUseAppSelector.mockReturnValue({ toolCallId: 'call-123' }) // Has pending permission
|
||||
|
||||
const toolResponse = createToolResponse({
|
||||
status: 'pending',
|
||||
partialArguments: undefined
|
||||
})
|
||||
|
||||
render(<MessageAgentTools toolResponse={toolResponse} />)
|
||||
|
||||
expect(screen.getByTestId('permission-card')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show pending indicator when no streaming and no permission', () => {
|
||||
const toolResponse = createToolResponse({
|
||||
status: 'pending',
|
||||
partialArguments: undefined
|
||||
})
|
||||
|
||||
render(<MessageAgentTools toolResponse={toolResponse} />)
|
||||
|
||||
// Should show the ToolStatusIndicator with loading icon
|
||||
expect(screen.getByTestId('loading-icon')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('TodoWrite streaming', () => {
|
||||
it('should render TodoWrite dedicated renderer with partial todos during streaming', () => {
|
||||
const toolResponse = createToolResponse({
|
||||
tool: { id: 'TodoWrite', name: 'TodoWrite', description: 'Write todos', type: 'provider' },
|
||||
status: 'streaming',
|
||||
partialArguments:
|
||||
'{"todos": [{"content": "First task", "status": "pending", "activeForm": "Working on first task"}'
|
||||
})
|
||||
|
||||
render(<MessageAgentTools toolResponse={toolResponse} />)
|
||||
|
||||
// Should render the DEDICATED TodoWriteTool component, not StreamingToolContent
|
||||
// TodoWriteTool uses 'Todo Write' (with space) as label
|
||||
expect(screen.getByText('Todo Write')).toBeInTheDocument()
|
||||
// The partial todo content should be visible in the dedicated renderer
|
||||
expect(screen.getByText(/First task/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Bash streaming', () => {
|
||||
it('should render Bash dedicated renderer with partial command during streaming', () => {
|
||||
const toolResponse = createToolResponse({
|
||||
tool: { id: 'Bash', name: 'Bash', description: 'Execute command', type: 'provider' },
|
||||
status: 'streaming',
|
||||
partialArguments: '{"command": "npm install",'
|
||||
})
|
||||
|
||||
render(<MessageAgentTools toolResponse={toolResponse} />)
|
||||
|
||||
// Should render the DEDICATED BashTool component
|
||||
expect(screen.getByText('Bash')).toBeInTheDocument()
|
||||
// Command should be visible in the dedicated renderer
|
||||
expect(screen.getByText(/npm install/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
12
src/renderer/src/pages/home/Messages/Tools/hooks/index.ts
Normal file
12
src/renderer/src/pages/home/Messages/Tools/hooks/index.ts
Normal file
@ -0,0 +1,12 @@
|
||||
// Tool approval hooks - unified abstraction for MCP and Agent tool approval
|
||||
export {
|
||||
isBlockWaitingApproval,
|
||||
type ToolApprovalActions,
|
||||
type ToolApprovalState,
|
||||
useAgentToolApproval,
|
||||
type UseAgentToolApprovalOptions,
|
||||
useMcpToolApproval,
|
||||
type UseMcpToolApprovalOptions,
|
||||
useToolApproval,
|
||||
type UseToolApprovalOptions
|
||||
} from './useToolApproval'
|
||||
@ -0,0 +1,163 @@
|
||||
import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'
|
||||
import { loggerService } from '@logger'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { selectPendingPermission, toolPermissionsActions } from '@renderer/store/toolPermissions'
|
||||
import type { NormalToolResponse } from '@renderer/types'
|
||||
import type { ToolMessageBlock } from '@renderer/types/newMessage'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import type { ToolApprovalActions, ToolApprovalState } from './useToolApproval'
|
||||
|
||||
const logger = loggerService.withContext('useAgentToolApproval')
|
||||
|
||||
export interface UseAgentToolApprovalOptions {
|
||||
/** Direct toolCallId (alternative to extracting from block) */
|
||||
toolCallId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for Agent tool approval logic
|
||||
* Can be used with:
|
||||
* - A ToolMessageBlock (extracts toolCallId from metadata)
|
||||
* - A direct toolCallId via options
|
||||
*/
|
||||
export function useAgentToolApproval(
|
||||
block?: ToolMessageBlock | null,
|
||||
options: UseAgentToolApprovalOptions = {}
|
||||
): ToolApprovalState & ToolApprovalActions {
|
||||
const { t } = useTranslation()
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const toolResponse = block?.metadata?.rawMcpToolResponse as NormalToolResponse | undefined
|
||||
const toolCallId = options.toolCallId ?? toolResponse?.toolCallId ?? ''
|
||||
|
||||
const request = useAppSelector((state) => selectPendingPermission(state.toolPermissions, toolCallId))
|
||||
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
|
||||
// Update time every 500ms to track expiration
|
||||
useEffect(() => {
|
||||
if (!request) return
|
||||
|
||||
logger.debug('Tracking agent tool permission', {
|
||||
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 isInvoking = request?.status === 'invoking'
|
||||
const isPending = request?.status === 'pending'
|
||||
|
||||
const handleDecision = useCallback(
|
||||
async (
|
||||
behavior: 'allow' | 'deny',
|
||||
extra?: {
|
||||
updatedInput?: Record<string, unknown>
|
||||
updatedPermissions?: PermissionUpdate[]
|
||||
message?: string
|
||||
}
|
||||
) => {
|
||||
if (!request) return
|
||||
|
||||
logger.debug('Submitting agent 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]
|
||||
)
|
||||
|
||||
const confirm = useCallback(() => {
|
||||
handleDecision('allow')
|
||||
}, [handleDecision])
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
handleDecision('deny')
|
||||
}, [handleDecision])
|
||||
|
||||
// Auto-approve with suggestions if available
|
||||
const autoApprove = useCallback(() => {
|
||||
if (request?.suggestions?.length) {
|
||||
handleDecision('allow', { updatedPermissions: request.suggestions })
|
||||
}
|
||||
}, [handleDecision, request?.suggestions])
|
||||
|
||||
// Determine isWaiting - only when pending and not expired
|
||||
const isWaiting = !!request && isPending && !isExpired
|
||||
// isExecuting - when invoking or submitting allow
|
||||
const isExecuting = isInvoking || isSubmittingAllow
|
||||
|
||||
return {
|
||||
// State
|
||||
isWaiting,
|
||||
isExecuting,
|
||||
countdown: undefined,
|
||||
expiresAt: request?.expiresAt,
|
||||
remainingSeconds,
|
||||
isExpired: !!request && isExpired,
|
||||
isSubmitting,
|
||||
// Agent-specific: input from permission request
|
||||
input: request?.input,
|
||||
|
||||
// Actions
|
||||
confirm,
|
||||
cancel,
|
||||
autoApprove: request?.suggestions?.length ? autoApprove : undefined
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,139 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { useMCPServers } from '@renderer/hooks/useMCPServers'
|
||||
import { useTimer } from '@renderer/hooks/useTimer'
|
||||
import type { MCPToolResponse } from '@renderer/types'
|
||||
import type { ToolMessageBlock } from '@renderer/types/newMessage'
|
||||
import { isToolAutoApproved } from '@renderer/utils/mcp-tools'
|
||||
import { cancelToolAction, confirmToolAction } from '@renderer/utils/userConfirmation'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import type { ToolApprovalActions, ToolApprovalState } from './useToolApproval'
|
||||
|
||||
const logger = loggerService.withContext('useMcpToolApproval')
|
||||
|
||||
const COUNTDOWN_TIME = 30
|
||||
|
||||
export interface UseMcpToolApprovalOptions {
|
||||
/** Disable countdown auto-approve */
|
||||
disableCountdown?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for MCP tool approval logic
|
||||
* Extracts approval state management from MessageMcpTool
|
||||
*/
|
||||
export function useMcpToolApproval(
|
||||
block: ToolMessageBlock,
|
||||
options: UseMcpToolApprovalOptions = {}
|
||||
): ToolApprovalState & ToolApprovalActions {
|
||||
const { disableCountdown = false } = options
|
||||
const { t } = useTranslation()
|
||||
const { mcpServers, updateMCPServer } = useMCPServers()
|
||||
const { setTimeoutTimer, clearTimeoutTimer } = useTimer()
|
||||
|
||||
const toolResponse = block.metadata?.rawMcpToolResponse as MCPToolResponse | undefined
|
||||
const tool = toolResponse?.tool
|
||||
const id = toolResponse?.id ?? ''
|
||||
const status = toolResponse?.status
|
||||
|
||||
const isPending = status === 'pending'
|
||||
|
||||
const isAutoApproved = useMemo(() => {
|
||||
if (!tool) return false
|
||||
return isToolAutoApproved(
|
||||
tool,
|
||||
mcpServers.find((s) => s.id === tool.serverId)
|
||||
)
|
||||
}, [tool, mcpServers])
|
||||
|
||||
const [countdown, setCountdown] = useState<number>(COUNTDOWN_TIME)
|
||||
const [isConfirmed, setIsConfirmed] = useState(isAutoApproved)
|
||||
|
||||
// Compute approval states
|
||||
const isWaiting = isPending && !isAutoApproved && !isConfirmed
|
||||
const isExecuting = isPending && (isAutoApproved || isConfirmed)
|
||||
|
||||
// Countdown timer effect
|
||||
useEffect(() => {
|
||||
if (!isWaiting || disableCountdown) return
|
||||
|
||||
if (countdown > 0) {
|
||||
setTimeoutTimer(
|
||||
`countdown-${id}`,
|
||||
() => {
|
||||
logger.debug(`countdown: ${countdown}`)
|
||||
setCountdown((prev) => prev - 1)
|
||||
},
|
||||
1000
|
||||
)
|
||||
} else if (countdown === 0) {
|
||||
setIsConfirmed(true)
|
||||
confirmToolAction(id)
|
||||
}
|
||||
|
||||
return () => clearTimeoutTimer(`countdown-${id}`)
|
||||
}, [countdown, id, isWaiting, disableCountdown, setTimeoutTimer, clearTimeoutTimer])
|
||||
|
||||
const cancelCountdown = useCallback(() => {
|
||||
clearTimeoutTimer(`countdown-${id}`)
|
||||
}, [clearTimeoutTimer, id])
|
||||
|
||||
const confirm = useCallback(() => {
|
||||
cancelCountdown()
|
||||
setIsConfirmed(true)
|
||||
confirmToolAction(id)
|
||||
}, [cancelCountdown, id])
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
cancelCountdown()
|
||||
cancelToolAction(id)
|
||||
}, [cancelCountdown, id])
|
||||
|
||||
const autoApprove = useCallback(async () => {
|
||||
cancelCountdown()
|
||||
|
||||
if (!tool || !tool.name) {
|
||||
return
|
||||
}
|
||||
|
||||
const server = mcpServers.find((s) => s.id === tool.serverId)
|
||||
if (!server) {
|
||||
return
|
||||
}
|
||||
|
||||
let disabledAutoApproveTools = [...(server.disabledAutoApproveTools || [])]
|
||||
|
||||
// Remove tool from disabledAutoApproveTools to enable auto-approve
|
||||
disabledAutoApproveTools = disabledAutoApproveTools.filter((name) => name !== tool.name)
|
||||
|
||||
const updatedServer = {
|
||||
...server,
|
||||
disabledAutoApproveTools
|
||||
}
|
||||
|
||||
updateMCPServer(updatedServer)
|
||||
|
||||
// Also confirm the current tool
|
||||
setIsConfirmed(true)
|
||||
confirmToolAction(id)
|
||||
|
||||
window.toast.success(t('message.tools.autoApproveEnabled', 'Auto-approve enabled for this tool'))
|
||||
}, [cancelCountdown, tool, mcpServers, updateMCPServer, id, t])
|
||||
|
||||
return {
|
||||
// State
|
||||
isWaiting,
|
||||
isExecuting,
|
||||
countdown,
|
||||
remainingSeconds: countdown,
|
||||
isExpired: false, // MCP tools don't expire, they auto-confirm
|
||||
isSubmitting: false,
|
||||
input: undefined, // MCP tools get input from toolResponse.arguments
|
||||
|
||||
// Actions
|
||||
confirm,
|
||||
cancel,
|
||||
autoApprove: isWaiting ? autoApprove : undefined
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,77 @@
|
||||
import type { ToolMessageBlock } from '@renderer/types/newMessage'
|
||||
|
||||
import { useAgentToolApproval } from './useAgentToolApproval'
|
||||
import { useMcpToolApproval, type UseMcpToolApprovalOptions } from './useMcpToolApproval'
|
||||
|
||||
/**
|
||||
* Unified tool approval state
|
||||
*/
|
||||
export interface ToolApprovalState {
|
||||
/** Whether the tool is waiting for user confirmation */
|
||||
isWaiting: boolean
|
||||
/** Whether the tool is currently executing after approval */
|
||||
isExecuting: boolean
|
||||
/** Countdown seconds (MCP only) */
|
||||
countdown?: number
|
||||
/** Expiration timestamp (Agent only) */
|
||||
expiresAt?: number
|
||||
/** Remaining seconds until auto-confirm (MCP) or expiration (Agent) */
|
||||
remainingSeconds: number
|
||||
/** Whether the request has expired (Agent only) */
|
||||
isExpired: boolean
|
||||
/** Whether a submission is in progress (Agent only) */
|
||||
isSubmitting: boolean
|
||||
/** Tool input from permission request (Agent only) */
|
||||
input?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified tool approval actions
|
||||
*/
|
||||
export interface ToolApprovalActions {
|
||||
/** Confirm/approve the tool execution */
|
||||
confirm: () => void | Promise<void>
|
||||
/** Cancel/deny the tool execution */
|
||||
cancel: () => void | Promise<void>
|
||||
/** Auto-approve this tool for future calls (if available) */
|
||||
autoApprove?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export interface UseToolApprovalOptions extends UseMcpToolApprovalOptions {
|
||||
/** Force a specific approval type */
|
||||
forceType?: 'mcp' | 'agent'
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified hook for tool approval - automatically selects between MCP and Agent approval
|
||||
* based on the tool type in the block metadata.
|
||||
*
|
||||
* @param block - The tool message block
|
||||
* @param options - Optional configuration
|
||||
* @returns Unified approval state and actions
|
||||
*/
|
||||
export function useToolApproval(
|
||||
block: ToolMessageBlock,
|
||||
options: UseToolApprovalOptions = {}
|
||||
): ToolApprovalState & ToolApprovalActions {
|
||||
const { forceType, ...mcpOptions } = options
|
||||
|
||||
const toolResponse = block.metadata?.rawMcpToolResponse
|
||||
const tool = toolResponse?.tool
|
||||
|
||||
const isMcpTool = forceType === 'mcp' || (forceType !== 'agent' && tool?.type === 'mcp')
|
||||
const mcpApproval = useMcpToolApproval(block, mcpOptions)
|
||||
const agentApproval = useAgentToolApproval(block)
|
||||
|
||||
return isMcpTool ? mcpApproval : agentApproval
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a block needs approval (either MCP or Agent)
|
||||
*/
|
||||
export function isBlockWaitingApproval(block: ToolMessageBlock): boolean {
|
||||
return block.metadata?.rawMcpToolResponse?.status === 'pending'
|
||||
}
|
||||
|
||||
export { useAgentToolApproval, type UseAgentToolApprovalOptions } from './useAgentToolApproval'
|
||||
export { useMcpToolApproval, type UseMcpToolApprovalOptions } from './useMcpToolApproval'
|
||||
104
src/renderer/src/pages/home/Messages/Tools/shared/ArgsTable.tsx
Normal file
104
src/renderer/src/pages/home/Messages/Tools/shared/ArgsTable.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { SkeletonSpan } from '../MessageAgentTools/GenericTools'
|
||||
|
||||
/**
|
||||
* Format argument value for display in table
|
||||
*/
|
||||
export const formatArgValue = (value: unknown): string => {
|
||||
if (value === null) return 'null'
|
||||
if (value === undefined) return ''
|
||||
if (typeof value === 'string') return value
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared argument table component for displaying tool parameters
|
||||
* Used by both MCP tools and Agent tools
|
||||
*/
|
||||
export function ToolArgsTable({
|
||||
args,
|
||||
title,
|
||||
isStreaming = false
|
||||
}: {
|
||||
args: Record<string, unknown> | unknown[] | null | undefined
|
||||
title?: string
|
||||
isStreaming?: boolean
|
||||
}) {
|
||||
if (!args) return null
|
||||
|
||||
// Handle both object and array args
|
||||
const entries: Array<[string, unknown]> = Array.isArray(args) ? [['arguments', args]] : Object.entries(args)
|
||||
|
||||
if (entries.length === 0 && !isStreaming) return null
|
||||
|
||||
return (
|
||||
<ArgsSection>
|
||||
{title && <ArgsSectionTitle>{title}</ArgsSectionTitle>}
|
||||
<ArgsTable>
|
||||
<tbody>
|
||||
{entries.map(([key, value]) => (
|
||||
<tr key={key}>
|
||||
<ArgKey>{key}</ArgKey>
|
||||
<ArgValue>{formatArgValue(value)}</ArgValue>
|
||||
</tr>
|
||||
))}
|
||||
{isStreaming && (
|
||||
<tr>
|
||||
<ArgKey>
|
||||
<SkeletonSpan width="60px" />
|
||||
</ArgKey>
|
||||
<ArgValue>
|
||||
<SkeletonSpan width="120px" />
|
||||
</ArgValue>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</ArgsTable>
|
||||
</ArgsSection>
|
||||
)
|
||||
}
|
||||
|
||||
// Styled components extracted from MessageMcpTool
|
||||
|
||||
export const ArgsSection = styled.div`
|
||||
padding: 8px 12px;
|
||||
font-family: var(--font-family-mono, monospace);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
`
|
||||
|
||||
export const ArgsSectionTitle = styled.div`
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-3);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 8px;
|
||||
`
|
||||
|
||||
export const ArgsTable = styled.table`
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
`
|
||||
|
||||
export const ArgKey = styled.td`
|
||||
color: var(--color-primary);
|
||||
padding: 4px 8px 4px 0;
|
||||
white-space: nowrap;
|
||||
vertical-align: top;
|
||||
font-weight: 500;
|
||||
width: 1%;
|
||||
`
|
||||
|
||||
export const ArgValue = styled.td`
|
||||
color: var(--color-text);
|
||||
padding: 4px 0;
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
`
|
||||
|
||||
export const ResponseSection = styled.div`
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
`
|
||||
@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Truncate output string to prevent UI performance issues
|
||||
* Tries to truncate at a newline boundary to avoid cutting in the middle of a line
|
||||
*/
|
||||
|
||||
const MAX_OUTPUT_LENGTH = 50000
|
||||
|
||||
/**
|
||||
* Count non-empty lines in a string
|
||||
*/
|
||||
export function countLines(output: string | undefined | null): number {
|
||||
if (!output) return 0
|
||||
return output.split('\n').filter((line) => line.trim()).length
|
||||
}
|
||||
|
||||
export interface TruncateResult {
|
||||
data: string
|
||||
isTruncated: boolean
|
||||
originalLength: number
|
||||
}
|
||||
|
||||
export function truncateOutput(
|
||||
output: string | undefined | null,
|
||||
maxLength: number = MAX_OUTPUT_LENGTH
|
||||
): TruncateResult {
|
||||
if (!output) {
|
||||
return { data: '', isTruncated: false, originalLength: 0 }
|
||||
}
|
||||
|
||||
const originalLength = output.length
|
||||
|
||||
if (output.length <= maxLength) {
|
||||
return { data: output, isTruncated: false, originalLength }
|
||||
}
|
||||
|
||||
// Truncate and try to find a newline boundary
|
||||
const truncated = output.slice(0, maxLength)
|
||||
const lastNewline = truncated.lastIndexOf('\n')
|
||||
|
||||
// Only use newline boundary if it's reasonably close to maxLength (within 20%)
|
||||
const data = lastNewline > maxLength * 0.8 ? truncated.slice(0, lastNewline) : truncated
|
||||
|
||||
return { data, isTruncated: true, originalLength }
|
||||
}
|
||||
@ -5,13 +5,11 @@ import { Input } from 'antd'
|
||||
import { startTransition, useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { SettingHelpLink, SettingHelpText, SettingHelpTextRow, SettingRow, SettingRowTitle } from '..'
|
||||
import { SettingHelpLink, SettingHelpTextRow, SettingRow, SettingRowTitle } from '..'
|
||||
|
||||
export const OcrPpocrSettings = () => {
|
||||
// Hack: Hard-coded for now
|
||||
const SERVING_DOC_URL = 'https://www.paddleocr.ai/latest/version3.x/deployment/serving.html'
|
||||
const AISTUDIO_URL = 'https://aistudio.baidu.com/pipeline/mine'
|
||||
|
||||
const API_URL = 'https://aistudio.baidu.com/paddleocr/task'
|
||||
const { t } = useTranslation()
|
||||
const { provider, updateConfig } = useOcrProvider(BuiltinOcrProviderIds.paddleocr)
|
||||
|
||||
@ -68,13 +66,9 @@ export const OcrPpocrSettings = () => {
|
||||
</SettingRow>
|
||||
|
||||
<SettingHelpTextRow style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<SettingHelpText style={{ marginBottom: 5 }}>{t('settings.tool.ocr.paddleocr.tip')}</SettingHelpText>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<SettingHelpLink target="_blank" href={SERVING_DOC_URL}>
|
||||
{t('settings.tool.ocr.paddleocr.serving_doc_url_label')}
|
||||
</SettingHelpLink>
|
||||
<SettingHelpLink target="_blank" href={AISTUDIO_URL}>
|
||||
{t('settings.tool.ocr.paddleocr.aistudio_url_label')}
|
||||
<SettingHelpLink target="_blank" href={API_URL}>
|
||||
{t('settings.tool.ocr.paddleocr.api_url_label')}
|
||||
</SettingHelpLink>
|
||||
</div>
|
||||
</SettingHelpTextRow>
|
||||
|
||||
@ -75,31 +75,34 @@ const PreprocessProviderSettings: FC<Props> = ({ provider: _provider }) => {
|
||||
<ProviderName> {preprocessProvider.name}</ProviderName>
|
||||
{officialWebsite && preprocessProviderConfig?.websites && (
|
||||
<Link target="_blank" href={preprocessProviderConfig.websites.official}>
|
||||
<ExportOutlined style={{ color: 'var(--color-text)', fontSize: '12px' }} />
|
||||
<ExportOutlined className="text-[--color-text] text-[12px]" />
|
||||
</Link>
|
||||
)}
|
||||
</Flex>
|
||||
</SettingTitle>
|
||||
<Divider style={{ width: '100%', margin: '10px 0' }} />
|
||||
<Divider className="my-[10px] w-full" />
|
||||
{hasObjectKey(preprocessProvider, 'apiKey') && (
|
||||
<>
|
||||
<SettingSubtitle
|
||||
style={{
|
||||
marginTop: 5,
|
||||
marginBottom: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
{t('settings.provider.api_key.label')}
|
||||
<Tooltip title={t('settings.provider.api.key.list.open')} mouseEnterDelay={0.5}>
|
||||
<Button type="text" size="small" onClick={openApiKeyList} icon={<List size={14} />} />
|
||||
</Tooltip>
|
||||
<SettingSubtitle className="mt-[5px] mb-[10px] flex items-center justify-between">
|
||||
{preprocessProvider.id === 'paddleocr'
|
||||
? t('settings.tool.preprocess.paddleocr.aistudio_access_token')
|
||||
: t('settings.provider.api_key.label')}
|
||||
{preprocessProvider.id !== 'paddleocr' && (
|
||||
<Tooltip title={t('settings.provider.api.key.list.open')} mouseEnterDelay={0.5}>
|
||||
<Button type="text" size="small" onClick={openApiKeyList} icon={<List size={14} />} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</SettingSubtitle>
|
||||
<Flex gap={8}>
|
||||
<Input.Password
|
||||
value={apiKey}
|
||||
placeholder={t('settings.provider.api_key.label')}
|
||||
placeholder={
|
||||
preprocessProvider.id === 'mineru'
|
||||
? t('settings.mineru.api_key')
|
||||
: preprocessProvider.id === 'paddleocr'
|
||||
? t('settings.tool.preprocess.paddleocr.aistudio_access_token')
|
||||
: t('settings.provider.api_key.label')
|
||||
}
|
||||
onChange={(e) => setApiKey(formatApiKeys(e.target.value))}
|
||||
onBlur={onUpdateApiKey}
|
||||
spellCheck={false}
|
||||
@ -107,28 +110,51 @@ const PreprocessProviderSettings: FC<Props> = ({ provider: _provider }) => {
|
||||
autoFocus={apiKey === ''}
|
||||
/>
|
||||
</Flex>
|
||||
<SettingHelpTextRow style={{ justifyContent: 'space-between', marginTop: 5 }}>
|
||||
<SettingHelpLink target="_blank" href={apiKeyWebsite}>
|
||||
{t('settings.provider.get_api_key')}
|
||||
</SettingHelpLink>
|
||||
<SettingHelpText>{t('settings.provider.api_key.tip')}</SettingHelpText>
|
||||
</SettingHelpTextRow>
|
||||
{preprocessProvider.id !== 'paddleocr' && (
|
||||
<SettingHelpTextRow className="mt-[5px] justify-between">
|
||||
<SettingHelpLink target="_blank" href={apiKeyWebsite}>
|
||||
{t('settings.provider.get_api_key')}
|
||||
</SettingHelpLink>
|
||||
<SettingHelpText>{t('settings.provider.api_key.tip')}</SettingHelpText>
|
||||
</SettingHelpTextRow>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasObjectKey(preprocessProvider, 'apiHost') && (
|
||||
<>
|
||||
<SettingSubtitle style={{ marginTop: 5, marginBottom: 10 }}>
|
||||
{t('settings.provider.api_host')}
|
||||
<SettingSubtitle className="mt-[5px] mb-[10px]">
|
||||
{preprocessProvider.id === 'paddleocr'
|
||||
? t('settings.tool.preprocess.paddleocr.api_url')
|
||||
: t('settings.provider.api_host')}
|
||||
</SettingSubtitle>
|
||||
<Flex>
|
||||
<Input
|
||||
value={apiHost}
|
||||
placeholder={t('settings.provider.api_host')}
|
||||
placeholder={
|
||||
preprocessProvider.id === 'paddleocr'
|
||||
? t('settings.tool.preprocess.paddleocr.api_url')
|
||||
: t('settings.provider.api_host')
|
||||
}
|
||||
onChange={(e) => setApiHost(e.target.value)}
|
||||
onBlur={onUpdateApiHost}
|
||||
/>
|
||||
</Flex>
|
||||
{preprocessProvider.id === 'paddleocr' && (
|
||||
<SettingHelpTextRow className="!flex-col">
|
||||
<div className="!flex !gap-3">
|
||||
<SettingHelpLink
|
||||
className="!inline-block"
|
||||
target="_blank"
|
||||
href="https://aistudio.baidu.com/paddleocr/task">
|
||||
{t('settings.tool.preprocess.paddleocr.api_url_label')}
|
||||
</SettingHelpLink>
|
||||
<SettingHelpLink className="!inline-block" target="_blank" href="https://aistudio.baidu.com/paddleocr">
|
||||
{t('settings.tool.preprocess.paddleocr.paddleocr_url_label')}
|
||||
</SettingHelpLink>
|
||||
</div>
|
||||
</SettingHelpTextRow>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
import { loggerService } from '@logger'
|
||||
import type { ExternalToolResult, GenerateImageResponse, MCPToolResponse, WebSearchResponse } from '@renderer/types'
|
||||
import type {
|
||||
ExternalToolResult,
|
||||
GenerateImageResponse,
|
||||
MCPToolResponse,
|
||||
NormalToolResponse,
|
||||
WebSearchResponse
|
||||
} from '@renderer/types'
|
||||
import type { Chunk } from '@renderer/types/chunk'
|
||||
import { ChunkType } from '@renderer/types/chunk'
|
||||
import type { Response } from '@renderer/types/newMessage'
|
||||
@ -23,9 +29,11 @@ export interface StreamProcessorCallbacks {
|
||||
onThinkingChunk?: (text: string, thinking_millsec?: number) => void
|
||||
onThinkingComplete?: (text: string, thinking_millsec?: number) => void
|
||||
// A tool call response chunk (from MCP)
|
||||
onToolCallPending?: (toolResponse: MCPToolResponse) => void
|
||||
onToolCallInProgress?: (toolResponse: MCPToolResponse) => void
|
||||
onToolCallComplete?: (toolResponse: MCPToolResponse) => void
|
||||
onToolCallPending?: (toolResponse: MCPToolResponse | NormalToolResponse) => void
|
||||
onToolCallInProgress?: (toolResponse: MCPToolResponse | NormalToolResponse) => void
|
||||
onToolCallComplete?: (toolResponse: MCPToolResponse | NormalToolResponse) => void
|
||||
// Tool argument streaming (partial arguments during streaming)
|
||||
onToolArgumentStreaming?: (toolResponse: MCPToolResponse | NormalToolResponse) => void
|
||||
// External tool call in progress
|
||||
onExternalToolInProgress?: () => void
|
||||
// Citation data received (e.g., from Internet and Knowledge Base)
|
||||
@ -109,6 +117,12 @@ export function createStreamProcessor(callbacks: StreamProcessorCallbacks = {})
|
||||
}
|
||||
break
|
||||
}
|
||||
case ChunkType.MCP_TOOL_STREAMING: {
|
||||
if (callbacks.onToolArgumentStreaming) {
|
||||
data.responses.forEach((toolResp) => callbacks.onToolArgumentStreaming!(toolResp))
|
||||
}
|
||||
break
|
||||
}
|
||||
case ChunkType.EXTERNEL_TOOL_IN_PROGRESS: {
|
||||
if (callbacks.onExternalToolInProgress) callbacks.onExternalToolInProgress()
|
||||
break
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { loggerService } from '@logger'
|
||||
import type { AppDispatch } from '@renderer/store'
|
||||
import { toolPermissionsActions } from '@renderer/store/toolPermissions'
|
||||
import type { MCPToolResponse } from '@renderer/types'
|
||||
import type { MCPToolResponse, NormalToolResponse } from '@renderer/types'
|
||||
import { WebSearchSource } from '@renderer/types'
|
||||
import type { ToolMessageBlock } from '@renderer/types/newMessage'
|
||||
import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
|
||||
@ -11,6 +11,8 @@ import type { BlockManager } from '../BlockManager'
|
||||
|
||||
const logger = loggerService.withContext('ToolCallbacks')
|
||||
|
||||
type ToolResponse = MCPToolResponse | NormalToolResponse
|
||||
|
||||
interface ToolCallbacksDependencies {
|
||||
blockManager: BlockManager
|
||||
assistantMsgId: string
|
||||
@ -26,7 +28,7 @@ export const createToolCallbacks = (deps: ToolCallbacksDependencies) => {
|
||||
let citationBlockId: string | null = null
|
||||
|
||||
return {
|
||||
onToolCallPending: (toolResponse: MCPToolResponse) => {
|
||||
onToolCallPending: (toolResponse: ToolResponse) => {
|
||||
logger.debug('onToolCallPending', toolResponse)
|
||||
|
||||
if (blockManager.hasInitialPlaceholder) {
|
||||
@ -55,7 +57,46 @@ export const createToolCallbacks = (deps: ToolCallbacksDependencies) => {
|
||||
}
|
||||
},
|
||||
|
||||
onToolCallComplete: (toolResponse: MCPToolResponse) => {
|
||||
onToolArgumentStreaming: (toolResponse: ToolResponse) => {
|
||||
// Find or create the tool block for streaming updates
|
||||
let existingBlockId = toolCallIdToBlockIdMap.get(toolResponse.id)
|
||||
|
||||
if (!existingBlockId) {
|
||||
// Create a new tool block if one doesn't exist yet
|
||||
if (blockManager.hasInitialPlaceholder) {
|
||||
const changes = {
|
||||
type: MessageBlockType.TOOL,
|
||||
status: MessageBlockStatus.PENDING,
|
||||
toolName: toolResponse.tool.name,
|
||||
metadata: { rawMcpToolResponse: toolResponse }
|
||||
}
|
||||
toolBlockId = blockManager.initialPlaceholderBlockId!
|
||||
blockManager.smartBlockUpdate(toolBlockId, changes, MessageBlockType.TOOL)
|
||||
toolCallIdToBlockIdMap.set(toolResponse.id, toolBlockId)
|
||||
existingBlockId = toolBlockId
|
||||
} else {
|
||||
const toolBlock = createToolBlock(assistantMsgId, toolResponse.id, {
|
||||
toolName: toolResponse.tool.name,
|
||||
status: MessageBlockStatus.PENDING,
|
||||
metadata: { rawMcpToolResponse: toolResponse }
|
||||
})
|
||||
toolBlockId = toolBlock.id
|
||||
blockManager.handleBlockTransition(toolBlock, MessageBlockType.TOOL)
|
||||
toolCallIdToBlockIdMap.set(toolResponse.id, toolBlock.id)
|
||||
existingBlockId = toolBlock.id
|
||||
}
|
||||
}
|
||||
|
||||
// Update the tool block with streaming arguments
|
||||
const changes: Partial<ToolMessageBlock> = {
|
||||
status: MessageBlockStatus.PENDING,
|
||||
metadata: { rawMcpToolResponse: toolResponse }
|
||||
}
|
||||
|
||||
blockManager.smartBlockUpdate(existingBlockId, changes, MessageBlockType.TOOL)
|
||||
},
|
||||
|
||||
onToolCallComplete: (toolResponse: ToolResponse) => {
|
||||
if (toolResponse?.id) {
|
||||
dispatch(toolPermissionsActions.removeByToolCallId({ toolCallId: toolResponse.id }))
|
||||
}
|
||||
|
||||
@ -85,7 +85,7 @@ const persistedReducer = persistReducer(
|
||||
{
|
||||
key: 'cherry-studio',
|
||||
storage,
|
||||
version: 192,
|
||||
version: 193,
|
||||
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs', 'toolPermissions'],
|
||||
migrate
|
||||
},
|
||||
|
||||
@ -3159,6 +3159,16 @@ const migrateConfig = {
|
||||
logger.error('migrate 192 error', error as Error)
|
||||
return state
|
||||
}
|
||||
},
|
||||
'193': (state: RootState) => {
|
||||
try {
|
||||
addPreprocessProviders(state, 'paddleocr')
|
||||
logger.info('migrate 193 success')
|
||||
return state
|
||||
} catch (error) {
|
||||
logger.error('migrate 193 error', error as Error)
|
||||
return state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -49,6 +49,12 @@ const initialState: PreprocessState = {
|
||||
name: 'Open MinerU',
|
||||
apiKey: '',
|
||||
apiHost: ''
|
||||
},
|
||||
{
|
||||
id: 'paddleocr',
|
||||
name: 'PaddleOCR',
|
||||
apiKey: '',
|
||||
apiHost: ''
|
||||
}
|
||||
],
|
||||
defaultProvider: 'mineru'
|
||||
|
||||
@ -24,6 +24,7 @@ export enum ChunkType {
|
||||
MCP_TOOL_PENDING = 'mcp_tool_pending',
|
||||
MCP_TOOL_IN_PROGRESS = 'mcp_tool_in_progress',
|
||||
MCP_TOOL_COMPLETE = 'mcp_tool_complete',
|
||||
MCP_TOOL_STREAMING = 'mcp_tool_streaming', // NEW: Streaming tool arguments
|
||||
EXTERNEL_TOOL_COMPLETE = 'externel_tool_complete',
|
||||
LLM_RESPONSE_CREATED = 'llm_response_created',
|
||||
LLM_RESPONSE_IN_PROGRESS = 'llm_response_in_progress',
|
||||
@ -329,6 +330,20 @@ export interface MCPToolCompleteChunk {
|
||||
type: ChunkType.MCP_TOOL_COMPLETE
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming tool arguments chunk - emitted during tool-input-delta events
|
||||
*/
|
||||
export interface MCPToolStreamingChunk {
|
||||
/**
|
||||
* The type of the chunk
|
||||
*/
|
||||
type: ChunkType.MCP_TOOL_STREAMING
|
||||
/**
|
||||
* The tool responses with streaming arguments
|
||||
*/
|
||||
responses: (MCPToolResponse | NormalToolResponse)[]
|
||||
}
|
||||
|
||||
export interface LLMResponseCompleteChunk {
|
||||
/**
|
||||
* The response
|
||||
@ -438,6 +453,7 @@ export type Chunk =
|
||||
| MCPToolPendingChunk // MCP工具调用等待中
|
||||
| MCPToolInProgressChunk // MCP工具调用中
|
||||
| MCPToolCompleteChunk // MCP工具调用完成
|
||||
| MCPToolStreamingChunk // MCP工具参数流式传输中
|
||||
| ExternalToolCompleteChunk // 外部工具调用完成,外部工具包含搜索互联网,知识库,MCP服务器
|
||||
| LLMResponseCreatedChunk // 大模型响应创建,返回即将创建的块类型
|
||||
| LLMResponseInProgressChunk // 大模型响应进行中
|
||||
|
||||
@ -820,7 +820,7 @@ export interface MCPConfig {
|
||||
isBunInstalled: boolean
|
||||
}
|
||||
|
||||
export type MCPToolResponseStatus = 'pending' | 'cancelled' | 'invoking' | 'done' | 'error'
|
||||
export type MCPToolResponseStatus = 'pending' | 'streaming' | 'cancelled' | 'invoking' | 'done' | 'error'
|
||||
|
||||
interface BaseToolResponse {
|
||||
id: string // unique id
|
||||
@ -828,6 +828,8 @@ interface BaseToolResponse {
|
||||
arguments: Record<string, unknown> | Record<string, unknown>[] | string | undefined
|
||||
status: MCPToolResponseStatus
|
||||
response?: any
|
||||
// Streaming arguments support
|
||||
partialArguments?: string // Accumulated partial JSON string during streaming
|
||||
}
|
||||
|
||||
export interface ToolUseResponse extends BaseToolResponse {
|
||||
@ -844,11 +846,13 @@ export interface MCPToolResponse extends Omit<ToolUseResponse | ToolCallResponse
|
||||
tool: MCPTool
|
||||
toolCallId?: string
|
||||
toolUseId?: string
|
||||
parentToolUseId?: string
|
||||
}
|
||||
|
||||
export interface NormalToolResponse extends Omit<ToolCallResponse, 'tool'> {
|
||||
tool: BaseTool
|
||||
toolCallId: string
|
||||
parentToolUseId?: string
|
||||
}
|
||||
|
||||
export interface MCPToolResultContent {
|
||||
|
||||
@ -108,7 +108,8 @@ export const PreprocessProviderIds = {
|
||||
doc2x: 'doc2x',
|
||||
mistral: 'mistral',
|
||||
mineru: 'mineru',
|
||||
'open-mineru': 'open-mineru'
|
||||
'open-mineru': 'open-mineru',
|
||||
paddleocr: 'paddleocr'
|
||||
} as const
|
||||
|
||||
export type PreprocessProviderId = keyof typeof PreprocessProviderIds
|
||||
@ -156,3 +157,7 @@ export interface KnowledgeSearchResult {
|
||||
score: number
|
||||
metadata: Record<string, any>
|
||||
}
|
||||
|
||||
export interface PreprocessReadPdfResult {
|
||||
numPages: number
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user