Compare commits

...

4 Commits

Author SHA1 Message Date
suyao
3da8b63673 Merge remote-tracking branch 'origin/main' into feat/sub_agents 2025-11-21 14:57:03 +08:00
SuYao
dcdd1bf852 refactor: replace renderToolContent function with ToolContent component for improved readability (#11300)
* refactor: replace renderToolContent function with ToolContent component for improved readability

* fix

* fix test
2025-11-21 09:55:46 +08:00
beyondkmp
a12b6bfeca feat: enable native language emoji search with CLDR data format (#11381)
* feat: add i18n support and local data to emoji picker

- Add emoji-picker-element-data package for offline-first emoji data
- Implement i18n translations for emoji picker UI (de, en, es, fr, ja, pt, ru, zh)
- Switch from CDN to local emoji data to improve performance and reliability
- Add locale mapping to match app language with emoji picker data
- Move emoji-picker-element import to EmojiPicker component for better encapsulation
- Use proper TypeScript types instead of 'any' for type safety

This improves user experience by providing localized emoji picker interface
and eliminating dependency on external CDN, ensuring the picker works offline.

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

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: enable native language emoji search with CLDR data format

Switch from emojibase to CLDR format for emoji-picker-element data to support full multi-language search functionality. Users can now search for emojis in their native language (e.g., German users can search "Herz" for ❤️, Spanish users can search "corazón"). Also improves type safety by using the LanguageVarious type for locale mappings.

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

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-20 19:23:27 +08:00
suyao
1175823ab8 feat: add support for sub-agents in agent and session management
- Added 'sub_agents' field to the BaseService, agents schema, and sessions schema.
- Implemented methods in AgentService and SessionService to handle sub-agent configurations.
- Updated ClaudeCodeService to load and manage sub-agents.
- Enhanced UI components to display and select sub-agents in the agent settings and activity directory.
- Added translations for sub-agent related UI elements in multiple languages.
- Created SubAgentsSettings component for managing sub-agent associations in agent settings.
2025-11-19 15:58:15 +08:00
43 changed files with 1221 additions and 248 deletions

View File

@@ -86,6 +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",
"express": "^5.1.0",
"font-list": "^2.0.0",
"graceful-fs": "^4.2.11",

View File

@@ -0,0 +1,2 @@
ALTER TABLE `agents` ADD `sub_agents` text;--> statement-breakpoint
ALTER TABLE `sessions` ADD `sub_agents` text;

View File

@@ -0,0 +1,360 @@
{
"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": {}
}
}

View File

@@ -22,6 +22,13 @@
"when": 1762526423527,
"tag": "0002_wealthy_naoko",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1763500397620,
"tag": "0003_smooth_talkback",
"breakpoints": true
}
]
}

View File

@@ -104,12 +104,6 @@ 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 })

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

View File

@@ -849,7 +849,7 @@ class FileStorage {
const resolvedPath = path.resolve(dirPath)
const stat = await fs.promises.stat(resolvedPath).catch((error) => {
logger.error(`[IPC - Error] Failed to access directory: ${resolvedPath}`, error as Error)
logger.error(`Failed to access directory: ${resolvedPath}`, error as Error)
throw error
})

View File

@@ -42,6 +42,7 @@ export abstract class BaseService {
'configuration',
'accessible_paths',
'allowed_tools',
'sub_agents',
'slash_commands'
]

View File

@@ -19,6 +19,7 @@ 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

View File

@@ -22,6 +22,7 @@ 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

View File

@@ -117,6 +117,19 @@ 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

View File

@@ -130,6 +130,7 @@ 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
@@ -169,6 +170,22 @@ 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
}

View File

@@ -21,6 +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', () => {
@@ -188,6 +193,111 @@ describe('Claude → AiSDK transform', () => {
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 parts: ReturnType<typeof transformSDKMessageToStreamParts>[number][] = []
@@ -300,4 +410,87 @@ 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')
})
})

