Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import monacoEditorPlugin from 'vite-plugin-monaco-editor-esm'
import path from 'node:path'
import tailwindcss from '@tailwindcss/vite'

const isCustomElement = (tag: string) =>
tag === 'voice-agent-widget' || tag.startsWith('ui-resource-renderer')

export default defineConfig({
main: {
Expand Down Expand Up @@ -82,8 +84,7 @@ export default defineConfig({
vue({
template: {
compilerOptions: {
// 将所有带短横线的标签名都视为自定义元素
isCustomElement: (tag) => tag.startsWith('ui-resource-renderer')
isCustomElement
}
}
}),
Expand Down
1 change: 1 addition & 0 deletions src/main/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const CONFIG_EVENTS = {
SYNC_SETTINGS_CHANGED: 'config:sync-settings-changed',
SEARCH_ENGINES_UPDATED: 'config:search-engines-updated',
SEARCH_PREVIEW_CHANGED: 'config:search-preview-changed',
AUTO_SCROLL_CHANGED: 'config:auto-scroll-changed',
NOTIFICATIONS_CHANGED: 'config:notifications-changed',
CONTENT_PROTECTION_CHANGED: 'config:content-protection-changed',
SOUND_ENABLED_CHANGED: 'config:sound-enabled-changed', // 新增:声音开关变更事件
Expand Down
42 changes: 42 additions & 0 deletions src/main/presenter/agentPresenter/acp/agentToolManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { presenter } from '@/presenter'
import { AgentFileSystemHandler } from './agentFileSystemHandler'
import { AgentBashHandler } from './agentBashHandler'
import { SkillTools } from '../../skillPresenter/skillTools'
import { questionToolSchema, QUESTION_TOOL_NAME } from '../tools/questionTool'
import {
ChatSettingsToolHandler,
buildChatSettingsToolDefinitions,
Expand Down Expand Up @@ -278,6 +279,9 @@ export class AgentToolManager {
defs.push(...fsDefs)
}

// 2. Built-in question tool (all modes)
defs.push(...this.getQuestionToolDefinitions())

// 3. Skill tools (agent mode only)
if (isAgentMode && this.isSkillsEnabled()) {
const skillDefs = this.getSkillToolDefinitions()
Expand Down Expand Up @@ -331,6 +335,21 @@ export class AgentToolManager {
args: Record<string, unknown>,
conversationId?: string
): Promise<AgentToolCallResult | string> {
if (toolName === QUESTION_TOOL_NAME) {
const validationResult = questionToolSchema.safeParse(args)
if (!validationResult.success) {
throw new Error(`Invalid arguments for question: ${validationResult.error.message}`)
}
return {
content: 'question_requested',
rawData: {
content: 'question_requested',
isError: false,
toolResult: validationResult.data
}
}
}

// Route to FileSystem tools
if (this.isFileSystemTool(toolName)) {
if (!this.fileSystemHandler) {
Expand Down Expand Up @@ -610,6 +629,29 @@ export class AgentToolManager {
]
}

private getQuestionToolDefinitions(): MCPToolDefinition[] {
return [
{
type: 'function',
function: {
name: QUESTION_TOOL_NAME,
description:
'Ask the user a structured question and pause the agent loop until the user responds.',
parameters: zodToJsonSchema(questionToolSchema) as {
type: string
properties: Record<string, unknown>
required?: string[]
}
},
server: {
name: 'agent-core',
icons: '❓',
description: 'Agent core tools'
}
}
]
}

private isFileSystemTool(toolName: string): boolean {
const filesystemTools = [
'read_file',
Expand Down
137 changes: 136 additions & 1 deletion src/main/presenter/agentPresenter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type {
ISQLitePresenter,
MESSAGE_METADATA
} from '@shared/presenter'
import type { AssistantMessage } from '@shared/chat'
import type { AssistantMessage, AssistantMessageBlock, UserMessageContent } from '@shared/chat'
import { eventBus, SendTarget } from '@/eventbus'
import { STREAM_EVENTS } from '@/events'
import { presenter } from '@/presenter'
Expand Down Expand Up @@ -161,6 +161,12 @@ export class AgentPresenter implements IAgentPresenter {
this.buildMessageMetadata(conversation)
)

try {
await this.resolvePendingQuestionIfNeeded(agentId, userMessage.id, content)
} catch (error) {
console.warn('[AgentPresenter] Failed to auto-resolve pending question:', error)
}

const assistantMessage = await this.streamGenerationHandler.generateAIResponse(
agentId,
userMessage.id
Expand Down Expand Up @@ -301,6 +307,25 @@ export class AgentPresenter implements IAgentPresenter {
)
}

async resolveQuestion(
messageId: string,
toolCallId: string,
answerText: string,
answerMessageId?: string
): Promise<void> {
await this.handleQuestionResolution(messageId, toolCallId, {
resolution: 'replied',
answerText,
answerMessageId
})
}

async rejectQuestion(messageId: string, toolCallId: string): Promise<void> {
await this.handleQuestionResolution(messageId, toolCallId, {
resolution: 'rejected'
})
}

async getMessageRequestPreview(agentId: string, messageId?: string): Promise<unknown> {
if (!messageId) {
return null
Expand All @@ -309,6 +334,115 @@ export class AgentPresenter implements IAgentPresenter {
return this.utilityHandler.getMessageRequestPreview(messageId)
}

private async handleQuestionResolution(
messageId: string,
toolCallId: string,
payload: {
resolution: 'replied' | 'rejected'
answerText?: string
answerMessageId?: string
}
): Promise<void> {
if (!messageId || !toolCallId) {
return
}

const message = await this.messageManager.getMessage(messageId)
if (!message || message.role !== 'assistant') {
throw new Error(`Message not found or not assistant (${messageId})`)
}

const content = message.content as AssistantMessageBlock[]
const questionBlock = content.find(
(block) =>
block.type === 'action' &&
block.action_type === 'question_request' &&
block.tool_call?.id === toolCallId
)

if (!questionBlock) {
throw new Error(
`Question block not found (messageId: ${messageId}, toolCallId: ${toolCallId})`
)
}

if (questionBlock.status !== 'pending') {
return
}

const isReplied = payload.resolution === 'replied'
questionBlock.status = isReplied ? 'success' : 'denied'
questionBlock.extra = {
...questionBlock.extra,
needsUserAction: false,
questionResolution: payload.resolution,
...(isReplied && payload.answerText ? { answerText: payload.answerText } : {}),
...(isReplied && payload.answerMessageId ? { answerMessageId: payload.answerMessageId } : {})
}

const generatingState = this.generatingMessages.get(messageId)
if (generatingState) {
const questionIndex = generatingState.message.content.findIndex(
(block) =>
block.type === 'action' &&
block.action_type === 'question_request' &&
block.tool_call?.id === toolCallId
)
if (questionIndex !== -1) {
const stateBlock = generatingState.message.content[questionIndex]
generatingState.message.content[questionIndex] = {
...stateBlock,
...questionBlock,
extra: questionBlock.extra ? { ...questionBlock.extra } : undefined,
tool_call: questionBlock.tool_call ? { ...questionBlock.tool_call } : undefined
}
}
}

await this.messageManager.editMessage(messageId, JSON.stringify(content))
presenter.sessionManager.clearPendingQuestion(message.conversationId)
presenter.sessionManager.setStatus(message.conversationId, 'idle')
}

private async resolvePendingQuestionIfNeeded(
conversationId: string,
userMessageId: string,
rawContent: string
): Promise<void> {
const session = await this.sessionManager.getSession(conversationId)
const pendingQuestion = session.runtime?.pendingQuestion
if (!pendingQuestion?.messageId || !pendingQuestion.toolCallId) {
return
}

const answerText = this.extractUserMessageText(rawContent)
if (!answerText.trim()) {
return
}

await this.handleQuestionResolution(pendingQuestion.messageId, pendingQuestion.toolCallId, {
resolution: 'replied',
answerText,
answerMessageId: userMessageId
})
}

private extractUserMessageText(rawContent: string): string {
if (!rawContent) return ''
try {
const parsed = JSON.parse(rawContent) as UserMessageContent
if (typeof parsed.text === 'string') {
return parsed.text
}
if (Array.isArray(parsed.content)) {
return parsed.content.map((block) => block.content || '').join('')
}
} catch (error) {
console.warn('[AgentPresenter] Failed to parse user message content:', error)
}
return rawContent
}

private buildMessageMetadata(conversation: CONVERSATION): MESSAGE_METADATA {
const { providerId, modelId } = conversation.settings
return {
Expand Down Expand Up @@ -416,6 +550,7 @@ export class AgentPresenter implements IAgentPresenter {
this.sessionManager.updateRuntime(state.conversationId, { userStopRequested: true })
this.sessionManager.setStatus(state.conversationId, 'paused')
this.sessionManager.clearPendingPermission(state.conversationId)
this.sessionManager.clearPendingQuestion(state.conversationId)
state.isCancelled = true

if (state.adaptiveBuffer) {
Expand Down
39 changes: 39 additions & 0 deletions src/main/presenter/agentPresenter/loop/toolCallHandler.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { AssistantMessageBlock } from '@shared/chat'
import type { QuestionInfo } from '@shared/types/core/question'
import { finalizeAssistantMessageBlocks } from '@shared/chat/messageBlocks'
import type {
LLMAgentEventData,
Expand Down Expand Up @@ -215,6 +216,44 @@ export class ToolCallHandler {
}
}

async processQuestionRequest(
state: GeneratingMessageState,
event: LLMAgentEventData,
currentTime: number
): Promise<void> {
const payload = event.question_request as QuestionInfo | undefined
if (!payload) return

this.finalizeLastBlock(state)

state.message.content.push({
type: 'action',
content: '',
status: 'pending',
timestamp: currentTime,
action_type: 'question_request',
tool_call: {
id: event.tool_call_id,
name: event.tool_call_name,
params: event.tool_call_params || '',
server_name: event.tool_call_server_name,
server_icons: event.tool_call_server_icons,
server_description: event.tool_call_server_description
},
extra: {
needsUserAction: true,
questionHeader: payload.header ?? '',
questionText: payload.question,
questionOptions: payload.options,
questionMultiple: Boolean(payload.multiple),
questionCustom: payload.custom !== false,
questionResolution: 'asked'
}
})

state.pendingToolCall = undefined
}

async processMcpUiResourcesFromToolCall(
state: GeneratingMessageState,
event: LLMAgentEventData,
Expand Down
Loading