文件
mynode/packages/lib/services/ai/tools/buildEditorTools.ts
chen 301194963b
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
feat: Initial clean release of CHM
2026-11-04 11:29:39 +08:00

235 行
7.6 KiB
TypeScript

import { hasOwnProperty } from '@joplin/utils/object';
import { _, _n } from '../../../locale';
import { NoteContext, ToolDefinition, ToolError, ToolInput } from './types';
import { EditorToolContext } from './types';
const Countable = require('../../../countable/Countable');
import buildTool from './utils/buildTool';
import Logger from '@joplin/utils/Logger';
import { applyAnchorEdits, supportedStructuredBlockTags } from './utils/applyNoteEdits';
import findFencedBlock from '../utils/findFencedBlock';
const logger = Logger.create('buildEditorTools');
export type EditOp =
| { op: 'replaceSelection'; text: string }
| { op: 'insertBefore'; anchor: string; text: string }
| { op: 'insertAfter'; anchor: string; text: string }
| { op: 'appendToNote'; text: string }
| { op: 'replaceRange'; anchor: string; text: string }
| { op: 'replaceFencedBlock'; tag: string; text: string }
;
const knownOps = new Set<EditOp['op']>([
'replaceSelection', 'insertBefore', 'insertAfter', 'appendToNote', 'replaceRange', 'replaceFencedBlock',
]);
// Tools that make changes to the current note body
const isValidEditOp = (operation: string): operation is EditOp['op'] => {
return knownOps.has(operation as EditOp['op']);
};
// Tools that interact with the editor
export const isEditorToolCall = (toolId: string) => {
if (!toolId.startsWith('editor_')) return false;
toolId = toolId.replace(/^editor_/, '');
return isValidEditOp(toolId) || toolId === 'readNoteBody';
};
const toolCallToEditOperation = (operationId: EditOp['op'], args: ToolInput): EditOp => {
return {
op: operationId,
...('text' in args && typeof args.text === 'string' ? { text: args.text } : {}),
...('anchor' in args && typeof args.anchor === 'string' ? { anchor: args.anchor } : {}),
...('tag' in args && typeof args.tag === 'string' ? { tag: args.tag } : {}),
} as EditOp;
};
const countWords = (text: string): number => Countable.countOnce(text, { stripTags: true }).words;
const describeEditOperation = (editOp: EditOp) => {
const describeWordsAdded = (count: number) => _n('Added %d word', 'Added %d words', count, count);
const describeWordsRemoved = (count: number) => _n('Removed %d word', 'Removed %d words', count, count);
let actionDescription;
if (editOp.op === 'replaceRange') {
actionDescription = [
describeWordsRemoved(countWords(editOp.anchor)),
describeWordsAdded(countWords(editOp.text)),
].join('\n');
} else if (editOp.op === 'replaceFencedBlock') {
actionDescription = _('Updated %s block', editOp.tag);
} else if (editOp.op === 'replaceSelection') {
actionDescription = _('Replaced selection');
} else {
actionDescription = describeWordsAdded(countWords(editOp.text));
}
return actionDescription;
};
const hasStructuredBlock = (note: NoteContext) => {
return supportedStructuredBlockTags.some(tag => !!findFencedBlock(note.body, tag, 0));
};
const buildEditorTools = ({ note, commands }: EditorToolContext) => {
const result: ToolDefinition[] = [];
const addSelectionOperations = () => {
result.push(buildTool({
id: 'editor_replaceSelection',
userDescription: (_input: ToolInput, output: string) => output,
description: 'Replaces the text currently selected by the user.',
inputSchema: {
type: 'object',
properties: {
text: { type: 'string' },
},
required: ['text'],
},
handler: async (args: ToolInput) => {
if (!hasOwnProperty(args, 'text') || typeof args.text !== 'string') {
throw new ToolError('missing or invalid `text` property');
} else {
try {
await commands.replaceSelection(args.text, note.selection);
} catch (error) {
logger.error(error.message ?? String(error));
throw new ToolError('failed to replace selection');
}
return describeEditOperation({ op: 'replaceSelection', text: args.text });
}
},
}));
};
const addEditOperations = () => {
const buildEditTool = (id: EditOp['op']) => ({
id: `editor_${id}`,
userDescription: (args: ToolInput) => (
describeEditOperation(toolCallToEditOperation(id, args))
),
handler: async (args: ToolInput) => {
const editOperation = toolCallToEditOperation(id, args);
const { newBody, appliedEdits } = applyAnchorEdits(note.body, [editOperation], 0);
await commands.updateNoteBody(newBody, note.body);
if (appliedEdits.length !== 1) {
throw new Error(`Invalid state: Wrong number of applied edits, ${appliedEdits.length}`);
}
const edit = appliedEdits[0];
if (edit.status === 'applied') {
return describeEditOperation(editOperation);
} else {
throw new ToolError(edit.status);
}
},
});
result.push(
{
...buildEditTool('appendToNote'),
description: 'Adds text to the end of the current note: Text is always added to the end of the note. This tool does not require an anchor.',
inputSchema: {
type: 'object',
properties: {
text: { type: 'string' },
},
required: ['text'],
},
},
);
const anchoredSchema = (anchorDescription: string, textDescription: string) => ({
type: 'object',
properties: {
anchor: { type: 'string', description: anchorDescription },
text: { type: 'string', description: textDescription },
},
required: ['anchor', 'text'],
});
result.push(
{
...buildEditTool('insertBefore'),
description: 'Add text before an `anchor` in the current note: Insert text immediately before the first occurrence of `anchor`.',
inputSchema: anchoredSchema(
'Text to find',
'What to insert just **before** the found text',
),
},
{
...buildEditTool('insertAfter'),
description: 'Add text after an `anchor` in the current note: Inserts text immediately after the first occurrence of `anchor` in the current note.',
inputSchema: anchoredSchema(
'Text to find',
'What to insert just **after** the found text',
),
},
{
...buildEditTool('replaceRange'),
description: 'Remove or replace text in the current note: Replaces the first occurrence of `anchor` with `text`.',
inputSchema: anchoredSchema(
'The text to replace',
'What to replace it with',
),
},
);
const hasFencedBlock = hasStructuredBlock(note);
if (hasFencedBlock) {
result.push({
...buildEditTool('replaceFencedBlock'),
description: [
'Replaces the inner content of the first ```<tag>``` fenced block in the current note.',
`"text" is the new content inside the fence (no fence markers). Supported tags: ${supportedStructuredBlockTags.join(', ')}.`,
'Use appendToNote to create a new fenced block.',
].join(' '),
inputSchema: {
type: 'object',
properties: {
tag: {
type: 'string',
enum: supportedStructuredBlockTags,
},
text: {
type: 'string',
description: 'The new content for the block content. Should not include the fenced block delimiters.',
},
},
required: ['tag', 'text'],
additionalProperties: false,
},
});
}
};
const addReadOperation = () => {
result.push(
{
id: 'editor_readNoteBody',
// Always enabled in the editor context:
enabled: true,
userDescription: () => _('Read note'),
description: 'Get the current, up-to-date content of the note. This returns the full content of the note that\'s currently open in Joplin\'s editor.',
inputSchema: {
type: 'object',
properties: {},
required: [],
additionalProperties: false,
},
handler: () => {
return Promise.resolve(note.body);
},
},
);
};
if (note.selection) {
addSelectionOperations();
} else {
addReadOperation();
addEditOperations();
}
return result;
};
export default buildEditorTools;