文件
mynode/packages/lib/services/ai/tools/utils/applyNoteEdits.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

143 行
4.5 KiB
TypeScript

import type { EditOp } from '../buildEditorTools';
import findFencedBlock from '../../utils/findFencedBlock';
export type ApplyEditStatus = 'applied' | 'anchor-not-found' | 'invalid';
// Structured-document fences where the model regenerates the whole block —
// plain code fences (```js, ```python) are deliberately excluded.
export const supportedStructuredBlockTags = ['jsoncanvas', 'mermaid', 'abc', 'fountain'];
export const structuredBlockTags = new Set<string>([...supportedStructuredBlockTags]);
export interface AppliedEdit {
op: EditOp;
status: ApplyEditStatus;
}
export interface EditApplicationResult {
newBody: string;
appliedEdits: AppliedEdit[];
}
const findAnchor = (body: string, anchor: string, cursorPos: number) => {
if (!anchor) return -1;
const first = body.indexOf(anchor);
if (first === -1) return -1;
// Prefer the occurrence closest to the cursor — matches user intent.
const second = body.indexOf(anchor, first + 1);
if (second === -1) return first;
let bestIndex = first;
let bestDistance = Math.abs(first - cursorPos);
let next = second;
while (next !== -1) {
const distance = Math.abs(next - cursorPos);
if (distance < bestDistance) {
bestDistance = distance;
bestIndex = next;
}
next = body.indexOf(anchor, next + 1);
}
return bestIndex;
};
const isValidEdit = (edit: EditOp, bodyLength: number): boolean => {
if (!edit || typeof edit !== 'object') return false;
if (typeof edit.op !== 'string') return false;
switch (edit.op) {
case 'appendToNote':
case 'replaceSelection':
return typeof edit.text === 'string';
case 'insertBefore':
case 'insertAfter':
return typeof edit.anchor === 'string' && edit.anchor.length > 0 && typeof edit.text === 'string';
case 'replaceRange':
if (typeof edit.anchor !== 'string' || !edit.anchor.length) return false;
if (typeof edit.text !== 'string') return false;
// Anchor >50% of body is almost certainly a mis-pick — would nuke it.
if (edit.anchor.length > bodyLength * 0.5) return false;
return true;
case 'replaceFencedBlock':
if (typeof edit.tag !== 'string' || !structuredBlockTags.has(edit.tag)) return false;
if (typeof edit.text !== 'string') return false;
return true;
default:
return false;
}
};
// Edits apply sequentially against the running newBody — a later anchor
// targeting text removed by an earlier edit won't be found. replaceSelection
// is body-independent and handled by the caller via the editor command.
export const applyAnchorEdits = (
body: string,
edits: EditOp[],
cursorPos: number,
): EditApplicationResult => {
let newBody = body;
const appliedEdits: AppliedEdit[] = [];
for (const edit of edits) {
if (!isValidEdit(edit, newBody.length)) {
appliedEdits.push({ op: edit, status: 'invalid' });
continue;
}
if (edit.op === 'replaceSelection') {
// Caller applies this via the editor; mark as done so it isn't retried.
appliedEdits.push({ op: edit, status: 'applied' });
continue;
}
if (edit.op === 'appendToNote') {
// Markdown paragraph breaks need a blank line.
let sep = '';
if (newBody.length) {
if (newBody.endsWith('\n\n')) sep = '';
else if (newBody.endsWith('\n')) sep = '\n';
else sep = '\n\n';
}
newBody = `${newBody}${sep}${edit.text}`;
appliedEdits.push({ op: edit, status: 'applied' });
continue;
}
if (edit.op === 'replaceFencedBlock') {
// Replace-only — creating a missing block is appendToNote's job.
const match = findFencedBlock(newBody, edit.tag, cursorPos);
if (!match) {
appliedEdits.push({ op: edit, status: 'anchor-not-found' });
continue;
}
const start = match.index;
const end = start + match[0].length;
const inner = edit.text.endsWith('\n') ? edit.text : `${edit.text}\n`;
newBody = `${newBody.slice(0, start)}\`\`\`${edit.tag}\n${inner}\`\`\`${newBody.slice(end)}`;
appliedEdits.push({ op: edit, status: 'applied' });
continue;
}
const index = findAnchor(newBody, edit.anchor, cursorPos);
if (index === -1) {
appliedEdits.push({ op: edit, status: 'anchor-not-found' });
continue;
}
if (edit.op === 'insertBefore') {
newBody = `${newBody.slice(0, index)}${edit.text}${newBody.slice(index)}`;
} else if (edit.op === 'insertAfter') {
const insertAt = index + edit.anchor.length;
newBody = `${newBody.slice(0, insertAt)}${edit.text}${newBody.slice(insertAt)}`;
} else if (edit.op === 'replaceRange') {
newBody = `${newBody.slice(0, index)}${edit.text}${newBody.slice(index + edit.anchor.length)}`;
}
appliedEdits.push({ op: edit, status: 'applied' });
}
return { newBody, appliedEdits };
};