mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-19 22:52:08 +08:00
Merge branch 'main' into betterSqlite
This commit is contained in:
commit
00754f3644
@ -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 })
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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', {
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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,37 +15,23 @@ interface ParsedBashOutput {
|
|||||||
tool_use_error?: string
|
tool_use_error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BashOutputTool({
|
const parseBashOutput = (output?: BashOutputToolOutput): ParsedBashOutput | null => {
|
||||||
input,
|
|
||||||
output
|
|
||||||
}: {
|
|
||||||
input: BashOutputToolInput
|
|
||||||
output?: BashOutputToolOutput
|
|
||||||
}): NonNullable<CollapseProps['items']>[number] {
|
|
||||||
// 解析 XML 输出
|
|
||||||
const parsedOutput = useMemo(() => {
|
|
||||||
if (!output) return null
|
if (!output) return null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parser = new DOMParser()
|
const parser = new DOMParser()
|
||||||
// 检查是否包含 tool_use_error 标签
|
|
||||||
const hasToolError = output.includes('<tool_use_error>')
|
const hasToolError = output.includes('<tool_use_error>')
|
||||||
// 包装成有效的 XML(如果还没有根元素)
|
|
||||||
const xmlStr = output.includes('<status>') || hasToolError ? `<root>${output}</root>` : output
|
const xmlStr = output.includes('<status>') || hasToolError ? `<root>${output}</root>` : output
|
||||||
const xmlDoc = parser.parseFromString(xmlStr, 'application/xml')
|
const xmlDoc = parser.parseFromString(xmlStr, 'application/xml')
|
||||||
|
|
||||||
// 检查是否有解析错误
|
|
||||||
const parserError = xmlDoc.querySelector('parsererror')
|
const parserError = xmlDoc.querySelector('parsererror')
|
||||||
if (parserError) {
|
if (parserError) return null
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const getElementText = (tagName: string): string | undefined => {
|
const getElementText = (tagName: string): string | undefined => {
|
||||||
const element = xmlDoc.getElementsByTagName(tagName)[0]
|
const element = xmlDoc.getElementsByTagName(tagName)[0]
|
||||||
return element?.textContent?.trim()
|
return element?.textContent?.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: ParsedBashOutput = {
|
return {
|
||||||
status: getElementText('status'),
|
status: getElementText('status'),
|
||||||
exit_code: getElementText('exit_code') ? parseInt(getElementText('exit_code')!) : undefined,
|
exit_code: getElementText('exit_code') ? parseInt(getElementText('exit_code')!) : undefined,
|
||||||
stdout: getElementText('stdout'),
|
stdout: getElementText('stdout'),
|
||||||
@ -54,18 +39,14 @@ export function BashOutputTool({
|
|||||||
timestamp: getElementText('timestamp'),
|
timestamp: getElementText('timestamp'),
|
||||||
tool_use_error: getElementText('tool_use_error')
|
tool_use_error: getElementText('tool_use_error')
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}, [output])
|
}
|
||||||
|
|
||||||
// 获取状态配置
|
const getStatusConfig = (parsedOutput: ParsedBashOutput | null) => {
|
||||||
const statusConfig = useMemo(() => {
|
|
||||||
if (!parsedOutput) return null
|
if (!parsedOutput) return null
|
||||||
|
|
||||||
// 如果有 tool_use_error,直接显示错误状态
|
|
||||||
if (parsedOutput.tool_use_error) {
|
if (parsedOutput.tool_use_error) {
|
||||||
return {
|
return {
|
||||||
color: 'danger',
|
color: 'danger',
|
||||||
@ -89,7 +70,17 @@ export function BashOutputTool({
|
|||||||
),
|
),
|
||||||
text: isCompleted ? (isSuccess ? 'Success' : 'Failed') : 'Running'
|
text: isCompleted ? (isSuccess ? 'Success' : 'Failed') : 'Running'
|
||||||
} as const
|
} as const
|
||||||
}, [parsedOutput])
|
}
|
||||||
|
|
||||||
|
export function BashOutputTool({
|
||||||
|
input,
|
||||||
|
output
|
||||||
|
}: {
|
||||||
|
input: BashOutputToolInput
|
||||||
|
output?: BashOutputToolOutput
|
||||||
|
}): NonNullable<CollapseProps['items']>[number] {
|
||||||
|
const parsedOutput = parseBashOutput(output)
|
||||||
|
const statusConfig = getStatusConfig(parsedOutput)
|
||||||
|
|
||||||
const children = parsedOutput ? (
|
const children = parsedOutput ? (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,33 +47,11 @@ 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__')) {
|
|
||||||
return 'MCP Server Tool'
|
|
||||||
}
|
|
||||||
return 'Tool'
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
key: 'unknown-tool',
|
|
||||||
label: (
|
|
||||||
<ToolTitle
|
|
||||||
icon={<Wrench className="h-4 w-4" />}
|
|
||||||
label={getToolDisplayName(toolName)}
|
|
||||||
params={getToolDescription()}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
children: (
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{input !== undefined && (
|
{input !== undefined && (
|
||||||
<div>
|
<div>
|
||||||
@ -81,11 +72,24 @@ export function UnknownToolRenderer({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{input === undefined && output === undefined && (
|
|
||||||
<div className="text-foreground-500 text-xs">No data available for this tool</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UnknownToolRenderer({
|
||||||
|
toolName = '',
|
||||||
|
input,
|
||||||
|
output
|
||||||
|
}: UnknownToolProps): NonNullable<CollapseProps['items']>[number] {
|
||||||
|
return {
|
||||||
|
key: 'unknown-tool',
|
||||||
|
label: (
|
||||||
|
<ToolTitle
|
||||||
|
icon={<Wrench className="h-4 w-4" />}
|
||||||
|
label={getToolDisplayName(toolName)}
|
||||||
|
params={getToolDescription(toolName)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
children: <UnknownToolContent input={input} output={output} />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
||||||
const toolContentItem = useMemo(() => {
|
|
||||||
const rendered = Renderer
|
|
||||||
? Renderer({ input: input as any, output: output as any })
|
? Renderer({ input: input as any, output: output as any })
|
||||||
: UnknownToolRenderer({ input: input as any, output: output as any, toolName })
|
: UnknownToolRenderer({ input: input as any, output: output as any, toolName })
|
||||||
return {
|
|
||||||
...rendered,
|
const toolContentItem: NonNullable<CollapseProps['items']>[number] = {
|
||||||
|
...renderedItem,
|
||||||
classNames: {
|
classNames: {
|
||||||
body: 'bg-foreground-50 p-2 text-foreground-900 dark:bg-foreground-100 max-h-96 p-2 overflow-scroll'
|
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} />
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user