mirror of
https://github.com/kdnk/obsidian-automatic-linker.git
synced 2026-07-22 05:37:46 +00:00
feat: add AI Link Enhancer for resolving ambiguous links
- Implemented AI-powered link disambiguation using local LLMs (Gemma 4 / LM Studio). - Refactored Trie and candidate mapping to support multiple link candidates per word. - Added 'AI Link Enhancer' command with a progress bar UI in Obsidian. - Enhanced link replacement logic to verify existing links and resolve ambiguities using context. - Added comprehensive unit tests for AI disambiguation and candidate mapping. - Updated settings to include AI configuration (endpoint, model, context length).
This commit is contained in:
parent
7855027117
commit
706bbf91ef
13 changed files with 707 additions and 80 deletions
56
PLAN_AI_DISAMBIGUATION.md
Normal file
56
PLAN_AI_DISAMBIGUATION.md
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
# プラン: Gemma 4 による「あいまいさ解消(Disambiguation)」機能の実装 (2-Pass 方式)
|
||||
|
||||
このプランは、Obsidian Automatic Linker において、Gemma 4 (Local LLM / LM Studio) を用いて文脈に最適なリンク先を選択、および既存の誤ったリンクを修正する機能を導入するものです。
|
||||
|
||||
## 1. 現状の分析と課題
|
||||
- **データ構造の制限**: `src/trie.ts` の `CandidateData` インターフェースが単一の `canonical` パスしか保持できない。
|
||||
- **重複登録の挙動**: `buildCandidateTrie` 関数において、同じ名前の単語やエイリアスが見つかった場合、最初に見つかったものが優先されるか、後から来たもので上書きされている。
|
||||
- **既存リンクの誤り**: 機械的な処理や手動で作成された不適切な `[[Note]]` リンクの存在。
|
||||
- **パフォーマンス**: AI 処理は低速なため、コマンドによる明示的な実行と、進捗バー付きの UI フィードバックが必要。
|
||||
|
||||
## 2. 実装フェーズ
|
||||
|
||||
### フェーズ 1: データ構造の拡張 (Data Structure)
|
||||
- [x] `CandidateData` インターフェースの修正 (`src/trie.ts`)
|
||||
- `canonical`, `scoped`, `namespace` を持つオブジェクトの配列を保持できるように変更。
|
||||
- [x] `buildCandidateTrie` の修正
|
||||
- 重複する名前やエイリアスがある場合、既存の候補リストに `push` するように変更。
|
||||
- [x] **完了条件**:
|
||||
- `src/trie.ts` の既存テストおよび新規追加テスト(複数候補の保持)がパスすること。
|
||||
- `pnpm lint` および `pnpm tsc` (タイプチェック) でエラーがないこと。
|
||||
|
||||
### フェーズ 2: AI 連携クライアントと「解決ロジック」の実装 (AI Integration)
|
||||
- [x] `src/utils/ai-client.ts` の新規作成
|
||||
- OpenAI 互換 API へのリクエスト処理とプロンプト設計。
|
||||
- [x] 非同期スキャン関数 `resolveAmbiguities` の実装
|
||||
- 1. 新規リンク候補(複数候補あり)の特定。
|
||||
- 2. 既存リンクの再検証(別のより良い候補がないか)の特定。
|
||||
- [x] **完了条件**:
|
||||
- モックを使用した `resolveAmbiguities` のテストがパスすること。
|
||||
- `pnpm lint` および `pnpm tsc` でエラーがないこと。
|
||||
|
||||
### フェーズ 3: リンク置換エンジンの拡張 (Sync Logic Enhancement)
|
||||
- [x] `src/replace-links/replace-links.ts` の `replaceLinks` 引数の拡張
|
||||
- `resolvedAmbiguities?: Map<string, string>` を受け取り、優先的に適用する。
|
||||
- [x] 既存リンクの「張り替え」ロジックの追加。
|
||||
- [x] **完了条件**:
|
||||
- `replace-links.test.ts` に AI 解決マップを使用したテストケースを追加し、パスすること。
|
||||
- `pnpm lint` および `pnpm tsc` でエラーがないこと。
|
||||
|
||||
### フェーズ 4: UI 実装とコマンドの追加 (UI & Integration)
|
||||
- [x] `Automatic Linker: Run AI Link Enhancer` コマンドの実装。
|
||||
- [x] 進捗バー付き `Notice` による UI フィードバックの実装。
|
||||
- [x] `src/settings/settings.ts` への AI 設定追加。
|
||||
- [x] **完了条件**:
|
||||
- Obsidian 上での実機動作確認(Notice の表示、リンクの修正・生成)。
|
||||
- 全体のビルド (`pnpm build`) が成功すること。
|
||||
|
||||
## 3. 共通の品質基準 (Definition of Done)
|
||||
- 各フェーズの最後には必ず以下のコマンドを実行し、エラーがないことを確認する。
|
||||
1. `pnpm test` (または `vitest`)
|
||||
2. `pnpm lint`
|
||||
3. `pnpm tsc`
|
||||
|
||||
## 4. リスクと対策
|
||||
- **エディタの不整合**: AI 処理開始時のテキストのスナップショットを保持し、置換時に大幅な変更があれば警告を出す。
|
||||
- **トークン制限**: 段落単位での分割処理により、長いノートにも対応。
|
||||
6
src/__mocks__/obsidian.ts
Normal file
6
src/__mocks__/obsidian.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export const request = async () => ({});
|
||||
export class Notice {
|
||||
constructor() {}
|
||||
hide() {}
|
||||
setMessage() {}
|
||||
}
|
||||
71
src/main.ts
71
src/main.ts
|
|
@ -37,6 +37,7 @@ import {
|
|||
} from "./settings/settings-info"
|
||||
import { buildCandidateTrie, CandidateData, TrieNode } from "./trie"
|
||||
import { updateEditor } from "./update-editor"
|
||||
import { resolveAmbiguities } from "./utils/resolve-ambiguities"
|
||||
|
||||
const sleep = (ms: number) => {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
|
|
@ -480,6 +481,76 @@ export default class AutomaticLinkerPlugin extends Plugin {
|
|||
},
|
||||
})
|
||||
|
||||
this.addCommand({
|
||||
id: "ai-link-enhancer",
|
||||
name: "Run AI Link Enhancer",
|
||||
icon: "sparkles",
|
||||
editorCallback: async (editor: Editor) => {
|
||||
if (!this.settings.aiEnabled) {
|
||||
new Notice("AI Link Enhancement is not enabled in settings.")
|
||||
return
|
||||
}
|
||||
|
||||
const activeFile = this.app.workspace.getActiveFile()
|
||||
if (!activeFile) return
|
||||
|
||||
const noticeFragment = document.createDocumentFragment()
|
||||
const container = noticeFragment.createEl("div")
|
||||
container.createEl("div", { text: "AI Link Enhancer: Analyzing context..." })
|
||||
const progress = container.createEl("progress")
|
||||
progress.setAttr("style", "width: 100%; height: 10px;")
|
||||
const notice = new Notice(noticeFragment, 0)
|
||||
|
||||
try {
|
||||
const fileContent = await this.app.vault.read(activeFile)
|
||||
const { contentStart } = getFrontMatterInfo(fileContent)
|
||||
const body = fileContent.slice(contentStart)
|
||||
|
||||
if (!this.candidateMap || !this.trie) {
|
||||
this.refreshFileDataAndTrie()
|
||||
}
|
||||
|
||||
if (!this.candidateMap || !this.trie) {
|
||||
new Notice("Failed to build index.")
|
||||
return
|
||||
}
|
||||
|
||||
const resolvedAmbiguitiesResult = await resolveAmbiguities(
|
||||
body,
|
||||
this.candidateMap,
|
||||
this.trie,
|
||||
this.settings,
|
||||
)
|
||||
|
||||
const resultBody = replaceLinks({
|
||||
body,
|
||||
linkResolverContext: {
|
||||
filePath: activeFile.path,
|
||||
trie: this.trie,
|
||||
candidateMap: this.candidateMap,
|
||||
},
|
||||
settings: this.settings,
|
||||
resolvedAmbiguities: resolvedAmbiguitiesResult,
|
||||
})
|
||||
|
||||
if (body !== resultBody) {
|
||||
updateEditor(body, resultBody, editor)
|
||||
new Notice("AI Link Enhancement completed.")
|
||||
}
|
||||
else {
|
||||
new Notice("No links to enhance.")
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error("AI Link Enhancer error:", error)
|
||||
new Notice("AI Link Enhancement failed. Check console for details.")
|
||||
}
|
||||
finally {
|
||||
notice.hide()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Optionally, override the default save command to run modifyLinks (throttled).
|
||||
const saveCommandDefinition = this.app?.commands?.commands?.["editor:save-file"]
|
||||
const saveCallback = saveCommandDefinition?.checkCallback
|
||||
|
|
|
|||
56
src/replace-links/__tests__/ai-disambiguation.test.ts
Normal file
56
src/replace-links/__tests__/ai-disambiguation.test.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { describe, it, expect } from "vitest"
|
||||
import { replaceLinks } from "../replace-links"
|
||||
import { buildCandidateTrie } from "../../trie"
|
||||
|
||||
describe("replaceLinks with AI disambiguation", () => {
|
||||
const allFiles = [
|
||||
{ path: "work/meeting", scoped: false, aliases: [] },
|
||||
{ path: "private/meeting", scoped: false, aliases: [] },
|
||||
]
|
||||
const { candidateMap, trie } = buildCandidateTrie(allFiles, undefined, true)
|
||||
const context = {
|
||||
filePath: "test.md",
|
||||
trie,
|
||||
candidateMap,
|
||||
}
|
||||
|
||||
it("should use the AI-resolved path for unlinked words", () => {
|
||||
const body = "I have a meeting."
|
||||
const resolvedAmbiguities = new Map([["meeting", "work/meeting"]])
|
||||
|
||||
const result = replaceLinks({
|
||||
body,
|
||||
linkResolverContext: context,
|
||||
resolvedAmbiguities,
|
||||
})
|
||||
|
||||
expect(result).toBe("I have a [[work/meeting|meeting]].")
|
||||
})
|
||||
|
||||
it("should correct existing links if resolvedAmbiguities contains them", () => {
|
||||
const body = "Check [[private/meeting|meeting]] notes."
|
||||
const resolvedAmbiguities = new Map([["[[private/meeting|meeting]]", "work/meeting"]])
|
||||
|
||||
const result = replaceLinks({
|
||||
body,
|
||||
linkResolverContext: context,
|
||||
resolvedAmbiguities,
|
||||
})
|
||||
|
||||
expect(result).toBe("Check [[work/meeting|meeting]] notes.")
|
||||
})
|
||||
|
||||
it("should handle existing links without alias for correction", () => {
|
||||
const body = "Check [[private/meeting]] notes."
|
||||
// The resolveAmbiguities scanner uses the full link as key
|
||||
const resolvedAmbiguities = new Map([["[[private/meeting]]", "work/meeting"]])
|
||||
|
||||
const result = replaceLinks({
|
||||
body,
|
||||
linkResolverContext: context,
|
||||
resolvedAmbiguities,
|
||||
})
|
||||
|
||||
expect(result).toBe("Check [[work/meeting|private/meeting]] notes.")
|
||||
})
|
||||
})
|
||||
|
|
@ -1046,34 +1046,42 @@ describe("replaceLinks (manual candidateMap/trie)", () => {
|
|||
[
|
||||
"x",
|
||||
{
|
||||
candidates: [{
|
||||
canonical: "namespace/x",
|
||||
scoped: true,
|
||||
namespace: "namespace",
|
||||
}],
|
||||
},
|
||||
],
|
||||
[
|
||||
"z",
|
||||
{
|
||||
candidates: [{
|
||||
canonical: "namespace/y/z",
|
||||
scoped: true,
|
||||
namespace: "namespace",
|
||||
}],
|
||||
},
|
||||
],
|
||||
[
|
||||
"root",
|
||||
{
|
||||
candidates: [{
|
||||
canonical: "root-note",
|
||||
scoped: true,
|
||||
namespace: "",
|
||||
}],
|
||||
},
|
||||
],
|
||||
// Candidate without namespace restriction.
|
||||
[
|
||||
"free",
|
||||
{
|
||||
candidates: [{
|
||||
canonical: "free-note",
|
||||
scoped: false,
|
||||
namespace: "other",
|
||||
}],
|
||||
},
|
||||
],
|
||||
// For alias testing:
|
||||
|
|
@ -1081,71 +1089,87 @@ describe("replaceLinks (manual candidateMap/trie)", () => {
|
|||
[
|
||||
"pages/HelloWorld",
|
||||
{
|
||||
candidates: [{
|
||||
canonical: "pages/HelloWorld",
|
||||
scoped: false,
|
||||
namespace: "pages",
|
||||
}],
|
||||
},
|
||||
],
|
||||
// Alias "Hello" is different from the shorthand, so canonical becomes "pages/HelloWorld|Hello".
|
||||
[
|
||||
"Hello",
|
||||
{
|
||||
candidates: [{
|
||||
canonical: "pages/HelloWorld|Hello",
|
||||
scoped: false,
|
||||
namespace: "pages",
|
||||
}],
|
||||
},
|
||||
],
|
||||
// Also register the shorthand candidate.
|
||||
[
|
||||
"HelloWorld",
|
||||
{
|
||||
candidates: [{
|
||||
canonical: "HelloWorld",
|
||||
scoped: false,
|
||||
namespace: "pages",
|
||||
}],
|
||||
},
|
||||
],
|
||||
// For "tags" test: candidate key "tags" should map to canonical "tags"
|
||||
[
|
||||
"pages/tags",
|
||||
{
|
||||
candidates: [{
|
||||
canonical: "pages/tags",
|
||||
scoped: false,
|
||||
namespace: "pages",
|
||||
}],
|
||||
},
|
||||
],
|
||||
[
|
||||
"tags",
|
||||
{
|
||||
candidates: [{
|
||||
canonical: "tags",
|
||||
scoped: false,
|
||||
namespace: "pages",
|
||||
}],
|
||||
},
|
||||
],
|
||||
// For Korean test, add candidate "문서"
|
||||
[
|
||||
"문서",
|
||||
{
|
||||
candidates: [{
|
||||
canonical: "문서",
|
||||
scoped: false,
|
||||
namespace: "namespace",
|
||||
}],
|
||||
},
|
||||
],
|
||||
// For Japanese test, add candidate "ひらがな"
|
||||
[
|
||||
"ひらがな",
|
||||
{
|
||||
candidates: [{
|
||||
canonical: "ひらがな",
|
||||
scoped: false,
|
||||
namespace: "namespace",
|
||||
}],
|
||||
},
|
||||
],
|
||||
// For Chinese test, add candidate "文档"
|
||||
[
|
||||
"文档",
|
||||
{
|
||||
candidates: [{
|
||||
canonical: "文档",
|
||||
scoped: false,
|
||||
namespace: "namespace",
|
||||
}],
|
||||
},
|
||||
],
|
||||
])
|
||||
|
|
@ -1266,9 +1290,11 @@ describe("replaceLinks (manual candidateMap/trie)", () => {
|
|||
// Add candidate "a" corresponding to a file at "pages/set/a"
|
||||
// with scoped enabled and an effective namespace of "set".
|
||||
candidateMap.set("a", {
|
||||
candidates: [{
|
||||
canonical: "set/a",
|
||||
scoped: true,
|
||||
namespace: "set",
|
||||
}],
|
||||
})
|
||||
const trie = buildTrie(Array.from(candidateMap.keys()))
|
||||
|
||||
|
|
|
|||
|
|
@ -32,11 +32,12 @@ export interface ReplaceLinksOptions {
|
|||
linkResolverContext: LinkResolverContext
|
||||
settings?: ReplaceLinksSettings
|
||||
linkGenerator?: LinkGenerator
|
||||
resolvedAmbiguities?: Map<string, string>
|
||||
}
|
||||
|
||||
// Constants and Regular Expressions
|
||||
const REGEX_PATTERNS = {
|
||||
PROTECTED: /(```[\s\S]*?```|`[^`]*`|\[\[[^\]]+\]\]|\[[^\]]+\]\([^)]+\)|\[[^\]]+\]|https?:\/\/[^\s]+)/g,
|
||||
PROTECTED: /(```[\s\S]*?```|`[^`]*`|\[\[([^\]]+)\]\]|\[[^\]]+\]\([^)]+\)|\[[^\]]+\]|https?:\/\/[^\s]+)/g,
|
||||
DATE_FORMAT: /^\d{4}-\d{2}-\d{2}$/,
|
||||
MONTH_NOTE: /^[0-9]{1,2}$/,
|
||||
CJK: /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u,
|
||||
|
|
@ -169,12 +170,12 @@ const isSelfLink = (
|
|||
currentFilePath: string,
|
||||
settings: ReplaceLinksSettings = {},
|
||||
): boolean => {
|
||||
if (!settings.preventSelfLinking) {
|
||||
if (!settings.preventSelfLinking || candidateData.candidates.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Extract the link path from the canonical path
|
||||
const { linkPath } = extractLinkParts(candidateData.canonical)
|
||||
const { linkPath } = extractLinkParts(candidateData.candidates[0].canonical)
|
||||
|
||||
// Normalize paths for comparison
|
||||
const normalizedLinkPath = normalizeCanonicalPath(
|
||||
|
|
@ -220,8 +221,11 @@ const createLinkContent = (
|
|||
originalMatchedText: string,
|
||||
settings: ReplaceLinksSettings = {},
|
||||
): { linkPath: string, alias?: string } => {
|
||||
if (candidateData.candidates.length === 0) {
|
||||
return { linkPath: originalMatchedText }
|
||||
}
|
||||
const { linkPath, alias, hasAlias } = extractLinkParts(
|
||||
candidateData.canonical,
|
||||
candidateData.candidates[0].canonical,
|
||||
)
|
||||
const normalizedPath = normalizeCanonicalPath(linkPath, settings.baseDir)
|
||||
|
||||
|
|
@ -365,6 +369,7 @@ const processCjkText = (
|
|||
filePath: string,
|
||||
linkGenerator: LinkGenerator,
|
||||
settings: ReplaceLinksSettings = {},
|
||||
resolvedAmbiguities?: Map<string, string>,
|
||||
): string => {
|
||||
// For CJK texts that might contain non-CJK terms like "taro-san", ensure we use a consistent approach
|
||||
// Pass the proper filePath to maintain correct namespace resolution
|
||||
|
|
@ -377,6 +382,7 @@ const processCjkText = (
|
|||
currentNamespace,
|
||||
linkGenerator,
|
||||
settings,
|
||||
resolvedAmbiguities,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -459,7 +465,11 @@ const processFallbackSearch = (
|
|||
|
||||
// Filter candidates based on namespace restrictions
|
||||
const filteredCandidates = longestMatch.candidateList.filter(
|
||||
([, data]) => !(data.scoped && data.namespace !== currentNamespace),
|
||||
([, data]) => {
|
||||
if (data.candidates.length === 0) return true
|
||||
const candidate = data.candidates[0]
|
||||
return !(candidate.scoped && candidate.namespace !== currentNamespace)
|
||||
},
|
||||
)
|
||||
|
||||
let bestCandidateData: CandidateData | null = null
|
||||
|
|
@ -649,6 +659,7 @@ const processStandardText = (
|
|||
currentNamespace: string,
|
||||
linkGenerator: LinkGenerator,
|
||||
settings: ReplaceLinksSettings = {},
|
||||
resolvedAmbiguities?: Map<string, string>,
|
||||
): string => {
|
||||
let result = ""
|
||||
let i = 0
|
||||
|
|
@ -787,8 +798,9 @@ const processStandardText = (
|
|||
// Skip if namespace restriction applies
|
||||
if (
|
||||
settings.proximityBasedLinking
|
||||
&& candidateData.scoped
|
||||
&& candidateData.namespace !== currentNamespace
|
||||
&& candidateData.candidates.length > 0
|
||||
&& candidateData.candidates[0].scoped
|
||||
&& candidateData.candidates[0].namespace !== currentNamespace
|
||||
) {
|
||||
result += candidate
|
||||
i += candidate.length
|
||||
|
|
@ -796,11 +808,25 @@ const processStandardText = (
|
|||
}
|
||||
|
||||
// Create the link
|
||||
const { linkPath, alias } = createLinkContent(
|
||||
let linkPath = ""
|
||||
let alias: string | undefined
|
||||
|
||||
if (resolvedAmbiguities?.has(candidate)) {
|
||||
const resolvedPath = resolvedAmbiguities.get(candidate)!
|
||||
const parts = extractLinkParts(resolvedPath)
|
||||
linkPath = parts.linkPath
|
||||
alias = parts.alias || candidate
|
||||
}
|
||||
else {
|
||||
const content = createLinkContent(
|
||||
candidateData,
|
||||
originalMatchedText,
|
||||
candidate,
|
||||
settings,
|
||||
)
|
||||
linkPath = content.linkPath
|
||||
alias = content.alias
|
||||
}
|
||||
|
||||
const isInTable = isIndexInsideMarkdownTable(text, i)
|
||||
const finalLink = linkGenerator({
|
||||
linkPath,
|
||||
|
|
@ -851,6 +877,7 @@ export const replaceLinks = ({
|
|||
ignoreDateFormats: true,
|
||||
},
|
||||
linkGenerator = defaultLinkGenerator,
|
||||
resolvedAmbiguities,
|
||||
}: ReplaceLinksOptions): string => {
|
||||
// Normalize the body text to NFC
|
||||
body = body.normalize("NFC")
|
||||
|
|
@ -880,6 +907,7 @@ export const replaceLinks = ({
|
|||
filePath,
|
||||
linkGenerator,
|
||||
settings,
|
||||
resolvedAmbiguities,
|
||||
)
|
||||
}
|
||||
else {
|
||||
|
|
@ -892,6 +920,7 @@ export const replaceLinks = ({
|
|||
currentNamespace,
|
||||
linkGenerator,
|
||||
settings,
|
||||
resolvedAmbiguities,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -939,12 +968,39 @@ export const replaceLinks = ({
|
|||
const mIndex = match.index
|
||||
const segment = bodyWithPlaceholders.slice(lastIndex, mIndex)
|
||||
resultBody += processTextSegment(segment)
|
||||
|
||||
const fullMatch = match[0]
|
||||
if (resolvedAmbiguities?.has(fullMatch)) {
|
||||
// Existing link replacement
|
||||
const resolvedPath = resolvedAmbiguities.get(fullMatch)!
|
||||
const { linkPath, alias: resolvedAlias } = extractLinkParts(resolvedPath)
|
||||
|
||||
// Try to extract existing alias from the matched link
|
||||
const existingLinkRegex = /\[\[([^|\]]+)(?:\|([^\]]+))?\]\]/
|
||||
const linkMatch = fullMatch.match(existingLinkRegex)
|
||||
const existingPath = linkMatch ? linkMatch[1] : ""
|
||||
const existingAlias = linkMatch ? linkMatch[2] : undefined
|
||||
|
||||
// Use resolved alias if present, otherwise use existing alias,
|
||||
// otherwise use existing path (as alias if it was a simple link)
|
||||
const finalAlias = resolvedAlias || existingAlias || (fullMatch.includes("|") ? undefined : existingPath)
|
||||
|
||||
const isInTable = isIndexInsideMarkdownTable(bodyWithPlaceholders, mIndex)
|
||||
resultBody += linkGenerator({
|
||||
linkPath,
|
||||
sourcePath: filePath,
|
||||
alias: finalAlias,
|
||||
isInTable,
|
||||
})
|
||||
}
|
||||
else {
|
||||
// Append the protected segment unchanged
|
||||
resultBody += match[0]
|
||||
lastIndex = mIndex + match[0].length
|
||||
resultBody += fullMatch
|
||||
}
|
||||
lastIndex = mIndex + fullMatch.length
|
||||
|
||||
// Prevent infinite loop on zero-length matches
|
||||
if (match[0].length === 0) {
|
||||
if (fullMatch.length === 0) {
|
||||
REGEX_PATTERNS.PROTECTED.lastIndex++
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ export type AutomaticLinkerSettings = {
|
|||
runLinterAfterFormatting: boolean // Run Obsidian Linter after automatic linker formatting
|
||||
runPrettierAfterFormatting: boolean // Run Prettier after automatic linker formatting
|
||||
formatDelayMs: number // Delay in milliseconds before running linter after formatting
|
||||
aiEnabled: boolean // Enable AI link enhancement
|
||||
aiEndpoint: string // API endpoint for AI (OpenAI compatible)
|
||||
aiModel: string // Model name for AI
|
||||
aiMaxContext: number // Maximum context length for AI
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: AutomaticLinkerSettings = {
|
||||
|
|
@ -48,4 +52,8 @@ export const DEFAULT_SETTINGS: AutomaticLinkerSettings = {
|
|||
runLinterAfterFormatting: false, // Default: do not run linter after formatting
|
||||
runPrettierAfterFormatting: false, // Run Prettier after automatic linker formatting
|
||||
formatDelayMs: 1, // Default: 1ms delay before running linter
|
||||
aiEnabled: false, // Default: disable AI enhancement
|
||||
aiEndpoint: "http://localhost:1234/v1", // Default: LM Studio
|
||||
aiModel: "gemma-4-7b", // Default: Gemma 4
|
||||
aiMaxContext: 500, // Default: 500 characters
|
||||
}
|
||||
|
|
|
|||
|
|
@ -431,5 +431,62 @@ export class AutomaticLinkerPluginSettingsTab extends PluginSettingTab {
|
|||
await this.plugin.saveData(this.plugin.settings)
|
||||
})
|
||||
})
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("AI Link Enhancement (Beta)")
|
||||
.setHeading()
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Enable AI Link Enhancement")
|
||||
.setDesc(
|
||||
"When enabled, an AI-powered link enhancer command will be available. It uses a local LLM to resolve ambiguous links and correct existing ones.",
|
||||
)
|
||||
.addToggle((toggle) => {
|
||||
toggle
|
||||
.setValue(this.plugin.settings.aiEnabled)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.aiEnabled = value
|
||||
await this.plugin.saveData(this.plugin.settings)
|
||||
})
|
||||
})
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("AI API Endpoint")
|
||||
.setDesc("The URL of your OpenAI-compatible AI server (e.g. LM Studio, Ollama).")
|
||||
.addText((text) => {
|
||||
text.setPlaceholder("http://localhost:1234/v1")
|
||||
.setValue(this.plugin.settings.aiEndpoint)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.aiEndpoint = value
|
||||
await this.plugin.saveData(this.plugin.settings)
|
||||
})
|
||||
})
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("AI Model")
|
||||
.setDesc("The name of the model to use (e.g. gemma-4-7b).")
|
||||
.addText((text) => {
|
||||
text.setPlaceholder("gemma-4-7b")
|
||||
.setValue(this.plugin.settings.aiModel)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.aiModel = value
|
||||
await this.plugin.saveData(this.plugin.settings)
|
||||
})
|
||||
})
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Max Context Length")
|
||||
.setDesc("Number of characters around the link to provide as context to the AI.")
|
||||
.addText((text) => {
|
||||
text.setPlaceholder("500")
|
||||
.setValue(this.plugin.settings.aiMaxContext.toString())
|
||||
.onChange(async (value) => {
|
||||
const parsedValue = parseInt(value)
|
||||
if (!isNaN(parsedValue) && parsedValue > 0) {
|
||||
this.plugin.settings.aiMaxContext = parsedValue
|
||||
await this.plugin.saveData(this.plugin.settings)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
74
src/trie.ts
74
src/trie.ts
|
|
@ -63,12 +63,16 @@ export const buildTrie = (words: string[], ignoreCase = false): TrieNode => {
|
|||
}
|
||||
|
||||
// CandidateData holds the canonical replacement string as well as namespace‐設定
|
||||
export interface CandidateData {
|
||||
export interface CandidateItem {
|
||||
canonical: string
|
||||
scoped: boolean
|
||||
namespace: string
|
||||
}
|
||||
|
||||
export interface CandidateData {
|
||||
candidates: CandidateItem[]
|
||||
}
|
||||
|
||||
export const buildCandidateTrie = (
|
||||
allFiles: PathAndAliases[],
|
||||
baseDir: string | undefined,
|
||||
|
|
@ -104,10 +108,23 @@ export const buildCandidateTrie = (
|
|||
// Build a mapping from candidate string to its CandidateData.
|
||||
const candidateMap = new Map<string, CandidateData>()
|
||||
|
||||
const addCandidate = (key: string, item: CandidateItem) => {
|
||||
const existing = candidateMap.get(key)
|
||||
if (existing) {
|
||||
// Check if this canonical path is already added to avoid duplicates
|
||||
if (!existing.candidates.some(c => c.canonical === item.canonical)) {
|
||||
existing.candidates.push(item)
|
||||
}
|
||||
}
|
||||
else {
|
||||
candidateMap.set(key, { candidates: [item] })
|
||||
}
|
||||
}
|
||||
|
||||
// Register normal candidates.
|
||||
for (const { full, short, scoped, namespace } of candidates) {
|
||||
// Register the full path and its case-insensitive variant if needed
|
||||
candidateMap.set(full, {
|
||||
// Register the full path
|
||||
addCandidate(full, {
|
||||
canonical: full,
|
||||
scoped,
|
||||
namespace,
|
||||
|
|
@ -124,25 +141,21 @@ export const buildCandidateTrie = (
|
|||
lastSegment,
|
||||
)
|
||||
) {
|
||||
// Register both the original case and lowercase versions
|
||||
candidateMap.set(lastSegment, {
|
||||
const item = {
|
||||
canonical: full,
|
||||
scoped,
|
||||
namespace,
|
||||
})
|
||||
}
|
||||
addCandidate(lastSegment, item)
|
||||
if (ignoreCase) {
|
||||
candidateMap.set(lastSegment.toLowerCase(), {
|
||||
canonical: full,
|
||||
scoped,
|
||||
namespace,
|
||||
})
|
||||
addCandidate(lastSegment.toLowerCase(), item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register the short path if available
|
||||
if (short) {
|
||||
candidateMap.set(short, {
|
||||
addCandidate(short, {
|
||||
canonical: full,
|
||||
scoped,
|
||||
namespace,
|
||||
|
|
@ -162,20 +175,15 @@ export const buildCandidateTrie = (
|
|||
// If alias equals the shorthand, use alias as canonical; otherwise use "full|alias".
|
||||
const canonicalForAlias
|
||||
= short && alias === short ? alias : `${file.path}|${alias}`
|
||||
if (!candidateMap.has(alias)) {
|
||||
candidateMap.set(alias, {
|
||||
const item = {
|
||||
canonical: canonicalForAlias,
|
||||
scoped: file.scoped,
|
||||
namespace: getTopLevelDirectoryName(file.path, baseDir),
|
||||
})
|
||||
}
|
||||
addCandidate(alias, item)
|
||||
// Register lowercase version when ignoreCase is enabled
|
||||
if (ignoreCase && !candidateMap.has(alias.toLowerCase())) {
|
||||
candidateMap.set(alias.toLowerCase(), {
|
||||
canonical: canonicalForAlias,
|
||||
scoped: file.scoped,
|
||||
namespace: getTopLevelDirectoryName(file.path, baseDir),
|
||||
})
|
||||
if (ignoreCase) {
|
||||
addCandidate(alias.toLowerCase(), item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -246,11 +254,33 @@ if (import.meta.vitest) {
|
|||
|
||||
expect(candidateMap.has("pages/docs/readme")).toBe(true)
|
||||
expect(candidateMap.has("docs/readme")).toBe(true)
|
||||
expect(candidateMap.get("intro")?.canonical).toBe(
|
||||
expect(candidateMap.get("intro")?.candidates[0].canonical).toBe(
|
||||
"pages/docs/readme|intro",
|
||||
)
|
||||
expect(trie.children.has("d")).toBe(true)
|
||||
expect(trie.children.has("h")).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle multiple candidates for the same word", () => {
|
||||
const allFiles: PathAndAliases[] = [
|
||||
{
|
||||
path: "work/meeting",
|
||||
scoped: false,
|
||||
aliases: [],
|
||||
},
|
||||
{
|
||||
path: "private/meeting",
|
||||
scoped: false,
|
||||
aliases: [],
|
||||
},
|
||||
]
|
||||
|
||||
const { candidateMap } = buildCandidateTrie(allFiles, undefined, true)
|
||||
|
||||
const meetingData = candidateMap.get("meeting")
|
||||
expect(meetingData?.candidates).toHaveLength(2)
|
||||
expect(meetingData?.candidates.map(c => c.canonical)).toContain("work/meeting")
|
||||
expect(meetingData?.candidates.map(c => c.canonical)).toContain("private/meeting")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
|||
76
src/utils/__tests__/resolve-ambiguities.test.ts
Normal file
76
src/utils/__tests__/resolve-ambiguities.test.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { describe, it, expect, vi } from "vitest"
|
||||
|
||||
vi.mock("obsidian", () => ({
|
||||
request: vi.fn(),
|
||||
}))
|
||||
|
||||
import { resolveAmbiguities } from "../resolve-ambiguities"
|
||||
import { CandidateData, TrieNode, buildTrie } from "../../trie"
|
||||
import { AutomaticLinkerSettings, DEFAULT_SETTINGS } from "../../settings/settings-info"
|
||||
import * as aiClient from "../ai-client"
|
||||
|
||||
vi.mock("../ai-client", () => ({
|
||||
callAI: vi.fn(),
|
||||
resolveAmbiguitiesBatch: vi.fn(),
|
||||
}))
|
||||
|
||||
describe("resolveAmbiguities", () => {
|
||||
const mockSettings: AutomaticLinkerSettings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
aiMaxContext: 50,
|
||||
}
|
||||
|
||||
const candidateMap = new Map<string, CandidateData>([
|
||||
[
|
||||
"meeting",
|
||||
{
|
||||
candidates: [
|
||||
{ canonical: "work/meeting", scoped: false, namespace: "work" },
|
||||
{ canonical: "private/meeting", scoped: false, namespace: "private" },
|
||||
],
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
const trie: TrieNode = buildTrie(["meeting"], true)
|
||||
|
||||
it("should identify ambiguous unlinked words", async () => {
|
||||
const text = "I have a meeting tomorrow."
|
||||
vi.mocked(aiClient.resolveAmbiguitiesBatch).mockResolvedValue(
|
||||
new Map([["meeting", "work/meeting"]])
|
||||
)
|
||||
|
||||
const result = await resolveAmbiguities(text, candidateMap, trie, mockSettings)
|
||||
|
||||
expect(aiClient.resolveAmbiguitiesBatch).toHaveBeenCalledWith(
|
||||
mockSettings,
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
word: "meeting",
|
||||
candidates: ["work/meeting", "private/meeting"],
|
||||
})
|
||||
])
|
||||
)
|
||||
expect(result.get("meeting")).toBe("work/meeting")
|
||||
})
|
||||
|
||||
it("should identify ambiguous existing links for verification", async () => {
|
||||
const text = "Check the [[private/meeting|meeting]] notes."
|
||||
vi.mocked(aiClient.resolveAmbiguitiesBatch).mockResolvedValue(
|
||||
new Map([["[[private/meeting|meeting]]", "work/meeting"]])
|
||||
)
|
||||
|
||||
const result = await resolveAmbiguities(text, candidateMap, trie, mockSettings)
|
||||
|
||||
expect(aiClient.resolveAmbiguitiesBatch).toHaveBeenCalledWith(
|
||||
mockSettings,
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
word: "[[private/meeting|meeting]]",
|
||||
candidates: ["work/meeting", "private/meeting"],
|
||||
})
|
||||
])
|
||||
)
|
||||
expect(result.get("[[private/meeting|meeting]]")).toBe("work/meeting")
|
||||
})
|
||||
})
|
||||
102
src/utils/ai-client.ts
Normal file
102
src/utils/ai-client.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { request } from "obsidian"
|
||||
import { AutomaticLinkerSettings } from "../settings/settings-info"
|
||||
|
||||
export interface AIResolveRequest {
|
||||
text: string
|
||||
word: string
|
||||
candidates: string[]
|
||||
}
|
||||
|
||||
export interface AIResolveResponse {
|
||||
selectedPath: string | null
|
||||
}
|
||||
|
||||
export const callAI = async (
|
||||
settings: AutomaticLinkerSettings,
|
||||
prompt: string,
|
||||
): Promise<string> => {
|
||||
const url = `${settings.aiEndpoint}/chat/completions`
|
||||
const body = {
|
||||
model: settings.aiModel,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: "You are an assistant that helps resolve ambiguous links in Obsidian notes. You must respond ONLY with a valid JSON object. Do not include any explanation or markdown code blocks.",
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: prompt,
|
||||
},
|
||||
],
|
||||
// Some local servers fail with response_format, so we rely on the prompt instructions
|
||||
// temperature: 0 to make it more deterministic
|
||||
temperature: 0,
|
||||
}
|
||||
|
||||
if (settings.debug) {
|
||||
console.log("AI Link Enhancer Request:", JSON.stringify(body, null, 2))
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request({
|
||||
url,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (settings.debug) {
|
||||
console.log("AI Link Enhancer Response:", response)
|
||||
}
|
||||
|
||||
const data = JSON.parse(response)
|
||||
return data.choices[0].message.content
|
||||
} catch (error) {
|
||||
console.error("AI Link Enhancer API Error:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export const resolveAmbiguitiesBatch = async (
|
||||
settings: AutomaticLinkerSettings,
|
||||
requests: AIResolveRequest[],
|
||||
): Promise<Map<string, string>> => {
|
||||
if (requests.length === 0) return new Map()
|
||||
|
||||
const prompt = `
|
||||
Please resolve the following ambiguous links.
|
||||
Select the most appropriate "selectedPath" from the given "candidates" based on the context.
|
||||
If no candidate is appropriate, return null for that item.
|
||||
|
||||
You MUST respond with a JSON object in the following format:
|
||||
{
|
||||
"results": [
|
||||
{ "word": "word1", "selectedPath": "path/to/note" },
|
||||
...
|
||||
]
|
||||
}
|
||||
|
||||
Input data:
|
||||
${JSON.stringify(requests, null, 2)}
|
||||
`
|
||||
|
||||
const resultRaw = await callAI(settings, prompt)
|
||||
|
||||
// Clean up potential markdown code blocks if the AI included them
|
||||
const cleanJson = resultRaw.replace(/```json\n?/, "").replace(/\n?```/, "").trim()
|
||||
|
||||
const result = JSON.parse(cleanJson)
|
||||
const resultMap = new Map<string, string>()
|
||||
|
||||
if (result.results && Array.isArray(result.results)) {
|
||||
for (const item of result.results) {
|
||||
if (item.selectedPath) {
|
||||
resultMap.set(item.word, item.selectedPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resultMap
|
||||
}
|
||||
79
src/utils/resolve-ambiguities.ts
Normal file
79
src/utils/resolve-ambiguities.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { CandidateData, TrieNode } from "../trie"
|
||||
import { AutomaticLinkerSettings } from "../settings/settings-info"
|
||||
import { resolveAmbiguitiesBatch, AIResolveRequest } from "./ai-client"
|
||||
|
||||
// A simplified scanner to find candidates that have multiple options or existing links to verify.
|
||||
export const resolveAmbiguities = async (
|
||||
text: string,
|
||||
candidateMap: Map<string, CandidateData>,
|
||||
trie: TrieNode,
|
||||
settings: AutomaticLinkerSettings,
|
||||
): Promise<Map<string, string>> => {
|
||||
const requests: AIResolveRequest[] = []
|
||||
|
||||
// 1. Scan for existing links [[Path|Alias]] or [[Path]]
|
||||
const existingLinkRegex = /\[\[([^|\]]+)(?:\|([^\]]+))?\]\]/g
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = existingLinkRegex.exec(text)) !== null) {
|
||||
const fullMatch = match[0]
|
||||
const path = match[1]
|
||||
const alias = match[2] || path
|
||||
|
||||
const candidateData = candidateMap.get(alias)
|
||||
if (candidateData && candidateData.candidates.length > 1) {
|
||||
const start = Math.max(0, match.index - settings.aiMaxContext)
|
||||
const end = Math.min(text.length, match.index + fullMatch.length + settings.aiMaxContext)
|
||||
|
||||
requests.push({
|
||||
word: fullMatch, // Using the full link as the "word" key for replacement
|
||||
text: text.slice(start, end),
|
||||
candidates: candidateData.candidates.map(c => c.canonical),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Scan for unlinked words with multiple candidates using the Trie
|
||||
// (This is a simplified version of the trie traversal logic)
|
||||
let i = 0
|
||||
while (i < text.length) {
|
||||
let node = trie
|
||||
let j = i
|
||||
let lastMatch: { word: string; data: CandidateData; index: number } | null = null
|
||||
|
||||
while (j < text.length && node.children.has(text[j].toLowerCase())) {
|
||||
node = node.children.get(text[j].toLowerCase())!
|
||||
if (node.candidate) {
|
||||
const data = candidateMap.get(node.candidate)
|
||||
if (data) {
|
||||
lastMatch = { word: node.candidate, data, index: i }
|
||||
}
|
||||
}
|
||||
j++
|
||||
}
|
||||
|
||||
if (lastMatch && lastMatch.data.candidates.length > 1) {
|
||||
// Check if it's already inside a link (simple check)
|
||||
const isInsideLink = text.slice(Math.max(0, i - 2), i) === "[[" ||
|
||||
text.slice(i + lastMatch.word.length, i + lastMatch.word.length + 2) === "]]"
|
||||
|
||||
if (!isInsideLink) {
|
||||
const start = Math.max(0, i - settings.aiMaxContext)
|
||||
const end = Math.min(text.length, i + lastMatch.word.length + settings.aiMaxContext)
|
||||
|
||||
requests.push({
|
||||
word: lastMatch.word,
|
||||
text: text.slice(start, end),
|
||||
candidates: lastMatch.data.candidates.map(c => c.canonical),
|
||||
})
|
||||
}
|
||||
i += lastMatch.word.length
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate requests by word to avoid redundant AI calls
|
||||
const uniqueRequests = Array.from(new Map(requests.map(r => [r.word, r])).values())
|
||||
|
||||
return await resolveAmbiguitiesBatch(settings, uniqueRequests)
|
||||
}
|
||||
|
|
@ -1,7 +1,11 @@
|
|||
import { defineConfig } from "vitest/config"
|
||||
import path from "path"
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
includeSource: ["src/**/*.{js,ts}"],
|
||||
alias: {
|
||||
"obsidian": path.resolve(__dirname, "./src/__mocks__/obsidian.ts"),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue