Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
68 changes: 56 additions & 12 deletions src/main/presenter/agentPresenter/message/messageBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { BrowserContextBuilder } from '../../browser/BrowserContextBuilder'
import { modelCapabilities } from '../../configPresenter/modelCapabilities'
import { enhanceSystemPromptWithDateTime } from '../utility/promptEnhancer'
import { ToolCallCenter } from '../tool/toolCallCenter'
import { nanoid } from 'nanoid'
import {
addContextMessages,
buildUserMessageContext,
Expand Down Expand Up @@ -244,6 +245,7 @@ export async function buildPostToolExecutionContext({
}: PostToolExecutionContextParams): Promise<ChatMessage[]> {
const { systemPrompt } = conversation.settings
const formattedMessages: ChatMessage[] = []
const supportsFunctionCall = Boolean(modelConfig?.functionCall)

if (systemPrompt) {
const finalSystemPrompt = enhanceSystemPromptWithDateTime(systemPrompt)
Expand All @@ -264,19 +266,61 @@ export async function buildPostToolExecutionContext({
content: finalUserContent
})

formattedMessages.push({
role: 'assistant',
content: currentAssistantMessage.content
.filter((block) => block.type === 'content')
.map((block) => block.content || '')
.join('\n')
})
const assistantText = currentAssistantMessage.content
.filter((block) => block.type === 'content')
.map((block) => block.content || '')
.join('\n')
.trim()

formattedMessages.push({
role: 'tool',
content: completedToolCall.response,
tool_call_id: completedToolCall.id
})
// OpenAI-compatible function-calling requires:
// assistant(tool_calls: [...]) -> tool(tool_call_id=...) pairing.
if (supportsFunctionCall) {
const toolCallId = completedToolCall.id || nanoid(8)
formattedMessages.push({
role: 'assistant',
content: assistantText || undefined,
tool_calls: [
{
id: toolCallId,
type: 'function',
function: {
name: completedToolCall.name,
arguments: completedToolCall.params || ''
}
}
]
})

formattedMessages.push({
role: 'tool',
content: completedToolCall.response,
tool_call_id: toolCallId
})
} else {
const formattedToolRecordText =
'<function_call>' +
JSON.stringify({
function_call_record: {
name: completedToolCall.name,
arguments: completedToolCall.params,
response: completedToolCall.response
}
}) +
'</function_call>'

const combinedText = [assistantText, formattedToolRecordText].filter(Boolean).join('\n')
formattedMessages.push({
role: 'assistant',
content: combinedText || undefined
})

const userPromptText =
'以上是你刚执行的工具调用及其响应信息,已帮你插入,请仔细阅读工具响应,并继续你的回答。'
formattedMessages.push({
role: 'user',
content: userPromptText
})
}

return formattedMessages
}
26 changes: 14 additions & 12 deletions src/main/presenter/tabPresenter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -696,17 +696,20 @@ export class TabPresenter implements ITabPresenter {
webContents.on('did-navigate', (_event, url) => {
const state = this.tabState.get(tabId)
if (state) {
state.url = url
// 如果没有标题,使用URL作为标题
if (!state.title || state.title === 'Untitled') {
state.title = url
const window = BrowserWindow.fromId(windowId)
if (window && !window.isDestroyed()) {
window.webContents.send(TAB_EVENTS.TITLE_UPDATED, {
tabId,
title: state.title,
windowId
})
const isLocalTab = state.url?.startsWith('local://')
if (!isLocalTab) {
state.url = url
// 如果没有标题,使用URL作为标题
if (!state.title || state.title === 'Untitled') {
state.title = url
const window = BrowserWindow.fromId(windowId)
if (window && !window.isDestroyed()) {
window.webContents.send(TAB_EVENTS.TITLE_UPDATED, {
tabId,
title: state.title,
windowId
})
}
}
this.notifyWindowTabsUpdate(windowId).catch(console.error) // Call async function, handle potential rejection
}
Expand Down Expand Up @@ -911,7 +914,6 @@ export class TabPresenter implements ITabPresenter {
const sourceWindowType = this.getWindowType(originalWindowId)
const newWindowOptions: Record<string, any> = {
forMovedTab: true,
activateTabId: tabId, // Pass the tabId to the new window presenter to activate it
windowType: sourceWindowType
}
if (screenX !== undefined && screenY !== undefined) {
Expand Down
8 changes: 2 additions & 6 deletions src/main/presenter/windowPresenter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1029,8 +1029,9 @@ export class WindowPresenter implements IWindowPresenter {
}

// 如果提供了 activateTabId,表示一个现有标签页 (WebContentsView) 将被 TabPresenter 关联到此新窗口
// 拖拽分离的场景在 attachTab 内激活,这里跳过以避免重复激活
// 激活逻辑 (设置可见性、bounds) 在 tabPresenter.attachTab / switchTab 中处理
if (options?.activateTabId !== undefined) {
if (options?.activateTabId !== undefined && !options?.forMovedTab) {
// 等待窗口加载完成,然后尝试激活指定标签页
shellWindow.webContents.once('did-finish-load', async () => {
console.log(
Expand Down Expand Up @@ -1102,11 +1103,6 @@ export class WindowPresenter implements IWindowPresenter {
}
})

if (process.platform === 'darwin') {
overlay.setHiddenInMissionControl(true)
overlay.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })
}

overlay.setIgnoreMouseEvents(true, { forward: true })

const syncOnMoved = () => {
Expand Down
3 changes: 2 additions & 1 deletion src/renderer/shell/stores/tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ export const useTabStore = defineStore('tab', () => {
const currentTabId = ref<number | null>(null)

const addTab = async (tab: { name: string; icon: string; viewType: string }) => {
if (tab.viewType !== 'playground') {
const shouldDeduplicate = tab.viewType !== 'playground' && tab.viewType !== 'chat'
if (shouldDeduplicate) {
const existing = tabs.value.find((t) => t.url === `local://${tab.viewType}`)
if (existing) {
setCurrentTabId(existing.id)
Expand Down
86 changes: 86 additions & 0 deletions test/main/presenter/agentPresenter/messageBuilder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, expect, it, vi } from 'vitest'

// messageBuilder imports the full main-process presenter graph; mock it out so we can unit-test
// pure message-building utilities without initializing Electron/main presenters.
vi.mock('@/presenter', () => ({ presenter: {} }))

describe('messageBuilder.buildPostToolExecutionContext', () => {
it('emits assistant(tool_calls) -> tool(tool_call_id) pairing when functionCall is enabled', async () => {
const { buildPostToolExecutionContext } =
await import('@/presenter/agentPresenter/message/messageBuilder')

const messages = await buildPostToolExecutionContext({
conversation: { settings: { systemPrompt: '' } } as any,
contextMessages: [],
userMessage: { role: 'user', content: { text: 'hi', files: [] } } as any,
currentAssistantMessage: {
role: 'assistant',
content: [{ type: 'content', content: 'calling tool' }]
} as any,
completedToolCall: {
id: 'call_1',
name: 'execute_command',
params: '{"command":"pwd"}',
response: '/tmp'
},
modelConfig: { functionCall: true } as any
})

expect(messages.map((m) => m.role)).toEqual(['user', 'assistant', 'tool'])
expect(messages[1].tool_calls?.[0]?.id).toBe('call_1')
expect(messages[2].tool_call_id).toBe('call_1')
})

it('generates a stable tool_call_id when upstream id is empty', async () => {
const { buildPostToolExecutionContext } =
await import('@/presenter/agentPresenter/message/messageBuilder')

const messages = await buildPostToolExecutionContext({
conversation: { settings: { systemPrompt: '' } } as any,
contextMessages: [],
userMessage: { role: 'user', content: { text: 'hi', files: [] } } as any,
currentAssistantMessage: {
role: 'assistant',
content: [{ type: 'content', content: 'calling tool' }]
} as any,
completedToolCall: {
id: '',
name: 'execute_command',
params: '{"command":"pwd"}',
response: '/tmp'
},
modelConfig: { functionCall: true } as any
})

const toolCallId = messages[1].tool_calls?.[0]?.id
expect(typeof toolCallId).toBe('string')
expect(toolCallId).toBeTruthy()
expect(messages[2].tool_call_id).toBe(toolCallId)
})

it('falls back to legacy function_call_record injection when functionCall is disabled', async () => {
const { buildPostToolExecutionContext } =
await import('@/presenter/agentPresenter/message/messageBuilder')

const messages = await buildPostToolExecutionContext({
conversation: { settings: { systemPrompt: '' } } as any,
contextMessages: [],
userMessage: { role: 'user', content: { text: 'hi', files: [] } } as any,
currentAssistantMessage: {
role: 'assistant',
content: [{ type: 'content', content: 'calling tool' }]
} as any,
completedToolCall: {
id: 'call_1',
name: 'execute_command',
params: '{"command":"pwd"}',
response: '/tmp'
},
modelConfig: { functionCall: false } as any
})

expect(messages.map((m) => m.role)).toEqual(['user', 'assistant', 'user'])
expect(String(messages[1].content)).toContain('<function_call>')
expect(messages.some((m) => m.role === 'tool')).toBe(false)
})
})