Merge branch 'feat/agents-new' of https://github.com/CherryHQ/cherry-studio into feat/agents-new

This commit is contained in:
suyao 2025-09-23 17:34:47 +08:00
commit 34ab01e0a1
No known key found for this signature in database
23 changed files with 653 additions and 100 deletions

View File

@ -228,3 +228,53 @@ export const createMessage = async (req: Request, res: Response): Promise<void>
res.end()
}
}
export const deleteMessage = async (req: Request, res: Response): Promise<Response> => {
try {
const { agentId, sessionId, messageId: messageIdParam } = req.params
const messageId = Number(messageIdParam)
await verifyAgentAndSession(agentId, sessionId)
const deleted = await sessionMessageService.deleteSessionMessage(sessionId, messageId)
if (!deleted) {
logger.warn(`Message ${messageId} not found for session ${sessionId}`)
return res.status(404).json({
error: {
message: 'Message not found for this session',
type: 'not_found',
code: 'session_message_not_found'
}
})
}
logger.info(`Message ${messageId} deleted successfully for session ${sessionId}`)
return res.status(204).send()
} catch (error: any) {
if (error?.status === 404) {
logger.warn('Delete message failed - missing resource', {
agentId: req.params.agentId,
sessionId: req.params.sessionId,
messageId: req.params.messageId,
error
})
return res.status(404).json({
error: {
message: error.message,
type: 'not_found',
code: error.code ?? 'session_message_not_found'
}
})
}
logger.error('Error deleting session message:', error)
return res.status(500).json({
error: {
message: 'Failed to delete session message',
type: 'internal_error',
code: 'session_message_delete_failed'
}
})
}
}

View File

@ -11,6 +11,7 @@ import {
validateSession,
validateSessionId,
validateSessionMessage,
validateSessionMessageId,
validateSessionReplace,
validateSessionUpdate
} from './validators'
@ -362,7 +363,7 @@ const agentsRouter = express.Router()
/**
* @swagger
* /api/agents:
* /agents:
* post:
* summary: Create a new agent
* tags: [Agents]
@ -391,7 +392,7 @@ agentsRouter.post('/', validateAgent, handleValidationErrors, agentHandlers.crea
/**
* @swagger
* /api/agents:
* /agents:
* get:
* summary: List all agents with pagination
* tags: [Agents]
@ -429,7 +430,7 @@ agentsRouter.get('/', validatePagination, handleValidationErrors, agentHandlers.
/**
* @swagger
* /api/agents/{agentId}:
* /agents/{agentId}:
* get:
* summary: Get agent by ID
* tags: [Agents]
@ -457,7 +458,7 @@ agentsRouter.get('/', validatePagination, handleValidationErrors, agentHandlers.
agentsRouter.get('/:agentId', validateAgentId, handleValidationErrors, agentHandlers.getAgent)
/**
* @swagger
* /api/agents/{agentId}:
* /agents/{agentId}:
* put:
* summary: Replace agent (full update)
* tags: [Agents]
@ -497,7 +498,7 @@ agentsRouter.get('/:agentId', validateAgentId, handleValidationErrors, agentHand
agentsRouter.put('/:agentId', validateAgentId, validateAgentReplace, handleValidationErrors, agentHandlers.updateAgent)
/**
* @swagger
* /api/agents/{agentId}:
* /agents/{agentId}:
* patch:
* summary: Update agent (partial update)
* tags: [Agents]
@ -537,7 +538,7 @@ agentsRouter.put('/:agentId', validateAgentId, validateAgentReplace, handleValid
agentsRouter.patch('/:agentId', validateAgentId, validateAgentUpdate, handleValidationErrors, agentHandlers.patchAgent)
/**
* @swagger
* /api/agents/{agentId}:
* /agents/{agentId}:
* delete:
* summary: Delete agent
* tags: [Agents]
@ -567,7 +568,7 @@ const createSessionsRouter = (): express.Router => {
// Session CRUD routes (nested under agent)
/**
* @swagger
* /api/agents/{agentId}/sessions:
* /agents/{agentId}/sessions:
* post:
* summary: Create a new session for an agent
* tags: [Sessions]
@ -608,7 +609,7 @@ const createSessionsRouter = (): express.Router => {
/**
* @swagger
* /api/agents/{agentId}/sessions:
* /agents/{agentId}/sessions:
* get:
* summary: List sessions for an agent
* tags: [Sessions]
@ -657,7 +658,7 @@ const createSessionsRouter = (): express.Router => {
sessionsRouter.get('/', validatePagination, handleValidationErrors, sessionHandlers.listSessions)
/**
* @swagger
* /api/agents/{agentId}/sessions/{sessionId}:
* /agents/{agentId}/sessions/{sessionId}:
* get:
* summary: Get session by ID
* tags: [Sessions]
@ -691,7 +692,7 @@ const createSessionsRouter = (): express.Router => {
sessionsRouter.get('/:sessionId', validateSessionId, handleValidationErrors, sessionHandlers.getSession)
/**
* @swagger
* /api/agents/{agentId}/sessions/{sessionId}:
* /agents/{agentId}/sessions/{sessionId}:
* put:
* summary: Replace session (full update)
* tags: [Sessions]
@ -743,7 +744,7 @@ const createSessionsRouter = (): express.Router => {
)
/**
* @swagger
* /api/agents/{agentId}/sessions/{sessionId}:
* /agents/{agentId}/sessions/{sessionId}:
* patch:
* summary: Update session (partial update)
* tags: [Sessions]
@ -795,7 +796,7 @@ const createSessionsRouter = (): express.Router => {
)
/**
* @swagger
* /api/agents/{agentId}/sessions/{sessionId}:
* /agents/{agentId}/sessions/{sessionId}:
* delete:
* summary: Delete session
* tags: [Sessions]
@ -834,7 +835,7 @@ const createMessagesRouter = (): express.Router => {
// Message CRUD routes (nested under agent/session)
/**
* @swagger
* /api/agents/{agentId}/sessions/{sessionId}/messages:
* /agents/{agentId}/sessions/{sessionId}/messages:
* post:
* summary: Create a new message in a session
* tags: [Messages]
@ -902,8 +903,45 @@ const createMessagesRouter = (): express.Router => {
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
*/
*/
messagesRouter.post('/', validateSessionMessage, handleValidationErrors, messageHandlers.createMessage)
/**
* @swagger
* /agents/{agentId}/sessions/{sessionId}/messages/{messageId}:
* delete:
* summary: Delete a message from a session
* tags: [Messages]
* parameters:
* - in: path
* name: agentId
* required: true
* schema:
* type: string
* description: Agent ID
* - in: path
* name: sessionId
* required: true
* schema:
* type: string
* description: Session ID
* - in: path
* name: messageId
* required: true
* schema:
* type: integer
* description: Message ID
* responses:
* 204:
* description: Message deleted successfully
* 404:
* description: Agent, session, or message not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
*/
messagesRouter.delete('/:messageId', validateSessionMessageId, handleValidationErrors, messageHandlers.deleteMessage)
return messagesRouter
}

