fix(web): align agent activity disclosure (#38999)

This commit is contained in:
yyh 2026-07-15 16:51:21 +08:00 committed by GitHub
parent 71d5c35e8f
commit b721e7a32d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 1007 additions and 353 deletions

View File

@ -1870,6 +1870,9 @@ describe('useChatWithHistory', () => {
id: 'msg-files',
query: 'Question with files',
answer: 'Answer with files',
answer_tokens: 10,
message_tokens: 5,
provider_response_latency: 66,
message_files: [
{
id: 'file-user-1',
@ -1967,6 +1970,12 @@ describe('useChatWithHistory', () => {
expect(messageWithFiles?.children?.[0]?.agent_thoughts?.[0]?.message_files).toHaveLength(1)
const normalAnswerNode = messageWithFiles?.children?.[0]
expect(normalAnswerNode?.more).toEqual({
time: '',
tokens: 15,
latency: '66.00',
tokens_per_second: '0.15',
})
const pausedAnswerNode = result!.current.appPrevChatTree.find(
(item) => item.id === 'question-msg-paused-branch',
)?.children?.[0]

View File

@ -61,6 +61,18 @@ function getFormattedChatList(messages: any[]) {
})
const answerFiles =
item.message_files?.filter((file: any) => file.belongs_to === 'assistant') || []
const answerTokens = item.answer_tokens ?? 0
const messageTokens = item.message_tokens ?? 0
const latency = Number(item.provider_response_latency)
const more =
item.provider_response_latency == null || !Number.isFinite(latency)
? undefined
: {
time: '',
tokens: answerTokens + messageTokens,
latency: latency.toFixed(2),
tokens_per_second: latency > 0 ? (answerTokens / latency).toFixed(2) : undefined,
}
const humanInputFormDataList: HumanInputFormData[] = []
const humanInputFilledFormDataList: HumanInputFilledFormData[] = []
let workflowRunId = ''
@ -111,6 +123,7 @@ function getFormattedChatList(messages: any[]) {
humanInputFormDataList,
humanInputFilledFormDataList,
workflow_run_id: workflowRunId,
more,
})
})
return newChatList

View File

@ -1,5 +1,6 @@
import type { ChatItem } from '../../../types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import { AgentRosterResponseContent } from '../agent-roster-response-content'
@ -9,13 +10,59 @@ vi.mock('react-i18next', async () => {
return {
...actual,
...createReactI18nextMock({
'common.chat.thought': 'Thought',
'agentV2.agentDetail.configure.answer.thinking': 'Thinking',
'agentV2.agentDetail.configure.answer.duration.minute': '{{count}}m',
'agentV2.agentDetail.configure.answer.duration.second': '{{count}}s',
'agentV2.agentDetail.configure.answer.activity.ranCommands': 'Ran commands',
'agentV2.agentDetail.configure.answer.activity.runningCommands': 'Running commands',
}),
}
})
const thinkingOnlyItem = {
id: 'answer-thinking-only',
content: '',
isAnswer: true,
agent_response_parts: [
{
type: 'thought',
thought: {
id: 'thought-thinking-only',
thought: 'internal thought should not render',
tool: '',
tool_input: '',
observation: '',
message_id: 'answer-thinking-only',
conversation_id: 'conversation-thinking-only',
position: 1,
},
},
],
} satisfies ChatItem
describe('AgentRosterResponseContent', () => {
it('should keep the live thinking status before visible activity arrives', () => {
render(<AgentRosterResponseContent item={thinkingOnlyItem} responding />)
expect(screen.getByRole('button', { name: /Thinking/ })).toHaveAttribute(
'aria-expanded',
'true',
)
expect(screen.queryByText('internal thought should not render')).not.toBeInTheDocument()
})
it('should keep a stopped thinking status without exposing internal thought', () => {
render(<AgentRosterResponseContent item={thinkingOnlyItem} responding={false} />)
expect(screen.getByRole('button', { name: 'Thinking' })).toHaveAttribute(
'aria-expanded',
'false',
)
expect(screen.queryByText('internal thought should not render')).not.toBeInTheDocument()
})
it('should render historical agent thought answer as markdown instead of thought process', async () => {
const user = userEvent.setup()
const item = {
id: 'answer-history',
content: '',
@ -37,55 +84,73 @@ describe('AgentRosterResponseContent', () => {
render(<AgentRosterResponseContent item={item} />)
expect(screen.getByRole('button', { name: /workFinished/i })).toBeInTheDocument()
const processToggle = screen.getByRole('button', { name: 'Thinking' })
expect(processToggle).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByText('history answer')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /workFinished/i }))
await user.click(processToggle)
expect(processToggle).toHaveAttribute('aria-expanded', 'true')
await waitFor(() => {
expect(screen.getByTestId('agent-roster-response-content')).toHaveTextContent(
'history answer',
)
expect(screen.getByText('history answer')).toBeInTheDocument()
})
expect(screen.queryByText('internal thought should not render')).not.toBeInTheDocument()
})
it('should render new agent response parts in event order when thoughts and messages interleave', async () => {
it('should preserve historical answer whitespace when rendering markdown', async () => {
const user = userEvent.setup()
const item = {
id: 'answer-history-code',
content: '',
isAnswer: true,
agent_thoughts: [
{
id: 'thought-history-code',
thought: 'internal thought should not render',
answer: ' const answer = 42',
tool: '',
tool_input: '',
observation: '',
message_id: 'answer-history-code',
conversation_id: 'conversation-history-code',
position: 1,
},
],
} satisfies ChatItem
render(<AgentRosterResponseContent item={item} />)
await user.click(screen.getByRole('button', { name: 'Thinking' }))
await waitFor(() => {
expect(screen.getByText('const answer = 42').tagName).toBe('CODE')
})
})
it('should keep one collapsible thinking timeline while response parts interleave', async () => {
const user = userEvent.setup()
const item = {
id: 'answer-1',
content: 'first answer second answer',
isAnswer: true,
agent_thoughts: [
{
id: 'thought-1',
thought: 'first thought',
tool: '',
tool_input: '',
observation: '',
message_id: 'answer-1',
conversation_id: 'conversation-1',
position: 1,
},
{
id: 'thought-2',
thought: 'second thought',
tool: '',
tool_input: '',
observation: '',
message_id: 'answer-1',
conversation_id: 'conversation-1',
position: 2,
},
],
agent_response_parts: [
{
type: 'thought',
thought: {
id: 'thought-1',
thought: 'first thought',
tool: '',
thought: 'raw first thought',
tool: 'load_tools',
tool_input: '',
tool_labels: {
load_tools: {
en_US: 'Loaded tools',
zh_Hans: '已加载工具',
},
shell_run: {
en_US: 'Ran commands',
zh_Hans: '运行了命令',
},
},
observation: '',
message_id: 'answer-1',
conversation_id: 'conversation-1',
@ -100,9 +165,19 @@ describe('AgentRosterResponseContent', () => {
type: 'thought',
thought: {
id: 'thought-2',
thought: 'second thought',
tool: '',
thought: 'raw second thought',
tool: 'shell_run',
tool_input: '',
tool_labels: {
load_tools: {
en_US: 'Loaded tools',
zh_Hans: '已加载工具',
},
shell_run: {
en_US: 'Ran commands',
zh_Hans: '运行了命令',
},
},
observation: '',
message_id: 'answer-1',
conversation_id: 'conversation-1',
@ -118,17 +193,28 @@ describe('AgentRosterResponseContent', () => {
render(<AgentRosterResponseContent item={item} responding />)
const processToggle = screen.getByRole('button', { name: /Thinking/ })
expect(processToggle).toHaveAttribute('aria-expanded', 'true')
await waitFor(() => {
expect(screen.getByTestId('agent-roster-response-content')).toHaveTextContent('second answer')
})
const content = screen.getByTestId('agent-roster-response-content').textContent ?? ''
expect(content.indexOf('first thought')).toBeLessThan(content.indexOf('first answer'))
expect(content.indexOf('first answer')).toBeLessThan(content.indexOf('second thought'))
expect(content.indexOf('second thought')).toBeLessThan(content.indexOf('second answer'))
expect(content.indexOf('Loaded tools')).toBeLessThan(content.indexOf('first answer'))
expect(content.indexOf('first answer')).toBeLessThan(content.indexOf('Ran commands'))
expect(content.indexOf('Ran commands')).toBeLessThan(content.indexOf('second answer'))
expect(screen.queryByText('raw first thought')).not.toBeInTheDocument()
expect(screen.queryByText('raw second thought')).not.toBeInTheDocument()
await user.click(processToggle)
expect(processToggle).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByText('Loaded tools')).not.toBeInTheDocument()
})
it('should show THOUGHT as the header when a thought process is expanded', () => {
it('should keep activity labels stable when their details are expanded', async () => {
const user = userEvent.setup()
const item = {
id: 'answer-expanded-thought',
content: '',
@ -136,10 +222,16 @@ describe('AgentRosterResponseContent', () => {
agent_thoughts: [
{
id: 'thought-expanded',
thought: 'visible thought summary',
tool: '',
tool_input: '',
observation: '',
thought: '',
tool: 'shell_run',
tool_input: '{"command":"ls"}',
tool_labels: {
shell_run: {
en_US: 'Ran commands',
zh_Hans: '运行了命令',
},
},
observation: 'README.md',
message_id: 'answer-expanded-thought',
conversation_id: 'conversation-expanded-thought',
position: 1,
@ -147,8 +239,326 @@ describe('AgentRosterResponseContent', () => {
],
} satisfies ChatItem
render(<AgentRosterResponseContent item={item} />)
await user.click(screen.getByRole('button', { name: 'Thinking' }))
const activityToggle = screen.getByRole('button', { name: 'Ran commands' })
expect(activityToggle).toHaveAttribute('aria-expanded', 'false')
await user.click(activityToggle)
expect(activityToggle).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByRole('button', { name: 'Ran commands' })).toBe(activityToggle)
expect(screen.getByText('{"command":"ls"}')).toBeInTheDocument()
})
it('should use the shell activity fallback only when no descriptive label is available', async () => {
const user = userEvent.setup()
const item = {
id: 'answer-shell-labels',
content: '',
isAnswer: true,
agent_thoughts: [
{
id: 'thought-shell-fallback',
thought: '',
tool: 'shell_run',
tool_input: 'pwd',
observation: '/workspace',
message_id: 'answer-shell-labels',
conversation_id: 'conversation-shell-labels',
position: 1,
},
{
id: 'thought-shell-described',
thought: '',
tool: 'shell_run',
tool_input: 'mkdir skill',
tool_labels: {
shell_run: {
en_US: 'Scaffold skill directory',
zh_Hans: '创建技能目录',
},
},
observation: '',
message_id: 'answer-shell-labels',
conversation_id: 'conversation-shell-labels',
position: 2,
},
],
} satisfies ChatItem
render(<AgentRosterResponseContent item={item} />)
await user.click(screen.getByRole('button', { name: 'Thinking' }))
expect(screen.getByRole('button', { name: 'Ran commands' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Scaffold skill directory' })).toBeInTheDocument()
})
it('should describe an unfinished shell run as running commands', () => {
const item = {
id: 'answer-running-shell',
content: '',
isAnswer: true,
agent_response_parts: [
{
type: 'thought',
thought: {
id: 'thought-running-shell',
thought: '',
tool: 'shell_run',
tool_input: 'pnpm test',
observation: '',
message_id: 'answer-running-shell',
conversation_id: 'conversation-running-shell',
position: 1,
},
},
],
} satisfies ChatItem
render(<AgentRosterResponseContent item={item} responding />)
expect(screen.getByRole('button', { name: 'THOUGHT' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Running commands' })).toBeInTheDocument()
})
it('should keep the live activity disclosure open when public messages arrive and completion starts', () => {
const thought = {
id: 'thought-transition',
thought: 'raw thinking',
tool: 'shell_run',
tool_input: '{"command":"ls"}',
tool_labels: {
shell_run: {
en_US: 'Ran commands',
zh_Hans: '运行了命令',
},
},
observation: 'README.md',
message_id: 'answer-transition',
conversation_id: 'conversation-transition',
position: 1,
}
const item = {
id: 'answer-transition',
content: '',
isAnswer: true,
agent_response_parts: [
{
type: 'thought',
thought,
},
],
} satisfies ChatItem
const { rerender } = render(<AgentRosterResponseContent item={item} responding />)
expect(screen.getByRole('button', { name: /Thinking/ })).toHaveAttribute(
'aria-expanded',
'true',
)
const itemWithMessage = {
...item,
agent_response_parts: [
...item.agent_response_parts,
{
type: 'message',
content: 'public answer',
},
],
} satisfies ChatItem
rerender(<AgentRosterResponseContent item={itemWithMessage} responding />)
expect(screen.getByRole('button', { name: /Thinking/ })).toHaveAttribute(
'aria-expanded',
'true',
)
expect(screen.getByText('public answer')).toBeInTheDocument()
rerender(<AgentRosterResponseContent item={itemWithMessage} responding={false} />)
expect(screen.getByRole('button', { name: /Thinking/ })).toHaveAttribute(
'aria-expanded',
'true',
)
rerender(
<AgentRosterResponseContent
item={{
...item,
content: 'final answer',
agent_response_parts: undefined,
agent_thoughts: [thought],
}}
content="final answer"
responding={false}
/>,
)
expect(screen.getByRole('button', { name: 'Thinking' })).toHaveAttribute(
'aria-expanded',
'false',
)
expect(screen.getByText('final answer')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Ran commands' })).not.toBeInTheDocument()
})
it('should preserve a manual live activity collapse as new events arrive', async () => {
const user = userEvent.setup()
const item = {
id: 'answer-manual-collapse',
content: '',
isAnswer: true,
agent_response_parts: [
{
type: 'thought',
thought: {
id: 'thought-manual-collapse',
thought: '',
tool: 'shell_run',
tool_input: 'pwd',
observation: '/workspace',
message_id: 'answer-manual-collapse',
conversation_id: 'conversation-manual-collapse',
position: 1,
},
},
],
} satisfies ChatItem
const { rerender } = render(<AgentRosterResponseContent item={item} responding />)
const processToggle = screen.getByRole('button', { name: /Thinking/ })
await user.click(processToggle)
expect(processToggle).toHaveAttribute('aria-expanded', 'false')
rerender(
<AgentRosterResponseContent
item={{
...item,
agent_response_parts: [
...item.agent_response_parts,
{ type: 'message', content: 'new progress update' },
],
}}
responding
/>,
)
expect(screen.getByRole('button', { name: /Thinking/ })).toHaveAttribute(
'aria-expanded',
'false',
)
expect(screen.queryByText('new progress update')).not.toBeInTheDocument()
})
it('should keep a historical answer and its activity in the same process entry', async () => {
const user = userEvent.setup()
const item = {
id: 'answer-history-with-activity',
content: 'final answer',
isAnswer: true,
agent_thoughts: [
{
id: 'thought-history-with-activity',
thought: 'internal thought should not render',
answer: 'public progress update',
tool: 'shell_run',
tool_input: 'pwd',
observation: '/workspace',
message_id: 'answer-history-with-activity',
conversation_id: 'conversation-history-with-activity',
position: 1,
},
],
} satisfies ChatItem
render(<AgentRosterResponseContent item={item} content={item.content} />)
await user.click(screen.getByRole('button', { name: 'Thinking' }))
expect(screen.getByText('public progress update')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Ran commands' })).toBeInTheDocument()
expect(screen.getByText('final answer')).toBeInTheDocument()
expect(screen.queryByText('internal thought should not render')).not.toBeInTheDocument()
})
it('should render a live message directly when there is no activity', () => {
const item = {
id: 'answer-live-message-only',
content: '',
isAnswer: true,
agent_response_parts: [{ type: 'message', content: 'direct answer' }],
} satisfies ChatItem
render(<AgentRosterResponseContent item={item} responding />)
expect(screen.queryByRole('button', { name: /Thinking/ })).not.toBeInTheDocument()
expect(screen.getByText('direct answer')).toBeInTheDocument()
})
it('should omit the activity disclosure for a completed response without activity', () => {
const item = {
id: 'answer-without-activity',
content: 'final answer',
isAnswer: true,
} satisfies ChatItem
render(<AgentRosterResponseContent item={item} content={item.content} />)
expect(screen.queryByRole('button', { name: /Thinking/ })).not.toBeInTheDocument()
expect(screen.getByText('final answer')).toBeInTheDocument()
})
it('should keep the completed thinking label and render the final answer outside it', async () => {
const user = userEvent.setup()
const item = {
id: 'answer-complete',
content: 'final answer',
isAnswer: true,
more: {
time: '',
tokens: 0,
latency: 66,
},
agent_thoughts: [
{
id: 'thought-complete',
thought: 'raw process detail',
tool: 'shell_run',
tool_input: '{"command":"ls"}',
tool_labels: {
shell_run: {
en_US: 'Ran commands',
zh_Hans: '运行了命令',
},
},
observation: 'README.md',
message_id: 'answer-complete',
conversation_id: 'conversation-complete',
position: 1,
},
],
} satisfies ChatItem
render(<AgentRosterResponseContent item={item} content={item.content} />)
const processToggle = screen.getByRole('button', {
name: 'Thinking · {{count}}m{{count}}s',
})
expect(processToggle).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByText('raw process detail')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Ran commands' })).not.toBeInTheDocument()
expect(screen.getByText('final answer')).toBeInTheDocument()
await user.click(processToggle)
expect(processToggle).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByRole('button', { name: 'Ran commands' })).toBeInTheDocument()
expect(screen.getByText('final answer')).toBeInTheDocument()
})
})

View File

@ -41,6 +41,13 @@ describe('More', () => {
expect(screen.queryByTestId('more-tps')).not.toBeInTheDocument()
})
it('should omit the timestamp separator when time is empty', () => {
render(<More more={{ ...mockMoreData, time: '' }} />)
expect(screen.queryByTestId('more-time')).not.toBeInTheDocument()
expect(screen.queryByText('·')).not.toBeInTheDocument()
})
it('should render nothing inside container if more prop is missing', () => {
render(<More more={undefined} />)
const containerDiv = screen.getByTestId('more-container')

View File

@ -26,7 +26,7 @@ const { mockSetShowAnnotationFullModal, mockProviderContext, mockT, mockAddAnnot
vi.mock('copy-to-clipboard', () => ({ default: vi.fn() }))
vi.mock('@langgenius/dify-ui/toast', () => ({
default: { notify: vi.fn() },
toast: { success: vi.fn() },
}))
vi.mock('@/context/modal-context', () => ({
@ -125,7 +125,11 @@ vi.mock(
)
vi.mock('@/app/components/base/new-audio-button', () => ({
default: () => <button data-testid="audio-btn">Play</button>,
default: ({ value }: { value: string }) => (
<button data-testid="audio-btn" data-value={value}>
Play
</button>
),
}))
vi.mock('@/app/components/base/chat/chat/log', () => ({
@ -209,6 +213,29 @@ const baseItem: ChatItem = {
isAnswer: true,
}
const createInterruptedItem = (messages: string[]): ChatItem => {
const thought = {
id: '1',
thought: 'internal thought should not be used',
tool: '',
tool_input: '',
observation: '',
message_id: '',
conversation_id: '',
position: 0,
}
return {
...baseItem,
content: '',
agent_response_parts: [
{ type: 'thought', thought },
...messages.map((content) => ({ type: 'message' as const, content })),
],
agent_thoughts: [thought],
}
}
const baseProps: OperationProps = {
item: baseItem,
question: 'What is this?',
@ -279,6 +306,26 @@ describe('Operation', () => {
expect(screen.getByTestId('annotation-ctrl'))!.toBeInTheDocument()
})
it('should hide content-dependent actions for an interrupted response without public content', () => {
mockContextValue.config = makeChatConfig({
text_to_speech: { enabled: true },
supportAnnotation: true,
annotation_reply: {
id: 'ar-1',
score_threshold: 0.5,
embedding_model: { embedding_provider_name: '', embedding_model_name: '' },
enabled: true,
},
})
renderOperation({ ...baseProps, item: createInterruptedItem([]) })
expect(screen.queryByTestId('audio-btn')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'operation.copy' })).not.toBeInTheDocument()
expect(screen.queryByTestId('annotation-ctrl')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'operation.regenerate' })).toBeInTheDocument()
})
it('should hide annotation button when chat is readonly', () => {
mockContextValue.readonly = true
mockContextValue.config = makeChatConfig({
@ -344,11 +391,11 @@ describe('Operation', () => {
expect(copy).toHaveBeenCalledWith('Hello world')
})
it('should aggregate agent_thoughts for copy content', async () => {
it('should copy the visible answer instead of agent thought summaries', async () => {
const user = userEvent.setup()
const item: ChatItem = {
...baseItem,
content: 'ignored',
content: 'Final answer',
agent_thoughts: [
{
id: '1',
@ -374,7 +421,39 @@ describe('Operation', () => {
}
renderOperation({ ...baseProps, item })
await user.click(screen.getByRole('button', { name: 'operation.copy' }))
expect(copy).toHaveBeenCalledWith('Hello World')
expect(copy).toHaveBeenCalledWith('Final answer')
})
it('should copy public response parts after an interrupted response', async () => {
const user = userEvent.setup()
const item = createInterruptedItem(['First public update', 'Second public update'])
renderOperation({ ...baseProps, item })
await user.click(screen.getByRole('button', { name: 'operation.copy' }))
expect(copy).toHaveBeenCalledWith('First public update\n\nSecond public update')
})
it('should copy public thought answers for legacy messages without content', async () => {
const user = userEvent.setup()
const item: ChatItem = {
...baseItem,
content: '',
agent_thoughts: [
{
id: '1',
thought: 'internal thought should not be copied',
answer: 'Public legacy answer',
tool: '',
tool_input: '',
observation: '',
message_id: '',
conversation_id: '',
position: 0,
},
],
}
renderOperation({ ...baseProps, item })
await user.click(screen.getByRole('button', { name: 'operation.copy' }))
expect(copy).toHaveBeenCalledWith('Public legacy answer')
})
})
@ -911,6 +990,22 @@ describe('Operation', () => {
)
})
it('should annotate public response parts instead of internal thoughts', async () => {
const user = userEvent.setup()
const item = createInterruptedItem(['Public interrupted answer'])
renderOperation({ ...baseProps, item })
await user.click(screen.getByTestId('annotation-add-btn'))
expect(mockContextValue.onAnnotationAdded).toHaveBeenCalledWith(
'ann-new',
'Test User',
'What is this?',
'Public interrupted answer',
0,
)
})
it('should show annotation full modal when limit reached', async () => {
const user = userEvent.setup()
mockProviderContext.enableBilling = true
@ -1000,6 +1095,17 @@ describe('Operation', () => {
expect(screen.getByTestId('audio-btn'))!.toBeInTheDocument()
})
it('should send public response parts to TTS instead of internal thoughts', () => {
const item = createInterruptedItem(['Public interrupted answer'])
renderOperation({ ...baseProps, item })
expect(screen.getByTestId('audio-btn')).toHaveAttribute(
'data-value',
'Public interrupted answer',
)
})
it('should not show audio button for humanInputFormDataList', () => {
const item = { ...baseItem, humanInputFormDataList: [{}] } as ChatItem
renderOperation({ ...baseProps, item })

View File

@ -1,11 +1,13 @@
import type { ReactNode } from 'react'
import type { ThoughtItem } from '@/app/components/base/chat/chat/type'
import type { ChatItem } from '@/app/components/base/chat/types'
import { cn } from '@langgenius/dify-ui/cn'
import type { Locale } from '@/i18n-config'
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from '@langgenius/dify-ui/collapsible'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { FileList } from '@/app/components/base/file-uploader'
import { Markdown } from '@/app/components/base/markdown'
import { renderI18nObject } from '@/i18n-config'
import { getLanguage } from '@/i18n-config/language'
type AgentRosterResponseContentProps = {
item: ChatItem
@ -13,7 +15,11 @@ type AgentRosterResponseContentProps = {
content?: string
}
type ToolProcess = {
const SHELL_TOOL_NAMES = new Set(['shell_run', 'shell_wait', 'shell_input', 'shell_interrupt'])
type ToolActivity = {
kind: 'shell' | 'tool'
key: string
name: string
label: string
input: string
@ -21,6 +27,18 @@ type ToolProcess = {
isFinished: boolean
}
type AgentActivityEntry =
| {
type: 'message'
content: string
key: string
}
| {
type: 'thought'
thought: ThoughtItem
key: string
}
function readIndexedValue(value: string, isArray: boolean, index: number) {
if (!isArray) return value
@ -32,7 +50,11 @@ function readIndexedValue(value: string, isArray: boolean, index: number) {
}
}
function getToolProcesses(thought: ThoughtItem, responding?: boolean): ToolProcess[] {
function getToolActivities(
thought: ThoughtItem,
language: string,
responding?: boolean,
): ToolActivity[] {
let toolNames = [thought.tool]
let isArray = false
@ -44,9 +66,13 @@ function getToolProcesses(thought: ThoughtItem, responding?: boolean): ToolProce
}
} catch {}
const labelLanguage = getLanguage(language as Locale)
return toolNames.filter(Boolean).map((name, index) => ({
kind: SHELL_TOOL_NAMES.has(name) ? 'shell' : 'tool',
key: `${name}-${index}`,
name,
label: thought.tool_labels?.toolName?.en_US ?? name,
label: renderI18nObject(thought.tool_labels?.[name] ?? {}, labelLanguage) || name,
input: readIndexedValue(thought.tool_input, isArray, index),
output: readIndexedValue(thought.observation, isArray, index),
isFinished: !!thought.observation || !responding,
@ -55,10 +81,11 @@ function getToolProcesses(thought: ThoughtItem, responding?: boolean): ToolProce
function formatDuration(seconds: number, t: ReturnType<typeof useTranslation<'agentV2'>>['t']) {
const safeSeconds = Math.max(0, seconds)
if (safeSeconds < 60)
if (safeSeconds < 60) {
return t(($) => $['agentDetail.configure.answer.duration.second'], {
count: Number(safeSeconds.toFixed(2)),
})
}
const minutes = Math.floor(safeSeconds / 60)
const remainingSeconds = Math.floor(safeSeconds % 60)
@ -66,31 +93,11 @@ function formatDuration(seconds: number, t: ReturnType<typeof useTranslation<'ag
return [
t(($) => $['agentDetail.configure.answer.duration.minute'], { count: minutes }),
t(($) => $['agentDetail.configure.answer.duration.second'], { count: remainingSeconds }),
].join(' ')
].join('')
}
function formatLatencyDuration(
latency: NonNullable<ChatItem['more']>['latency'],
t: ReturnType<typeof useTranslation<'agentV2'>>['t'],
) {
const numericLatency = Number(latency)
if (!Number.isNaN(numericLatency)) return formatDuration(numericLatency, t)
return String(latency)
}
function getCompletedTitle(
latency: NonNullable<ChatItem['more']>['latency'] | undefined,
t: ReturnType<typeof useTranslation<'agentV2'>>['t'],
) {
const numericLatency = Number(latency)
if (latency != null && !Number.isNaN(numericLatency) && numericLatency > 0) {
return t(($) => $['agentDetail.configure.answer.workedFor'], {
duration: formatLatencyDuration(latency, t),
})
}
return t(($) => $['agentDetail.configure.answer.workFinished'])
function getThoughtKey(thought: ThoughtItem) {
return thought.id || `${thought.message_id}-${thought.position}`
}
function hashString(value: string) {
@ -100,10 +107,45 @@ function hashString(value: string) {
return (hash >>> 0).toString(36)
}
function getAgentResponsePartBaseKey(part: NonNullable<ChatItem['agent_response_parts']>[number]) {
if (part.type === 'message') return `message-${part.content.length}-${hashString(part.content)}`
function hasVisibleActivity(thought: ThoughtItem) {
return !!thought.tool || !!thought.message_files?.length
}
return `thought-${part.thought.id || `${part.thought.message_id}-${part.thought.position}`}`
function getAgentActivityEntries(item: ChatItem): AgentActivityEntry[] {
if (item.agent_response_parts?.length) {
const keyOccurrences = new Map<string, number>()
return item.agent_response_parts.flatMap<AgentActivityEntry>((part) => {
const baseKey =
part.type === 'message'
? `message-${part.content.length}-${hashString(part.content)}`
: `thought-${getThoughtKey(part.thought)}`
const occurrence = keyOccurrences.get(baseKey) ?? 0
keyOccurrences.set(baseKey, occurrence + 1)
const key = occurrence ? `${baseKey}-${occurrence}` : baseKey
if (part.type === 'message')
return part.content ? [{ type: 'message', content: part.content, key }] : []
return hasVisibleActivity(part.thought)
? [{ type: 'thought', thought: part.thought, key }]
: []
})
}
return [...(item.agent_thoughts ?? [])]
.sort((left, right) => left.position - right.position)
.flatMap((thought) => {
const parts: AgentActivityEntry[] = []
const key = getThoughtKey(thought)
const answer = thought.answer
if (answer?.trim()) parts.push({ type: 'message', content: answer, key: `message-${key}` })
if (hasVisibleActivity(thought))
parts.push({ type: 'thought', thought, key: `thought-${key}` })
return parts
})
}
function useWorkingDuration(enabled?: boolean) {
@ -113,7 +155,7 @@ function useWorkingDuration(enabled?: boolean) {
useEffect(() => {
if (!enabled) return
startedAtRef.current = Date.now()
startedAtRef.current ??= Date.now()
const timer = window.setInterval(() => {
setNow(Date.now())
}, 1000)
@ -121,197 +163,115 @@ function useWorkingDuration(enabled?: boolean) {
return () => window.clearInterval(timer)
}, [enabled])
const elapsedSeconds =
enabled && startedAtRef.current !== null
? Math.max(0, Math.floor((now - startedAtRef.current) / 1000))
: 0
return elapsedSeconds
if (!enabled || startedAtRef.current === null) return 0
return Math.max(0, Math.floor((now - startedAtRef.current) / 1000))
}
function ProcessShell({
children,
collapsed,
expandedTitle,
icon,
title,
defaultOpen = false,
}: {
children?: ReactNode
collapsed?: boolean
expandedTitle?: ReactNode
icon: ReactNode
title: ReactNode
defaultOpen?: boolean
}) {
const [open, setOpen] = useState(defaultOpen)
const canExpand = !!children
const expanded = canExpand && open && !collapsed
function ResponseMessage({ content }: { content: string }) {
return (
<div
className={cn(
'rounded-xl p-2',
expanded ? 'bg-background-default' : 'bg-background-default-subtle',
)}
className="max-w-full min-w-0 overflow-hidden px-1 body-md-regular text-text-primary"
data-testid="agent-content-markdown"
>
<button
type="button"
className="flex w-full items-center gap-1 text-left"
onClick={() => canExpand && setOpen((value) => !value)}
>
<span className="flex size-5 shrink-0 items-center justify-center text-text-secondary">
{icon}
</span>
<span
className={cn(
'min-w-0 flex-1 truncate text-text-secondary',
expanded ? 'system-xs-medium-uppercase' : 'system-sm-regular',
)}
>
{expanded ? (expandedTitle ?? title) : title}
</span>
{canExpand &&
(expanded ? (
<span
className="i-ri-arrow-down-s-line size-4 shrink-0 text-text-quaternary"
aria-hidden
/>
) : (
<span
className="i-ri-arrow-right-s-line size-4 shrink-0 text-text-quaternary"
aria-hidden
/>
))}
</button>
{expanded && (
<div className="mt-1 flex gap-1">
<div className="w-5 shrink-0 border-l border-divider-subtle" />
<div className="min-w-0 flex-1 py-1 body-sm-regular text-text-tertiary">{children}</div>
</div>
)}
<Markdown content={content} />
</div>
)
}
function ThoughtProcess({ thought, defaultOpen }: { thought: ThoughtItem; defaultOpen?: boolean }) {
function ToolActivityItem({ tool }: { tool: ToolActivity }) {
const { t } = useTranslation()
const thoughtTitle = t(($) => $['chat.thought'], { ns: 'common' })
const summary = thought.thought.trim() || thoughtTitle
const hasDetails = !!tool.input || !!tool.output
const label =
tool.name === 'shell_run' && tool.label === tool.name
? tool.isFinished
? t(($) => $['agentDetail.configure.answer.activity.ranCommands'], { ns: 'agentV2' })
: t(($) => $['agentDetail.configure.answer.activity.runningCommands'], {
ns: 'agentV2',
})
: tool.label
return (
<ProcessShell
icon={<span className="i-custom-public-thought-imagine size-3.5" aria-hidden />}
title={summary}
expandedTitle={thoughtTitle.toUpperCase()}
defaultOpen={defaultOpen}
>
<Markdown content={summary} />
</ProcessShell>
)
}
function ToolProcessCard({ tool }: { tool: ToolProcess }) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
return (
<div className="rounded-xl bg-background-default-subtle p-2">
<button
type="button"
className="flex w-full items-center gap-1 text-left"
onClick={() => setOpen((value) => !value)}
>
<span className="flex size-5 shrink-0 items-center justify-center rounded-md border border-divider-subtle bg-components-icon-bg-midnight-solid p-[3px] text-text-primary-on-surface shadow-xs">
{tool.isFinished ? (
const content = (
<>
<span className="inline-flex min-w-0 items-center gap-1">
<span className="flex size-3.5 shrink-0 items-center justify-center text-text-tertiary">
{!tool.isFinished ? (
<span className="i-ri-loader-2-line size-3.5 animate-spin" aria-hidden />
) : tool.kind === 'shell' ? (
<span className="i-ri-terminal-box-line size-3.5" aria-hidden />
) : (
<span className="i-ri-loader-2-line size-3.5 animate-spin" aria-hidden />
<span className="i-ri-hammer-line size-3.5" aria-hidden />
)}
</span>
<span className="min-w-0 flex-1 truncate system-sm-medium text-text-secondary">
{tool.label}
</span>
{open ? (
<span
className="i-ri-arrow-down-s-line size-4 shrink-0 text-text-quaternary"
aria-hidden
/>
) : (
<span
className="i-ri-arrow-right-s-line size-4 shrink-0 text-text-quaternary"
aria-hidden
/>
)}
</button>
{open && (
<div className="mt-2 space-y-1">
<div className="rounded-[10px] bg-components-panel-on-panel-item-bg px-3 py-2">
<div className="system-xs-semibold-uppercase text-text-tertiary">
{t(($) => $['thought.requestTitle'], { ns: 'tools' })}
<span className="min-w-0 truncate text-text-secondary">{label}</span>
</span>
{hasDetails && (
<span
className="i-ri-arrow-right-s-line size-4 shrink-0 text-text-tertiary transition-transform duration-100 ease-out group-data-panel-open/tool:rotate-90 motion-reduce:transition-none"
aria-hidden
/>
)}
</>
)
return (
<div className="flex w-full max-w-full min-w-0 flex-col items-start">
{hasDetails ? (
<Collapsible className="w-full max-w-full items-start">
<CollapsibleTrigger className="group/tool h-6 min-h-0 w-auto max-w-full justify-start gap-0 rounded-md p-1 text-left system-xs-medium text-text-tertiary hover:not-data-disabled:bg-state-base-hover focus-visible:bg-state-base-hover">
{content}
</CollapsibleTrigger>
<CollapsiblePanel className="w-full max-w-full">
<div className="w-full max-w-full min-w-0 space-y-1 pb-1">
{!!tool.input && (
<div className="w-full max-w-full min-w-0 overflow-hidden rounded-[10px] bg-components-input-bg-normal px-3 py-2">
<div className="system-xs-semibold-uppercase text-text-tertiary">
{t(($) => $['thought.requestTitle'], { ns: 'tools' })}
</div>
<div className="mt-1 max-w-full min-w-0 code-xs-regular wrap-break-word whitespace-pre-wrap text-text-secondary">
{tool.input}
</div>
</div>
)}
{!!tool.output && (
<div className="w-full max-w-full min-w-0 overflow-hidden rounded-[10px] bg-components-input-bg-normal px-3 py-2">
<div className="system-xs-semibold-uppercase text-text-tertiary">
{t(($) => $['thought.responseTitle'], { ns: 'tools' })}
</div>
<div className="mt-1 max-w-full min-w-0 code-xs-regular wrap-break-word whitespace-pre-wrap text-text-secondary">
{tool.output}
</div>
</div>
)}
</div>
<div className="mt-1 code-xs-regular wrap-break-word text-text-secondary">
{tool.input}
</div>
</div>
<div className="rounded-[10px] bg-components-panel-on-panel-item-bg px-3 py-2">
<div className="system-xs-semibold-uppercase text-text-tertiary">
{t(($) => $['thought.responseTitle'], { ns: 'tools' })}
</div>
<div className="mt-1 code-xs-regular wrap-break-word text-text-secondary">
{tool.output}
</div>
</div>
</CollapsiblePanel>
</Collapsible>
) : (
<div className="inline-flex h-6 w-auto max-w-full items-center rounded-md p-1 system-xs-medium">
{content}
</div>
)}
</div>
)
}
function AgentThoughtsProcessList({ item, responding }: { item: ChatItem; responding?: boolean }) {
return (
<div className="mt-2 flex flex-col gap-1">
{item.agent_thoughts?.map((thought, index) => (
<AgentThoughtProcessItem
key={thought.id || `${thought.message_id}-${thought.position}`}
thought={thought}
responding={responding}
defaultOpen={index === 0}
/>
))}
</div>
)
}
function AgentThoughtProcessItem({
function AgentActivityItem({
thought,
responding,
defaultOpen,
}: {
thought: ThoughtItem
responding?: boolean
defaultOpen?: boolean
}) {
const tools = getToolProcesses(thought, responding)
const answer = thought.answer?.trim()
const { i18n } = useTranslation()
const tools = getToolActivities(thought, i18n.language, responding)
return (
<div className="flex flex-col gap-1">
{answer && (
<div
className="px-2 py-2 body-md-regular text-text-primary"
data-testid="agent-content-markdown"
>
<Markdown content={thought.answer || ''} />
</div>
)}
{!answer && thought.thought && <ThoughtProcess thought={thought} defaultOpen={defaultOpen} />}
<div className="flex w-full max-w-full min-w-0 flex-col py-0.5">
{tools.map((tool) => (
<ToolProcessCard key={`${thought.id}-${tool.name}`} tool={tool} />
<ToolActivityItem key={`${getThoughtKey(thought)}-${tool.key}`} tool={tool} />
))}
{!!thought.message_files?.length && (
<FileList
className="px-2 py-1"
className="py-1"
files={thought.message_files}
showDeleteAction={false}
showDownloadAction
@ -322,80 +282,58 @@ function AgentThoughtProcessItem({
)
}
function AgentResponsePartList({ item, responding }: { item: ChatItem; responding?: boolean }) {
const keyOccurrences = new Map<string, number>()
return (
<div className="flex flex-col gap-1">
{item.agent_response_parts?.map((part, index) => {
const baseKey = getAgentResponsePartBaseKey(part)
const occurrence = keyOccurrences.get(baseKey) ?? 0
keyOccurrences.set(baseKey, occurrence + 1)
const partKey = occurrence ? `${baseKey}-${occurrence}` : baseKey
if (part.type === 'message') {
if (!part.content) return null
return (
<div
key={partKey}
className="px-2 py-2 body-md-regular text-text-primary"
data-testid="agent-content-markdown"
>
<Markdown content={part.content} />
</div>
)
}
return (
<AgentThoughtProcessItem
key={partKey}
thought={part.thought}
responding={responding}
defaultOpen={index === 0}
/>
)
})}
</div>
)
}
function AgentThoughtsProcessGroup({ item, responding }: { item: ChatItem; responding?: boolean }) {
function AgentActivityDisclosure({
item,
entries,
responding,
defaultOpen,
}: {
item: ChatItem
entries: AgentActivityEntry[]
responding?: boolean
defaultOpen?: boolean
}) {
const { t } = useTranslation('agentV2')
const [open, setOpen] = useState(false)
const workingDuration = formatDuration(useWorkingDuration(responding), t)
const completedTitle = getCompletedTitle(item.more?.latency, t)
if (responding) {
return (
<div className="flex flex-col">
<div className="flex h-9 w-full items-center border-b border-divider-subtle text-left">
<span className="system-md-regular text-text-tertiary">
{t(($) => $['agentDetail.configure.answer.workingFor'], { duration: workingDuration })}
</span>
</div>
<AgentThoughtsProcessList item={item} responding={responding} />
</div>
)
}
const workingDuration = useWorkingDuration(responding)
const latency = Number(item.more?.latency)
const duration = responding
? formatDuration(workingDuration, t)
: Number.isFinite(latency) && latency > 0
? formatDuration(latency, t)
: undefined
const thinking = t(($) => $['agentDetail.configure.answer.thinking'])
const title = duration ? `${thinking} · ${duration}` : thinking
return (
<div className="flex flex-col">
<button
type="button"
className="flex h-9 w-full items-center gap-1 border-b border-divider-subtle text-left"
aria-expanded={open}
onClick={() => setOpen((value) => !value)}
<Collapsible className="w-full max-w-full items-start gap-1" defaultOpen={defaultOpen}>
<CollapsibleTrigger
aria-label={title}
className="group/thinking h-6 min-h-0 w-auto max-w-full justify-start gap-1 rounded-md p-1 text-left system-xs-medium text-text-tertiary hover:not-data-disabled:bg-state-base-hover focus-visible:bg-state-base-hover"
>
<span className="system-md-regular text-text-tertiary">{completedTitle}</span>
{open ? (
<span className="i-ri-arrow-down-s-line size-4 text-text-tertiary" aria-hidden />
) : (
<span className="i-ri-arrow-right-s-line size-4 text-text-tertiary" aria-hidden />
<span>{thinking}</span>
{duration && (
<>
<span className="system-xs-regular text-text-quaternary">·</span>
<span>{duration}</span>
</>
)}
</button>
{open && <AgentThoughtsProcessList item={item} responding={responding} />}
</div>
<span
className="i-ri-arrow-right-s-line size-4 transition-transform duration-100 ease-out group-data-panel-open/thinking:rotate-90 motion-reduce:transition-none"
aria-hidden
/>
</CollapsibleTrigger>
<CollapsiblePanel className="w-full max-w-full">
<div className="flex w-full max-w-full min-w-0 flex-col gap-1 overflow-hidden">
{entries.map((entry) =>
entry.type === 'message' ? (
<ResponseMessage key={entry.key} content={entry.content} />
) : (
<AgentActivityItem key={entry.key} thought={entry.thought} responding={responding} />
),
)}
</div>
</CollapsiblePanel>
</Collapsible>
)
}
@ -404,37 +342,47 @@ export function AgentRosterResponseContent({
responding,
content,
}: AgentRosterResponseContentProps) {
const { annotation, agent_thoughts } = item
if (annotation?.logAnnotation) {
if (item.annotation?.logAnnotation) {
return (
<Markdown
content={annotation.logAnnotation.content || ''}
content={item.annotation.logAnnotation.content || ''}
data-testid="agent-content-markdown"
/>
)
}
const entries = getAgentActivityEntries(item)
const hasLiveResponseParts = !!item.agent_response_parts?.length
const hasThinkingStatus =
entries.length === 0 && !!item.agent_response_parts?.some((part) => part.type === 'thought')
const hasActivity =
hasThinkingStatus ||
(hasLiveResponseParts ? entries.some((entry) => entry.type === 'thought') : entries.length > 0)
const standaloneMessages = hasLiveResponseParts
? hasActivity
? []
: entries.flatMap((entry) => (entry.type === 'message' ? [entry] : []))
: content
? [{ type: 'message' as const, content, key: 'final-answer' }]
: []
return (
<div className="flex w-full flex-col gap-1" data-testid="agent-roster-response-content">
{!!item.agent_response_parts?.length && (
<AgentResponsePartList item={item} responding={responding} />
)}
{!item.agent_response_parts?.length && (
<>
{!!agent_thoughts?.length && (
<AgentThoughtsProcessGroup item={item} responding={responding} />
)}
{content && (
<div
className="px-2 py-2 body-md-regular text-text-primary"
data-testid="agent-content-markdown"
>
<Markdown content={content} />
</div>
)}
</>
<div
className="flex w-full max-w-full min-w-0 flex-col gap-1 overflow-hidden"
data-testid="agent-roster-response-content"
>
{hasActivity && (
<AgentActivityDisclosure
key={hasLiveResponseParts ? 'live' : 'history'}
item={item}
entries={entries}
responding={responding}
defaultOpen={hasLiveResponseParts && (!!responding || entries.length > 0)}
/>
)}
{standaloneMessages.map((message) => (
<ResponseMessage key={message.key} content={message.content} />
))}
</div>
)
}

View File

@ -40,10 +40,18 @@ const More: FC<MoreProps> = ({ more }) => {
{`${more.tokens_per_second} tokens/s`}
</div>
)}
<div className="mx-2 shrink-0">·</div>
<div className="max-w-[25%] shrink-0 truncate" title={more.time} data-testid="more-time">
{more.time}
</div>
{!!more.time && (
<>
<div className="mx-2 shrink-0">·</div>
<div
className="max-w-[25%] shrink-0 truncate"
title={more.time}
data-testid="more-time"
>
{more.time}
</div>
</>
)}
</>
)}
</div>

View File

@ -42,6 +42,23 @@ const feedbackTooltipClassName = 'max-w-[260px]'
const answerActiveFlexClassName = 'group-hover:flex group-has-[[data-popup-open]]:flex'
const answerActiveBlockClassName = 'group-hover:block group-has-[[data-popup-open]]:block'
function joinPublicContent(blocks: Array<string | undefined>) {
return blocks.filter((block): block is string => !!block?.trim()).join('\n\n')
}
function getPublicResponseContent(item: ChatItem) {
if (item.content.trim()) return item.content
const responseContent = joinPublicContent(
item.agent_response_parts?.map((part) =>
part.type === 'message' ? part.content : undefined,
) ?? [],
)
if (responseContent) return responseContent
return joinPublicContent(item.agent_thoughts?.map((thought) => thought.answer) ?? [])
}
const FeedbackTooltip = ({ content, children }: FeedbackTooltipProps) => {
return (
<Tooltip>
@ -74,16 +91,8 @@ function Operation({
const [isShowReplyModal, setIsShowReplyModal] = useState(false)
const [isShowFeedbackModal, setIsShowFeedbackModal] = useState(false)
const [feedbackContent, setFeedbackContent] = useState('')
const {
id,
isOpeningStatement,
content: messageContent,
annotation,
feedback,
adminFeedback,
agent_thoughts,
humanInputFormDataList,
} = item
const { id, isOpeningStatement, annotation, feedback, adminFeedback, humanInputFormDataList } =
item
const [userLocalFeedback, setUserLocalFeedback] = useState(feedback)
const [adminLocalFeedback, setAdminLocalFeedback] = useState(adminFeedback)
const [feedbackTarget, setFeedbackTarget] = useState<'user' | 'admin'>('user')
@ -91,11 +100,8 @@ function Operation({
const userFeedback = feedback
const content = useMemo(() => {
if (agent_thoughts?.length) return agent_thoughts.reduce((acc, cur) => acc + cur.thought, '')
return messageContent
}, [agent_thoughts, messageContent])
const content = getPublicResponseContent(item)
const hasPublicContent = !!content.trim()
const displayUserFeedback = userLocalFeedback ?? userFeedback
@ -110,6 +116,7 @@ function Operation({
!readonly && !!onAnnotationAdded && !!onAnnotationEdited && !!onAnnotationRemoved
const shouldShowAnnotationAction =
canManageAnnotation &&
hasPublicContent &&
!!config?.supportAnnotation &&
!!config.annotation_reply?.enabled &&
!humanInputFormDataList?.length
@ -177,7 +184,7 @@ function Operation({
let width = 0
if (!isOpeningStatement) width += 26
if (!isOpeningStatement && showPromptLog) width += 28 + 8
if (!isOpeningStatement && config?.text_to_speech?.enabled) width += 26
if (!isOpeningStatement && config?.text_to_speech?.enabled && hasPublicContent) width += 26
if (!isOpeningStatement && shouldShowAnnotationAction) width += 26
if (shouldShowUserFeedbackBar) width += hasUserFeedback ? 28 + 8 : 60 + 8
if (shouldShowAdminFeedbackBar)
@ -187,6 +194,7 @@ function Operation({
}, [
config?.text_to_speech?.enabled,
hasAdminFeedback,
hasPublicContent,
hasUserFeedback,
isOpeningStatement,
shouldShowAdminFeedbackBar,
@ -365,10 +373,12 @@ function Operation({
)}
data-testid="operation-actions"
>
{config?.text_to_speech?.enabled && !humanInputFormDataList?.length && (
<NewAudioButton id={id} value={content} voice={config?.text_to_speech?.voice} />
)}
{!humanInputFormDataList?.length && (
{config?.text_to_speech?.enabled &&
hasPublicContent &&
!humanInputFormDataList?.length && (
<NewAudioButton id={id} value={content} voice={config?.text_to_speech?.voice} />
)}
{hasPublicContent && !humanInputFormDataList?.length && (
<ActionButton
aria-label={copyLabel}
onClick={() => {

View File

@ -775,6 +775,59 @@ describe('AgentPreviewChat', () => {
})
})
it('should preserve tool labels when formatting chat history', async () => {
chatMessagesGetMock.mockResolvedValue({
data: [
{
id: 'message-with-tool-label',
conversation_id: 'conversation-1',
query: 'run pwd',
answer: '',
inputs: {},
message: [],
message_files: [],
agent_thoughts: [
{
id: 'thought-with-tool-label',
message_id: 'message-with-tool-label',
thought: '',
answer: '',
tool: 'shell_run',
tool_input: 'pwd',
tool_labels: {
shell_run: {
en_US: 'Ran commands',
zh_Hans: '运行了命令',
},
},
observation: '/workspace',
position: 1,
},
],
feedbacks: [],
status: 'success',
from_source: 'console',
},
],
})
renderPreviewChat({ conversationId: 'conversation-1' })
await waitFor(() => {
const formattedTree = useChatMock.mock.calls.find((call) => {
const chatTree = call[2]
return JSON.stringify(chatTree).includes('thought-with-tool-label')
})?.[2]
expect(formattedTree?.[0]?.children?.[0]?.agent_thoughts?.[0]?.tool_labels).toEqual({
shell_run: {
en_US: 'Ran commands',
zh_Hans: '运行了命令',
},
})
})
})
it('should notify the owner when a send settles with an error', async () => {
const onSendInterrupted = vi.fn()
renderPreviewChat({

View File

@ -271,12 +271,33 @@ const toLogMessages = (
]
}
function toToolLabels(value: AgentThought['tool_labels']): ThoughtItem['tool_labels'] {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
const toolLabels: NonNullable<ThoughtItem['tool_labels']> = {}
for (const [name, label] of Object.entries(value)) {
if (!label || typeof label !== 'object' || Array.isArray(label)) continue
const enUS = 'en_US' in label ? label.en_US : undefined
const zhHans = 'zh_Hans' in label ? label.zh_Hans : undefined
if (typeof enUS !== 'string' || typeof zhHans !== 'string') continue
toolLabels[name] = { en_US: enUS, zh_Hans: zhHans }
for (const [locale, localizedLabel] of Object.entries(label)) {
if (typeof localizedLabel === 'string') toolLabels[name][locale] = localizedLabel
}
}
return Object.keys(toolLabels).length ? toolLabels : undefined
}
const toAgentThoughtItem = (thought: AgentThought, conversationId: string): ThoughtItem => ({
id: thought.id,
tool: thought.tool ?? '',
thought: thought.thought ?? '',
answer: thought.answer ?? '',
tool_input: thought.tool_input ?? '',
tool_labels: toToolLabels(thought.tool_labels),
message_id: thought.message_id,
conversation_id: conversationId,
observation: thought.observation ?? '',

View File

@ -63,8 +63,11 @@
"agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value",
"agentDetail.configure.advancedSettings.label": "الإعدادات المتقدمة",
"agentDetail.configure.advancedSettings.toggle": "تبديل الإعدادات المتقدمة",
"agentDetail.configure.answer.activity.ranCommands": "تم تشغيل الأوامر",
"agentDetail.configure.answer.activity.runningCommands": "جارٍ تشغيل الأوامر",
"agentDetail.configure.answer.duration.minute": "{{count}} د",
"agentDetail.configure.answer.duration.second": "{{count}} ث",
"agentDetail.configure.answer.thinking": "جارٍ التفكير",
"agentDetail.configure.answer.workFinished": "اكتمل العمل",
"agentDetail.configure.answer.workedFor": "عمل لمدة {{duration}}",
"agentDetail.configure.answer.workingFor": "يعمل منذ {{duration}}",

View File

@ -63,8 +63,11 @@
"agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value",
"agentDetail.configure.advancedSettings.label": "Erweiterte Einstellungen",
"agentDetail.configure.advancedSettings.toggle": "Erweiterte Einstellungen umschalten",
"agentDetail.configure.answer.activity.ranCommands": "Befehle ausgeführt",
"agentDetail.configure.answer.activity.runningCommands": "Befehle werden ausgeführt",
"agentDetail.configure.answer.duration.minute": "{{count}} Min.",
"agentDetail.configure.answer.duration.second": "{{count}} s",
"agentDetail.configure.answer.thinking": "Denkt nach",
"agentDetail.configure.answer.workFinished": "Arbeit abgeschlossen",
"agentDetail.configure.answer.workedFor": "Gearbeitet für {{duration}}",
"agentDetail.configure.answer.workingFor": "Arbeitet seit {{duration}}",

View File

@ -63,8 +63,11 @@
"agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value",
"agentDetail.configure.advancedSettings.label": "Advanced Settings",
"agentDetail.configure.advancedSettings.toggle": "Toggle advanced settings",
"agentDetail.configure.answer.activity.ranCommands": "Ran commands",
"agentDetail.configure.answer.activity.runningCommands": "Running commands",
"agentDetail.configure.answer.duration.minute": "{{count}}m",
"agentDetail.configure.answer.duration.second": "{{count}}s",
"agentDetail.configure.answer.thinking": "Thinking",
"agentDetail.configure.answer.workFinished": "Work finished",
"agentDetail.configure.answer.workedFor": "Worked for {{duration}}",
"agentDetail.configure.answer.workingFor": "Working for {{duration}}",

View File

@ -63,8 +63,11 @@
"agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value",
"agentDetail.configure.advancedSettings.label": "Configuración avanzada",
"agentDetail.configure.advancedSettings.toggle": "Alternar configuración avanzada",
"agentDetail.configure.answer.activity.ranCommands": "Comandos ejecutados",
"agentDetail.configure.answer.activity.runningCommands": "Ejecutando comandos",
"agentDetail.configure.answer.duration.minute": "{{count}} min",
"agentDetail.configure.answer.duration.second": "{{count}} s",
"agentDetail.configure.answer.thinking": "Pensando",
"agentDetail.configure.answer.workFinished": "Trabajo finalizado",
"agentDetail.configure.answer.workedFor": "Trabajó durante {{duration}}",
"agentDetail.configure.answer.workingFor": "Trabajando durante {{duration}}",

View File

@ -63,8 +63,11 @@
"agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value",
"agentDetail.configure.advancedSettings.label": "تنظیمات پیشرفته",
"agentDetail.configure.advancedSettings.toggle": "تغییر تنظیمات پیشرفته",
"agentDetail.configure.answer.activity.ranCommands": "دستورات اجرا شدند",
"agentDetail.configure.answer.activity.runningCommands": "در حال اجرای دستورات",
"agentDetail.configure.answer.duration.minute": "{{count}} دقیقه",
"agentDetail.configure.answer.duration.second": "{{count}} ثانیه",
"agentDetail.configure.answer.thinking": "در حال فکر کردن",
"agentDetail.configure.answer.workFinished": "کار تمام شد",
"agentDetail.configure.answer.workedFor": "به مدت {{duration}} کار کرد",
"agentDetail.configure.answer.workingFor": "در حال کار به مدت {{duration}}",

View File

@ -63,8 +63,11 @@
"agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value",
"agentDetail.configure.advancedSettings.label": "Paramètres avancés",
"agentDetail.configure.advancedSettings.toggle": "Basculer les paramètres avancés",
"agentDetail.configure.answer.activity.ranCommands": "Commandes exécutées",
"agentDetail.configure.answer.activity.runningCommands": "Exécution des commandes",
"agentDetail.configure.answer.duration.minute": "{{count}} min",
"agentDetail.configure.answer.duration.second": "{{count}} s",
"agentDetail.configure.answer.thinking": "Réflexion en cours",
"agentDetail.configure.answer.workFinished": "Travail terminé",
"agentDetail.configure.answer.workedFor": "A travaillé pendant {{duration}}",
"agentDetail.configure.answer.workingFor": "Travaille depuis {{duration}}",

View File

@ -63,8 +63,11 @@
"agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value",
"agentDetail.configure.advancedSettings.label": "उन्नत सेटिंग्स",
"agentDetail.configure.advancedSettings.toggle": "उन्नत सेटिंग्स टॉगल करें",
"agentDetail.configure.answer.activity.ranCommands": "कमांड चलाए गए",
"agentDetail.configure.answer.activity.runningCommands": "कमांड चलाए जा रहे हैं",
"agentDetail.configure.answer.duration.minute": "{{count}} मिनट",
"agentDetail.configure.answer.duration.second": "{{count}} सेकंड",
"agentDetail.configure.answer.thinking": "सोच रहा है",
"agentDetail.configure.answer.workFinished": "काम पूरा हुआ",
"agentDetail.configure.answer.workedFor": "{{duration}} तक काम किया",
"agentDetail.configure.answer.workingFor": "{{duration}} से काम कर रहा है",

View File

@ -63,8 +63,11 @@
"agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value",
"agentDetail.configure.advancedSettings.label": "Pengaturan Lanjutan",
"agentDetail.configure.advancedSettings.toggle": "Alihkan pengaturan lanjutan",
"agentDetail.configure.answer.activity.ranCommands": "Perintah dijalankan",
"agentDetail.configure.answer.activity.runningCommands": "Menjalankan perintah",
"agentDetail.configure.answer.duration.minute": "{{count}} mnt",
"agentDetail.configure.answer.duration.second": "{{count}} dtk",
"agentDetail.configure.answer.thinking": "Sedang berpikir",
"agentDetail.configure.answer.workFinished": "Pekerjaan selesai",
"agentDetail.configure.answer.workedFor": "Bekerja selama {{duration}}",
"agentDetail.configure.answer.workingFor": "Bekerja selama {{duration}}",

View File

@ -63,8 +63,11 @@
"agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value",
"agentDetail.configure.advancedSettings.label": "Impostazioni avanzate",
"agentDetail.configure.advancedSettings.toggle": "Attiva/disattiva impostazioni avanzate",
"agentDetail.configure.answer.activity.ranCommands": "Comandi eseguiti",
"agentDetail.configure.answer.activity.runningCommands": "Esecuzione dei comandi",
"agentDetail.configure.answer.duration.minute": "{{count}} min",
"agentDetail.configure.answer.duration.second": "{{count}} s",
"agentDetail.configure.answer.thinking": "Sto pensando",
"agentDetail.configure.answer.workFinished": "Lavoro terminato",
"agentDetail.configure.answer.workedFor": "Ha lavorato per {{duration}}",
"agentDetail.configure.answer.workingFor": "Sta lavorando da {{duration}}",

View File

@ -63,8 +63,11 @@
"agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value",
"agentDetail.configure.advancedSettings.label": "詳細設定",
"agentDetail.configure.advancedSettings.toggle": "詳細設定の表示を切り替え",
"agentDetail.configure.answer.activity.ranCommands": "コマンドを実行しました",
"agentDetail.configure.answer.activity.runningCommands": "コマンドを実行中",
"agentDetail.configure.answer.duration.minute": "{{count}}分",
"agentDetail.configure.answer.duration.second": "{{count}}秒",
"agentDetail.configure.answer.thinking": "思考中",
"agentDetail.configure.answer.workFinished": "作業が完了しました",
"agentDetail.configure.answer.workedFor": "{{duration}} 実行しました",
"agentDetail.configure.answer.workingFor": "{{duration}} 実行中",

View File

@ -63,8 +63,11 @@
"agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value",
"agentDetail.configure.advancedSettings.label": "고급 설정",
"agentDetail.configure.advancedSettings.toggle": "고급 설정 전환",
"agentDetail.configure.answer.activity.ranCommands": "명령 실행됨",
"agentDetail.configure.answer.activity.runningCommands": "명령 실행 중",
"agentDetail.configure.answer.duration.minute": "{{count}}분",
"agentDetail.configure.answer.duration.second": "{{count}}초",
"agentDetail.configure.answer.thinking": "생각 중",
"agentDetail.configure.answer.workFinished": "작업 완료",
"agentDetail.configure.answer.workedFor": "{{duration}} 동안 작업함",
"agentDetail.configure.answer.workingFor": "{{duration}} 동안 작업 중",

View File

@ -63,8 +63,11 @@
"agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value",
"agentDetail.configure.advancedSettings.label": "Geavanceerde instellingen",
"agentDetail.configure.advancedSettings.toggle": "Geavanceerde instellingen in/uit",
"agentDetail.configure.answer.activity.ranCommands": "Opdrachten uitgevoerd",
"agentDetail.configure.answer.activity.runningCommands": "Opdrachten uitvoeren",
"agentDetail.configure.answer.duration.minute": "{{count}} min",
"agentDetail.configure.answer.duration.second": "{{count}} s",
"agentDetail.configure.answer.thinking": "Aan het nadenken",
"agentDetail.configure.answer.workFinished": "Werk voltooid",
"agentDetail.configure.answer.workedFor": "Gewerkt gedurende {{duration}}",
"agentDetail.configure.answer.workingFor": "Bezig gedurende {{duration}}",

View File

@ -63,8 +63,11 @@
"agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value",
"agentDetail.configure.advancedSettings.label": "Ustawienia zaawansowane",
"agentDetail.configure.advancedSettings.toggle": "Przełącz ustawienia zaawansowane",
"agentDetail.configure.answer.activity.ranCommands": "Uruchomiono polecenia",
"agentDetail.configure.answer.activity.runningCommands": "Uruchamianie poleceń",
"agentDetail.configure.answer.duration.minute": "{{count}} min",
"agentDetail.configure.answer.duration.second": "{{count}} s",
"agentDetail.configure.answer.thinking": "Myśli",
"agentDetail.configure.answer.workFinished": "Praca zakończona",
"agentDetail.configure.answer.workedFor": "Pracował przez {{duration}}",
"agentDetail.configure.answer.workingFor": "Pracuje od {{duration}}",

View File

@ -63,8 +63,11 @@
"agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value",
"agentDetail.configure.advancedSettings.label": "Configurações avançadas",
"agentDetail.configure.advancedSettings.toggle": "Alternar configurações avançadas",
"agentDetail.configure.answer.activity.ranCommands": "Comandos executados",
"agentDetail.configure.answer.activity.runningCommands": "Executando comandos",
"agentDetail.configure.answer.duration.minute": "{{count}} min",
"agentDetail.configure.answer.duration.second": "{{count}} s",
"agentDetail.configure.answer.thinking": "Pensando",
"agentDetail.configure.answer.workFinished": "Trabalho concluído",
"agentDetail.configure.answer.workedFor": "Trabalhou por {{duration}}",
"agentDetail.configure.answer.workingFor": "Trabalhando há {{duration}}",

View File

@ -63,8 +63,11 @@
"agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value",
"agentDetail.configure.advancedSettings.label": "Setări avansate",
"agentDetail.configure.advancedSettings.toggle": "Comută setările avansate",
"agentDetail.configure.answer.activity.ranCommands": "Comenzi executate",
"agentDetail.configure.answer.activity.runningCommands": "Se execută comenzile",
"agentDetail.configure.answer.duration.minute": "{{count}} min",
"agentDetail.configure.answer.duration.second": "{{count}} s",
"agentDetail.configure.answer.thinking": "Se gândește",
"agentDetail.configure.answer.workFinished": "Lucrare finalizată",
"agentDetail.configure.answer.workedFor": "A lucrat timp de {{duration}}",
"agentDetail.configure.answer.workingFor": "Lucrează de {{duration}}",

View File

@ -63,8 +63,11 @@
"agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value",
"agentDetail.configure.advancedSettings.label": "Расширенные настройки",
"agentDetail.configure.advancedSettings.toggle": "Переключить расширенные настройки",
"agentDetail.configure.answer.activity.ranCommands": "Команды выполнены",
"agentDetail.configure.answer.activity.runningCommands": "Выполнение команд",
"agentDetail.configure.answer.duration.minute": "{{count}} мин",
"agentDetail.configure.answer.duration.second": "{{count}} с",
"agentDetail.configure.answer.thinking": "Размышляет",
"agentDetail.configure.answer.workFinished": "Работа завершена",
"agentDetail.configure.answer.workedFor": "Работал {{duration}}",
"agentDetail.configure.answer.workingFor": "Работает {{duration}}",

View File

@ -63,8 +63,11 @@
"agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value",
"agentDetail.configure.advancedSettings.label": "Napredne nastavitve",
"agentDetail.configure.advancedSettings.toggle": "Preklopi napredne nastavitve",
"agentDetail.configure.answer.activity.ranCommands": "Ukazi izvedeni",
"agentDetail.configure.answer.activity.runningCommands": "Izvajanje ukazov",
"agentDetail.configure.answer.duration.minute": "{{count}} min",
"agentDetail.configure.answer.duration.second": "{{count}} s",
"agentDetail.configure.answer.thinking": "Razmišlja",
"agentDetail.configure.answer.workFinished": "Delo končano",
"agentDetail.configure.answer.workedFor": "Delal {{duration}}",
"agentDetail.configure.answer.workingFor": "Dela {{duration}}",

View File

@ -63,8 +63,11 @@
"agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value",
"agentDetail.configure.advancedSettings.label": "การตั้งค่าขั้นสูง",
"agentDetail.configure.advancedSettings.toggle": "สลับการตั้งค่าขั้นสูง",
"agentDetail.configure.answer.activity.ranCommands": "เรียกใช้คำสั่งแล้ว",
"agentDetail.configure.answer.activity.runningCommands": "กำลังเรียกใช้คำสั่ง",
"agentDetail.configure.answer.duration.minute": "{{count}} นาที",
"agentDetail.configure.answer.duration.second": "{{count}} วินาที",
"agentDetail.configure.answer.thinking": "กำลังคิด",
"agentDetail.configure.answer.workFinished": "งานเสร็จสิ้น",
"agentDetail.configure.answer.workedFor": "ทำงานเป็นเวลา {{duration}}",
"agentDetail.configure.answer.workingFor": "กำลังทำงานเป็นเวลา {{duration}}",

View File

@ -63,8 +63,11 @@
"agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value",
"agentDetail.configure.advancedSettings.label": "Gelişmiş Ayarlar",
"agentDetail.configure.advancedSettings.toggle": "Gelişmiş ayarları değiştir",
"agentDetail.configure.answer.activity.ranCommands": "Komutlar çalıştırıldı",
"agentDetail.configure.answer.activity.runningCommands": "Komutlar çalıştırılıyor",
"agentDetail.configure.answer.duration.minute": "{{count}} dk",
"agentDetail.configure.answer.duration.second": "{{count}} sn",
"agentDetail.configure.answer.thinking": "Düşünüyor",
"agentDetail.configure.answer.workFinished": "İş tamamlandı",
"agentDetail.configure.answer.workedFor": "{{duration}} çalıştı",
"agentDetail.configure.answer.workingFor": "{{duration}} çalışıyor",

View File

@ -63,8 +63,11 @@
"agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value",
"agentDetail.configure.advancedSettings.label": "Розширені налаштування",
"agentDetail.configure.advancedSettings.toggle": "Перемкнути розширені налаштування",
"agentDetail.configure.answer.activity.ranCommands": "Команди виконано",
"agentDetail.configure.answer.activity.runningCommands": "Виконання команд",
"agentDetail.configure.answer.duration.minute": "{{count}} хв",
"agentDetail.configure.answer.duration.second": "{{count}} с",
"agentDetail.configure.answer.thinking": "Розмірковує",
"agentDetail.configure.answer.workFinished": "Роботу завершено",
"agentDetail.configure.answer.workedFor": "Працював {{duration}}",
"agentDetail.configure.answer.workingFor": "Працює {{duration}}",

View File

@ -63,8 +63,11 @@
"agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value",
"agentDetail.configure.advancedSettings.label": "Cài đặt nâng cao",
"agentDetail.configure.advancedSettings.toggle": "Bật/tắt cài đặt nâng cao",
"agentDetail.configure.answer.activity.ranCommands": "Đã chạy lệnh",
"agentDetail.configure.answer.activity.runningCommands": "Đang chạy lệnh",
"agentDetail.configure.answer.duration.minute": "{{count}} phút",
"agentDetail.configure.answer.duration.second": "{{count}} giây",
"agentDetail.configure.answer.thinking": "Đang suy nghĩ",
"agentDetail.configure.answer.workFinished": "Công việc đã hoàn tất",
"agentDetail.configure.answer.workedFor": "Đã làm việc trong {{duration}}",
"agentDetail.configure.answer.workingFor": "Đang làm việc trong {{duration}}",

View File

@ -63,8 +63,11 @@
"agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value",
"agentDetail.configure.advancedSettings.label": "高级设置",
"agentDetail.configure.advancedSettings.toggle": "展开或收起高级设置",
"agentDetail.configure.answer.activity.ranCommands": "已运行命令",
"agentDetail.configure.answer.activity.runningCommands": "正在运行命令",
"agentDetail.configure.answer.duration.minute": "{{count}}分",
"agentDetail.configure.answer.duration.second": "{{count}}秒",
"agentDetail.configure.answer.thinking": "思考过程",
"agentDetail.configure.answer.workFinished": "工作已完成",
"agentDetail.configure.answer.workedFor": "工作了 {{duration}}",
"agentDetail.configure.answer.workingFor": "工作中 {{duration}}",

View File

@ -63,8 +63,11 @@
"agentDetail.configure.advancedSettings.envEditor.valuePlaceholder": "Value",
"agentDetail.configure.advancedSettings.label": "進階設定",
"agentDetail.configure.advancedSettings.toggle": "展開或收合進階設定",
"agentDetail.configure.answer.activity.ranCommands": "已執行命令",
"agentDetail.configure.answer.activity.runningCommands": "正在執行命令",
"agentDetail.configure.answer.duration.minute": "{{count}}分",
"agentDetail.configure.answer.duration.second": "{{count}}秒",
"agentDetail.configure.answer.thinking": "思考過程",
"agentDetail.configure.answer.workFinished": "工作已完成",
"agentDetail.configure.answer.workedFor": "工作了 {{duration}}",
"agentDetail.configure.answer.workingFor": "工作中 {{duration}}",