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

174 行
5.6 KiB
TypeScript

import MarkdownIt from 'markdown-it';
export interface OutlineSection {
path: string;
heading: string;
level: number;
// Character offsets into the body. The section starts on its heading line
// and ends at the next heading of equal or higher level, so subsections are
// always included.
start: number;
end: number;
}
export interface OutlineEntry {
path: string;
heading: string;
chars: number;
}
interface RawHeading {
level: number;
text: string;
start: number;
}
// Headings can contain newlines (multi-line setext headings) and tabs, both of
// which would break the line-based outline format. Collapsing whitespace is
// applied to the outline and to both sides of a section lookup, so a heading
// copied from the outline still resolves.
export const normalizeHeading = (heading: string) => heading.replace(/\s+/g, ' ').trim();
const lineOffsets = (body: string) => {
const offsets = [0];
for (let i = 0; i < body.length; i++) {
if (body[i] === '\n') offsets.push(i + 1);
}
return offsets;
};
const parseHeadings = (body: string) => {
const md = new MarkdownIt();
const tokens = md.parse(body, {});
const offsets = lineOffsets(body);
const headings: RawHeading[] = [];
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
if (token.type !== 'heading_open' || !token.map) continue;
const inline = tokens[i + 1];
headings.push({
level: Number(token.tag.substring(1)),
text: inline && inline.type === 'inline' ? normalizeHeading(inline.content) : '',
start: offsets[token.map[0]] ?? body.length,
});
}
return headings;
};
// Positional paths are built from the heading nesting, e.g. "1", "1.1",
// "1.2.1". Section "0" is reserved for the content before the first heading.
const buildPaths = (headings: RawHeading[]) => {
const stack: { level: number; counter: number }[] = [];
const siblingCounts = new Map<string, number>();
const paths: string[] = [];
for (const heading of headings) {
// Notes with irregular heading levels (a deep heading before a shallow
// one, or a skipped level) must still produce unique paths, so each
// heading is nested under the closest preceding shallower heading
// rather than under its literal level.
while (stack.length && stack[stack.length - 1].level >= heading.level) {
stack.pop();
}
stack.push({ level: heading.level, counter: 0 });
const parentPath = stack.slice(0, -1).map(s => s.counter).join('.');
const siblings = (siblingCounts.get(parentPath) ?? 0) + 1;
siblingCounts.set(parentPath, siblings);
stack[stack.length - 1].counter = siblings;
paths.push(stack.map(s => s.counter).join('.'));
}
return paths;
};
export const noteSections = (body: string): OutlineSection[] => {
const headings = parseHeadings(body);
const paths = buildPaths(headings);
const sections: OutlineSection[] = [];
const preambleEnd = headings.length ? headings[0].start : body.length;
sections.push({ path: '0', heading: '', level: 0, start: 0, end: preambleEnd });
for (let i = 0; i < headings.length; i++) {
let end = body.length;
for (let j = i + 1; j < headings.length; j++) {
if (headings[j].level <= headings[i].level) {
end = headings[j].start;
break;
}
}
sections.push({
path: paths[i],
heading: headings[i].text,
level: headings[i].level,
start: headings[i].start,
end,
});
}
return sections;
};
export const noteOutline = (body: string): OutlineEntry[] => {
const sections = noteSections(body);
// A note without headings is a single "0" section covering the whole body,
// which tells the model to fall back to offset / max_chars.
const entries = sections.length === 1 ? sections : sections.filter(s => s.path !== '0' || s.end > s.start);
return entries.map(s => ({
path: s.path,
heading: s.heading,
chars: s.end - s.start,
}));
};
// Rendered as text rather than JSON: notes with hundreds of headings would
// produce an outline larger than the body cap it is meant to replace, and text
// lets it be paged with the same character offsets as the body.
export const renderOutline = (body: string) => {
const lines = ['path\tchars\theading'];
for (const entry of noteOutline(body)) {
lines.push(`${entry.path}\t${entry.chars}\t${entry.heading}`);
}
return lines.join('\n');
};
export interface SectionResolution {
section: OutlineSection|null;
error: string|null;
}
const parentHeading = (sections: OutlineSection[], section: OutlineSection) => {
const index = sections.indexOf(section);
for (let i = index - 1; i >= 0; i--) {
if (sections[i].level < section.level && sections[i].path !== '0') return sections[i].heading;
}
return null;
};
export const resolveSection = (body: string, address: string): SectionResolution => {
const sections = noteSections(body);
const trimmed = normalizeHeading(address);
const byPath = sections.find(s => s.path === trimmed);
if (byPath) return { section: byPath, error: null };
if (/^[0-9]+(\.[0-9]+)*$/.test(trimmed)) {
return { section: null, error: `Unknown section path: "${trimmed}". Call with outline: true to list the available sections.` };
}
const matches = sections.filter(s => s.path !== '0' && s.heading.toLowerCase() === trimmed.toLowerCase());
if (matches.length === 1) return { section: matches[0], error: null };
if (matches.length > 1) {
const candidates = matches.map(s => {
const parent = parentHeading(sections, s);
return parent ? `${s.path} (under "${parent}")` : s.path;
}).join(', ');
return { section: null, error: `Several headings match "${trimmed}". Specify one of these paths instead: ${candidates}` };
}
return { section: null, error: `No section matches "${trimmed}". Call with outline: true to list the available sections.` };
};