View File

@ -1,7 +1,11 @@
import { CreateSessionMessageRequestSchema } from '@types'
import { CreateSessionMessageRequestSchema, SessionMessageIdParamSchema } from '@types'
import { createZodValidator } from './zodValidator'
export const validateSessionMessage = createZodValidator({
body: CreateSessionMessageRequestSchema
})
export const validateSessionMessageId = createZodValidator({
params: SessionMessageIdParamSchema
})

View File

@ -102,19 +102,14 @@ class MCPApiService extends EventEmitter {
async getServerInfo(id: string): Promise<any> {
try {
logger.silly(`getServerInfo called with id: ${id}`)
const server = await this.getServerById(id)
if (!server) {
logger.warn(`Server with id ${id} not found`)
return null
}
logger.silly(`Returning server info for id ${id}`)
const client = await mcpService.initClient(server)
const tools = await client.listTools()
logger.silly(`Server with id ${id} info:`, { tools: JSON.stringify(tools.tools) })
return {
id: server.id,
name: server.name,

View File

@ -11,7 +11,16 @@ import { handleZoomFactor } from '@main/utils/zoom'
import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH, UpgradeChannel } from '@shared/config/constant'
import { IpcChannel } from '@shared/IpcChannel'
import { FileMetadata, Notification, OcrProvider, Provider, Shortcut, SupportedOcrFile, ThemeMode } from '@types'
import {
AgentPersistedMessage,
FileMetadata,
Notification,
OcrProvider,
Provider,
Shortcut,
SupportedOcrFile,
ThemeMode,
} from "@types";
import checkDiskSpace from 'check-disk-space'
import { BrowserWindow, dialog, ipcMain, ProxyConfig, session, shell, systemPreferences, webContents } from 'electron'
import fontList from 'font-list'
@ -209,14 +218,20 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
}
})
ipcMain.handle(IpcChannel.AgentMessage_GetHistory, async (_event, { sessionId }: { sessionId: string }) => {
try {
return await agentMessageRepository.getSessionHistory(sessionId)
} catch (error) {
logger.error('Failed to get agent session history', error as Error)
throw error
ipcMain.handle(
IpcChannel.AgentMessage_GetHistory,
async (
_event,
{ sessionId }: { sessionId: string }
): Promise<AgentPersistedMessage[]> => {
try {
return await agentMessageRepository.getSessionHistory(sessionId);
} catch (error) {
logger.error("Failed to get agent session history", error as Error);
throw error;
}
}
})
);
//only for mac
if (isMac) {

View File

@ -6,7 +6,7 @@ import type {
ListOptions
} from '@types'
import { TextStreamPart } from 'ai'
import { desc, eq } from 'drizzle-orm'
import { and, desc, eq } from 'drizzle-orm'
import { BaseService } from '../BaseService'
import { sessionMessagesTable } from '../database/schema'
@ -145,6 +145,16 @@ export class SessionMessageService extends BaseService {
return { messages }
}
async deleteSessionMessage(sessionId: string, messageId: number): Promise<boolean> {
this.ensureInitialized()
const result = await this.database
.delete(sessionMessagesTable)
.where(and(eq(sessionMessagesTable.id, messageId), eq(sessionMessagesTable.session_id, sessionId)))
return result.rowsAffected > 0
}
async createSessionMessage(
session: GetAgentSessionResponse,
messageData: CreateSessionMessageRequest,
@ -270,6 +280,7 @@ export class SessionMessageService extends BaseService {
.orderBy(desc(sessionMessagesTable.created_at))
.limit(1)
logger.silly('Last agent session ID result:', { agentSessionId: result[0]?.agent_session_id, sessionId })
return result[0]?.agent_session_id || ''
} catch (error) {
logger.error('Failed to get last agent session ID', {

View File

@ -74,6 +74,8 @@ class ClaudeCodeService implements AgentServiceInterface {
ELECTRON_RUN_AS_NODE: '1'
}
const errorChunks: string[] = []
// Build SDK options from parameters
const options: Options = {
abortController,
@ -82,7 +84,8 @@ class ClaudeCodeService implements AgentServiceInterface {
model: modelInfo.modelId,
pathToClaudeCodeExecutable: this.claudeExecutablePath,
stderr: (chunk: string) => {
logger.info('claude stderr', { chunk })
logger.warn('claude stderr', { chunk })
errorChunks.push(chunk)
},
appendSystemPrompt: session.instructions,
permissionMode: session.configuration?.permission_mode,
@ -116,11 +119,16 @@ class ClaudeCodeService implements AgentServiceInterface {
logger.silly('Starting Claude Code SDK query', {
prompt,
options
cwd: options.cwd,
model: options.model,
permissionMode: options.permissionMode,
maxTurns: options.maxTurns,
allowedTools: options.allowedTools,
resume: options.resume
})
// Start async processing
this.processSDKQuery(prompt, options, aiStream)
this.processSDKQuery(prompt, options, aiStream, errorChunks)
return aiStream
}
@ -142,7 +150,12 @@ class ClaudeCodeService implements AgentServiceInterface {
/**
* Process SDK query and emit stream events
*/
private async processSDKQuery(prompt: string, options: Options, stream: ClaudeCodeStream): Promise<void> {
private async processSDKQuery(
prompt: string,
options: Options,
stream: ClaudeCodeStream,
errorChunks: string[]
): Promise<void> {
const jsonOutput: SDKMessage[] = []
let hasCompleted = false
const startTime = Date.now()
@ -209,17 +222,12 @@ class ClaudeCodeService implements AgentServiceInterface {
return
}
// Original error handling for non-abort errors
logger.error('SDK query error:', {
error: errorObj instanceof Error ? errorObj.message : String(errorObj),
duration,
messageCount: jsonOutput.length
})
errorChunks.push(errorObj instanceof Error ? errorObj.message : String(errorObj))
const errorMessage = errorChunks.join('\n\n')
// Emit error event
stream.emit('data', {
type: 'error',
error: errorObj instanceof Error ? errorObj : new Error(String(errorObj))
error: new Error(errorMessage)
})
}
}

View File

@ -206,6 +206,16 @@ export class AgentApiClient {
}
}
public async deleteSessionMessage(agentId: string, sessionId: string, messageId: number): Promise<void> {
const base = this.getSessionMessagesPath(agentId, sessionId)
const url = `${base}/${messageId}`
try {
await this.axios.delete(url)
} catch (error) {
throw processError(error, 'Failed to delete session message.')
}
}
public async updateSession(agentId: string, session: UpdateSessionForm): Promise<GetAgentSessionResponse> {
const url = this.getSessionPaths(agentId).withId(session.id)
try {

View File

@ -36,7 +36,13 @@ export const DEFAULT_MESSAGE_MENUBAR_BUTTON_IDS: MessageMenubarButtonId[] = [
'more-menu'
]
export const SESSION_MESSAGE_MENUBAR_BUTTON_IDS: MessageMenubarButtonId[] = ['copy', 'translate', 'notes', 'more-menu']
export const SESSION_MESSAGE_MENUBAR_BUTTON_IDS: MessageMenubarButtonId[] = [
'copy',
'translate',
'notes',
'delete',
'more-menu'
]
const messageMenubarRegistry = new Map<MessageMenubarScope, MessageMenubarScopeConfig>([
[DEFAULT_MESSAGE_MENUBAR_SCOPE, { buttonIds: [...DEFAULT_MESSAGE_MENUBAR_BUTTON_IDS] }],

View File

@ -133,7 +133,7 @@
"plan": {
"behavior": "Solo herramientas de solo lectura. La ejecución está deshabilitada.",
"description": "Claude solo puede usar herramientas de solo lectura y presenta un plan antes de ejecutarlo.",
"title": "[to be translated]:Planning mode (coming soon)"
"title": "Modo de planificación (próximamente)"
}
},
"preapproved": {

View File

@ -0,0 +1,190 @@
import { AccordionItem, Card, CardBody, Chip, Code } from '@heroui/react'
import { CheckCircle, Terminal, XCircle } from 'lucide-react'
import { useMemo } from 'react'
import { ToolTitle } from './GenericTools'
import type { BashOutputToolInput, BashOutputToolOutput } from './types'
import { AgentToolsType } from './types'
interface ParsedBashOutput {
status?: string
exit_code?: number
stdout?: string
stderr?: string
timestamp?: string
tool_use_error?: string
}
export function BashOutputTool({ input, output }: { input: BashOutputToolInput; output?: BashOutputToolOutput }) {
// 解析 XML 输出
const parsedOutput = useMemo(() => {
if (!output) return null
try {
const parser = new DOMParser()
// 检查是否包含 tool_use_error 标签
const hasToolError = output.includes('<tool_use_error>')
// 包装成有效的 XML如果还没有根元素
const xmlStr = output.includes('<status>') || hasToolError ? `<root>${output}</root>` : output
const xmlDoc = parser.parseFromString(xmlStr, 'application/xml')
// 检查是否有解析错误
const parserError = xmlDoc.querySelector('parsererror')
if (parserError) {
return null
}
const getElementText = (tagName: string): string | undefined => {
const element = xmlDoc.getElementsByTagName(tagName)[0]
return element?.textContent?.trim()
}
const result: ParsedBashOutput = {
status: getElementText('status'),
exit_code: getElementText('exit_code') ? parseInt(getElementText('exit_code')!) : undefined,
stdout: getElementText('stdout'),
stderr: getElementText('stderr'),
timestamp: getElementText('timestamp'),
tool_use_error: getElementText('tool_use_error')
}
return result
} catch {
return null
}
}, [output])
// 获取状态配置
const statusConfig = useMemo(() => {
if (!parsedOutput) return null
// 如果有 tool_use_error直接显示错误状态
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
}, [parsedOutput])
return (
<AccordionItem
key={AgentToolsType.BashOutput}
aria-label="BashOutput Tool"
title={
<ToolTitle
icon={<Terminal className="h-4 w-4" />}
label="Bash Output"
params={
<div className="flex items-center gap-2">
<Code size="sm" className="py-0 text-xs">
{input.bash_id}
</Code>
{statusConfig && (
<Chip
size="sm"
color={statusConfig.color}
variant="flat"
startContent={statusConfig.icon}
className="h-5">
{statusConfig.text}
</Chip>
)}
</div>
}
/>
}
classNames={{
content: 'space-y-3 px-1'
}}>
{parsedOutput ? (
<>
{/* Status Info */}
<div className="flex flex-wrap items-center gap-2">
{parsedOutput.exit_code !== undefined && (
<Chip size="sm" color={parsedOutput.exit_code === 0 ? 'success' : 'danger'} variant="flat">
Exit Code: {parsedOutput.exit_code}
</Chip>
)}
{parsedOutput.timestamp && (
<Code size="sm" className="py-0 text-xs">
{new Date(parsedOutput.timestamp).toLocaleString()}
</Code>
)}
</div>
{/* Standard Output */}
{parsedOutput.stdout && (
<Card className="bg-default-50 dark:bg-default-900/20" shadow="none">
<CardBody className="p-3">
<div className="mb-2 font-medium text-default-600 text-xs">stdout:</div>
<pre className="whitespace-pre-wrap font-mono text-default-700 text-xs dark:text-default-300">
{parsedOutput.stdout}
</pre>
</CardBody>
</Card>
)}
{/* Standard Error */}
{parsedOutput.stderr && (
<Card
className="border border-danger-200 bg-danger-50/30 dark:border-danger-800 dark:bg-danger-900/10"
shadow="none">
<CardBody className="p-3">
<div className="mb-2 font-medium text-danger-600 text-xs">stderr:</div>
<pre className="whitespace-pre-wrap font-mono text-danger-600 text-xs dark:text-danger-400">
{parsedOutput.stderr}
</pre>
</CardBody>
</Card>
)}
{/* Tool Use Error */}
{parsedOutput.tool_use_error && (
<Card
className="border border-danger-200 bg-danger-50/30 dark:border-danger-800 dark:bg-danger-900/10"
shadow="none">
<CardBody className="p-3">
<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>
</div>
<pre className="whitespace-pre-wrap font-mono text-danger-600 text-xs dark:text-danger-400">
{parsedOutput.tool_use_error}
</pre>
</CardBody>
</Card>
)}
</>
) : (
// 原始输出(如果解析失败或非 XML 格式)
output && (
<Card className="bg-default-50 dark:bg-default-900/20" shadow="none">
<CardBody className="p-3">
<pre className="whitespace-pre-wrap font-mono text-default-700 text-xs dark:text-default-300">
{output}
</pre>
</CardBody>
</Card>
)
)}
</AccordionItem>
)
}

View File

@ -21,7 +21,7 @@ export function BashTool({ input, output }: { input: BashToolInputType; output?:
/>
}
subtitle={
<Code size="sm" className="line-clamp-1 w-max max-w-full text-ellipsis text-xs">
<Code size="sm" className="line-clamp-1 w-max max-w-full text-ellipsis py-0 text-xs">
{input.command}
</Code>
}>

View File

@ -0,0 +1,46 @@
import { AccordionItem } from '@heroui/react'
import { FileEdit } from 'lucide-react'
import { ToolTitle } from './GenericTools'
import type { EditToolInput, EditToolOutput } from './types'
import { AgentToolsType } from './types'
// 处理多行文本显示
export const renderCodeBlock = (content: string, variant: 'old' | 'new') => {
const lines = content.split('\n')
const textColorClass =
variant === 'old' ? 'text-danger-600 dark:text-danger-400' : 'text-success-600 dark:text-success-400'
return (
// 删除线
<pre className={`whitespace-pre-wrap font-mono text-xs ${textColorClass}`}>
{lines.map((line, idx) => (
<div key={idx} className="flex hover:bg-default-100/50 dark:hover:bg-default-900/50">
<span className="mr-3 min-w-[2rem] select-none text-right opacity-50">
{variant === 'old' && '-'}
{variant === 'new' && '+'}
{idx + 1}
</span>
<span className={`flex-1 ${variant === 'old' && 'line-through'}`}>{line || ' '}</span>
</div>
))}
</pre>
)
}
export function EditTool({ input, output }: { input: EditToolInput; output?: EditToolOutput }) {
return (
<AccordionItem
key={AgentToolsType.Edit}
aria-label="Edit Tool"
title={<ToolTitle icon={<FileEdit className="h-4 w-4" />} label="Edit" params={input.file_path} />}>
{/* Diff View */}
{/* Old Content */}
{renderCodeBlock(input.old_string, 'old')}
{/* New Content */}
{renderCodeBlock(input.new_string, 'new')}
{/* Output */}
{output}
</AccordionItem>
)
}

View File

@ -0,0 +1,24 @@
import { AccordionItem } from '@heroui/react'
import { DoorOpen } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import { ToolTitle } from './GenericTools'
import type { ExitPlanModeToolInput, ExitPlanModeToolOutput } from './types'
import { AgentToolsType } from './types'
export function ExitPlanModeTool({ input, output }: { input: ExitPlanModeToolInput; output?: ExitPlanModeToolOutput }) {
return (
<AccordionItem
key={AgentToolsType.ExitPlanMode}
aria-label="ExitPlanMode Tool"
title={
<ToolTitle
icon={<DoorOpen className="h-4 w-4" />}
label="ExitPlanMode"
stats={`${input.plan.split('\n\n').length} plans`}
/>
}>
{<ReactMarkdown>{input.plan + '\n\n' + (output ?? '')}</ReactMarkdown>}
</AccordionItem>
)
}

View File

@ -0,0 +1,23 @@
import { AccordionItem } from '@heroui/react'
import { FileText } from 'lucide-react'
import { renderCodeBlock } from './EditTool'
import { ToolTitle } from './GenericTools'
import type { MultiEditToolInput, MultiEditToolOutput } from './types'
import { AgentToolsType } from './types'
export function MultiEditTool({ input, output }: { input: MultiEditToolInput; output?: MultiEditToolOutput }) {
return (
<AccordionItem
key={AgentToolsType.MultiEdit}
aria-label="MultiEdit Tool"
title={<ToolTitle icon={<FileText className="h-4 w-4" />} label="MultiEdit" params={input.file_path} />}>
{input.edits.map((edit, index) => (
<div key={index}>
{renderCodeBlock(edit.old_string, 'old')}
{renderCodeBlock(edit.new_string, 'new')}
</div>
))}
</AccordionItem>
)
}

View File

@ -0,0 +1,19 @@
import { AccordionItem } from '@heroui/react'
import { FileText } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import { ToolTitle } from './GenericTools'
import type { NotebookEditToolInput, NotebookEditToolOutput } from './types'
import { AgentToolsType } from './types'
export function NotebookEditTool({ input, output }: { input: NotebookEditToolInput; output?: NotebookEditToolOutput }) {
return (
<AccordionItem
key={AgentToolsType.NotebookEdit}
aria-label="NotebookEdit Tool"
title={<ToolTitle icon={<FileText className="h-4 w-4" />} label="NotebookEdit" />}
subtitle={input.notebook_path}>
<ReactMarkdown>{output}</ReactMarkdown>
</AccordionItem>
)
}

View File

@ -1,24 +1,46 @@
import { AccordionItem } from '@heroui/react'
import { FileText } from 'lucide-react'
import { useMemo } from 'react'
import ReactMarkdown from 'react-markdown'
import { ToolTitle } from './GenericTools'
import type { ReadToolInput as ReadToolInputType, ReadToolOutput as ReadToolOutputType } from './types'
import type { ReadToolInput as ReadToolInputType, ReadToolOutput as ReadToolOutputType, TextOutput } from './types'
import { AgentToolsType } from './types'
export function ReadTool({ input, output }: { input: ReadToolInputType; output?: ReadToolOutputType }) {
// 将 output 统一转换为字符串
const outputString = useMemo(() => {
if (!output) return null
// 如果是 TextOutput[] 类型,提取所有 text 内容
if (Array.isArray(output)) {
return output
.filter((item): item is TextOutput => item.type === 'text')
.map((item) => item.text)
.join('')
}
// 如果是字符串,直接返回
return output
}, [output])
// 如果有输出,计算统计信息
const stats = output
? {
lineCount: output.split('\n').length,
fileSize: new Blob([output]).size,
formatSize: (bytes: number) => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
}
: null
const stats = useMemo(() => {
if (!outputString) return null
const bytes = new Blob([outputString]).size
const formatSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
return {
lineCount: outputString.split('\n').length,
fileSize: bytes,
formatSize
}
}, [outputString])
return (
<AccordionItem
@ -29,14 +51,10 @@ export function ReadTool({ input, output }: { input: ReadToolInputType; output?:
icon={<FileText className="h-4 w-4" />}
label="Read File"
params={input.file_path.split('/').pop()}
stats={output && stats ? `${stats.lineCount} lines, ${stats.formatSize(stats.fileSize)}` : undefined}
stats={stats ? `${stats.lineCount} lines, ${stats.formatSize(stats.fileSize)}` : undefined}
/>
}>
{output ? (
// <div className="h-full scroll-auto">
<ReactMarkdown>{output}</ReactMarkdown>
// </div>
) : null}
{outputString ? <ReactMarkdown>{outputString}</ReactMarkdown> : null}
</AccordionItem>
)
}

View File

@ -14,31 +14,28 @@ const getStatusConfig = (status: TodoItem['status']) => {
case 'completed':
return {
color: 'success' as const,
icon: <CheckCircle className="h-3 w-3" />,
label: '已完成'
icon: <CheckCircle className="h-3 w-3" />
}
case 'in_progress':
return {
color: 'primary' as const,
icon: <Clock className="h-3 w-3" />,
label: '进行中'
icon: <Clock className="h-3 w-3" />
}
case 'pending':
return {
color: 'default' as const,
icon: <Circle className="h-3 w-3" />,
label: '待处理'
icon: <Circle className="h-3 w-3" />
}
default:
return {
color: 'default' as const,
icon: <Circle className="h-3 w-3" />,
label: '待处理'
icon: <Circle className="h-3 w-3" />
}
}
}
export function TodoWriteTool({ input, output }: { input: TodoWriteToolInputType; output?: TodoWriteToolOutputType }) {
const doneCount = input.todos.filter((todo) => todo.status === 'completed').length
return (
<AccordionItem
key={AgentToolsType.TodoWrite}
@ -46,7 +43,8 @@ export function TodoWriteTool({ input, output }: { input: TodoWriteToolInputType
title={
<ToolTitle
icon={<ListTodo className="h-4 w-4" />}
label="Todo Update"
label="Todo Write"
params={`${doneCount} Done`}
stats={`${input.todos.length} ${input.todos.length === 1 ? 'item' : 'items'}`}
/>
}>
@ -55,15 +53,10 @@ export function TodoWriteTool({ input, output }: { input: TodoWriteToolInputType
const statusConfig = getStatusConfig(todo.status)
return (
<Card key={index} className="shadow-sm">
<CardBody>
<CardBody className="p-2">
<div className="flex items-start gap-3">
<Chip
color={statusConfig.color}
variant="flat"
size="sm"
startContent={statusConfig.icon}
className="flex-shrink-0">
{statusConfig.label}
<Chip color={statusConfig.color} variant="flat" size="sm" className="flex-shrink-0">
{statusConfig.icon}
</Chip>
<div className="min-w-0 flex-1">
<div className={`text-sm ${todo.status === 'completed' ? 'text-default-500 line-through' : ''}`}>

View File

@ -6,9 +6,14 @@ import { NormalToolResponse } from '@renderer/types'
export * from './types'
// 导入所有渲染器
import { BashOutputTool } from './BashOutputTool'
import { BashTool } from './BashTool'
import { EditTool } from './EditTool'
import { ExitPlanModeTool } from './ExitPlanModeTool'
import { GlobTool } from './GlobTool'
import { GrepTool } from './GrepTool'
import { MultiEditTool } from './MultiEditTool'
import { NotebookEditTool } from './NotebookEditTool'
import { ReadTool } from './ReadTool'
import { SearchTool } from './SearchTool'
import { TaskTool } from './TaskTool'
@ -31,7 +36,12 @@ export const toolRenderers = {
[AgentToolsType.WebSearch]: WebSearchTool,
[AgentToolsType.Grep]: GrepTool,
[AgentToolsType.Write]: WriteTool,
[AgentToolsType.WebFetch]: WebFetchTool
[AgentToolsType.WebFetch]: WebFetchTool,
[AgentToolsType.Edit]: EditTool,
[AgentToolsType.MultiEdit]: MultiEditTool,
[AgentToolsType.BashOutput]: BashOutputTool,
[AgentToolsType.NotebookEdit]: NotebookEditTool,
[AgentToolsType.ExitPlanMode]: ExitPlanModeTool
} as const
// 类型守卫函数
@ -51,7 +61,8 @@ function renderToolContent(toolName: AgentToolsType, input: ToolInput, output?:
'p-0 [&>div:first-child]:!flex-none [&>div:first-child]:flex [&>div:first-child]:flex-col [&>div:first-child]:text-start [&>div:first-child]:max-w-full',
indicator: 'flex-shrink-0',
subtitle: 'text-xs',
content: 'rounded-md bg-foreground-50 p-2 text-foreground-900 dark:bg-foreground-100 h-fit max-h-96 scroll-auto'
content:
'rounded-md bg-foreground-50 p-2 text-foreground-900 dark:bg-foreground-100 max-h-96 p-2 overflow-scroll'
}}
defaultExpandedKeys={toolName === AgentToolsType.TodoWrite ? [AgentToolsType.TodoWrite] : []}>
{/* <Renderer input={input as any} output={output as any} /> */}

View File

@ -8,7 +8,17 @@ export enum AgentToolsType {
WebSearch = 'WebSearch',
Grep = 'Grep',
Write = 'Write',
WebFetch = 'WebFetch'
WebFetch = 'WebFetch',
Edit = 'Edit',
MultiEdit = 'MultiEdit',
BashOutput = 'BashOutput',
NotebookEdit = 'NotebookEdit',
ExitPlanMode = 'ExitPlanMode'
}
export type TextOutput = {
type: 'text'
text: string
}
// Read 工具的类型定义
@ -16,7 +26,7 @@ export interface ReadToolInput {
file_path: string
}
export type ReadToolOutput = string
export type ReadToolOutput = string | TextOutput[]
// Task 工具的类型定义
export type TaskToolInput = {
@ -25,10 +35,7 @@ export type TaskToolInput = {
subagent_type: string
}
export type TaskToolOutput = {
type: 'text'
text: string
}[]
export type TaskToolOutput = TextOutput[]
// Bash 工具的类型定义
export type BashToolInput = {
@ -84,6 +91,7 @@ export interface GrepToolInput {
export type GrepToolOutput = string
// Write 工具的类型定义
export type WriteToolInput = {
content: string
file_path: string
@ -91,6 +99,44 @@ export type WriteToolInput = {
export type WriteToolOutput = string
// Edit 工具的类型定义
export type EditToolInput = {
file_path: string
old_string: string
new_string: string
}
export type EditToolOutput = string
// MultiEdit 工具的类型定义
export type MultiEditToolInput = {
file_path: string
edits: {
old_string: string
new_string: string
}[]
}
export type MultiEditToolOutput = string
// BashOutput 工具的类型定义
export type BashOutputToolInput = {
bash_id: string
}
export type BashOutputToolOutput = string
// NotebookEdit 工具的类型定义
export type NotebookEditToolInput = {
notebook_path: string
edit_mode: string
cell_type: string
new_source: string
}
export type NotebookEditToolOutput = string
export type ExitPlanModeToolInput = {
plan: string
}
export type ExitPlanModeToolOutput = string
// 联合类型
export type ToolInput =
| ReadToolInput
@ -103,6 +149,11 @@ export type ToolInput =
| WebFetchToolInput
| GrepToolInput
| WriteToolInput
| EditToolInput
| MultiEditToolInput
| BashOutputToolInput
| NotebookEditToolInput
| ExitPlanModeToolInput
export type ToolOutput =
| ReadToolOutput
| TaskToolOutput
@ -114,6 +165,11 @@ export type ToolOutput =
| GrepToolOutput
| WebFetchToolOutput
| WriteToolOutput
| EditToolOutput
| MultiEditToolOutput
| BashOutputToolOutput
| NotebookEditToolOutput
| ExitPlanModeToolOutput
// 工具渲染器接口
export interface ToolRenderer {
render: (props: { input: ToolInput; output?: ToolOutput }) => React.ReactElement

View File

@ -35,6 +35,11 @@ const ChooseTool = (toolResponse: NormalToolResponse): React.ReactNode | null =>
case 'Grep':
case 'Write':
case 'WebFetch':
case 'Edit':
case 'MultiEdit':
case 'BashOutput':
case 'NotebookEdit':
case 'ExitPlanMode':
return <MessageAgentTools toolResponse={toolResponse} />
default:
return null

View File

@ -108,6 +108,7 @@ export const AgentToolingSettings: FC<AgentToolingSettingsProps> = ({ agent, upd
const { t } = useTranslation()
const client = useAgentClient()
const { mcpServers: allServers } = useMCPServers()
const [modal, contextHolder] = Modal.useModal()
const [configuration, setConfiguration] = useState<AgentConfigurationState>(defaultConfiguration)
const [selectedMode, setSelectedMode] = useState<PermissionMode>(defaultConfiguration.permission_mode)
@ -192,8 +193,12 @@ export const AgentToolingSettings: FC<AgentToolingSettingsProps> = ({ agent, upd
}
if (removedDefaults.length > 0) {
Modal.confirm({
title: t('agent.settings.tooling.permissionMode.confirmChange.title', 'Change permission mode?'),
modal.confirm({
title: (
<span className="text-foreground">
{t('agent.settings.tooling.permissionMode.confirmChange.title', 'Change permission mode?')}
</span>
),
content: (
<div className="flex flex-col gap-2">
<p className="text-foreground-500 text-sm">
@ -207,7 +212,11 @@ export const AgentToolingSettings: FC<AgentToolingSettingsProps> = ({ agent, upd
<ul className="mt-1 list-disc pl-4">
{removedDefaults.map((id) => {
const tool = availableTools.find((item) => item.id === id)
return <li key={id}>{tool?.name ?? id}</li>
return (
<li className="text-foreground" key={id}>
{tool?.name ?? id}
</li>
)
})}
</ul>
</div>
@ -215,13 +224,27 @@ export const AgentToolingSettings: FC<AgentToolingSettingsProps> = ({ agent, upd
),
okText: t('common.confirm'),
cancelText: t('common.cancel'),
onOk: applyChange
onOk: applyChange,
classNames: {
content: 'bg-background! border! border-solid! rounded border-grey border-default-200!'
}
})
} else {
void applyChange()
}
},
[agent, selectedMode, isUpdatingMode, availableTools, userAddedIds, autoToolIds, configuration, updateAgent, t]
[
agent,
selectedMode,
isUpdatingMode,
availableTools,
userAddedIds,
autoToolIds,
configuration,
updateAgent,
modal,
t
]
)
const handleToggleTool = useCallback(
@ -300,6 +323,7 @@ export const AgentToolingSettings: FC<AgentToolingSettingsProps> = ({ agent, upd
return (
<SettingsContainer>
{contextHolder}
<SettingsItem>
<SettingsTitle>
{t('agent.settings.tooling.steps.permissionMode.title', 'Step 1 · Permission mode')}
@ -309,6 +333,7 @@ export const AgentToolingSettings: FC<AgentToolingSettingsProps> = ({ agent, upd
const isSelected = card.mode === selectedMode
const disabled = card.unsupported
const showCaution = card.caution
return (
<Card
key={card.mode}
@ -338,13 +363,15 @@ export const AgentToolingSettings: FC<AgentToolingSettingsProps> = ({ agent, upd
<CardBody className="gap-2 text-left text-xs">
<span className="text-foreground-600">{t(card.behaviorKey, card.behaviorFallback)}</span>
{showCaution ? (
<span className="flex items-center gap-1 text-danger-600">
<ShieldAlert size={14} />
{t(
'agent.settings.tooling.permissionMode.bypassPermissions.warning',
'Use with caution — all tools will run without asking for approval.'
)}
</span>
<div className="flex items-center gap-1">
<ShieldAlert size={24} />
<span className="text-danger-600">
{t(
'agent.settings.tooling.permissionMode.bypassPermissions.warning',
'Use with caution — all tools will run without asking for approval.'
)}
</span>
</div>
) : null}
</CardBody>
</Card>
@ -375,7 +402,7 @@ export const AgentToolingSettings: FC<AgentToolingSettingsProps> = ({ agent, upd
onValueChange={setSearchTerm}
placeholder={t('agent.settings.tooling.preapproved.search', 'Search tools')}
aria-label={t('agent.settings.tooling.preapproved.search', 'Search tools')}
className="max-w-md"
className="w-full"
/>
<div className="flex flex-col gap-3">
{filteredTools.length === 0 ? (

View File

@ -267,6 +267,10 @@ export const SessionIdParamSchema = z.object({
sessionId: z.string().min(1, 'Session ID is required')
})
export const SessionMessageIdParamSchema = z.object({
messageId: z.coerce.number().int().positive('Message ID must be a positive integer')
})
// Query validation schemas
export const PaginationQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(100).optional().default(20),