Compare commits

..

2 Commits

Author SHA1 Message Date
beyondkmp 92d761bb8a add version 2025-11-20 10:58:10 +08:00
beyondkmp 9210386508 refactor: improve EmojiPicker type safety and code organization
- Add proper type declarations for emoji-picker custom element
- Define EmojiPickerElement and EmojiClickEvent interfaces
- Replace @ts-ignore with proper TypeScript types
- Separate dataSource initialization into its own useEffect
- Use local emoji data from emoji-picker-element-data

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 10:19:36 +08:00
51 changed files with 435 additions and 1375 deletions
+1 -1
View File
@@ -86,7 +86,7 @@
"@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch",
"@paymoapp/electron-shutdown-handler": "^1.1.2",
"@strongtz/win32-arm64-msvc": "^0.4.7",
"emoji-picker-element-data": "^1",
"emoji-picker-element-data": "^1.8.0",
"express": "^5.1.0",
"font-list": "^2.0.0",
"graceful-fs": "^4.2.11",
@@ -1,2 +0,0 @@
ALTER TABLE `agents` ADD `sub_agents` text;--> statement-breakpoint
ALTER TABLE `sessions` ADD `sub_agents` text;
@@ -1,360 +0,0 @@
{
"version": "6",
"dialect": "sqlite",
"id": "9aeb5f21-fed7-4dbf-973d-c344681b71c2",
"prevId": "0cf3d79e-69bf-4dba-8df4-996b9b67d2e8",
"tables": {
"agents": {
"name": "agents",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"accessible_paths": {
"name": "accessible_paths",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"instructions": {
"name": "instructions",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"model": {
"name": "model",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"plan_model": {
"name": "plan_model",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"small_model": {
"name": "small_model",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"mcps": {
"name": "mcps",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"allowed_tools": {
"name": "allowed_tools",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sub_agents": {
"name": "sub_agents",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"configuration": {
"name": "configuration",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session_messages": {
"name": "session_messages",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"session_id": {
"name": "session_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"agent_session_id": {
"name": "agent_session_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "''"
},
"metadata": {
"name": "metadata",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"migrations": {
"name": "migrations",
"columns": {
"version": {
"name": "version",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"tag": {
"name": "tag",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"executed_at": {
"name": "executed_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"agent_type": {
"name": "agent_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"agent_id": {
"name": "agent_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"accessible_paths": {
"name": "accessible_paths",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"instructions": {
"name": "instructions",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"model": {
"name": "model",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"plan_model": {
"name": "plan_model",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"small_model": {
"name": "small_model",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"mcps": {
"name": "mcps",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"allowed_tools": {
"name": "allowed_tools",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sub_agents": {
"name": "sub_agents",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"slash_commands": {
"name": "slash_commands",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"configuration": {
"name": "configuration",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
@@ -22,13 +22,6 @@
"when": 1762526423527,
"tag": "0002_wealthy_naoko",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1763500397620,
"tag": "0003_smooth_talkback",
"breakpoints": true
}
]
}
+6
View File
@@ -104,6 +104,12 @@ const router = express
logger.warn('No models available from providers', { filter })
}
logger.info('Models response ready', {
filter,
total: response.total,
modelIds: response.data.map((m) => m.id)
})
return res.json(response satisfies ApiModelsResponse)
} catch (error: any) {
logger.error('Error fetching models', { error })
+1 -1
View File
@@ -32,7 +32,7 @@ export class ModelsService {
for (const model of models) {
const provider = providers.find((p) => p.id === model.provider)
// logger.debug(`Processing model ${model.id}`)
logger.debug(`Processing model ${model.id}`)
if (!provider) {
logger.debug(`Skipping model ${model.id} . Reason: Provider not found.`)
continue
+1 -1
View File
@@ -849,7 +849,7 @@ class FileStorage {
const resolvedPath = path.resolve(dirPath)
const stat = await fs.promises.stat(resolvedPath).catch((error) => {
logger.error(`Failed to access directory: ${resolvedPath}`, error as Error)
logger.error(`[IPC - Error] Failed to access directory: ${resolvedPath}`, error as Error)
throw error
})
-1
View File
@@ -42,7 +42,6 @@ export abstract class BaseService {
'configuration',
'accessible_paths',
'allowed_tools',
'sub_agents',
'slash_commands'
]
@@ -19,7 +19,6 @@ export const agentsTable = sqliteTable('agents', {
mcps: text('mcps'), // JSON array of MCP tool IDs
allowed_tools: text('allowed_tools'), // JSON array of allowed tool IDs (whitelist)
sub_agents: text('sub_agents'), // JSON array of sub-agent IDs
configuration: text('configuration'), // JSON, extensible settings
@@ -22,7 +22,6 @@ export const sessionsTable = sqliteTable('sessions', {
mcps: text('mcps'), // JSON array of MCP tool IDs
allowed_tools: text('allowed_tools'), // JSON array of allowed tool IDs (whitelist)
sub_agents: text('sub_agents'), // JSON array of sub-agent IDs
slash_commands: text('slash_commands'), // JSON array of slash command objects from SDK init
configuration: text('configuration'), // JSON, extensible settings
@@ -117,19 +117,6 @@ export class AgentService extends BaseService {
return agent
}
async getAgentConfigForSDK(id: string): Promise<AgentEntity | null> {
this.ensureInitialized()
const result = await this.database.select().from(agentsTable).where(eq(agentsTable.id, id)).limit(1)
if (!result[0]) {
return null
}
const agent = this.deserializeJsonFields(result[0]) as AgentEntity
return agent
}
async listAgents(options: ListOptions = {}): Promise<{ agents: AgentEntity[]; total: number }> {
this.ensureInitialized() // Build query with pagination
@@ -130,7 +130,6 @@ export class SessionService extends BaseService {
small_model: serializedData.small_model || null,
mcps: serializedData.mcps || null,
allowed_tools: serializedData.allowed_tools || null,
sub_agents: serializedData.sub_agents || null,
configuration: serializedData.configuration || null,
created_at: now,
updated_at: now
@@ -170,22 +169,6 @@ export class SessionService extends BaseService {
session.slash_commands = await this.listSlashCommands(session.agent_type, agentId)
}
// Load installed plugins from cache file
const workdir = session.accessible_paths?.[0]
if (workdir) {
try {
session.plugins = await pluginService.listInstalledFromCache(workdir)
} catch (error) {
logger.warn(`Failed to load installed plugins for session ${id}`, {
workdir,
error: error instanceof Error ? error.message : String(error)
})
session.plugins = []
}
} else {
session.plugins = []
}
return session
}
@@ -21,16 +21,11 @@ describe('stripLocalCommandTags', () => {
'<local-command-stdout>line1</local-command-stdout>\nkeep\n<local-command-stderr>Error</local-command-stderr>'
expect(stripLocalCommandTags(input)).toBe('line1\nkeep\nError')
})
it('if no tags present, returns original string', () => {
const input = 'just some normal text'
expect(stripLocalCommandTags(input)).toBe(input)
})
})
describe('Claude → AiSDK transform', () => {
it('handles tool call streaming lifecycle', () => {
const state = new ClaudeStreamState({ agentSessionId: baseStreamMetadata.session_id })
const state = new ClaudeStreamState()
const parts: ReturnType<typeof transformSDKMessageToStreamParts>[number][] = []
const messages: SDKMessage[] = [
@@ -187,119 +182,14 @@ describe('Claude → AiSDK transform', () => {
(typeof parts)[number],
{ type: 'tool-result' }
>
expect(toolResult.toolCallId).toBe('session-123:tool-1')
expect(toolResult.toolCallId).toBe('tool-1')
expect(toolResult.toolName).toBe('Bash')
expect(toolResult.input).toEqual({ command: 'ls' })
expect(toolResult.output).toBe('ok')
})
it('handles tool calls without streaming events (no content_block_start/stop)', () => {
const state = new ClaudeStreamState({ agentSessionId: '12344' })
const parts: ReturnType<typeof transformSDKMessageToStreamParts>[number][] = []
const messages: SDKMessage[] = [
{
...baseStreamMetadata,
type: 'assistant',
uuid: uuid(20),
message: {
id: 'msg-tool-no-stream',
type: 'message',
role: 'assistant',
model: 'claude-test',
content: [
{
type: 'tool_use',
id: 'tool-read',
name: 'Read',
input: { file_path: '/test.txt' }
},
{
type: 'tool_use',
id: 'tool-bash',
name: 'Bash',
input: { command: 'ls -la' }
}
],
stop_reason: 'tool_use',
stop_sequence: null,
usage: {
input_tokens: 10,
output_tokens: 20
}
}
} as unknown as SDKMessage,
{
...baseStreamMetadata,
type: 'user',
uuid: uuid(21),
message: {
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'tool-read',
content: 'file contents',
is_error: false
}
]
}
} as SDKMessage,
{
...baseStreamMetadata,
type: 'user',
uuid: uuid(22),
message: {
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'tool-bash',
content: 'total 42\n...',
is_error: false
}
]
}
} as SDKMessage
]
for (const message of messages) {
const transformed = transformSDKMessageToStreamParts(message, state)
parts.push(...transformed)
}
const types = parts.map((part) => part.type)
expect(types).toEqual(['tool-call', 'tool-call', 'tool-result', 'tool-result'])
const toolCalls = parts.filter((part) => part.type === 'tool-call') as Extract<
(typeof parts)[number],
{ type: 'tool-call' }
>[]
expect(toolCalls).toHaveLength(2)
expect(toolCalls[0].toolName).toBe('Read')
expect(toolCalls[0].toolCallId).toBe('12344:tool-read')
expect(toolCalls[1].toolName).toBe('Bash')
expect(toolCalls[1].toolCallId).toBe('12344:tool-bash')
const toolResults = parts.filter((part) => part.type === 'tool-result') as Extract<
(typeof parts)[number],
{ type: 'tool-result' }
>[]
expect(toolResults).toHaveLength(2)
// This is the key assertion - toolName should NOT be 'unknown'
expect(toolResults[0].toolName).toBe('Read')
expect(toolResults[0].toolCallId).toBe('12344:tool-read')
expect(toolResults[0].input).toEqual({ file_path: '/test.txt' })
expect(toolResults[0].output).toBe('file contents')
expect(toolResults[1].toolName).toBe('Bash')
expect(toolResults[1].toolCallId).toBe('12344:tool-bash')
expect(toolResults[1].input).toEqual({ command: 'ls -la' })
expect(toolResults[1].output).toBe('total 42\n...')
})
it('handles streaming text completion', () => {
const state = new ClaudeStreamState({ agentSessionId: baseStreamMetadata.session_id })
const state = new ClaudeStreamState()
const parts: ReturnType<typeof transformSDKMessageToStreamParts>[number][] = []
const messages: SDKMessage[] = [
@@ -410,87 +300,4 @@ describe('Claude → AiSDK transform', () => {
expect(finishStep.finishReason).toBe('stop')
expect(finishStep.usage).toEqual({ inputTokens: 2, outputTokens: 4, totalTokens: 6 })
})
it('emits fallback text when Claude sends a snapshot instead of deltas', () => {
const state = new ClaudeStreamState({ agentSessionId: '12344' })
const parts: ReturnType<typeof transformSDKMessageToStreamParts>[number][] = []
const messages: SDKMessage[] = [
{
...baseStreamMetadata,
type: 'stream_event',
uuid: uuid(30),
event: {
type: 'message_start',
message: {
id: 'msg-fallback',
type: 'message',
role: 'assistant',
model: 'claude-test',
content: [],
stop_reason: null,
stop_sequence: null,
usage: {}
}
}
} as unknown as SDKMessage,
{
...baseStreamMetadata,
type: 'stream_event',
uuid: uuid(31),
event: {
type: 'content_block_start',
index: 0,
content_block: {
type: 'text',
text: ''
}
}
} as unknown as SDKMessage,
{
...baseStreamMetadata,
type: 'assistant',
uuid: uuid(32),
message: {
id: 'msg-fallback-content',
type: 'message',
role: 'assistant',
model: 'claude-test',
content: [
{
type: 'text',
text: 'Final answer without streaming deltas.'
}
],
stop_reason: 'end_turn',
stop_sequence: null,
usage: {
input_tokens: 3,
output_tokens: 7
}
}
} as unknown as SDKMessage
]
for (const message of messages) {
const transformed = transformSDKMessageToStreamParts(message, state)
parts.push(...transformed)
}
const types = parts.map((part) => part.type)
expect(types).toEqual(['start-step', 'text-start', 'text-delta', 'text-end', 'finish-step'])
const delta = parts.find((part) => part.type === 'text-delta') as Extract<
(typeof parts)[number],
{ type: 'text-delta' }
>
expect(delta.text).toBe('Final answer without streaming deltas.')
const finish = parts.find((part) => part.type === 'finish-step') as Extract<
(typeof parts)[number],
{ type: 'finish-step' }
>
expect(finish.usage).toEqual({ inputTokens: 3, outputTokens: 7, totalTokens: 10 })
expect(finish.finishReason).toBe('stop')
})
})
@@ -10,21 +10,8 @@
* Every Claude turn gets its own instance. `resetStep` should be invoked once the finish event has
* been emitted to avoid leaking state into the next turn.
*/
import { loggerService } from '@logger'
import type { FinishReason, LanguageModelUsage, ProviderMetadata } from 'ai'
/**
* Builds a namespaced tool call ID by combining session ID with raw tool call ID.
* This ensures tool calls from different sessions don't conflict even if they have
* the same raw ID from the SDK.
*
* @param sessionId - The agent session ID
* @param rawToolCallId - The raw tool call ID from SDK (e.g., "WebFetch_0")
*/
export function buildNamespacedToolCallId(sessionId: string, rawToolCallId: string): string {
return `${sessionId}:${rawToolCallId}`
}
/**
* Shared fields for every block that Claude can stream (text, reasoning, tool).
*/
@@ -47,7 +34,6 @@ type ReasoningBlockState = BaseBlockState & {
type ToolBlockState = BaseBlockState & {
kind: 'tool'
toolCallId: string
rawToolCallId: string
toolName: string
inputBuffer: string
providerMetadata?: ProviderMetadata
@@ -62,17 +48,12 @@ type PendingUsageState = {
}
type PendingToolCall = {
rawToolCallId: string
toolCallId: string
toolName: string
input: unknown
providerMetadata?: ProviderMetadata
}
type ClaudeStreamStateOptions = {
agentSessionId: string
}
/**
* Tracks the lifecycle of Claude streaming blocks (text, thinking, tool calls)
* across individual websocket events. The transformer relies on this class to
@@ -80,20 +61,12 @@ type ClaudeStreamStateOptions = {
* usage/finish metadata once Anthropic closes a message.
*/
export class ClaudeStreamState {
private logger
private readonly agentSessionId: string
private blocksByIndex = new Map<number, BlockState>()
private toolIndexByNamespacedId = new Map<string, number>()
private toolIndexById = new Map<string, number>()
private pendingUsage: PendingUsageState = {}
private pendingToolCalls = new Map<string, PendingToolCall>()
private stepActive = false
constructor(options: ClaudeStreamStateOptions) {
this.logger = loggerService.withContext('ClaudeStreamState')
this.agentSessionId = options.agentSessionId
this.logger.silly('ClaudeStreamState', options)
}
/** Marks the beginning of a new AiSDK step. */
beginStep(): void {
this.stepActive = true
@@ -131,21 +104,19 @@ export class ClaudeStreamState {
/** Caches tool metadata so subsequent input deltas and results can find it. */
openToolBlock(
index: number,
params: { rawToolCallId: string; toolName: string; providerMetadata?: ProviderMetadata }
params: { toolCallId: string; toolName: string; providerMetadata?: ProviderMetadata }
): ToolBlockState {
const toolCallId = buildNamespacedToolCallId(this.agentSessionId, params.rawToolCallId)
const block: ToolBlockState = {
kind: 'tool',
id: toolCallId,
id: params.toolCallId,
index,
toolCallId,
rawToolCallId: params.rawToolCallId,
toolCallId: params.toolCallId,
toolName: params.toolName,
inputBuffer: '',
providerMetadata: params.providerMetadata
}
this.blocksByIndex.set(index, block)
this.toolIndexByNamespacedId.set(toolCallId, index)
this.toolIndexById.set(params.toolCallId, index)
return block
}
@@ -153,32 +124,14 @@ export class ClaudeStreamState {
return this.blocksByIndex.get(index)
}
getFirstOpenTextBlock(): TextBlockState | undefined {
const candidates: TextBlockState[] = []
for (const block of this.blocksByIndex.values()) {
if (block.kind === 'text') {
candidates.push(block)
}
}
if (candidates.length === 0) {
return undefined
}
candidates.sort((a, b) => a.index - b.index)
return candidates[0]
}
getToolBlockById(toolCallId: string): ToolBlockState | undefined {
const index = this.toolIndexByNamespacedId.get(toolCallId)
const index = this.toolIndexById.get(toolCallId)
if (index === undefined) return undefined
const block = this.blocksByIndex.get(index)
if (!block || block.kind !== 'tool') return undefined
return block
}
getToolBlockByRawId(rawToolCallId: string): ToolBlockState | undefined {
return this.getToolBlockById(buildNamespacedToolCallId(this.agentSessionId, rawToolCallId))
}
/** Appends streamed text to a text block, returning the updated state when present. */
appendTextDelta(index: number, text: string): TextBlockState | undefined {
const block = this.blocksByIndex.get(index)
@@ -205,12 +158,10 @@ export class ClaudeStreamState {
/** Records a tool call to be consumed once its result arrives from the user. */
registerToolCall(
rawToolCallId: string,
toolCallId: string,
payload: { toolName: string; input: unknown; providerMetadata?: ProviderMetadata }
): void {
const toolCallId = buildNamespacedToolCallId(this.agentSessionId, rawToolCallId)
this.pendingToolCalls.set(rawToolCallId, {
rawToolCallId,
this.pendingToolCalls.set(toolCallId, {
toolCallId,
toolName: payload.toolName,
input: payload.input,
@@ -219,10 +170,10 @@ export class ClaudeStreamState {
}
/** Retrieves and clears the buffered tool call metadata for the given id. */
consumePendingToolCall(rawToolCallId: string): PendingToolCall | undefined {
const entry = this.pendingToolCalls.get(rawToolCallId)
consumePendingToolCall(toolCallId: string): PendingToolCall | undefined {
const entry = this.pendingToolCalls.get(toolCallId)
if (entry) {
this.pendingToolCalls.delete(rawToolCallId)
this.pendingToolCalls.delete(toolCallId)
}
return entry
}
@@ -231,13 +182,13 @@ export class ClaudeStreamState {
* Persists the final input payload for a tool block once the provider signals
* completion so that downstream tool results can reference the original call.
*/
completeToolBlock(toolCallId: string, toolName: string, input: unknown, providerMetadata?: ProviderMetadata): void {
const block = this.getToolBlockByRawId(toolCallId)
completeToolBlock(toolCallId: string, input: unknown, providerMetadata?: ProviderMetadata): void {
this.registerToolCall(toolCallId, {
toolName,
toolName: this.getToolBlockById(toolCallId)?.toolName ?? 'unknown',
input,
providerMetadata
})
const block = this.getToolBlockById(toolCallId)
if (block) {
block.resolvedInput = input
}
@@ -249,7 +200,7 @@ export class ClaudeStreamState {
if (!block) return undefined
this.blocksByIndex.delete(index)
if (block.kind === 'tool') {
this.toolIndexByNamespacedId.delete(block.toolCallId)
this.toolIndexById.delete(block.toolCallId)
}
return block
}
@@ -276,7 +227,7 @@ export class ClaudeStreamState {
/** Drops cached block metadata for the currently active message. */
resetBlocks(): void {
this.blocksByIndex.clear()
this.toolIndexByNamespacedId.clear()
this.toolIndexById.clear()
}
/** Resets the entire step lifecycle after emitting a terminal frame. */
@@ -285,10 +236,6 @@ export class ClaudeStreamState {
this.resetPendingUsage()
this.stepActive = false
}
getNamespacedToolCallId(rawToolCallId: string): string {
return buildNamespacedToolCallId(this.agentSessionId, rawToolCallId)
}
}
export type { PendingToolCall }
@@ -2,13 +2,7 @@
import { EventEmitter } from 'node:events'
import { createRequire } from 'node:module'
import type {
AgentDefinition,
CanUseTool,
McpHttpServerConfig,
Options,
SDKMessage
} from '@anthropic-ai/claude-agent-sdk'
import type { CanUseTool, McpHttpServerConfig, Options, SDKMessage } from '@anthropic-ai/claude-agent-sdk'
import { query } from '@anthropic-ai/claude-agent-sdk'
import { loggerService } from '@logger'
import { config as apiConfigService } from '@main/apiServer/config'
@@ -16,10 +10,9 @@ import { validateModelId } from '@main/apiServer/utils'
import getLoginShellEnvironment from '@main/utils/shell-env'
import { app } from 'electron'
import { agentService, type GetAgentSessionResponse } from '../..'
import type { GetAgentSessionResponse } from '../..'
import type { AgentServiceInterface, AgentStream, AgentStreamEvent } from '../../interfaces/AgentStreamInterface'
import { sessionService } from '../SessionService'
import { buildNamespacedToolCallId } from './claude-stream-state'
import { promptForToolApproval } from './tool-permissions'
import { ClaudeStreamState, transformSDKMessageToStreamParts } from './transform'
@@ -157,36 +150,7 @@ class ClaudeCodeService implements AgentServiceInterface {
return { behavior: 'allow', updatedInput: input }
}
return promptForToolApproval(toolName, input, {
...options,
toolCallId: buildNamespacedToolCallId(session.id, options.toolUseID)
})
}
const subAgents: Record<string, AgentDefinition> = {}
if (session.sub_agents && session.sub_agents.length > 0) {
for (const subAgentId of session.sub_agents) {
try {
const agentConfig = await agentService.getAgentConfigForSDK(subAgentId)
if (agentConfig) {
subAgents[subAgentId] = {
// TODO: support custom model for sub-agents
model: 'inherit',
description: agentConfig.description ?? '',
prompt: agentConfig.instructions ?? '',
tools: agentConfig.allowed_tools
}
logger.info('Loaded sub-agent', { subAgentId })
} else {
logger.warn('Sub-agent not found', { subAgentId })
}
} catch (error) {
logger.error('Failed to load sub-agent config', {
subAgentId,
error: error instanceof Error ? error.message : String(error)
})
}
}
return promptForToolApproval(toolName, input, options)
}
// Build SDK options from parameters
@@ -382,7 +346,7 @@ class ClaudeCodeService implements AgentServiceInterface {
const jsonOutput: SDKMessage[] = []
let hasCompleted = false
const startTime = Date.now()
const streamState = new ClaudeStreamState({ agentSessionId: sessionId })
const streamState = new ClaudeStreamState()
try {
for await (const message of query({ prompt: promptStream, options })) {
@@ -446,6 +410,23 @@ class ClaudeCodeService implements AgentServiceInterface {
}
}
if (message.type === 'assistant' || message.type === 'user') {
logger.silly('claude response', {
message,
content: JSON.stringify(message.message.content)
})
} else if (message.type === 'stream_event') {
// logger.silly('Claude stream event', {
// message,
// event: JSON.stringify(message.event)
// })
} else {
logger.silly('Claude response', {
message,
event: JSON.stringify(message)
})
}
const chunks = transformSDKMessageToStreamParts(message, streamState)
for (const chunk of chunks) {
stream.emit('data', {
@@ -37,7 +37,6 @@ type RendererPermissionRequestPayload = {
requestId: string
toolName: string
toolId: string
toolCallId: string
description?: string
requiresPermissions: boolean
input: Record<string, unknown>
@@ -207,19 +206,10 @@ const ensureIpcHandlersRegistered = () => {
})
}
type PromptForToolApprovalOptions = {
signal: AbortSignal
suggestions?: PermissionUpdate[]
// NOTICE: This ID is namespaced with session ID, not the raw SDK tool call ID.
// Format: `${sessionId}:${rawToolCallId}`, e.g., `session_123:WebFetch_0`
toolCallId: string
}
export async function promptForToolApproval(
toolName: string,
input: Record<string, unknown>,
options: PromptForToolApprovalOptions
options?: { signal: AbortSignal; suggestions?: PermissionUpdate[] }
): Promise<PermissionResult> {
if (shouldAutoApproveTools) {
logger.debug('promptForToolApproval auto-approving tool for test', {
@@ -255,7 +245,6 @@ export async function promptForToolApproval(
logger.info('Requesting user approval for tool usage', {
requestId,
toolName,
toolCallId: options.toolCallId,
description: toolMetadata?.description
})
@@ -263,7 +252,6 @@ export async function promptForToolApproval(
requestId,
toolName,
toolId: toolMetadata?.id ?? toolName,
toolCallId: options.toolCallId,
description: toolMetadata?.description,
requiresPermissions: toolMetadata?.requirePermissions ?? false,
input: sanitizedInput,
@@ -278,7 +266,6 @@ export async function promptForToolApproval(
logger.debug('Registering tool permission request', {
requestId,
toolName,
toolCallId: options.toolCallId,
requiresPermissions: requestPayload.requiresPermissions,
timeoutMs: TOOL_APPROVAL_TIMEOUT_MS,
suggestionCount: sanitizedSuggestions.length
@@ -286,11 +273,7 @@ export async function promptForToolApproval(
return new Promise<PermissionResult>((resolve) => {
const timeout = setTimeout(() => {
logger.info('User tool permission request timed out', {
requestId,
toolName,
toolCallId: options.toolCallId
})
logger.info('User tool permission request timed out', { requestId, toolName })
finalizeRequest(requestId, { behavior: 'deny', message: 'Timed out waiting for approval' }, 'timeout')
}, TOOL_APPROVAL_TIMEOUT_MS)
@@ -304,11 +287,7 @@ export async function promptForToolApproval(
if (options?.signal) {
const abortListener = () => {
logger.info('Tool permission request aborted before user responded', {
requestId,
toolName,
toolCallId: options.toolCallId
})
logger.info('Tool permission request aborted before user responded', { requestId, toolName })
finalizeRequest(requestId, defaultDenyUpdate, 'aborted')
}
@@ -110,7 +110,7 @@ const sdkMessageToProviderMetadata = (message: SDKMessage): ProviderMetadata =>
* blocks across calls so that incremental deltas can be correlated correctly.
*/
export function transformSDKMessageToStreamParts(sdkMessage: SDKMessage, state: ClaudeStreamState): AgentStreamPart[] {
logger.silly('Transforming SDKMessage', { message: JSON.stringify(sdkMessage) })
logger.silly('Transforming SDKMessage', { message: sdkMessage })
switch (sdkMessage.type) {
case 'assistant':
return handleAssistantMessage(sdkMessage, state)
@@ -186,13 +186,14 @@ function handleAssistantMessage(
for (const block of content) {
switch (block.type) {
case 'text': {
const sanitizedText = stripLocalCommandTags(block.text)
if (sanitizedText) {
textBlocks.push(sanitizedText)
case 'text':
if (!isStreamingActive) {
const sanitizedText = stripLocalCommandTags(block.text)
if (sanitizedText) {
textBlocks.push(sanitizedText)
}
}
break
}
case 'tool_use':
handleAssistantToolUse(block as ToolUseContent, providerMetadata, state, chunks)
break
@@ -202,16 +203,7 @@ function handleAssistantMessage(
}
}
if (textBlocks.length === 0) {
return chunks
}
const combinedText = textBlocks.join('')
if (!combinedText) {
return chunks
}
if (!isStreamingActive) {
if (!isStreamingActive && textBlocks.length > 0) {
const id = message.uuid?.toString() || generateMessageId()
state.beginStep()
chunks.push({
@@ -227,7 +219,7 @@ function handleAssistantMessage(
chunks.push({
type: 'text-delta',
id,
text: combinedText,
text: textBlocks.join(''),
providerMetadata
})
chunks.push({
@@ -238,27 +230,7 @@ function handleAssistantMessage(
return finalizeNonStreamingStep(message, state, chunks)
}
const existingTextBlock = state.getFirstOpenTextBlock()
const fallbackId = existingTextBlock?.id || message.uuid?.toString() || generateMessageId()
if (!existingTextBlock) {
chunks.push({
type: 'text-start',
id: fallbackId,
providerMetadata
})
}
chunks.push({
type: 'text-delta',
id: fallbackId,
text: combinedText,
providerMetadata
})
chunks.push({
type: 'text-end',
id: fallbackId,
providerMetadata
})
return finalizeNonStreamingStep(message, state, chunks)
return chunks
}
/**
@@ -271,16 +243,15 @@ function handleAssistantToolUse(
state: ClaudeStreamState,
chunks: AgentStreamPart[]
): void {
const toolCallId = state.getNamespacedToolCallId(block.id)
chunks.push({
type: 'tool-call',
toolCallId,
toolCallId: block.id,
toolName: block.name,
input: block.input,
providerExecuted: true,
providerMetadata
})
state.completeToolBlock(block.id, block.name, block.input, providerMetadata)
state.completeToolBlock(block.id, block.input, providerMetadata)
}
/**
@@ -360,11 +331,10 @@ function handleUserMessage(
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,
toolCallId: toolResult.tool_use_id,
toolName: pendingCall?.toolName ?? 'unknown',
input: pendingCall?.input,
error: toolResult.content,
@@ -373,7 +343,7 @@ function handleUserMessage(
} else {
chunks.push({
type: 'tool-result',
toolCallId,
toolCallId: toolResult.tool_use_id,
toolName: pendingCall?.toolName ?? 'unknown',
input: pendingCall?.input,
output: toolResult.content,
@@ -487,9 +457,6 @@ function handleStreamEvent(
}
case 'message_stop': {
if (!state.hasActiveStep()) {
break
}
const pending = state.getPendingUsage()
chunks.push({
type: 'finish-step',
@@ -547,7 +514,7 @@ function handleContentBlockStart(
}
case 'tool_use': {
const block = state.openToolBlock(index, {
rawToolCallId: contentBlock.id,
toolCallId: contentBlock.id,
toolName: contentBlock.name,
providerMetadata
})
@@ -2,125 +2,68 @@ import 'emoji-picker-element'
import TwemojiCountryFlagsWoff2 from '@renderer/assets/fonts/country-flag-fonts/TwemojiCountryFlags.woff2?url'
import { useTheme } from '@renderer/context/ThemeProvider'
import type { LanguageVarious } from '@renderer/types'
import { polyfillCountryFlagEmojis } from 'country-flag-emoji-polyfill'
// i18n translations from emoji-picker-element
import de from 'emoji-picker-element/i18n/de'
import en from 'emoji-picker-element/i18n/en'
import es from 'emoji-picker-element/i18n/es'
import fr from 'emoji-picker-element/i18n/fr'
import ja from 'emoji-picker-element/i18n/ja'
import pt_PT from 'emoji-picker-element/i18n/pt_PT'
import ru_RU from 'emoji-picker-element/i18n/ru_RU'
import zh_CN from 'emoji-picker-element/i18n/zh_CN'
import type Picker from 'emoji-picker-element/picker'
import type { EmojiClickEvent, NativeEmoji } from 'emoji-picker-element/shared'
// Emoji data from emoji-picker-element-data (local, no CDN)
// Using CLDR format for full multi-language search support (28 languages)
import dataDE from 'emoji-picker-element-data/de/cldr/data.json?url'
import dataEN from 'emoji-picker-element-data/en/cldr/data.json?url'
import dataES from 'emoji-picker-element-data/es/cldr/data.json?url'
import dataFR from 'emoji-picker-element-data/fr/cldr/data.json?url'
import dataJA from 'emoji-picker-element-data/ja/cldr/data.json?url'
import dataPT from 'emoji-picker-element-data/pt/cldr/data.json?url'
import dataRU from 'emoji-picker-element-data/ru/cldr/data.json?url'
import dataZH from 'emoji-picker-element-data/zh/cldr/data.json?url'
import dataZH_HANT from 'emoji-picker-element-data/zh-hant/cldr/data.json?url'
import emojiData from 'emoji-picker-element-data/en/emojibase/data.json'
import type { FC } from 'react'
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
interface EmojiPickerElement extends HTMLElement {
dataSource: typeof emojiData
}
interface EmojiClickEvent extends CustomEvent {
detail: {
unicode?: string
emoji?: { unicode: string }
}
}
interface Props {
onEmojiClick: (emoji: string) => void
}
// Mapping from app locale to emoji-picker-element i18n
const i18nMap: Record<LanguageVarious, typeof en> = {
'en-US': en,
'zh-CN': zh_CN,
'zh-TW': zh_CN, // Closest available
'de-DE': de,
'el-GR': en, // No Greek available, fallback to English
'es-ES': es,
'fr-FR': fr,
'ja-JP': ja,
'pt-PT': pt_PT,
'ru-RU': ru_RU
}
// Mapping from app locale to emoji data URL
// Using CLDR format provides native language search support for all locales
const dataSourceMap: Record<LanguageVarious, string> = {
'en-US': dataEN,
'zh-CN': dataZH,
'zh-TW': dataZH_HANT,
'de-DE': dataDE,
'el-GR': dataEN, // No Greek CLDR available, fallback to English
'es-ES': dataES,
'fr-FR': dataFR,
'ja-JP': dataJA,
'pt-PT': dataPT,
'ru-RU': dataRU
}
// Mapping from app locale to emoji-picker-element locale string
// Must match the data source locale for proper IndexedDB caching
const localeMap: Record<LanguageVarious, string> = {
'en-US': 'en',
'zh-CN': 'zh',
'zh-TW': 'zh-hant',
'de-DE': 'de',
'el-GR': 'en',
'es-ES': 'es',
'fr-FR': 'fr',
'ja-JP': 'ja',
'pt-PT': 'pt',
'ru-RU': 'ru'
declare module 'react' {
namespace JSX {
interface IntrinsicElements {
'emoji-picker': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement> & { class?: string }, HTMLElement>
}
}
}
const EmojiPicker: FC<Props> = ({ onEmojiClick }) => {
const { theme } = useTheme()
const { i18n } = useTranslation()
const ref = useRef<Picker>(null)
const currentLocale = i18n.language as LanguageVarious
const ref = useRef<EmojiPickerElement>(null)
useEffect(() => {
polyfillCountryFlagEmojis('Twemoji Mozilla', TwemojiCountryFlagsWoff2)
}, [])
// Configure picker with i18n and dataSource
// 初始化 dataSource
useEffect(() => {
const picker = ref.current
if (picker) {
picker.i18n = i18nMap[currentLocale] || en
picker.dataSource = dataSourceMap[currentLocale] || dataEN
picker.locale = localeMap[currentLocale] || 'en'
if (ref.current) {
ref.current.dataSource = emojiData
}
}, [currentLocale])
}, [])
// 事件监听
useEffect(() => {
const picker = ref.current
const refValue = ref.current
if (picker) {
const handleEmojiClick = (event: EmojiClickEvent) => {
if (refValue) {
const handleEmojiClick = (event: Event) => {
event.stopPropagation()
const { detail } = event
// Use detail.unicode (processed with skin tone) or fallback to emoji's unicode for native emoji
const unicode = detail.unicode || ('unicode' in detail.emoji ? (detail.emoji as NativeEmoji).unicode : '')
onEmojiClick(unicode)
const emojiEvent = event as EmojiClickEvent
onEmojiClick(emojiEvent.detail.unicode || emojiEvent.detail.emoji?.unicode || '')
}
// 添加事件监听器
picker.addEventListener('emoji-click', handleEmojiClick)
refValue.addEventListener('emoji-click', handleEmojiClick)
// 清理事件监听器
return () => {
picker.removeEventListener('emoji-click', handleEmojiClick)
refValue.removeEventListener('emoji-click', handleEmojiClick)
}
}
return
}, [onEmojiClick])
// @ts-ignore next-line
return <emoji-picker ref={ref} class={theme === 'dark' ? 'dark' : 'light'} style={{ border: 'none' }} />
}
@@ -1,4 +1,5 @@
import { loggerService } from '@logger'
import ClaudeIcon from '@renderer/assets/images/models/claude.png'
import { ErrorBoundary } from '@renderer/components/ErrorBoundary'
import { TopView } from '@renderer/components/TopView'
import { permissionModeCards } from '@renderer/config/agent'
@@ -8,6 +9,7 @@ import SelectAgentBaseModelButton from '@renderer/pages/home/components/SelectAg
import type {
AddAgentForm,
AgentEntity,
AgentType,
ApiModel,
BaseAgentForm,
PermissionMode,
@@ -15,22 +17,30 @@ import type {
UpdateAgentForm
} from '@renderer/types'
import { AgentConfigurationSchema, isAgentType } from '@renderer/types'
import { Button, Input, Modal, Select } from 'antd'
import { Avatar, Button, Input, Modal, Select } from 'antd'
import { AlertTriangleIcon } from 'lucide-react'
import type { ChangeEvent, FormEvent } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import type { BaseOption } from './shared'
const { TextArea } = Input
const logger = loggerService.withContext('AddAgentPopup')
interface AgentTypeOption extends BaseOption {
type: 'type'
key: AgentEntity['type']
name: AgentEntity['name']
}
type AgentWithTools = AgentEntity & { tools?: Tool[] }
const buildAgentForm = (existing?: AgentWithTools): BaseAgentForm => ({
type: existing?.type ?? 'claude-code',
name: existing?.name ?? 'Agent',
name: existing?.name ?? 'Claude Code',
description: existing?.description,
instructions: existing?.instructions,
model: existing?.model ?? '',
@@ -90,6 +100,54 @@ const PopupContainer: React.FC<Props> = ({ agent, afterSubmit, resolve }) => {
})
}, [])
// add supported agents type here.
const agentConfig = useMemo(
() =>
[
{
type: 'type',
key: 'claude-code',
label: 'Claude Code',
name: 'Claude Code',
avatar: ClaudeIcon
}
] as const satisfies AgentTypeOption[],
[]
)
const agentOptions = useMemo(
() =>
agentConfig.map((option) => ({
value: option.key,
label: (
<OptionWrapper>
<Avatar src={option.avatar} size={24} />
<span>{option.label}</span>
</OptionWrapper>
)
})),
[agentConfig]
)
const onAgentTypeChange = useCallback(
(value: AgentType) => {
const prevConfig = agentConfig.find((config) => config.key === form.type)
let newName: string | undefined = form.name
if (prevConfig && prevConfig.name === form.name) {
const newConfig = agentConfig.find((config) => config.key === value)
if (newConfig) {
newName = newConfig.name
}
}
setForm((prev) => ({
...prev,
type: value,
name: newName
}))
},
[agentConfig, form.name, form.type]
)
const onNameChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setForm((prev) => ({
...prev,
@@ -97,12 +155,12 @@ const PopupContainer: React.FC<Props> = ({ agent, afterSubmit, resolve }) => {
}))
}, [])
// const onDescChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
// setForm((prev) => ({
// ...prev,
// description: e.target.value
// }))
// }, [])
const onDescChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
setForm((prev) => ({
...prev,
description: e.target.value
}))
}, [])
const onInstChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
setForm((prev) => ({
@@ -276,6 +334,16 @@ const PopupContainer: React.FC<Props> = ({ agent, afterSubmit, resolve }) => {
<StyledForm onSubmit={onSubmit}>
<FormContent>
<FormRow>
<FormItem style={{ flex: 1 }}>
<Label>{t('agent.type.label')}</Label>
<Select
value={form.type}
onChange={onAgentTypeChange}
options={agentOptions}
disabled={isEditing(agent)}
style={{ width: '100%' }}
/>
</FormItem>
<FormItem style={{ flex: 1 }}>
<Label>
{t('common.name')} <RequiredMark>*</RequiredMark>
@@ -295,7 +363,7 @@ const PopupContainer: React.FC<Props> = ({ agent, afterSubmit, resolve }) => {
avatarSize={24}
iconSize={16}
buttonStyle={{
padding: '3px 8px',
padding: '8px 12px',
width: '100%',
border: '1px solid var(--color-border)',
borderRadius: 6,
@@ -314,6 +382,7 @@ const PopupContainer: React.FC<Props> = ({ agent, afterSubmit, resolve }) => {
onChange={onPermissionModeChange}
style={{ width: '100%' }}
placeholder={t('agent.settings.tooling.permissionMode.placeholder', 'Select permission mode')}
dropdownStyle={{ minWidth: '500px' }}
optionLabelProp="label">
{permissionModeCards.map((item) => (
<Select.Option key={item.mode} value={item.mode} label={t(item.titleKey, item.titleFallback)}>
@@ -369,10 +438,10 @@ const PopupContainer: React.FC<Props> = ({ agent, afterSubmit, resolve }) => {
<TextArea rows={3} value={form.instructions ?? ''} onChange={onInstChange} />
</FormItem>
{/* <FormItem>
<FormItem>
<Label>{t('common.description')}</Label>
<TextArea rows={1} value={form.description ?? ''} onChange={onDescChange} />
</FormItem> */}
<TextArea rows={2} value={form.description ?? ''} onChange={onDescChange} />
</FormItem>
</FormContent>
<FormFooter>
@@ -506,7 +575,14 @@ const FormFooter = styled.div`
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px;
padding-top: 16px;
border-top: 1px solid var(--color-border);
`
const OptionWrapper = styled.div`
display: flex;
align-items: center;
gap: 8px;
`
const PermissionOptionWrapper = styled.div`
-7
View File
@@ -156,12 +156,6 @@
"uninstalling": "Uninstalling..."
},
"prompt": "Prompt Settings",
"sub_agents": {
"placeholder": "Select sub agents",
"tab": "Sub Agents",
"title": "Sub Agents",
"tooltip": "Select other agents that can be delegated tasks by this agent"
},
"tooling": {
"mcp": {
"description": "Connect MCP servers to unlock additional tools you can approve above.",
@@ -647,7 +641,6 @@
"description": "No files available in accessible directories",
"label": "No File Found"
},
"sub_agent": "Sub-Agent",
"title": "Activity Directory"
},
"auto_resize": "Auto resize height",
-7
View File
@@ -156,12 +156,6 @@
"uninstalling": "卸载中..."
},
"prompt": "提示词设置",
"sub_agents": {
"placeholder": "选择子智能体",
"tab": "子智能体",
"title": "子智能体",
"tooltip": "选择可以被此智能体委派任务的其他智能体"
},
"tooling": {
"mcp": {
"description": "连接 MCP 服务器即可解锁更多可在上方预先授权的工具。",
@@ -647,7 +641,6 @@
"description": "可访问目录中没有可用文件",
"label": "未找到文件"
},
"sub_agent": "子代理",
"title": "活动目录"
},
"auto_resize": "自动调整高度",
-7
View File
@@ -156,12 +156,6 @@
"uninstalling": "解除安裝中..."
},
"prompt": "提示設定",
"sub_agents": {
"placeholder": "選擇子助手",
"tab": "子助手",
"title": "子助手",
"tooltip": "選擇可以被此助手委派任務的其他助手"
},
"tooling": {
"mcp": {
"description": "連線 MCP 伺服器即可解鎖更多可在上方預先授權的工具。",
@@ -647,7 +641,6 @@
"description": "可存取的目錄中沒有檔案",
"label": "找不到檔案"
},
"sub_agent": "子代理",
"title": "活動目錄"
},
"auto_resize": "自動調整高度",
@@ -156,12 +156,6 @@
"uninstalling": "Deinstallation läuft..."
},
"prompt": "Prompt-Einstellungen",
"sub_agents": {
"placeholder": "[to be translated]:Select sub agents",
"tab": "[to be translated]:Sub Agents",
"title": "[to be translated]:Sub Agents",
"tooltip": "[to be translated]:Select other agents that can be delegated tasks by this agent"
},
"tooling": {
"mcp": {
"description": "Verbinden Sie MCP-Server, um weitere Tools freizuschalten, die oben vorab autorisiert werden können.",
@@ -156,12 +156,6 @@
"uninstalling": "Απεγκατάσταση..."
},
"prompt": "Ρυθμίσεις Προτροπής",
"sub_agents": {
"placeholder": "[to be translated]:Select sub agents",
"tab": "[to be translated]:Sub Agents",
"title": "[to be translated]:Sub Agents",
"tooltip": "[to be translated]:Select other agents that can be delegated tasks by this agent"
},
"tooling": {
"mcp": {
"description": "Συνδέστε διακομιστές MCP για να ξεκλειδώσετε πρόσθετα εργαλεία που μπορείτε να εγκρίνετε παραπάνω.",
@@ -156,12 +156,6 @@
"uninstalling": "Desinstalando..."
},
"prompt": "Configuración de indicaciones",
"sub_agents": {
"placeholder": "[to be translated]:Select sub agents",
"tab": "[to be translated]:Sub Agents",
"title": "[to be translated]:Sub Agents",
"tooltip": "[to be translated]:Select other agents that can be delegated tasks by this agent"
},
"tooling": {
"mcp": {
"description": "Conecta servidores MCP para desbloquear herramientas adicionales que puedes aprobar arriba.",
@@ -156,12 +156,6 @@
"uninstalling": "Désinstallation en cours..."
},
"prompt": "Paramètres de l'invite",
"sub_agents": {
"placeholder": "[to be translated]:Select sub agents",
"tab": "[to be translated]:Sub Agents",
"title": "[to be translated]:Sub Agents",
"tooltip": "[to be translated]:Select other agents that can be delegated tasks by this agent"
},
"tooling": {
"mcp": {
"description": "Connectez des serveurs MCP pour débloquer des outils supplémentaires que vous pouvez approuver ci-dessus.",
@@ -156,12 +156,6 @@
"uninstalling": "アンインストール中..."
},
"prompt": "プロンプト設定",
"sub_agents": {
"placeholder": "[to be translated]:Select sub agents",
"tab": "[to be translated]:Sub Agents",
"title": "[to be translated]:Sub Agents",
"tooltip": "[to be translated]:Select other agents that can be delegated tasks by this agent"
},
"tooling": {
"mcp": {
"description": "MCPサーバーを接続して、上で承認できる追加ツールを解放します。",
@@ -156,12 +156,6 @@
"uninstalling": "Desinstalando..."
},
"prompt": "Configurações de Prompt",
"sub_agents": {
"placeholder": "[to be translated]:Select sub agents",
"tab": "[to be translated]:Sub Agents",
"title": "[to be translated]:Sub Agents",
"tooltip": "[to be translated]:Select other agents that can be delegated tasks by this agent"
},
"tooling": {
"mcp": {
"description": "Conecte servidores MCP para desbloquear ferramentas adicionais que você pode aprovar acima.",
@@ -156,12 +156,6 @@
"uninstalling": "Удаление..."
},
"prompt": "Настройки подсказки",
"sub_agents": {
"placeholder": "[to be translated]:Select sub agents",
"tab": "[to be translated]:Sub Agents",
"title": "[to be translated]:Sub Agents",
"tooltip": "[to be translated]:Select other agents that can be delegated tasks by this agent"
},
"tooling": {
"mcp": {
"description": "Подключите серверы MCP, чтобы разблокировать дополнительные инструменты, которые вы можете одобрить выше.",
@@ -103,23 +103,12 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
// Prepare session data for tools
const sessionData = useMemo(() => {
if (!session) return undefined
// Get installed agent plugins from session.plugins
const agentPlugins = (session.plugins ?? [])
.filter((plugin) => plugin.type === 'agent')
.map((plugin) => ({
id: plugin.filename,
name: plugin.metadata.name ?? plugin.filename.replace(/\.md$/i, ''),
description: plugin.metadata.description
}))
return {
agentId,
sessionId,
slashCommands: session.slash_commands,
tools: session.tools,
accessiblePaths: session.accessible_paths ?? [],
subAgents: agentPlugins
accessiblePaths: session.accessible_paths ?? []
}
}, [session, agentId, sessionId])
@@ -169,8 +158,6 @@ interface InnerProps {
sessionId?: string
slashCommands?: Array<{ command: string; description?: string }>
tools?: Array<{ id: string; name: string; type: string; description?: string }>
accessiblePaths?: string[]
subAgents?: Array<{ id: string; name: string; description?: string }>
}
actionsRef: React.MutableRefObject<{
resizeTextArea: () => void
@@ -25,12 +25,11 @@ const activityDirectoryTool = defineTool({
const { quickPanel, quickPanelController, actions, session } = context
const { onTextChange } = actions
// Get accessible paths and sub-agents from session data
// Get accessible paths from session data
const accessiblePaths = session?.accessiblePaths ?? []
const subAgents = session?.subAgents ?? []
// Only render if we have accessible paths or sub-agents
if (accessiblePaths.length === 0 && subAgents.length === 0) {
// Only render if we have accessible paths
if (accessiblePaths.length === 0) {
return null
}
@@ -39,7 +38,6 @@ const activityDirectoryTool = defineTool({
quickPanel={quickPanel}
quickPanelController={quickPanelController}
accessiblePaths={accessiblePaths}
subAgents={subAgents}
setText={onTextChange as React.Dispatch<React.SetStateAction<string>>}
/>
)
@@ -13,17 +13,10 @@ interface Props {
quickPanel: ToolQuickPanelApi
quickPanelController: ToolQuickPanelController
accessiblePaths: string[]
subAgents?: Array<{ id: string; name: string; description?: string }>
setText: React.Dispatch<React.SetStateAction<string>>
}
const ActivityDirectoryButton: FC<Props> = ({
quickPanel,
quickPanelController,
accessiblePaths,
subAgents,
setText
}) => {
const ActivityDirectoryButton: FC<Props> = ({ quickPanel, quickPanelController, accessiblePaths, setText }) => {
const { t } = useTranslation()
const { handleOpenQuickPanel } = useActivityDirectoryPanel(
@@ -31,7 +24,6 @@ const ActivityDirectoryButton: FC<Props> = ({
quickPanel,
quickPanelController,
accessiblePaths,
subAgents,
setText
},
'button'
@@ -15,9 +15,8 @@ const ActivityDirectoryQuickPanelManager = ({ context }: ManagerProps) => {
session
} = context
// Get accessible paths and sub-agents from session data
// Get accessible paths from session data
const accessiblePaths = session?.accessiblePaths ?? []
const subAgents = session?.subAgents ?? []
// Always call hooks unconditionally (React rules)
useActivityDirectoryPanel(
@@ -25,7 +24,6 @@ const ActivityDirectoryQuickPanelManager = ({ context }: ManagerProps) => {
quickPanel,
quickPanelController,
accessiblePaths,
subAgents,
setText: onTextChange as React.Dispatch<React.SetStateAction<string>>
},
'manager'
@@ -2,7 +2,7 @@ import { loggerService } from '@logger'
import type { QuickPanelListItem } from '@renderer/components/QuickPanel'
import { QuickPanelReservedSymbol } from '@renderer/components/QuickPanel'
import type { ToolQuickPanelApi, ToolQuickPanelController } from '@renderer/pages/home/Inputbar/types'
import { Bot, File, Folder } from 'lucide-react'
import { File, Folder } from 'lucide-react'
import type React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -25,22 +25,15 @@ export type ActivityDirectoryTriggerInfo = {
symbol?: QuickPanelReservedSymbol
}
interface SubAgentInfo {
id: string
name: string
description?: string
}
interface Params {
quickPanel: ToolQuickPanelApi
quickPanelController: ToolQuickPanelController
accessiblePaths: string[]
subAgents?: SubAgentInfo[]
setText: React.Dispatch<React.SetStateAction<string>>
}
export const useActivityDirectoryPanel = (params: Params, role: 'button' | 'manager' = 'button') => {
const { quickPanel, quickPanelController, accessiblePaths, subAgents = [], setText } = params
const { quickPanel, quickPanelController, accessiblePaths, setText } = params
const { registerTrigger, registerRootMenu } = quickPanel
const { open, close, updateList, isVisible, symbol } = quickPanelController
const { t } = useTranslation()
@@ -245,68 +238,6 @@ export const useActivityDirectoryPanel = (params: Params, role: 'button' | 'mana
[close, insertFilePath]
)
/**
* Insert sub-agent name at @ position
*/
const insertSubAgentName = useCallback(
(agentName: string, triggerInfo?: ActivityDirectoryTriggerInfo) => {
setText((currentText) => {
const symbol = triggerInfo?.symbol ?? QuickPanelReservedSymbol.MentionModels
const triggerIndex =
triggerInfo?.position !== undefined
? triggerInfo.position
: symbol === QuickPanelReservedSymbol.Root
? currentText.lastIndexOf('/')
: currentText.lastIndexOf('@')
if (triggerIndex !== -1) {
let endPos = triggerIndex + 1
while (endPos < currentText.length && !/\s/.test(currentText[endPos])) {
endPos++
}
return currentText.slice(0, triggerIndex) + agentName + ' ' + currentText.slice(endPos)
}
// If no trigger found, append at end
return currentText + ' ' + agentName + ' '
})
},
[setText]
)
/**
* Handle sub-agent selection
*/
const onSelectSubAgent = useCallback(
(agentName: string) => {
const trigger = triggerInfoRef.current
insertSubAgentName(agentName, trigger)
close()
},
[close, insertSubAgentName]
)
/**
* Create sub-agent list items for QuickPanel
*/
const createSubAgentItems = useCallback(
(agents: SubAgentInfo[]): QuickPanelListItem[] => {
if (agents.length === 0) {
return []
}
return agents.map((agent) => ({
label: agent.name,
description: agent.description || t('chat.input.activity_directory.sub_agent'),
icon: <Bot size={16} />,
filterText: `${agent.name} ${agent.description || ''} ${agent.id}`,
action: () => onSelectSubAgent(agent.name),
isSelected: false
}))
},
[onSelectSubAgent, t]
)
/**
* Create file list items for QuickPanel from a file list
*/
@@ -360,18 +291,12 @@ export const useActivityDirectoryPanel = (params: Params, role: 'button' | 'mana
)
/**
* Create combined list items for QuickPanel (sub-agents + files)
* Create file list items for QuickPanel (for current state)
*/
const combinedItems = useMemo<QuickPanelListItem[]>(() => {
const agentItems = createSubAgentItems(subAgents)
const files = createFileItems(fileList, isLoading)
// Combine: sub-agents first, then files
return [...agentItems, ...files]
}, [createSubAgentItems, subAgents, createFileItems, fileList, isLoading])
// Keep fileItems for backward compatibility
const fileItems = combinedItems
const fileItems = useMemo<QuickPanelListItem[]>(
() => createFileItems(fileList, isLoading),
[createFileItems, fileList, isLoading]
)
/**
* Handle search text change - load files and update list
@@ -386,13 +311,11 @@ export const useActivityDirectoryPanel = (params: Params, role: 'button' | 'mana
const hasChanged = updateFileListState(newFiles)
if (hasChanged) {
// Combine sub-agents and files
const agentItems = createSubAgentItems(subAgents)
const fileItems = createFileItems(newFiles, false)
updateList([...agentItems, ...fileItems])
const newItems = createFileItems(newFiles, false)
updateList(newItems)
}
},
[loadFiles, createFileItems, createSubAgentItems, subAgents, updateList, updateFileListState]
[loadFiles, createFileItems, updateList, updateFileListState]
)
/**
@@ -413,10 +336,8 @@ export const useActivityDirectoryPanel = (params: Params, role: 'button' | 'mana
const files = await loadFiles()
updateFileListState(files)
// Create items from sub-agents and loaded files immediately
const agentItems = createSubAgentItems(subAgents)
const fileItems = createFileItems(files, false)
const items = [...agentItems, ...fileItems]
// Create items from the loaded files immediately
const items = createFileItems(files, false)
open({
title: t('chat.input.activity_directory.description'),
@@ -456,18 +377,7 @@ export const useActivityDirectoryPanel = (params: Params, role: 'button' | 'mana
onSearchChange: handleSearchChange
})
},
[
loadFiles,
open,
removeTriggerSymbolAndText,
setText,
t,
handleSearchChange,
createFileItems,
createSubAgentItems,
subAgents,
updateFileListState
]
[loadFiles, open, removeTriggerSymbolAndText, setText, t, handleSearchChange, createFileItems, updateFileListState]
)
/**
@@ -68,7 +68,6 @@ export interface ToolContext {
slashCommands?: Array<{ command: string; description?: string }>
tools?: Array<{ id: string; name: string; type: string; description?: string }>
accessiblePaths?: string[]
subAgents?: Array<{ id: string; name: string; description?: string }>
}
}
@@ -1,6 +1,7 @@
import type { CollapseProps } from 'antd'
import { Tag } from 'antd'
import { CheckCircle, Terminal, XCircle } from 'lucide-react'
import { useMemo } from 'react'
import { ToolTitle } from './GenericTools'
import type { BashOutputToolInput, BashOutputToolOutput } from './types'
@@ -15,63 +16,6 @@ interface ParsedBashOutput {
tool_use_error?: string
}
const parseBashOutput = (output?: BashOutputToolOutput): ParsedBashOutput | null => {
if (!output) return null
try {
const parser = new DOMParser()
const hasToolError = output.includes('<tool_use_error>')
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()
}
return {
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')
}
} catch {
return 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,8 +23,73 @@ export function BashOutputTool({
input: BashOutputToolInput
output?: BashOutputToolOutput
}): NonNullable<CollapseProps['items']>[number] {
const parsedOutput = parseBashOutput(output)
const statusConfig = getStatusConfig(parsedOutput)
// 解析 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])
const children = parsedOutput ? (
<div className="flex flex-col gap-4">
@@ -1,47 +1,12 @@
import type { CollapseProps } from 'antd'
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, TextOutput } from './types'
import { AgentToolsType } from './types'
const removeSystemReminderTags = (text: string): string => {
return text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/gi, '')
}
const normalizeOutputString = (output?: ReadToolOutputType): string | null => {
if (!output) return null
const toText = (item: TextOutput) => removeSystemReminderTags(item.text)
if (Array.isArray(output)) {
return output
.filter((item): item is TextOutput => item.type === 'text')
.map(toText)
.join('')
}
return removeSystemReminderTags(output)
}
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
}
}
export function ReadTool({
input,
output
@@ -49,8 +14,50 @@ export function ReadTool({
input: ReadToolInputType
output?: ReadToolOutputType
}): NonNullable<CollapseProps['items']>[number] {
const outputString = normalizeOutputString(output)
const stats = getOutputStats(outputString)
// 移除 system-reminder 标签及其内容的辅助函数
const removeSystemReminderTags = (text: string): string => {
// 使用正则表达式匹配 <system-reminder> 标签及其内容,包括换行符
return text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/gi, '')
}
// 将 output 统一转换为字符串
const outputString = useMemo(() => {
if (!output) return null
let processedOutput: string
// 如果是 TextOutput[] 类型,提取所有 text 内容
if (Array.isArray(output)) {
processedOutput = output
.filter((item): item is TextOutput => item.type === 'text')
.map((item) => removeSystemReminderTags(item.text))
.join('')
} else {
// 如果是字符串,直接使用
processedOutput = output
}
// 移除 system-reminder 标签及其内容
return removeSystemReminderTags(processedOutput)
}, [output])
// 如果有输出,计算统计信息
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 {
key: AgentToolsType.Read,
@@ -11,24 +11,11 @@ interface UnknownToolProps {
output?: unknown
}
const getToolDisplayName = (name: string) => {
if (name.startsWith('mcp__')) {
const parts = name.substring(5).split('__')
if (parts.length >= 2) {
return `${parts[0]}:${parts.slice(1).join(':')}`
}
}
return name
}
const getToolDescription = (toolName: string) => {
if (toolName.startsWith('mcp__')) {
return 'MCP Server Tool'
}
return 'Tool'
}
const UnknownToolContent = ({ input, output }: { input?: unknown; output?: unknown }) => {
export function UnknownToolRenderer({
toolName = '',
input,
output
}: UnknownToolProps): NonNullable<CollapseProps['items']>[number] {
const { highlightCode } = useCodeStyle()
const [inputHtml, setInputHtml] = useState<string>('')
const [outputHtml, setOutputHtml] = useState<string>('')
@@ -47,49 +34,58 @@ const UnknownToolContent = ({ input, output }: { input?: unknown; output?: unkno
}
}, [output, highlightCode])
if (input === undefined && output === undefined) {
return <div className="text-foreground-500 text-xs">No data available for this tool</div>
const getToolDisplayName = (name: string) => {
if (name.startsWith('mcp__')) {
const parts = name.substring(5).split('__')
if (parts.length >= 2) {
return `${parts[0]}:${parts.slice(1).join(':')}`
}
}
return name
}
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>
)}
const getToolDescription = () => {
if (toolName.startsWith('mcp__')) {
return 'MCP Server Tool'
}
return 'Tool'
}
{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>
)
}
export function UnknownToolRenderer({
toolName = '',
input,
output
}: UnknownToolProps): NonNullable<CollapseProps['items']>[number] {
return {
key: 'unknown-tool',
label: (
<ToolTitle
icon={<Wrench className="h-4 w-4" />}
label={getToolDisplayName(toolName)}
params={getToolDescription(toolName)}
params={getToolDescription()}
/>
),
children: <UnknownToolContent input={input} output={output} />
children: (
<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>
)}
{input === undefined && output === undefined && (
<div className="text-foreground-500 text-xs">No data available for this tool</div>
)}
</div>
)
}
}
@@ -6,6 +6,8 @@ import { Collapse } from 'antd'
// 导出所有类型
export * from './types'
import { useMemo } from 'react'
// 导入所有渲染器
import ToolPermissionRequestCard from '../ToolPermissionRequestCard'
import { BashOutputTool } from './BashOutputTool'
@@ -55,19 +57,22 @@ export function isValidAgentToolsType(toolName: unknown): toolName is AgentTools
return typeof toolName === 'string' && Object.values(AgentToolsType).includes(toolName as AgentToolsType)
}
// 统一的渲染组件
function ToolContent({ toolName, input, output }: { toolName: AgentToolsType; input: ToolInput; output?: ToolOutput }) {
// 统一的渲染函数
function renderToolContent(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 })
const toolContentItem: NonNullable<CollapseProps['items']>[number] = {
...renderedItem,
classNames: {
body: 'bg-foreground-50 p-2 text-foreground-900 dark:bg-foreground-100 max-h-96 p-2 overflow-scroll'
}
}
// eslint-disable-next-line react-hooks/rules-of-hooks
const toolContentItem = useMemo(() => {
const rendered = Renderer
? Renderer({ input: input as any, output: output as any })
: UnknownToolRenderer({ input: input as any, output: output as any, toolName })
return {
...rendered,
classNames: {
body: 'bg-foreground-50 p-2 text-foreground-900 dark:bg-foreground-100 max-h-96 p-2 overflow-scroll'
} as NonNullable<CollapseProps['items']>[number]['classNames']
} as NonNullable<CollapseProps['items']>[number]
}, [Renderer, input, output, toolName])
return (
<Collapse
@@ -93,7 +98,5 @@ export function MessageAgentTools({ toolResponse }: { toolResponse: NormalToolRe
return <ToolPermissionRequestCard toolResponse={toolResponse} />
}
return (
<ToolContent toolName={tool.name as AgentToolsType} input={args as ToolInput} output={response as ToolOutput} />
)
return renderToolContent(tool.name as AgentToolsType, args as ToolInput, response as ToolOutput)
}
@@ -1,7 +1,7 @@
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 { selectPendingPermissionByToolName, toolPermissionsActions } from '@renderer/store/toolPermissions'
import type { NormalToolResponse } from '@renderer/types'
import { Button } from 'antd'
import { ChevronDown, CirclePlay, CircleX } from 'lucide-react'
@@ -17,7 +17,9 @@ interface Props {
export function ToolPermissionRequestCard({ toolResponse }: Props) {
const { t } = useTranslation()
const dispatch = useAppDispatch()
const request = useAppSelector((state) => selectPendingPermission(state.toolPermissions, toolResponse.toolCallId))
const request = useAppSelector((state) =>
selectPendingPermissionByToolName(state.toolPermissions, toolResponse.tool.name)
)
const [now, setNow] = useState(() => Date.now())
const [showDetails, setShowDetails] = useState(false)
@@ -11,7 +11,6 @@ import EssentialSettings from './EssentialSettings'
import PluginSettings from './PluginSettings'
import PromptSettings from './PromptSettings'
import { AgentLabel, LeftMenu, Settings, StyledMenu, StyledModal } from './shared'
import SubAgentsSettings from './SubAgentsSettings'
import ToolingSettings from './ToolingSettings'
interface AgentSettingPopupShowParams {
@@ -23,7 +22,7 @@ interface AgentSettingPopupParams extends AgentSettingPopupShowParams {
resolve: () => void
}
type AgentSettingPopupTab = 'essential' | 'prompt' | 'tooling' | 'advanced' | 'plugins' | 'sub-agents' | 'session-mcps'
type AgentSettingPopupTab = 'essential' | 'prompt' | 'tooling' | 'advanced' | 'plugins' | 'session-mcps'
const AgentSettingPopupContainer: React.FC<AgentSettingPopupParams> = ({ tab, agentId, resolve }) => {
const [open, setOpen] = useState(true)
@@ -63,10 +62,6 @@ const AgentSettingPopupContainer: React.FC<AgentSettingPopupParams> = ({ tab, ag
key: 'plugins',
label: t('agent.settings.plugins.tab', 'Plugins')
},
{
key: 'sub-agents',
label: t('agent.settings.sub_agents.tab', 'Sub-agents')
},
{
key: 'advanced',
label: t('agent.settings.advance.title', 'Advanced Settings')
@@ -112,7 +107,6 @@ const AgentSettingPopupContainer: React.FC<AgentSettingPopupParams> = ({ tab, ag
{menu === 'prompt' && <PromptSettings agentBase={agent} update={updateAgent} />}
{menu === 'tooling' && <ToolingSettings agentBase={agent} update={updateAgent} />}
{menu === 'plugins' && <PluginSettings agentBase={agent} update={updateAgent} />}
{menu === 'sub-agents' && <SubAgentsSettings agentBase={agent} update={updateAgent} />}
{menu === 'advanced' && <AdvancedSettings agentBase={agent} update={updateAgent} />}
</Settings>
</div>
@@ -1,13 +1,21 @@
import { getAgentTypeAvatar } from '@renderer/config/agent'
import type { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent'
import type { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession'
import { getAgentTypeLabel } from '@renderer/i18n/label'
import type { GetAgentResponse, GetAgentSessionResponse } from '@renderer/types'
import { isAgentEntity } from '@renderer/types'
import { Avatar } from 'antd'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { AccessibleDirsSetting } from './AccessibleDirsSetting'
import { AvatarSetting } from './AvatarSetting'
import { DescriptionSetting } from './DescriptionSetting'
import { ModelSetting } from './ModelSetting'
import { NameSetting } from './NameSetting'
import { SettingsContainer } from './shared'
import { SettingsContainer, SettingsItem, SettingsTitle } from './shared'
// const logger = loggerService.withContext('AgentEssentialSettings')
type EssentialSettingsProps =
| {
@@ -22,10 +30,26 @@ type EssentialSettingsProps =
}
const EssentialSettings: FC<EssentialSettingsProps> = ({ agentBase, update, showModelSetting = true }) => {
const { t } = useTranslation()
if (!agentBase) return null
const isAgent = isAgentEntity(agentBase)
return (
<SettingsContainer>
{isAgent && (
<SettingsItem inline>
<SettingsTitle>{t('agent.type.label')}</SettingsTitle>
<div className="flex items-center gap-2">
<Avatar size={24} src={getAgentTypeAvatar(agentBase.type)} className="h-6 w-6 text-lg" />
<span>{(agentBase?.name ?? agentBase?.type) ? getAgentTypeLabel(agentBase.type) : ''}</span>
</div>
</SettingsItem>
)}
{isAgent && (
<AvatarSetting agent={agentBase} update={update as ReturnType<typeof useUpdateAgent>['updateAgent']} />
)}
<NameSetting base={agentBase} update={update} />
{showModelSetting && <ModelSetting base={agentBase} update={update} />}
<AccessibleDirsSetting base={agentBase} update={update} />
@@ -1,8 +1,6 @@
import { EmojiAvatarWithPicker } from '@renderer/components/Avatar/EmojiAvatarWithPicker'
import type { AgentBaseWithId, UpdateAgentBaseForm, UpdateAgentFunctionUnion } from '@renderer/types'
import { AgentConfigurationSchema, isAgentEntity, isAgentType } from '@renderer/types'
import { Input } from 'antd'
import { useCallback, useState } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingsItem, SettingsTitle } from './shared'
@@ -15,61 +13,26 @@ export interface NameSettingsProps {
export const NameSetting = ({ base, update }: NameSettingsProps) => {
const { t } = useTranslation()
const [name, setName] = useState<string | undefined>(base?.name?.trim())
const updateName = async (name: UpdateAgentBaseForm['name']) => {
if (!base) return
return update({ id: base.id, name: name?.trim() })
}
// Avatar logic
const isAgent = isAgentEntity(base)
const isDefault = isAgent ? isAgentType(base.configuration?.avatar) : false
const [emoji, setEmoji] = useState(isAgent && !isDefault ? (base.configuration?.avatar ?? '⭐️') : '⭐️')
const updateAvatar = useCallback(
(avatar: string) => {
if (!isAgent || !base) return
const parsedConfiguration = AgentConfigurationSchema.parse(base.configuration ?? {})
const payload = {
id: base.id,
configuration: {
...parsedConfiguration,
avatar
}
}
update(payload)
},
[base, update, isAgent]
)
if (!base) return null
return (
<SettingsItem inline>
<SettingsTitle>{t('common.name')}</SettingsTitle>
<div className="flex max-w-70 flex-1 items-center gap-1">
{isAgent && (
<EmojiAvatarWithPicker
emoji={emoji}
onPick={(emoji: string) => {
setEmoji(emoji)
if (isAgent && emoji === base?.configuration?.avatar) return
updateAvatar(emoji)
}}
/>
)}
<Input
placeholder={t('common.agent_one') + t('common.name')}
value={name}
onChange={(e) => setName(e.target.value)}
onBlur={() => {
if (name !== base.name) {
updateName(name)
}
}}
className="flex-1"
/>
</div>
<Input
placeholder={t('common.agent_one') + t('common.name')}
value={name}
onChange={(e) => setName(e.target.value)}
onBlur={() => {
if (name !== base.name) {
updateName(name)
}
}}
className="max-w-70 flex-1"
/>
</SettingsItem>
)
}
@@ -1,57 +0,0 @@
import { useAgents } from '@renderer/hooks/agents/useAgents'
import type { GetAgentResponse, GetAgentSessionResponse, UpdateAgentFunctionUnion } from '@renderer/types'
import { Form, Select, Spin } from 'antd'
import { useTranslation } from 'react-i18next'
interface SubAgentsSettingsProps {
agentBase: GetAgentResponse | GetAgentSessionResponse | undefined | null
update: UpdateAgentFunctionUnion
}
const SubAgentsSettings: React.FC<SubAgentsSettingsProps> = ({ agentBase, update }) => {
const { t } = useTranslation()
const [form] = Form.useForm()
const { agents, isLoading } = useAgents()
if (!agentBase) return
const handleValuesChange = (changedValues: { sub_agents: string[] }) => {
update({
id: agentBase.id,
...changedValues
})
}
if (isLoading) {
return <Spin />
}
const availableAgents = agents?.filter((agent) => agent.id !== agentBase.id) || []
return (
<Form
form={form}
layout="vertical"
initialValues={{ sub_agents: agentBase.sub_agents || [] }}
onValuesChange={handleValuesChange}
style={{ maxWidth: 600 }}>
<Form.Item
name="sub_agents"
label={t('agent.settings.sub_agents.title')}
tooltip={t('agent.settings.sub_agents.tooltip')}>
<Select
mode="multiple"
placeholder={t('agent.settings.sub_agents.placeholder')}
loading={isLoading}
options={availableAgents.map((agent) => ({
label: agent.name,
value: agent.id
}))}
filterOption={(input, option) => (option?.label ?? '').toLowerCase().includes(input.toLowerCase())}
/>
</Form.Item>
</Form>
)
}
export default SubAgentsSettings
@@ -1,5 +1,3 @@
import 'emoji-picker-element'
import { CloseCircleFilled } from '@ant-design/icons'
import CodeEditor from '@renderer/components/CodeEditor'
import EmojiPicker from '@renderer/components/EmojiPicker'
@@ -109,6 +109,7 @@ const InstallNpxUv: FC<Props> = ({ mini = false }) => {
<Container>
<Alert
type={isUvInstalled ? 'success' : 'warning'}
banner
style={{ borderRadius: 'var(--list-item-border-radius)' }}
description={
<VStack>
@@ -139,6 +140,7 @@ const InstallNpxUv: FC<Props> = ({ mini = false }) => {
/>
<Alert
type={isBunInstalled ? 'success' : 'warning'}
banner
style={{ borderRadius: 'var(--list-item-border-radius)' }}
description={
<VStack>
@@ -140,7 +140,7 @@ const MCPSettings: FC = () => {
<Route
path="mcp-install"
element={
<SettingContainer style={{ backgroundColor: 'inherit' }}>
<SettingContainer theme={theme}>
<InstallNpxUv />
</SettingContainer>
}
+1 -3
View File
@@ -585,11 +585,9 @@ const fetchAndProcessAgentResponseImpl = async (
return
}
// Only mark as cleared if there was a previous session ID (not initial assignment)
sessionWasCleared = !!latestAgentSessionId
latestAgentSessionId = sessionId
agentSession.agentSessionId = sessionId
sessionWasCleared = true
logger.debug(`Agent session ID updated`, {
topicId,
+3 -4
View File
@@ -6,7 +6,6 @@ export type ToolPermissionRequestPayload = {
requestId: string
toolName: string
toolId: string
toolCallId: string
description?: string
requiresPermissions: boolean
input: Record<string, unknown>
@@ -83,12 +82,12 @@ export const selectActiveToolPermission = (state: ToolPermissionsState): ToolPer
return activeEntries[0]
}
export const selectPendingPermission = (
export const selectPendingPermissionByToolName = (
state: ToolPermissionsState,
toolCallId: string
toolName: string
): ToolPermissionEntry | undefined => {
const activeEntries = Object.values(state.requests)
.filter((entry) => entry.toolCallId === toolCallId)
.filter((entry) => entry.toolName === toolName)
.filter(
(entry) => entry.status === 'pending' || entry.status === 'submitting-allow' || entry.status === 'submitting-deny'
)
+1 -4
View File
@@ -82,7 +82,6 @@ export const AgentBaseSchema = z.object({
// Tools
mcps: z.array(z.string()).optional(), // Array of MCP tool IDs
allowed_tools: z.array(z.string()).optional(), // Array of allowed tool IDs (whitelist)
sub_agents: z.array(z.string()).optional(), // Array of sub-agent IDs
slash_commands: z.array(SlashCommandSchema).optional(), // Array of slash commands merged from builtin and SDK
// Configuration
@@ -133,7 +132,7 @@ export const AgentSessionEntitySchema = AgentBaseSchema.extend({
id: z.string(),
agent_id: z.string(), // Primary agent ID for the session
agent_type: AgentTypeSchema,
sub_agents: z.array(z.string()).optional(), // Array of sub-agent IDs involved in the session
// sub_agent_ids?: string[] // Array of sub-agent IDs involved in the session
created_at: z.iso.datetime(),
updated_at: z.iso.datetime()
@@ -206,7 +205,6 @@ export type BaseAgentForm = {
model: string
accessible_paths: string[]
allowed_tools: string[]
sub_agents?: string[]
mcps?: string[]
configuration?: AgentConfiguration
}
@@ -288,7 +286,6 @@ export interface UpdateSessionRequest extends Partial<AgentBase> {}
export const GetAgentSessionResponseSchema = AgentSessionEntitySchema.extend({
tools: z.array(ToolSchema).optional(), // All tools available to the session (including built-in and custom)
sub_agents: z.array(z.string()).optional(), // Array of sub-agent IDs
messages: z.array(AgentSessionMessageEntitySchema).optional(), // Messages in the session
plugins: z
.array(
+2 -2
View File
@@ -10074,7 +10074,7 @@ __metadata:
electron-window-state: "npm:^5.0.3"
emittery: "npm:^1.0.3"
emoji-picker-element: "npm:^1.22.1"
emoji-picker-element-data: "npm:^1"
emoji-picker-element-data: "npm:^1.8.0"
epub: "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch"
eslint: "npm:^9.22.0"
eslint-plugin-import-zod: "npm:^1.2.0"
@@ -13656,7 +13656,7 @@ __metadata:
languageName: node
linkType: hard
"emoji-picker-element-data@npm:^1":
"emoji-picker-element-data@npm:^1.8.0":
version: 1.8.0
resolution: "emoji-picker-element-data@npm:1.8.0"
checksum: 10c0/c8976b636205a0cc90d2690859a1193add71a948dadf743962b47c338a4c3715768404d0ccbc02156608b44abf41f3e1d51756e06f1bbed9d164dd4cb1752103