Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions packages/global/core/ai/llm/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,12 @@ export type ChatCompletionUserMessageParam = z.infer<typeof ChatCompletionUserMe

/**
* Tool message: 对齐 openai SDK 的 ChatCompletionToolMessageParam,新增可选 `name`
* SDK content 仅允许纯文本 part
* content 允许 string 或 content part 数组(含 image_url),
* 以便工具返回的图片 URL 作为图像进入下一轮请求
*/
export const ChatCompletionToolMessageParamSchema = z.object({
role: z.literal('tool'),
content: z.union([z.string(), z.array(ChatCompletionContentPartTextSchema)]),
content: z.union([z.string(), z.array(ChatCompletionContentPartSchema)]),
tool_call_id: z.string(),
name: z.string().optional()
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,13 @@ const parseToolArguments = (value: string) => {
};

const getContentText = (
content:
| string
| null
| Array<{ type: 'text'; text: string } | { type: 'refusal'; refusal: string }>
| undefined
content: string | null | Array<{ type: string; text?: string; refusal?: string }> | undefined
) => {
if (!content) return '';
if (typeof content === 'string') return content;
return content.map((item) => (item.type === 'text' ? item.text : item.refusal)).join('');
return content
.map((item) => (item.type === 'text' ? (item.text ?? '') : (item.refusal ?? '')))
.join('');
};

/**
Expand Down
8 changes: 7 additions & 1 deletion packages/service/core/ai/llm/promptCall/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,15 @@ export const promptToolCallMessageRewrite = (
delete message.tool_calls;
}
} else if (message.role === 'tool') {
const toolContent =
typeof message.content === 'string'
? message.content
: Array.isArray(message.content)
? message.content.map((item) => (item.type === 'text' ? item.text : '')).join('')
: '';
cloneMessages.splice(i, 1, {
role: 'user',
content: `<ToolResponse>\n${message.content}\n</ToolResponse>`
content: `<ToolResponse>\n${toolContent}\n</ToolResponse>`
});
}
}
Expand Down
17 changes: 17 additions & 0 deletions packages/service/core/ai/llm/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -747,6 +747,23 @@ export const loadRequestMessages = async ({
...formatAssistantItem(item, supportReason),
content: formatContent
};
} else if (item.role === ChatCompletionRequestMessageRoleEnum.Tool) {
// 工具返回的文本里可能包含图片 URL,提取为 image_url 内容块进入下一轮
if (typeof item.content === 'string' && item.content !== '') {
const parts = parseTextUserContentParts(item.content);
// 只有提取到媒体 part(存在非 text part)时才结构化,否则保持 string 兼容
const hasMedia = parts.some((part) => part.type !== 'text');
if (hasMedia) {
const normalizedContent = await normalizeMediaContentParts(parts);
if (normalizedContent.length > 0) {
return {
...item,
content: normalizedContent
};
}
}
}
return item;
} else {
return item;
}
Expand Down
59 changes: 59 additions & 0 deletions packages/service/test/core/ai/llm/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1154,6 +1154,65 @@ describe('loadRequestMessages function tests', () => {
});
});

describe('Tool message image extraction', () => {
it('should extract image URL from tool message into image_url part when useVision is true', async () => {
serviceEnv.MULTIPLE_DATA_TO_BASE64 = false;
const imageUrl = 'https://example.com/screenshot.png';
const messages: ChatCompletionMessageParam[] = [
{ role: ChatCompletionRequestMessageRoleEnum.User, content: 'Generate a chart' },
{
role: ChatCompletionRequestMessageRoleEnum.Tool,
tool_call_id: 'call1',
content: `Generated chart: ${imageUrl}`
}
];

const result = await loadRequestMessages({ messages, useVision: true });

const toolMessage = result.find((m) => m.role === ChatCompletionRequestMessageRoleEnum.Tool);
expect(toolMessage).toBeDefined();
const content = toolMessage!.content as any[];
expect(Array.isArray(content)).toBe(true);
expect(
content.some((item: any) => item.type === 'image_url' && item.image_url.url === imageUrl)
).toBe(true);
expect(content.some((item: any) => item.type === 'text')).toBe(true);
});

it('should keep tool message as string when useVision is false', async () => {
const imageUrl = 'https://example.com/screenshot.png';
const messages: ChatCompletionMessageParam[] = [
{ role: ChatCompletionRequestMessageRoleEnum.User, content: 'Generate a chart' },
{
role: ChatCompletionRequestMessageRoleEnum.Tool,
tool_call_id: 'call1',
content: `Generated chart: ${imageUrl}`
}
];

const result = await loadRequestMessages({ messages, useVision: false });

const toolMessage = result.find((m) => m.role === ChatCompletionRequestMessageRoleEnum.Tool);
expect(toolMessage!.content).toBe(`Generated chart: ${imageUrl}`);
});

it('should keep tool message as string when it contains no image URL', async () => {
const messages: ChatCompletionMessageParam[] = [
{ role: ChatCompletionRequestMessageRoleEnum.User, content: 'Hello' },
{
role: ChatCompletionRequestMessageRoleEnum.Tool,
tool_call_id: 'call1',
content: 'plain text result'
}
];

const result = await loadRequestMessages({ messages, useVision: true });

const toolMessage = result.find((m) => m.role === ChatCompletionRequestMessageRoleEnum.Tool);
expect(toolMessage!.content).toBe('plain text result');
});
});

describe('Complex scenarios', () => {
it('should handle mixed conversation with all message types', async () => {
const messages: ChatCompletionMessageParam[] = [
Expand Down
Loading