logancyang_obsidian-copilot/src/commands/index.ts
Zero Liu 6ca2dc01ea
chore(eslint): enable no-explicit-any; fix ~395 violations (#2452)
* chore(eslint): enable no-explicit-any; fix ~395 violations

Switches @typescript-eslint/no-explicit-any from "off" to "error" and
replaces explicit `any` with proper types or `unknown` + narrowing
across ~100 source files and 15 test files. Eleven eslint-disable
comments remain: one for a BaseLanguageModel prototype patch and ten
for Orama<any> API surfaces where typed alternatives poison Orama's
internal inference to `never`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(scripts): cast chainContext to ChainManager in printPromptDebugEntry

The earlier `as any` → `as unknown` rewrite left buildAgentPromptDebugReport's
chainManager argument typed as `unknown`, which CI's `tsc -noEmit` (without
`--skipLibCheck`) flagged. Restore the cast through the real ChainManager type.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(eslint): fix remaining no-explicit-any and unnecessary-type-assertion errors

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(eslint): drop redundant no-explicit-any rule

Already set to "error" by typescript-eslint/recommendedTypeChecked via
obsidianmd's recommended config.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 02:08:45 -07:00

683 lines
24 KiB
TypeScript

import { logFileManager } from "@/logFileManager";
import { FileCache } from "@/cache/fileCache";
import { ProjectContextCache } from "@/cache/projectContextCache";
import { logError } from "@/logger";
import {
clearRecordedPromptPayload,
flushRecordedPromptPayloadToLog,
} from "@/LLMProviders/chainRunner/utils/promptPayloadRecorder";
import { CustomCommandSettingsModal } from "@/commands/CustomCommandSettingsModal";
import { EMPTY_COMMAND } from "@/commands/constants";
import { CustomCommandManager } from "@/commands/customCommandManager";
import { getCachedCustomCommands } from "@/commands/state";
import { CustomCommand } from "@/commands/type";
import {
QUICK_COMMAND_SYSTEM_PROMPT,
appendIncludeNoteContextPlaceholders,
} from "@/commands/quickCommandPrompts";
import { CustomCommandChatModal } from "@/commands/CustomCommandChatModal";
import { ConfirmModal } from "@/components/modals/ConfirmModal";
import { ApplyCustomCommandModal } from "@/components/modals/ApplyCustomCommandModal";
import { YoutubeTranscriptModal } from "@/components/modals/YoutubeTranscriptModal";
import { checkIsPlusUser } from "@/plusUtils";
// Debug modals removed with search v3
import CopilotPlugin from "@/main";
import { shouldUseMiyo } from "@/miyo/miyoUtils";
import { getAllQAMarkdownContent } from "@/search/searchUtils";
import { CopilotSettings } from "@/settings/model";
import { NoteSelectedTextContext, WebSelectedTextContext } from "@/types/message";
import { ensureFolderExists, isSourceModeOn } from "@/utils";
import { Editor, MarkdownView, Notice, TFile } from "obsidian";
import { v4 as uuidv4 } from "uuid";
import { COMMAND_IDS, COMMAND_ICONS, COMMAND_NAMES, CommandId } from "../constants";
import { setSelectedTextContexts } from "@/aiParams";
/**
* Add a command to the plugin. Supports async callbacks; errors are logged.
*/
export function addCommand(
plugin: CopilotPlugin,
id: CommandId,
callback: () => void | Promise<void>
) {
plugin.addCommand({
id,
name: COMMAND_NAMES[id],
icon: COMMAND_ICONS[id],
callback: () => {
const result = callback();
if (result instanceof Promise) {
result.catch((err) => logError(`Command ${id} failed`, err));
}
},
});
}
/**
* Add an editor command to the plugin. Supports async callbacks; errors are logged.
*/
function addEditorCommand(
plugin: CopilotPlugin,
id: CommandId,
callback: (editor: Editor) => void | Promise<void>
) {
plugin.addCommand({
id,
name: COMMAND_NAMES[id],
icon: COMMAND_ICONS[id],
editorCallback: (editor) => {
const result = callback(editor);
if (result instanceof Promise) {
result.catch((err) => logError(`Editor command ${id} failed`, err));
}
},
});
}
/**
* Add a check command to the plugin.
*/
export function addCheckCommand(
plugin: CopilotPlugin,
id: CommandId,
callback: (checking: boolean) => boolean | void
) {
plugin.addCommand({
id,
name: COMMAND_NAMES[id],
icon: COMMAND_ICONS[id],
checkCallback: callback,
});
}
export function registerCommands(
plugin: CopilotPlugin,
prev: CopilotSettings | undefined,
next: CopilotSettings
) {
addEditorCommand(plugin, COMMAND_IDS.COUNT_WORD_AND_TOKENS_SELECTION, async (editor: Editor) => {
const selectedText = editor.getSelection();
const wordCount = selectedText.split(" ").length;
const tokenCount = await plugin.projectManager
.getCurrentChainManager()
.chatModelManager.countTokens(selectedText);
new Notice(`Selected text contains ${wordCount} words and ${tokenCount} tokens.`);
});
addCommand(plugin, COMMAND_IDS.COUNT_TOTAL_VAULT_TOKENS, async () => {
try {
const allContent = await getAllQAMarkdownContent(plugin.app);
const totalTokens = await plugin.projectManager
.getCurrentChainManager()
.chatModelManager.countTokens(allContent);
new Notice(`Total tokens in your vault: ${totalTokens}`);
} catch (error) {
logError("Error counting tokens: ", error);
new Notice("An error occurred while counting tokens.");
}
});
addCommand(plugin, COMMAND_IDS.TOGGLE_COPILOT_CHAT_WINDOW, () => {
plugin.toggleView();
});
addCommand(plugin, COMMAND_IDS.OPEN_COPILOT_CHAT_WINDOW, async () => {
await plugin.activateView();
});
addCommand(plugin, COMMAND_IDS.NEW_CHAT, async () => {
clearRecordedPromptPayload();
await plugin.newChat();
});
// Quick Command - opens a modal dialog for quick interactions
// Note: For inline floating panel experience, use Quick Ask instead
addCheckCommand(plugin, COMMAND_IDS.TRIGGER_QUICK_COMMAND, (checking: boolean) => {
const activeView = plugin.app.workspace.getActiveViewOfType(MarkdownView);
if (checking) {
// Return true only if we're not in source mode
return !!(!isSourceModeOn() && activeView && activeView.editor);
}
// Need to check this again because it can still be triggered via shortcut.
if (isSourceModeOn()) {
new Notice("Quick command is not available in source mode.");
return false;
}
// When not checking, execute the command
if (!activeView || !activeView.editor) {
new Notice("No active editor found.");
return false;
}
const editor = activeView.editor;
const selectedText = editor.getSelection();
if (!selectedText.trim()) {
new Notice("Please select some text first. Selected text is required for quick commands.");
return false;
}
// Directly open the Modal
const quickCommand: CustomCommand = {
title: "Quick Command",
content: "", // Empty content, wait for user input
showInContextMenu: false,
showInSlashMenu: false,
order: 0,
modelKey: "", // Empty = inherit from quickCommandModelKey
lastUsedMs: Date.now(),
};
const modal = new CustomCommandChatModal(plugin.app, {
selectedText,
command: quickCommand,
systemPrompt: QUICK_COMMAND_SYSTEM_PROMPT,
behaviorConfig: {
autoExecuteOnOpen: false,
hideContentAreaOnIdle: true,
commandLabel: "Quick Command",
commandIcon: null, // No icon for Quick Command
showIncludeNoteContext: true, // Show the Note checkbox
modelSelectionScope: "quick-command", // Persist model changes to quickCommandModelKey
firstSubmitTransform: (input, includeNoteContext) =>
appendIncludeNoteContextPlaceholders(input, includeNoteContext),
},
});
modal.open();
return true;
});
addCommand(plugin, COMMAND_IDS.CLEAR_LOCAL_COPILOT_INDEX, async () => {
const { getSettings } = await import("@/settings/model");
const settings = getSettings();
const isMiyoEnabled = shouldUseMiyo(settings);
if (isMiyoEnabled) {
new Notice(
"Miyo folders are managed in Miyo. Remove the folder there if you want to clear it."
);
return;
}
const clearMessage =
"This will permanently delete all document indexes in Copilot. This action cannot be undone.\n\nAre you sure you want to proceed?";
const confirmed = await new Promise<boolean>((resolve) => {
new ConfirmModal(
plugin.app,
() => resolve(true),
clearMessage,
"Clear Semantic Index",
"Clear Index",
"Cancel",
() => resolve(false)
).open();
});
if (!confirmed) return;
try {
const VectorStoreManager = (await import("@/search/vectorStoreManager")).default;
await VectorStoreManager.getInstance().clearIndex();
new Notice("Cleared local Copilot semantic index.");
} catch (err) {
logError("Error clearing semantic index:", err);
new Notice("Failed to clear semantic index.");
}
});
addCommand(plugin, COMMAND_IDS.GARBAGE_COLLECT_COPILOT_INDEX, async () => {
try {
const { getSettings } = await import("@/settings/model");
if (shouldUseMiyo(getSettings())) {
new Notice(
"Miyo manages file cleanup automatically. Run Index (refresh) vault to trigger a scan if needed."
);
return;
}
const VectorStoreManager = (await import("@/search/vectorStoreManager")).default;
const removedCount = await VectorStoreManager.getInstance().garbageCollectVectorStore();
new Notice(`Garbage collection completed. Removed ${removedCount} stale documents.`);
} catch (err) {
logError("Error during garbage collection:", err);
new Notice("Failed to garbage collect semantic index.");
}
});
// Removed legacy build-only command; use refresh and force reindex commands instead
addCommand(plugin, COMMAND_IDS.INDEX_VAULT_TO_COPILOT_INDEX, async () => {
try {
const { getSettings } = await import("@/settings/model");
const settings = getSettings();
if (settings.enableSemanticSearchV3) {
// Use VectorStoreManager for semantic search indexing
const VectorStoreManager = (await import("@/search/vectorStoreManager")).default;
const count = await VectorStoreManager.getInstance().indexVaultToVectorStore(false, {
userInitiated: true,
});
if (shouldUseMiyo(settings)) {
new Notice("Miyo folder index refresh started. Open the Miyo app to check details.");
} else {
new Notice(`Semantic search index refreshed with ${count} documents.`);
}
} else {
// V3 search builds indexes on demand
new Notice("Lexical search builds indexes on demand. No manual indexing required.");
}
} catch (err) {
logError("Error building index:", err);
new Notice("An error occurred while building the index.");
}
});
addCommand(plugin, COMMAND_IDS.FORCE_REINDEX_VAULT_TO_COPILOT_INDEX, async () => {
const confirmed = await new Promise<boolean>((resolve) => {
new ConfirmModal(
plugin.app,
() => resolve(true),
"This will delete and rebuild your entire vault index from scratch. This operation cannot be undone. Are you sure you want to proceed?",
"Force Reindex Vault",
"Continue",
"Cancel",
() => resolve(false)
).open();
});
if (!confirmed) return;
try {
const { getSettings } = await import("@/settings/model");
const settings = getSettings();
if (settings.enableSemanticSearchV3) {
// Use VectorStoreManager for semantic search indexing
const VectorStoreManager = (await import("@/search/vectorStoreManager")).default;
const count = await VectorStoreManager.getInstance().indexVaultToVectorStore(true, {
userInitiated: true,
});
if (shouldUseMiyo(settings)) {
new Notice("Miyo folder index refresh started. Open the Miyo app to check details.");
} else {
new Notice(`Semantic search index rebuilt with ${count} documents.`);
}
} else {
// V3 search builds indexes on demand
new Notice("Lexical search builds indexes on demand. No manual indexing required.");
}
} catch (err) {
logError("Error rebuilding index:", err);
new Notice("An error occurred while rebuilding the index.");
}
});
addCommand(plugin, COMMAND_IDS.LOAD_COPILOT_CHAT_CONVERSATION, async () => {
await plugin.loadCopilotChatHistory();
});
addCommand(plugin, COMMAND_IDS.LIST_INDEXED_FILES, async () => {
try {
const VectorStoreManager = (await import("@/search/vectorStoreManager")).default;
const indexedPaths = await VectorStoreManager.getInstance().getIndexedFiles();
// Get all markdown files from vault
const { getMatchingPatterns, shouldIndexFile } = await import("@/search/searchUtils");
const { inclusions, exclusions } = getMatchingPatterns();
const allMarkdownFiles = plugin.app.vault.getMarkdownFiles();
const emptyFiles = new Set<string>();
const unindexedFiles = new Set<string>();
const excludedFiles = new Set<string>();
const indexedFiles = new Set<string>(indexedPaths);
// Categorize files
for (const file of allMarkdownFiles) {
// Check if file should be indexed based on settings
if (!shouldIndexFile(file, inclusions, exclusions)) {
excludedFiles.add(file.path);
continue;
}
const content = await plugin.app.vault.cachedRead(file);
if (!content || content.trim().length === 0) {
emptyFiles.add(file.path);
} else if (!indexedFiles.has(file.path)) {
unindexedFiles.add(file.path);
}
}
// Create content for the file
const content = [
"# Copilot Files Status",
`- Indexed files: ${indexedFiles.size}`,
`- Unindexed files: ${unindexedFiles.size}`,
`- Empty files: ${emptyFiles.size}`,
`- Excluded files: ${excludedFiles.size}`,
"",
"## Indexed Files",
...(indexedFiles.size > 0
? Array.from(indexedFiles)
.sort()
.map((file) => `- [[${file}]]`)
: ["No indexed files found."]),
"",
"## Unindexed Files",
...(unindexedFiles.size > 0
? Array.from(unindexedFiles)
.sort()
.map((file) => `- [[${file}]]`)
: ["No unindexed files found."]),
"",
"## Empty Files",
...(emptyFiles.size > 0
? Array.from(emptyFiles)
.sort()
.map((file) => `- [[${file}]]`)
: ["No empty files found."]),
"",
"## Excluded Files (based on settings)",
...(excludedFiles.size > 0
? Array.from(excludedFiles)
.sort()
.map((file) => `- [[${file}]]`)
: ["No excluded files."]),
].join("\n");
// Create or update the file in the vault
const fileName = `Copilot-Indexed-Files-${new Date().toLocaleDateString().replace(/\//g, "-")}.md`;
const folderPath = "copilot";
const filePath = `${folderPath}/${fileName}`;
// Ensure destination folder exists (supports mobile and nested)
await ensureFolderExists(folderPath);
const existingFile = plugin.app.vault.getAbstractFileByPath(filePath);
if (existingFile instanceof TFile) {
await plugin.app.vault.modify(existingFile, content);
} else {
await plugin.app.vault.create(filePath, content);
}
// Open the file
const file = plugin.app.vault.getAbstractFileByPath(filePath);
if (file instanceof TFile) {
await plugin.app.workspace.getLeaf().openFile(file);
new Notice(`Listed ${indexedFiles.size} indexed files`);
}
} catch (error) {
logError("Error listing indexed files:", error);
new Notice("Failed to list indexed files.");
}
});
addCommand(plugin, COMMAND_IDS.INSPECT_COPILOT_INDEX_BY_NOTE_PATHS, async () => {
try {
const activeFile = plugin.app.workspace.getActiveFile();
if (!activeFile) {
new Notice("No active file. Please open a note first.");
return;
}
const VectorStoreManager = (await import("@/search/vectorStoreManager")).default;
const { DBOperations } = await import("@/search/dbOperations");
const db = await VectorStoreManager.getInstance().getDb();
const hits = await DBOperations.getDocsByPath(db, activeFile.path);
if (!hits || hits.length === 0) {
new Notice(`No embedding data found for: ${activeFile.path}`);
return;
}
// Map hits to chunks (getDocsByPath returns {document, score} format)
const chunks: Record<string, unknown>[] = hits.map(
(hit) => hit.document as unknown as Record<string, unknown>
);
const content = [
`# Embedding Debug: ${activeFile.basename}`,
"",
`**Path:** ${activeFile.path}`,
`**Chunks:** ${chunks.length}`,
`**Embedding Model:** ${(chunks[0]?.embeddingModel as string | undefined) || "unknown"}`,
"",
...chunks.flatMap((chunk: Record<string, unknown>, index: number) => {
const embedding = (chunk.embedding as number[] | undefined) || [];
const preview = embedding
.slice(0, 10)
.map((v: number) => v.toFixed(6))
.join(", ");
return [
`## Chunk ${index + 1}`,
`- **ID:** ${chunk.id as string}`,
`- **Content Preview:** "${((chunk.content as string | undefined) || "").substring(0, 200)}..."`,
`- **Vector Length:** ${embedding.length}`,
`- **Vector Preview:** [${preview}${embedding.length > 10 ? ", ..." : ""}]`,
`- **Tags:** ${((chunk.tags as string[] | undefined) || []).join(", ") || "none"}`,
`- **Characters:** ${(chunk.nchars as number | undefined) || 0}`,
"",
];
}),
].join("\n");
// Create the debug file
const fileName = `Copilot-Embedding-Debug-${activeFile.basename.replace(/[\\/:*?"<>|]/g, "_")}.md`;
const folderPath = "copilot";
const filePath = `${folderPath}/${fileName}`;
await ensureFolderExists(folderPath);
const existingFile = plugin.app.vault.getAbstractFileByPath(filePath);
if (existingFile instanceof TFile) {
await plugin.app.vault.modify(existingFile, content);
} else {
await plugin.app.vault.create(filePath, content);
}
const file = plugin.app.vault.getAbstractFileByPath(filePath);
if (file instanceof TFile) {
await plugin.app.workspace.getLeaf().openFile(file);
new Notice(`Embedding debug info for ${chunks.length} chunk(s)`);
}
} catch (error) {
logError("Error inspecting embeddings:", error);
new Notice("Failed to inspect embeddings. Is the index loaded?");
}
});
// Add clear Copilot cache command
addCommand(plugin, COMMAND_IDS.CLEAR_COPILOT_CACHE, async () => {
try {
await plugin.fileParserManager.clearPDFCache();
// Clear project context cache
await ProjectContextCache.getInstance().clearAllCache();
// Clear file content cache (get FileCache instance and clear it)
const fileCache = FileCache.getInstance<string>();
await fileCache.clear();
new Notice("All Copilot caches cleared successfully");
} catch (error) {
logError("Error clearing Copilot caches:", error);
new Notice("Failed to clear Copilot caches");
}
});
// Create Copilot log file
addCommand(plugin, COMMAND_IDS.OPEN_LOG_FILE, async () => {
try {
await flushRecordedPromptPayloadToLog();
await logFileManager.openLogFile();
} catch (error) {
logError("Error creating Copilot log file:", error);
new Notice("Failed to create Copilot log file.");
}
});
// Clear Copilot log file (delete on disk and clear in-memory buffer)
addCommand(plugin, COMMAND_IDS.CLEAR_LOG_FILE, async () => {
try {
await logFileManager.clear();
new Notice("Copilot log cleared.");
} catch (error) {
logError("Error clearing Copilot log file:", error);
new Notice("Failed to clear Copilot log file.");
}
});
// Add selection to chat context command (manual)
addEditorCommand(plugin, COMMAND_IDS.ADD_SELECTION_TO_CHAT_CONTEXT, async (editor: Editor) => {
const selectedText = editor.getSelection();
if (!selectedText) {
new Notice("No text selected");
return;
}
const activeFile = plugin.app.workspace.getActiveFile();
if (!activeFile) {
new Notice("No active file");
return;
}
// Get selection range to determine line numbers
const selectionRange = editor.listSelections()[0];
if (!selectionRange) {
new Notice("Could not determine selection range");
return;
}
const startLine = selectionRange.anchor.line + 1; // Convert to 1-based line numbers
const endLine = selectionRange.head.line + 1;
// Create selected text context
const selectedTextContext: NoteSelectedTextContext = {
id: uuidv4(),
content: selectedText,
sourceType: "note",
noteTitle: activeFile.basename,
notePath: activeFile.path,
startLine: Math.min(startLine, endLine),
endLine: Math.max(startLine, endLine),
};
// Mutually exclusive: only keep the latest selection
setSelectedTextContexts([selectedTextContext]);
// Open chat window to show the context was added
await plugin.activateView();
});
// Add web selection to chat context command (manual)
addCommand(plugin, COMMAND_IDS.ADD_WEB_SELECTION_TO_CHAT_CONTEXT, async () => {
const { Platform } = await import("obsidian");
if (!Platform.isDesktopApp) {
new Notice("Web selection is only available on desktop");
return;
}
const { getWebViewerService } = await import(
"@/services/webViewerService/webViewerServiceSingleton"
);
try {
const service = getWebViewerService(plugin.app);
const leaf = service.getActiveLeaf() ?? service.getLastActiveLeaf();
if (!leaf) {
new Notice("No active Web Tab found");
return;
}
const selectedMarkdown = await service.getSelectedMarkdown(leaf);
if (!selectedMarkdown.trim()) {
new Notice("No text selected in Web Tab");
return;
}
const pageInfo = service.getPageInfo(leaf);
// Create web selected text context
const webSelectedTextContext: WebSelectedTextContext = {
id: uuidv4(),
content: selectedMarkdown,
sourceType: "web",
title: pageInfo.title || "Untitled",
url: pageInfo.url,
faviconUrl: pageInfo.faviconUrl || undefined,
};
// Mutually exclusive: only keep the latest selection
setSelectedTextContexts([webSelectedTextContext]);
// Open chat window to show the context was added
await plugin.activateView();
} catch (error) {
logError("Error adding web selection to context:", error);
new Notice("Failed to get web selection");
}
});
// Add command to create a new custom command
addCommand(plugin, COMMAND_IDS.ADD_CUSTOM_COMMAND, async () => {
const commands = getCachedCustomCommands();
const newCommand = { ...EMPTY_COMMAND };
const modal = new CustomCommandSettingsModal(
plugin.app,
commands,
newCommand,
async (updatedCommand) => {
await CustomCommandManager.getInstance().createCommand(updatedCommand);
}
);
modal.open();
});
// Add command to apply a custom command
addCommand(plugin, COMMAND_IDS.APPLY_CUSTOM_COMMAND, () => {
const modal = new ApplyCustomCommandModal(plugin.app);
modal.open();
});
// Add command to download YouTube script (Copilot Plus only)
addCommand(plugin, COMMAND_IDS.DOWNLOAD_YOUTUBE_SCRIPT, async () => {
const isPlusUser = await checkIsPlusUser();
if (!isPlusUser) {
new Notice("Download YouTube Script (plus) is a Copilot Plus feature");
return;
}
const modal = new YoutubeTranscriptModal(plugin.app);
modal.open();
});
// Add Quick Ask command (recommended shortcut: cmd/ctrl+K)
// Quick Ask is the floating panel that appears near the selection in the editor
addCheckCommand(plugin, COMMAND_IDS.TRIGGER_QUICK_ASK, (checking: boolean) => {
const activeView = plugin.app.workspace.getActiveViewOfType(MarkdownView);
if (checking) {
// Return true only if we're not in source mode and have an active editor
return !!(!isSourceModeOn() && activeView && activeView.editor);
}
// Need to check this again because it can still be triggered via shortcut
if (isSourceModeOn()) {
new Notice("Quick Ask is not available in source mode.");
return false;
}
if (!activeView || !activeView.editor) {
new Notice("No active editor found.");
return false;
}
// Get the CM6 EditorView from the Obsidian editor
const view = activeView.editor.cm;
if (!view) {
new Notice("Could not access CodeMirror editor.");
return false;
}
// Show the Quick Ask panel (pass activeView for leaf binding)
plugin.quickAskController.show(activeView, view);
return true;
});
}