mirror of
https://github.com/laurent22/joplin.git
synced 2026-10-25 02:47:28 +00:00
Desktop: Chat panel: Fix OpenAI, Anthropic compatibility (#16188)
This commit is contained in:
1 parent
0f3d81a983
commit
b46a96e03c
8 files changed
+75
-42
No files matched your search
@@ -127,7 +127,7 @@ test.describe('wcag', () => {
|
||||
await mainScreen.chatPanel.waitForMessageCount(2);
|
||||
|
||||
await mainScreen.chatPanel.sendMessage(
|
||||
'/tool editor.appendToNote {"text": "test"}\n/reply-with done',
|
||||
'/tool editor_appendToNote {"text": "test"}\n/reply-with done',
|
||||
);
|
||||
await mainScreen.chatPanel.waitForMessageCount(5);
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ describe('noteChat', () => {
|
||||
const allowedSchemaOperations = editSchemaItems.map(item => item.id).sort();
|
||||
expect(
|
||||
allowedSchemaOperations.filter(id => isEditorToolCall(id)),
|
||||
).toEqual([...expectedOperations].map(o => `editor.${o}`).sort());
|
||||
).toEqual([...expectedOperations].map(o => `editor_${o}`).sort());
|
||||
});
|
||||
|
||||
test('estimateTokens approximates char/4', () => {
|
||||
@@ -130,7 +130,7 @@ describe('noteChat', () => {
|
||||
test('toolDefinitions advertises replaceFencedBlock with structured-block guidance', () => {
|
||||
const { context, commands } = makeTestContext({ title: 'n', body: '```abc\n```', selection: null });
|
||||
const tools = new ToolIndex({ note: context, commands }).getTools();
|
||||
const toolDefinition = tools.find(tool => tool.id === 'editor.replaceFencedBlock');
|
||||
const toolDefinition = tools.find(tool => tool.id === 'editor_replaceFencedBlock');
|
||||
expect(toolDefinition).toBeTruthy();
|
||||
expect(toolDefinition.description).toContain('jsoncanvas');
|
||||
// Guidance to use the op for structured blocks, appendToNote for creation.
|
||||
@@ -149,7 +149,7 @@ describe('noteChat', () => {
|
||||
role: ChatRole.Assistant,
|
||||
content: 'Testing...'.repeat(5),
|
||||
toolCalls: [{
|
||||
toolName: 'editor.replaceSelection',
|
||||
toolName: 'editor_replaceSelection',
|
||||
callId: 'call-1',
|
||||
arguments: { text: 'Testing... '.repeat(50) },
|
||||
parseError: null,
|
||||
@@ -161,8 +161,8 @@ describe('noteChat', () => {
|
||||
|
||||
test('runTools should describe successful edit operations', async () => {
|
||||
const toolCalls: ChatToolCall[] = [
|
||||
{ toolName: 'editor.appendToNote', callId: 'call-1', arguments: { text: 'Test.' }, parseError: null },
|
||||
{ toolName: 'editor.replaceRange', callId: 'call-2', arguments: { anchor: 'Body', text: 'Updated' }, parseError: null },
|
||||
{ toolName: 'editor_appendToNote', callId: 'call-1', arguments: { text: 'Test.' }, parseError: null },
|
||||
{ toolName: 'editor_replaceRange', callId: 'call-2', arguments: { anchor: 'Body', text: 'Updated' }, parseError: null },
|
||||
];
|
||||
|
||||
const { onContext, commands } = makeTestContext({});
|
||||
@@ -179,14 +179,14 @@ describe('noteChat', () => {
|
||||
expect(result).toMatchObject([
|
||||
{
|
||||
role: ChatRole.Tool,
|
||||
toolName: 'editor.appendToNote',
|
||||
toolName: 'editor_appendToNote',
|
||||
toolCallId: 'call-1',
|
||||
isError: false,
|
||||
userDescription: 'Added 1 word',
|
||||
},
|
||||
{
|
||||
role: ChatRole.Tool,
|
||||
toolName: 'editor.replaceRange',
|
||||
toolName: 'editor_replaceRange',
|
||||
toolCallId: 'call-2',
|
||||
isError: false,
|
||||
userDescription: 'Removed 1 word\nAdded 1 word',
|
||||
@@ -199,14 +199,14 @@ describe('noteChat', () => {
|
||||
const failingAndSucceedingToolCall = (repeat: number) => [
|
||||
`/repeat ${repeat}`,
|
||||
// one failing tool
|
||||
`/tool editor.replaceRange ${JSON.stringify({ anchor: 'does not exist', text: 'replaced' })}`,
|
||||
`/tool editor_replaceRange ${JSON.stringify({ anchor: 'does not exist', text: 'replaced' })}`,
|
||||
// and one tool call that should succeed
|
||||
'/tool editor.appendToNote { "text": "test" }',
|
||||
'/tool editor_appendToNote { "text": "test" }',
|
||||
].join('\n');
|
||||
|
||||
const userMessage = failingAndSucceedingToolCall(32);
|
||||
const result = await withWarningSilenced(
|
||||
/call editor.replaceRange failed/,
|
||||
/call editor_replaceRange failed/,
|
||||
() => runNoteChat(
|
||||
onContext,
|
||||
[],
|
||||
@@ -221,8 +221,8 @@ describe('noteChat', () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
failedAttempts.push(
|
||||
{ role: ChatRole.Assistant },
|
||||
{ role: ChatRole.Tool, isError: true, toolName: 'editor.replaceRange' },
|
||||
{ role: ChatRole.Tool, isError: false, toolName: 'editor.appendToNote' },
|
||||
{ role: ChatRole.Tool, isError: true, toolName: 'editor_replaceRange' },
|
||||
{ role: ChatRole.Tool, isError: false, toolName: 'editor_appendToNote' },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -236,7 +236,7 @@ describe('noteChat', () => {
|
||||
|
||||
...failedAttempts,
|
||||
|
||||
{ role: ChatRole.Assistant, content: 'tool not found: editor.replaceRange\ntool not found: editor.appendToNote' },
|
||||
{ role: ChatRole.Assistant, content: 'tool not found: editor_replaceRange\ntool not found: editor_appendToNote' },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -255,8 +255,8 @@ describe('noteChat', () => {
|
||||
expect(result).toMatchObject([
|
||||
{ role: ChatRole.System },
|
||||
{ role: ChatRole.User, content: 'test' },
|
||||
{ role: ChatRole.Assistant, content: '', toolCalls: [{ toolName: 'editor.readNoteBody' }] },
|
||||
{ role: ChatRole.Tool, content: 'Body', toolName: 'editor.readNoteBody' },
|
||||
{ role: ChatRole.Assistant, content: '', toolCalls: [{ toolName: 'editor_readNoteBody' }] },
|
||||
{ role: ChatRole.Tool, content: 'Body', toolName: 'editor_readNoteBody' },
|
||||
{ role: ChatRole.Assistant, content: '' },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import Setting from '../../models/Setting';
|
||||
import Note from '../../models/Note';
|
||||
import Tag from '../../models/Tag';
|
||||
import { NoteEntity } from '../database/types';
|
||||
import { NoteContext } from './tools/types';
|
||||
import { NoteContext, ToolDefinition } from './tools/types';
|
||||
|
||||
const runChatForNote = (note: NoteEntity, history: ChatMessage[], message: string) => {
|
||||
const getContext = async () => {
|
||||
@@ -43,6 +43,32 @@ describe('noteChat.tools', () => {
|
||||
Setting.setValue('ai.enabled', true);
|
||||
});
|
||||
|
||||
test('should use valid identifiers for tools', async () => {
|
||||
const note = await Note.save({ title: 'test' });
|
||||
|
||||
const allToolDefinitions = async () => {
|
||||
const chat = await runChatForNote(
|
||||
note,
|
||||
[{ role: ChatRole.User, content: 'testing' }],
|
||||
'/list-tools',
|
||||
);
|
||||
const lastMessage = chat[chat.length - 1];
|
||||
expect(typeof lastMessage?.content).toBe('string');
|
||||
const responseJson: ToolDefinition[] = JSON.parse(lastMessage.content as string);
|
||||
return responseJson;
|
||||
};
|
||||
|
||||
// Certain providers have more restrictive ID requirements. All tools exposed in the chat panel
|
||||
// should satisfy these requirements.
|
||||
// See https://discourse.joplinapp.org/t/400-error-with-ai-chat/50615
|
||||
const isValidId = (id: string) => !!id.match(/^[a-zA-Z0-9_-]{1,128}$/);
|
||||
const idValidity = (await allToolDefinitions()).map(tool => ({
|
||||
id: tool.id,
|
||||
valid: isValidId(tool.id),
|
||||
}));
|
||||
expect(idValidity).toEqual(idValidity.map(result => ({ ...result, valid: true })));
|
||||
});
|
||||
|
||||
test('should allow external tools when enabled in settings', async () => {
|
||||
const note = await Note.save({ title: 'test' });
|
||||
|
||||
|
||||
@@ -63,8 +63,8 @@ const systemPrompt = (note: NoteContext) => {
|
||||
} else {
|
||||
lines.push(
|
||||
'Tool guidance:',
|
||||
'- You have access to various tools that allow updating the note. For example, if you need to add the text "test" to the end of the note, do this using the "editor.appendToNote" tool.',
|
||||
'- For tasks modifying the current note, prefer "editor." tools to global tools.',
|
||||
'- You have access to various tools that allow updating the note. For example, if you need to add the text "test" to the end of the note, do this using the "editor_appendToNote" tool.',
|
||||
'- For tasks modifying the current note, prefer "editor_" tools to global tools.',
|
||||
'- Some tools call for anchors. Anchors must be exact substrings of the current note body. Keep them short but unique.',
|
||||
);
|
||||
}
|
||||
@@ -95,14 +95,14 @@ const createHistory = (history: ChatMessage[], newMessage: string, context: Note
|
||||
content: '',
|
||||
hide: true,
|
||||
toolCalls: [
|
||||
{ callId, arguments: { }, toolName: 'editor.readNoteBody', parseError: null },
|
||||
{ callId, arguments: { }, toolName: 'editor_readNoteBody', parseError: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: ChatRole.Tool,
|
||||
content: context.body,
|
||||
toolCallId: callId,
|
||||
toolName: 'editor.readNoteBody',
|
||||
toolName: 'editor_readNoteBody',
|
||||
userDescription: '',
|
||||
isEdit: false,
|
||||
isError: false,
|
||||
@@ -271,7 +271,7 @@ const runTools = async (
|
||||
const currentContext = await getContext();
|
||||
let chatResponses: ChatToolMessage[] = [];
|
||||
|
||||
const isEdit = (toolName: string) => isEditorToolCall(toolName) && toolName !== 'editor.readNoteBody';
|
||||
const isEdit = (toolName: string) => isEditorToolCall(toolName) && toolName !== 'editor_readNoteBody';
|
||||
|
||||
const respondFailure = (action: ChatToolCall, reason: string, userDescription?: string) => {
|
||||
chatResponses.push({
|
||||
|
||||
@@ -52,7 +52,6 @@ const convertTool = (tool: ToolSpec) => {
|
||||
name: tool.id,
|
||||
description: tool.description,
|
||||
parameters: tool.inputSchema,
|
||||
strict: true,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -89,7 +88,7 @@ const convertMessage = (message: ChatMessage) => {
|
||||
return [{
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
...(message.toolCalls ? {
|
||||
...(message.toolCalls?.length ? {
|
||||
tool_calls: message.toolCalls.map(call => {
|
||||
return {
|
||||
id: call.callId,
|
||||
|
||||
@@ -7,13 +7,15 @@ const parseUserCommand = (message: ChatStandardMessage, availableTools: ToolSpec
|
||||
const reply = [];
|
||||
let repeat = 0;
|
||||
for (const line of message.content.split('\n')) {
|
||||
const command = line.match(/^\/(tool|describe-tool|reply-with|repeat) (.*)$/);
|
||||
if (!command) continue;
|
||||
const commandMatch = line.match(/^\/(tool|describe-tool|list-tools|reply-with|repeat)( .*)?$/);
|
||||
if (!commandMatch) continue;
|
||||
const command = commandMatch[1];
|
||||
const argument = (commandMatch[2] ?? '').replace(/^ /, '');
|
||||
|
||||
const isToolCall = command[1] === 'tool';
|
||||
const isDescribeToolRequest = command[1] === 'describe-tool';
|
||||
const isToolCall = command === 'tool';
|
||||
const isDescribeToolRequest = command === 'describe-tool';
|
||||
if (isToolCall || isDescribeToolRequest) {
|
||||
const args = command[2].split(' ');
|
||||
const args = argument.split(' ');
|
||||
const toolName = args[0];
|
||||
const tool = availableTools.find(t => t.id === toolName);
|
||||
if (!tool) {
|
||||
@@ -28,10 +30,12 @@ const parseUserCommand = (message: ChatStandardMessage, availableTools: ToolSpec
|
||||
parseError: null,
|
||||
});
|
||||
}
|
||||
} else if (command[1] === 'reply-with') {
|
||||
reply.push(command[2]);
|
||||
} else if (command[1] === 'repeat') {
|
||||
repeat = Number(command[2]);
|
||||
} else if (command === 'list-tools') {
|
||||
reply.push(JSON.stringify(availableTools));
|
||||
} else if (command === 'reply-with') {
|
||||
reply.push(argument);
|
||||
} else if (command === 'repeat') {
|
||||
repeat = Number(argument);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,8 +30,8 @@ const isValidEditOp = (operation: string): operation is EditOp['op'] => {
|
||||
|
||||
// Tools that interact with the editor
|
||||
export const isEditorToolCall = (toolId: string) => {
|
||||
if (!toolId.startsWith('editor.')) return false;
|
||||
toolId = toolId.replace(/^editor\./, '');
|
||||
if (!toolId.startsWith('editor_')) return false;
|
||||
toolId = toolId.replace(/^editor_/, '');
|
||||
return isValidEditOp(toolId) || toolId === 'readNoteBody';
|
||||
};
|
||||
|
||||
@@ -74,7 +74,7 @@ const buildEditorTools = ({ note, commands }: EditorToolContext) => {
|
||||
const result: ToolDefinition[] = [];
|
||||
const addSelectionOperations = () => {
|
||||
result.push(buildTool({
|
||||
id: 'editor.replaceSelection',
|
||||
id: 'editor_replaceSelection',
|
||||
userDescription: (_input: ToolInput, output: string) => output,
|
||||
description: 'Replaces the text currently selected by the user.',
|
||||
inputSchema: {
|
||||
@@ -83,7 +83,6 @@ const buildEditorTools = ({ note, commands }: EditorToolContext) => {
|
||||
text: { type: 'string' },
|
||||
},
|
||||
required: ['text'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
handler: async (args: ToolInput) => {
|
||||
if (!hasOwnProperty(args, 'text') || typeof args.text !== 'string') {
|
||||
@@ -103,7 +102,7 @@ const buildEditorTools = ({ note, commands }: EditorToolContext) => {
|
||||
|
||||
const addEditOperations = () => {
|
||||
const buildEditTool = (id: EditOp['op']) => ({
|
||||
id: `editor.${id}`,
|
||||
id: `editor_${id}`,
|
||||
userDescription: (args: ToolInput) => (
|
||||
describeEditOperation(toolCallToEditOperation(id, args))
|
||||
),
|
||||
@@ -135,7 +134,6 @@ const buildEditorTools = ({ note, commands }: EditorToolContext) => {
|
||||
text: { type: 'string' },
|
||||
},
|
||||
required: ['text'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -147,7 +145,6 @@ const buildEditorTools = ({ note, commands }: EditorToolContext) => {
|
||||
text: { type: 'string', description: textDescription },
|
||||
},
|
||||
required: ['anchor', 'text'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
result.push(
|
||||
{
|
||||
@@ -206,7 +203,7 @@ const buildEditorTools = ({ note, commands }: EditorToolContext) => {
|
||||
const addReadOperation = () => {
|
||||
result.push(
|
||||
{
|
||||
id: 'editor.readNoteBody',
|
||||
id: 'editor_readNoteBody',
|
||||
// Always enabled in the editor context:
|
||||
enabled: true,
|
||||
userDescription: () => _('Read note'),
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { ToolDefinition, ToolOutput } from '../types';
|
||||
|
||||
const buildTool = <OutputType extends ToolOutput> (spec: ToolDefinition<OutputType>) => {
|
||||
return spec as ToolDefinition<unknown>;
|
||||
return {
|
||||
...spec,
|
||||
inputSchema: {
|
||||
// Some providers (e.g. OpenAI) fail if additionalProperties is not false:
|
||||
additionalProperties: false,
|
||||
...spec.inputSchema,
|
||||
},
|
||||
} as ToolDefinition<unknown>;
|
||||
};
|
||||
|
||||
export default buildTool;
|
||||
Reference in new issue
Block a user