feat: Initial clean release of CHM
一些检查失败了
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
一些检查失败了
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
这个提交包含在:
@@ -0,0 +1,234 @@
|
||||
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;
|
||||
在新工单中引用
屏蔽一个用户