Merge branch 'main' into betterSqlite

This commit is contained in:
beyondkmp 2025-11-21 10:27:20 +08:00
commit 00754f3644
11 changed files with 417 additions and 215 deletions

View File

@ -104,12 +104,6 @@ const router = express
logger.warn('No models available from providers', { filter }) 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) return res.json(response satisfies ApiModelsResponse)
} catch (error: any) { } catch (error: any) {
logger.error('Error fetching models', { error }) logger.error('Error fetching models', { error })

View File

@ -32,7 +32,7 @@ export class ModelsService {
for (const model of models) { for (const model of models) {
const provider = providers.find((p) => p.id === model.provider) const provider = providers.find((p) => p.id === model.provider)
logger.debug(`Processing model ${model.id}`) // logger.debug(`Processing model ${model.id}`)
if (!provider) { if (!provider) {
logger.debug(`Skipping model ${model.id} . Reason: Provider not found.`) logger.debug(`Skipping model ${model.id} . Reason: Provider not found.`)
continue continue

View File

@ -21,6 +21,11 @@ describe('stripLocalCommandTags', () => {
'<local-command-stdout>line1</local-command-stdout>\nkeep\n<local-command-stderr>Error</local-command-stderr>' '<local-command-stdout>line1</local-command-stdout>\nkeep\n<local-command-stderr>Error</local-command-stderr>'
expect(stripLocalCommandTags(input)).toBe('line1\nkeep\nError') 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', () => { describe('Claude → AiSDK transform', () => {
@ -188,6 +193,111 @@ describe('Claude → AiSDK transform', () => {
expect(toolResult.output).toBe('ok') 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', () => { it('handles streaming text completion', () => {
const state = new ClaudeStreamState({ agentSessionId: baseStreamMetadata.session_id }) const state = new ClaudeStreamState({ agentSessionId: baseStreamMetadata.session_id })
const parts: ReturnType<typeof transformSDKMessageToStreamParts>[number][] = [] const parts: ReturnType<typeof transformSDKMessageToStreamParts>[number][] = []
@ -300,4 +410,87 @@ describe('Claude → AiSDK transform', () => {
expect(finishStep.finishReason).toBe('stop') expect(finishStep.finishReason).toBe('stop')
expect(finishStep.usage).toEqual({ inputTokens: 2, outputTokens: 4, totalTokens: 6 }) 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) 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 { getToolBlockById(toolCallId: string): ToolBlockState | undefined {
const index = this.toolIndexByNamespacedId.get(toolCallId) const index = this.toolIndexByNamespacedId.get(toolCallId)
if (index === undefined) return undefined 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 * Persists the final input payload for a tool block once the provider signals
* completion so that downstream tool results can reference the original call. * 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) const block = this.getToolBlockByRawId(toolCallId)
this.registerToolCall(toolCallId, { this.registerToolCall(toolCallId, {
toolName: block?.toolName ?? 'unknown', toolName,
input, input,
providerMetadata providerMetadata
}) })

View File

@ -414,23 +414,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) const chunks = transformSDKMessageToStreamParts(message, streamState)
for (const chunk of chunks) { for (const chunk of chunks) {
stream.emit('data', { 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. * blocks across calls so that incremental deltas can be correlated correctly.
*/ */
export function transformSDKMessageToStreamParts(sdkMessage: SDKMessage, state: ClaudeStreamState): AgentStreamPart[] { 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) { switch (sdkMessage.type) {
case 'assistant': case 'assistant':
return handleAssistantMessage(sdkMessage, state) return handleAssistantMessage(sdkMessage, state)
@ -186,14 +186,13 @@ function handleAssistantMessage(
for (const block of content) { for (const block of content) {
switch (block.type) { switch (block.type) {
case 'text': case 'text': {
if (!isStreamingActive) { const sanitizedText = stripLocalCommandTags(block.text)
const sanitizedText = stripLocalCommandTags(block.text) if (sanitizedText) {
if (sanitizedText) { textBlocks.push(sanitizedText)
textBlocks.push(sanitizedText)
}
} }
break break
}
case 'tool_use': case 'tool_use':
handleAssistantToolUse(block as ToolUseContent, providerMetadata, state, chunks) handleAssistantToolUse(block as ToolUseContent, providerMetadata, state, chunks)
break 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() const id = message.uuid?.toString() || generateMessageId()
state.beginStep() state.beginStep()
chunks.push({ chunks.push({
@ -219,7 +227,7 @@ function handleAssistantMessage(
chunks.push({ chunks.push({
type: 'text-delta', type: 'text-delta',
id, id,
text: textBlocks.join(''), text: combinedText,
providerMetadata providerMetadata
}) })
chunks.push({ chunks.push({
@ -230,7 +238,27 @@ function handleAssistantMessage(
return finalizeNonStreamingStep(message, state, chunks) 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, providerExecuted: true,
providerMetadata 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': { case 'message_stop': {
if (!state.hasActiveStep()) {
break
}
const pending = state.getPendingUsage() const pending = state.getPendingUsage()
chunks.push({ chunks.push({
type: 'finish-step', type: 'finish-step',

View File

@ -1,7 +1,6 @@
import type { CollapseProps } from 'antd' import type { CollapseProps } from 'antd'
import { Tag } from 'antd' import { Tag } from 'antd'
import { CheckCircle, Terminal, XCircle } from 'lucide-react' import { CheckCircle, Terminal, XCircle } from 'lucide-react'
import { useMemo } from 'react'
import { ToolTitle } from './GenericTools' import { ToolTitle } from './GenericTools'
import type { BashOutputToolInput, BashOutputToolOutput } from './types' import type { BashOutputToolInput, BashOutputToolOutput } from './types'
@ -16,6 +15,63 @@ interface ParsedBashOutput {
tool_use_error?: string 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({ export function BashOutputTool({
input, input,
output output
@ -23,73 +79,8 @@ export function BashOutputTool({
input: BashOutputToolInput input: BashOutputToolInput
output?: BashOutputToolOutput output?: BashOutputToolOutput
}): NonNullable<CollapseProps['items']>[number] { }): NonNullable<CollapseProps['items']>[number] {
// 解析 XML 输出 const parsedOutput = parseBashOutput(output)
const parsedOutput = useMemo(() => { const statusConfig = getStatusConfig(parsedOutput)
if (!output) return null
try {
const parser = new DOMParser()
// 检查是否包含 tool_use_error 标签
const hasToolError = output.includes('<tool_use_error>')
// 包装成有效的 XML如果还没有根元素
const xmlStr = output.includes('<status>') || hasToolError ? `<root>${output}</root>` : output
const xmlDoc = parser.parseFromString(xmlStr, 'application/xml')
// 检查是否有解析错误
const parserError = xmlDoc.querySelector('parsererror')
if (parserError) {
return null
}
const getElementText = (tagName: string): string | undefined => {
const element = xmlDoc.getElementsByTagName(tagName)[0]
return element?.textContent?.trim()
}
const result: ParsedBashOutput = {
status: getElementText('status'),
exit_code: getElementText('exit_code') ? parseInt(getElementText('exit_code')!) : undefined,
stdout: getElementText('stdout'),
stderr: getElementText('stderr'),
timestamp: getElementText('timestamp'),
tool_use_error: getElementText('tool_use_error')
}
return result
} catch {
return null
}
}, [output])
// 获取状态配置
const statusConfig = useMemo(() => {
if (!parsedOutput) return null
// 如果有 tool_use_error直接显示错误状态
if (parsedOutput.tool_use_error) {
return {
color: 'danger',
icon: <XCircle className="h-3.5 w-3.5" />,
text: 'Error'
} as const
}
const isCompleted = parsedOutput.status === 'completed'
const isSuccess = parsedOutput.exit_code === 0
return {
color: isCompleted && isSuccess ? 'success' : isCompleted && !isSuccess ? 'danger' : 'warning',
icon:
isCompleted && isSuccess ? (
<CheckCircle className="h-3.5 w-3.5" />
) : isCompleted && !isSuccess ? (
<XCircle className="h-3.5 w-3.5" />
) : (
<Terminal className="h-3.5 w-3.5" />
),
text: isCompleted ? (isSuccess ? 'Success' : 'Failed') : 'Running'
} as const
}, [parsedOutput])
const children = parsedOutput ? ( const children = parsedOutput ? (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">

View File

@ -1,12 +1,47 @@
import type { CollapseProps } from 'antd' import type { CollapseProps } from 'antd'
import { FileText } from 'lucide-react' import { FileText } from 'lucide-react'
import { useMemo } from 'react'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import { ToolTitle } from './GenericTools' import { ToolTitle } from './GenericTools'
import type { ReadToolInput as ReadToolInputType, ReadToolOutput as ReadToolOutputType, TextOutput } from './types' import type { ReadToolInput as ReadToolInputType, ReadToolOutput as ReadToolOutputType, TextOutput } from './types'
import { AgentToolsType } 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({ export function ReadTool({
input, input,
output output
@ -14,50 +49,8 @@ export function ReadTool({
input: ReadToolInputType input: ReadToolInputType
output?: ReadToolOutputType output?: ReadToolOutputType
}): NonNullable<CollapseProps['items']>[number] { }): NonNullable<CollapseProps['items']>[number] {
// 移除 system-reminder 标签及其内容的辅助函数 const outputString = normalizeOutputString(output)
const removeSystemReminderTags = (text: string): string => { const stats = getOutputStats(outputString)
// 使用正则表达式匹配 <system-reminder> 标签及其内容,包括换行符
return text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/gi, '')
}
// 将 output 统一转换为字符串
const outputString = useMemo(() => {
if (!output) return null
let processedOutput: string
// 如果是 TextOutput[] 类型,提取所有 text 内容
if (Array.isArray(output)) {
processedOutput = output
.filter((item): item is TextOutput => item.type === 'text')
.map((item) => removeSystemReminderTags(item.text))
.join('')
} else {
// 如果是字符串,直接使用
processedOutput = output
}
// 移除 system-reminder 标签及其内容
return removeSystemReminderTags(processedOutput)
}, [output])
// 如果有输出,计算统计信息
const stats = useMemo(() => {
if (!outputString) return null
const bytes = new Blob([outputString]).size
const formatSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
return {
lineCount: outputString.split('\n').length,
fileSize: bytes,
formatSize
}
}, [outputString])
return { return {
key: AgentToolsType.Read, key: AgentToolsType.Read,

View File

@ -11,11 +11,24 @@ interface UnknownToolProps {
output?: unknown output?: unknown
} }
export function UnknownToolRenderer({ const getToolDisplayName = (name: string) => {
toolName = '', if (name.startsWith('mcp__')) {
input, const parts = name.substring(5).split('__')
output if (parts.length >= 2) {
}: UnknownToolProps): NonNullable<CollapseProps['items']>[number] { 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 { highlightCode } = useCodeStyle()
const [inputHtml, setInputHtml] = useState<string>('') const [inputHtml, setInputHtml] = useState<string>('')
const [outputHtml, setOutputHtml] = useState<string>('') const [outputHtml, setOutputHtml] = useState<string>('')
@ -34,58 +47,49 @@ export function UnknownToolRenderer({
} }
}, [output, highlightCode]) }, [output, highlightCode])
const getToolDisplayName = (name: string) => { if (input === undefined && output === undefined) {
if (name.startsWith('mcp__')) { return <div className="text-foreground-500 text-xs">No data available for this tool</div>
const parts = name.substring(5).split('__')
if (parts.length >= 2) {
return `${parts[0]}:${parts.slice(1).join(':')}`
}
}
return name
} }
const getToolDescription = () => { return (
if (toolName.startsWith('mcp__')) { <div className="space-y-3">
return 'MCP Server Tool' {input !== undefined && (
} <div>
return 'Tool' <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 { return {
key: 'unknown-tool', key: 'unknown-tool',
label: ( label: (
<ToolTitle <ToolTitle
icon={<Wrench className="h-4 w-4" />} icon={<Wrench className="h-4 w-4" />}
label={getToolDisplayName(toolName)} label={getToolDisplayName(toolName)}
params={getToolDescription()} params={getToolDescription(toolName)}
/> />
), ),
children: ( children: <UnknownToolContent input={input} output={output} />
<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>
)
} }
} }

View File

@ -6,8 +6,6 @@ import { Collapse } from 'antd'
// 导出所有类型 // 导出所有类型
export * from './types' export * from './types'
import { useMemo } from 'react'
// 导入所有渲染器 // 导入所有渲染器
import ToolPermissionRequestCard from '../ToolPermissionRequestCard' import ToolPermissionRequestCard from '../ToolPermissionRequestCard'
import { BashOutputTool } from './BashOutputTool' 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) 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 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: NonNullable<CollapseProps['items']>[number] = {
const toolContentItem = useMemo(() => { ...renderedItem,
const rendered = Renderer classNames: {
? Renderer({ input: input as any, output: output as any }) body: 'bg-foreground-50 p-2 text-foreground-900 dark:bg-foreground-100 max-h-96 p-2 overflow-scroll'
: UnknownToolRenderer({ input: input as any, output: output as any, toolName }) }
return { }
...rendered,
classNames: {
body: 'bg-foreground-50 p-2 text-foreground-900 dark:bg-foreground-100 max-h-96 p-2 overflow-scroll'
} as NonNullable<CollapseProps['items']>[number]['classNames']
} as NonNullable<CollapseProps['items']>[number]
}, [Renderer, input, output, toolName])
return ( return (
<Collapse <Collapse
@ -98,5 +93,7 @@ export function MessageAgentTools({ toolResponse }: { toolResponse: NormalToolRe
return <ToolPermissionRequestCard toolResponse={toolResponse} /> 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

@ -585,9 +585,11 @@ const fetchAndProcessAgentResponseImpl = async (
return return
} }
// Only mark as cleared if there was a previous session ID (not initial assignment)
sessionWasCleared = !!latestAgentSessionId
latestAgentSessionId = sessionId latestAgentSessionId = sessionId
agentSession.agentSessionId = sessionId agentSession.agentSessionId = sessionId
sessionWasCleared = true
logger.debug(`Agent session ID updated`, { logger.debug(`Agent session ID updated`, {
topicId, topicId,