View File

@@ -153,6 +153,20 @@ 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)
if (index === undefined) return undefined
@@ -217,10 +231,10 @@ 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, input: unknown, providerMetadata?: ProviderMetadata): void {
completeToolBlock(toolCallId: string, toolName: string, input: unknown, providerMetadata?: ProviderMetadata): void {
const block = this.getToolBlockByRawId(toolCallId)
this.registerToolCall(toolCallId, {
toolName: block?.toolName ?? 'unknown',
toolName,
input,
providerMetadata
})

View File

@@ -2,7 +2,13 @@
import { EventEmitter } from 'node:events'
import { createRequire } from 'node:module'
import type { CanUseTool, McpHttpServerConfig, Options, SDKMessage } from '@anthropic-ai/claude-agent-sdk'
import type {
AgentDefinition,
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'
@@ -10,7 +16,7 @@ import { validateModelId } from '@main/apiServer/utils'
import getLoginShellEnvironment from '@main/utils/shell-env'
import { app } from 'electron'
import type { GetAgentSessionResponse } from '../..'
import { agentService, type GetAgentSessionResponse } from '../..'
import type { AgentServiceInterface, AgentStream, AgentStreamEvent } from '../../interfaces/AgentStreamInterface'
import { sessionService } from '../SessionService'
import { buildNamespacedToolCallId } from './claude-stream-state'
@@ -157,6 +163,32 @@ class ClaudeCodeService implements AgentServiceInterface {
})
}
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)
})
}
}
}
// Build SDK options from parameters
const options: Options = {
abortController,
@@ -414,23 +446,6 @@ 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', {

View File

@@ -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: sdkMessage })
logger.silly('Transforming SDKMessage', { message: JSON.stringify(sdkMessage) })
switch (sdkMessage.type) {
case 'assistant':
return handleAssistantMessage(sdkMessage, state)
@@ -186,14 +186,13 @@ function handleAssistantMessage(
for (const block of content) {
switch (block.type) {
case 'text':
if (!isStreamingActive) {
const sanitizedText = stripLocalCommandTags(block.text)
if (sanitizedText) {
textBlocks.push(sanitizedText)
}
case 'text': {
const sanitizedText = stripLocalCommandTags(block.text)
if (sanitizedText) {
textBlocks.push(sanitizedText)
}
break
}
case 'tool_use':
handleAssistantToolUse(block as ToolUseContent, providerMetadata, state, chunks)
break
@@ -203,7 +202,16 @@ function handleAssistantMessage(
}
}
if (!isStreamingActive && textBlocks.length > 0) {
if (textBlocks.length === 0) {
return chunks
}
const combinedText = textBlocks.join('')
if (!combinedText) {
return chunks
}
if (!isStreamingActive) {
const id = message.uuid?.toString() || generateMessageId()
state.beginStep()
chunks.push({
@@ -219,7 +227,7 @@ function handleAssistantMessage(
chunks.push({
type: 'text-delta',
id,
text: textBlocks.join(''),
text: combinedText,
providerMetadata
})
chunks.push({
@@ -230,7 +238,27 @@ function handleAssistantMessage(
return finalizeNonStreamingStep(message, state, chunks)
}
return 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)
}
/**
@@ -252,7 +280,7 @@ function handleAssistantToolUse(
providerExecuted: true,
providerMetadata
})
state.completeToolBlock(block.id, block.input, providerMetadata)
state.completeToolBlock(block.id, block.name, block.input, providerMetadata)
}
/**
@@ -459,6 +487,9 @@ function handleStreamEvent(
}
case 'message_stop': {
if (!state.hasActiveStep()) {
break
}
const pending = state.getPendingUsage()
chunks.push({
type: 'finish-step',

View File

@@ -1,35 +1,120 @@
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 type { FC } from 'react'
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
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'
}
const EmojiPicker: FC<Props> = ({ onEmojiClick }) => {
const { theme } = useTheme()
const ref = useRef<HTMLDivElement>(null)
const { i18n } = useTranslation()
const ref = useRef<Picker>(null)
const currentLocale = i18n.language as LanguageVarious
useEffect(() => {
polyfillCountryFlagEmojis('Twemoji Mozilla', TwemojiCountryFlagsWoff2)
}, [])
// Configure picker with i18n and dataSource
useEffect(() => {
const refValue = ref.current
const picker = ref.current
if (picker) {
picker.i18n = i18nMap[currentLocale] || en
picker.dataSource = dataSourceMap[currentLocale] || dataEN
picker.locale = localeMap[currentLocale] || 'en'
}
}, [currentLocale])
if (refValue) {
const handleEmojiClick = (event: any) => {
useEffect(() => {
const picker = ref.current
if (picker) {
const handleEmojiClick = (event: EmojiClickEvent) => {
event.stopPropagation()
onEmojiClick(event.detail.unicode || event.detail.emoji.unicode)
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)
}
// 添加事件监听器
refValue.addEventListener('emoji-click', handleEmojiClick)
picker.addEventListener('emoji-click', handleEmojiClick)
// 清理事件监听器
return () => {
refValue.removeEventListener('emoji-click', handleEmojiClick)
picker.removeEventListener('emoji-click', handleEmojiClick)
}
}
return

View File

@@ -156,6 +156,12 @@
"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.",
@@ -641,6 +647,7 @@
"description": "No files available in accessible directories",
"label": "No File Found"
},
"sub_agent": "Sub-Agent",
"title": "Activity Directory"
},
"auto_resize": "Auto resize height",

View File

@@ -156,6 +156,12 @@
"uninstalling": "卸载中..."
},
"prompt": "提示词设置",
"sub_agents": {
"placeholder": "选择子智能体",
"tab": "子智能体",
"title": "子智能体",
"tooltip": "选择可以被此智能体委派任务的其他智能体"
},
"tooling": {
"mcp": {
"description": "连接 MCP 服务器即可解锁更多可在上方预先授权的工具。",
@@ -641,6 +647,7 @@
"description": "可访问目录中没有可用文件",
"label": "未找到文件"
},
"sub_agent": "子代理",
"title": "活动目录"
},
"auto_resize": "自动调整高度",

View File

@@ -156,6 +156,12 @@
"uninstalling": "解除安裝中..."
},
"prompt": "提示設定",
"sub_agents": {
"placeholder": "選擇子助手",
"tab": "子助手",
"title": "子助手",
"tooltip": "選擇可以被此助手委派任務的其他助手"
},
"tooling": {
"mcp": {
"description": "連線 MCP 伺服器即可解鎖更多可在上方預先授權的工具。",
@@ -641,6 +647,7 @@
"description": "可存取的目錄中沒有檔案",
"label": "找不到檔案"
},
"sub_agent": "子代理",
"title": "活動目錄"
},
"auto_resize": "自動調整高度",

View File

@@ -156,6 +156,12 @@
"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.",

View File

@@ -156,6 +156,12 @@
"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 για να ξεκλειδώσετε πρόσθετα εργαλεία που μπορείτε να εγκρίνετε παραπάνω.",

View File

@@ -156,6 +156,12 @@
"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.",

View File

@@ -156,6 +156,12 @@
"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.",

View File

@@ -156,6 +156,12 @@
"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サーバーを接続して、上で承認できる追加ツールを解放します。",

View File

@@ -156,6 +156,12 @@
"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.",

View File

@@ -156,6 +156,12 @@
"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, чтобы разблокировать дополнительные инструменты, которые вы можете одобрить выше.",

View File

@@ -103,12 +103,23 @@ 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 ?? []
accessiblePaths: session.accessible_paths ?? [],
subAgents: agentPlugins
}
}, [session, agentId, sessionId])
@@ -158,6 +169,8 @@ 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

View File

@@ -25,11 +25,12 @@ const activityDirectoryTool = defineTool({
const { quickPanel, quickPanelController, actions, session } = context
const { onTextChange } = actions
// Get accessible paths from session data
// Get accessible paths and sub-agents from session data
const accessiblePaths = session?.accessiblePaths ?? []
const subAgents = session?.subAgents ?? []
// Only render if we have accessible paths
if (accessiblePaths.length === 0) {
// Only render if we have accessible paths or sub-agents
if (accessiblePaths.length === 0 && subAgents.length === 0) {
return null
}
@@ -38,6 +39,7 @@ const activityDirectoryTool = defineTool({
quickPanel={quickPanel}
quickPanelController={quickPanelController}
accessiblePaths={accessiblePaths}
subAgents={subAgents}
setText={onTextChange as React.Dispatch<React.SetStateAction<string>>}
/>
)

View File

@@ -13,10 +13,17 @@ 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, setText }) => {
const ActivityDirectoryButton: FC<Props> = ({
quickPanel,
quickPanelController,
accessiblePaths,
subAgents,
setText
}) => {
const { t } = useTranslation()
const { handleOpenQuickPanel } = useActivityDirectoryPanel(
@@ -24,6 +31,7 @@ const ActivityDirectoryButton: FC<Props> = ({ quickPanel, quickPanelController,
quickPanel,
quickPanelController,
accessiblePaths,
subAgents,
setText
},
'button'

View File

@@ -15,8 +15,9 @@ const ActivityDirectoryQuickPanelManager = ({ context }: ManagerProps) => {
session
} = context
// Get accessible paths from session data
// Get accessible paths and sub-agents from session data
const accessiblePaths = session?.accessiblePaths ?? []
const subAgents = session?.subAgents ?? []
// Always call hooks unconditionally (React rules)
useActivityDirectoryPanel(
@@ -24,6 +25,7 @@ const ActivityDirectoryQuickPanelManager = ({ context }: ManagerProps) => {
quickPanel,
quickPanelController,
accessiblePaths,
subAgents,
setText: onTextChange as React.Dispatch<React.SetStateAction<string>>
},
'manager'

View File

@@ -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 { File, Folder } from 'lucide-react'
import { Bot, File, Folder } from 'lucide-react'
import type React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -25,15 +25,22 @@ 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, setText } = params
const { quickPanel, quickPanelController, accessiblePaths, subAgents = [], setText } = params
const { registerTrigger, registerRootMenu } = quickPanel
const { open, close, updateList, isVisible, symbol } = quickPanelController
const { t } = useTranslation()
@@ -238,6 +245,68 @@ 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
*/
@@ -291,12 +360,18 @@ export const useActivityDirectoryPanel = (params: Params, role: 'button' | 'mana
)
/**
* Create file list items for QuickPanel (for current state)
* Create combined list items for QuickPanel (sub-agents + files)
*/
const fileItems = useMemo<QuickPanelListItem[]>(
() => createFileItems(fileList, isLoading),
[createFileItems, fileList, isLoading]
)
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
/**
* Handle search text change - load files and update list
@@ -311,11 +386,13 @@ export const useActivityDirectoryPanel = (params: Params, role: 'button' | 'mana
const hasChanged = updateFileListState(newFiles)
if (hasChanged) {
const newItems = createFileItems(newFiles, false)
updateList(newItems)
// Combine sub-agents and files
const agentItems = createSubAgentItems(subAgents)
const fileItems = createFileItems(newFiles, false)
updateList([...agentItems, ...fileItems])
}
},
[loadFiles, createFileItems, updateList, updateFileListState]
[loadFiles, createFileItems, createSubAgentItems, subAgents, updateList, updateFileListState]
)
/**
@@ -336,8 +413,10 @@ export const useActivityDirectoryPanel = (params: Params, role: 'button' | 'mana
const files = await loadFiles()
updateFileListState(files)
// Create items from the loaded files immediately
const items = createFileItems(files, false)
// Create items from sub-agents and loaded files immediately
const agentItems = createSubAgentItems(subAgents)
const fileItems = createFileItems(files, false)
const items = [...agentItems, ...fileItems]
open({
title: t('chat.input.activity_directory.description'),
@@ -377,7 +456,18 @@ export const useActivityDirectoryPanel = (params: Params, role: 'button' | 'mana
onSearchChange: handleSearchChange
})
},
[loadFiles, open, removeTriggerSymbolAndText, setText, t, handleSearchChange, createFileItems, updateFileListState]
[
loadFiles,
open,
removeTriggerSymbolAndText,
setText,
t,
handleSearchChange,
createFileItems,
createSubAgentItems,
subAgents,
updateFileListState
]
)
/**

View File

@@ -68,6 +68,7 @@ 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 }>
}
}

View File

@@ -1,7 +1,6 @@
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'
@@ -16,6 +15,63 @@ 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
@@ -23,73 +79,8 @@ export function BashOutputTool({
input: BashOutputToolInput
output?: BashOutputToolOutput
}): NonNullable<CollapseProps['items']>[number] {
// 解析 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 parsedOutput = parseBashOutput(output)
const statusConfig = getStatusConfig(parsedOutput)
const children = parsedOutput ? (
<div className="flex flex-col gap-4">

View File

@@ -1,12 +1,47 @@
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
@@ -14,50 +49,8 @@ export function ReadTool({
input: ReadToolInputType
output?: ReadToolOutputType
}): NonNullable<CollapseProps['items']>[number] {
// 移除 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])
const outputString = normalizeOutputString(output)
const stats = getOutputStats(outputString)
return {
key: AgentToolsType.Read,

View File

@@ -11,11 +11,24 @@ interface UnknownToolProps {
output?: unknown
}
export function UnknownToolRenderer({
toolName = '',
input,
output
}: UnknownToolProps): NonNullable<CollapseProps['items']>[number] {
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 }) => {
const { highlightCode } = useCodeStyle()
const [inputHtml, setInputHtml] = useState<string>('')
const [outputHtml, setOutputHtml] = useState<string>('')
@@ -34,58 +47,49 @@ export function UnknownToolRenderer({
}
}, [output, highlightCode])
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
if (input === undefined && output === undefined) {
return <div className="text-foreground-500 text-xs">No data available for this tool</div>
}
const getToolDescription = () => {
if (toolName.startsWith('mcp__')) {
return 'MCP Server Tool'
}
return 'Tool'
}
return (
<div className="space-y-3">
{input !== undefined && (
<div>
<div className="mb-1 font-semibold text-foreground-600 text-xs dark:text-foreground-400">Input:</div>
<div
className="overflow-x-auto rounded bg-gray-50 dark:bg-gray-900"
dangerouslySetInnerHTML={{ __html: inputHtml }}
/>
</div>
)}
{output !== undefined && (
<div>
<div className="mb-1 font-semibold text-foreground-600 text-xs dark:text-foreground-400">Output:</div>
<div
className="rounded bg-gray-50 dark:bg-gray-900 [&>*]:whitespace-pre-line"
dangerouslySetInnerHTML={{ __html: outputHtml }}
/>
</div>
)}
</div>
)
}
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()}
params={getToolDescription(toolName)}
/>
),
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>
)
children: <UnknownToolContent input={input} output={output} />
}
}

View File

@@ -6,8 +6,6 @@ import { Collapse } from 'antd'
// 导出所有类型
export * from './types'
import { useMemo } from 'react'
// 导入所有渲染器
import ToolPermissionRequestCard from '../ToolPermissionRequestCard'
import { BashOutputTool } from './BashOutputTool'
@@ -57,22 +55,19 @@ export function isValidAgentToolsType(toolName: unknown): toolName is AgentTools
return typeof toolName === 'string' && Object.values(AgentToolsType).includes(toolName as AgentToolsType)
}
// 统一的渲染函数
function renderToolContent(toolName: AgentToolsType, input: ToolInput, output?: ToolOutput) {
// 统一的渲染组件
function ToolContent({ toolName, input, output }: { toolName: AgentToolsType; input: ToolInput; output?: ToolOutput }) {
const Renderer = toolRenderers[toolName]
const renderedItem = Renderer
? Renderer({ input: input as any, output: output as any })
: UnknownToolRenderer({ input: input as any, output: output as any, toolName })
// 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])
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'
}
}
return (
<Collapse
@@ -98,5 +93,7 @@ export function MessageAgentTools({ toolResponse }: { toolResponse: NormalToolRe
return <ToolPermissionRequestCard toolResponse={toolResponse} />
}
return renderToolContent(tool.name as AgentToolsType, args as ToolInput, response as ToolOutput)
return (
<ToolContent toolName={tool.name as AgentToolsType} input={args as ToolInput} output={response as ToolOutput} />
)
}

View File

@@ -11,6 +11,7 @@ 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 {
@@ -22,7 +23,7 @@ interface AgentSettingPopupParams extends AgentSettingPopupShowParams {
resolve: () => void
}
type AgentSettingPopupTab = 'essential' | 'prompt' | 'tooling' | 'advanced' | 'plugins' | 'session-mcps'
type AgentSettingPopupTab = 'essential' | 'prompt' | 'tooling' | 'advanced' | 'plugins' | 'sub-agents' | 'session-mcps'
const AgentSettingPopupContainer: React.FC<AgentSettingPopupParams> = ({ tab, agentId, resolve }) => {
const [open, setOpen] = useState(true)
@@ -62,6 +63,10 @@ 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')
@@ -107,6 +112,7 @@ 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>

View File

@@ -0,0 +1,57 @@
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

View File

@@ -1,5 +1,3 @@
import 'emoji-picker-element'
import { CheckOutlined, LoadingOutlined, RollbackOutlined, ThunderboltOutlined } from '@ant-design/icons'
import { loggerService } from '@logger'
import EmojiPicker from '@renderer/components/EmojiPicker'

View File

@@ -585,9 +585,11 @@ 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,

View File

@@ -82,6 +82,7 @@ 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
@@ -132,7 +133,7 @@ export const AgentSessionEntitySchema = AgentBaseSchema.extend({
id: z.string(),
agent_id: z.string(), // Primary agent ID for the session
agent_type: AgentTypeSchema,
// sub_agent_ids?: string[] // Array of sub-agent IDs involved in the session
sub_agents: z.array(z.string()).optional(), // Array of sub-agent IDs involved in the session
created_at: z.iso.datetime(),
updated_at: z.iso.datetime()
@@ -205,6 +206,7 @@ export type BaseAgentForm = {
model: string
accessible_paths: string[]
allowed_tools: string[]
sub_agents?: string[]
mcps?: string[]
configuration?: AgentConfiguration
}
@@ -286,6 +288,7 @@ 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(

View File

@@ -10074,6 +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"
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"
@@ -13655,6 +13656,13 @@ __metadata:
languageName: node
linkType: hard
"emoji-picker-element-data@npm:^1":
version: 1.8.0
resolution: "emoji-picker-element-data@npm:1.8.0"
checksum: 10c0/c8976b636205a0cc90d2690859a1193add71a948dadf743962b47c338a4c3715768404d0ccbc02156608b44abf41f3e1d51756e06f1bbed9d164dd4cb1752103
languageName: node
linkType: hard
"emoji-picker-element@npm:^1.22.1":
version: 1.26.3
resolution: "emoji-picker-element@npm:1.26.3"