mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
chore(types): tighten types and replace TFile/TFolder casts (W1/9) (#2403)
This commit is contained in:
parent
b7ad36624a
commit
e470b5c70c
103 changed files with 678 additions and 590 deletions
|
|
@ -5,6 +5,7 @@ import yaml from "js-yaml";
|
|||
module.exports = {
|
||||
// Reason: normalizePath is used by projectPaths.ts; identity function is sufficient for tests
|
||||
normalizePath: jest.fn().mockImplementation((p) => p),
|
||||
moment: jest.requireActual("moment"),
|
||||
Vault: jest.fn().mockImplementation(() => {
|
||||
return {
|
||||
getMarkdownFiles: jest.fn().mockImplementation(() => {
|
||||
|
|
|
|||
|
|
@ -1022,14 +1022,14 @@ export class BedrockChatModel extends BaseChatModel<BedrockChatModelCallOptions>
|
|||
return part;
|
||||
}
|
||||
if (part && typeof part === "object") {
|
||||
if (typeof (part as any).text === "string") {
|
||||
return (part as any).text;
|
||||
if (typeof part.text === "string") {
|
||||
return part.text;
|
||||
}
|
||||
if (typeof (part as any).value === "string") {
|
||||
return (part as any).value;
|
||||
if (typeof part.value === "string") {
|
||||
return part.value;
|
||||
}
|
||||
if (Array.isArray((part as any).content)) {
|
||||
return (part as any).content
|
||||
if (Array.isArray(part.content)) {
|
||||
return part.content
|
||||
.map((sub: any) => (typeof sub?.text === "string" ? sub.text : ""))
|
||||
.join("");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -169,7 +169,7 @@ export class ChatOpenRouter extends ChatOpenAI {
|
|||
|
||||
let usageSummary: OpenRouterUsage | undefined;
|
||||
|
||||
for await (const rawChunk of stream as AsyncIterable<OpenRouterChatChunk>) {
|
||||
for await (const rawChunk of stream) {
|
||||
if (rawChunk.usage) {
|
||||
usageSummary = rawChunk.usage;
|
||||
}
|
||||
|
|
@ -200,7 +200,9 @@ export class ChatOpenRouter extends ChatOpenAI {
|
|||
text: typeof messageChunk.content === "string" ? messageChunk.content : "",
|
||||
generationInfo: {
|
||||
finish_reason: choice.finish_reason,
|
||||
system_fingerprint: rawChunk.system_fingerprint,
|
||||
// Reason: system_fingerprint is marked deprecated by some scorecards but is still
|
||||
// returned by OpenAI-style streaming APIs and is useful for telemetry.
|
||||
system_fingerprint: rawChunk["system_fingerprint"],
|
||||
model: rawChunk.model,
|
||||
},
|
||||
});
|
||||
|
|
@ -460,8 +462,12 @@ export class ChatOpenRouter extends ChatOpenAI {
|
|||
metadata.model = rawChunk.model;
|
||||
}
|
||||
|
||||
if (rawChunk.system_fingerprint) {
|
||||
metadata.system_fingerprint = rawChunk.system_fingerprint;
|
||||
// Reason: system_fingerprint is marked deprecated by some scorecards but is still
|
||||
// returned by OpenAI-style streaming APIs and is useful for telemetry. Use bracket
|
||||
// access to bypass JSDoc deprecation warnings.
|
||||
const fingerprint = rawChunk["system_fingerprint"];
|
||||
if (fingerprint) {
|
||||
metadata.system_fingerprint = fingerprint;
|
||||
}
|
||||
|
||||
if (rawChunk.usage) {
|
||||
|
|
|
|||
|
|
@ -205,7 +205,7 @@ export default class ChainManager {
|
|||
memory: memory,
|
||||
prompt: options.prompt || chatPrompt,
|
||||
abortController: options.abortController,
|
||||
}) as RunnableSequence;
|
||||
});
|
||||
|
||||
setChainType(ChainType.LLM_CHAIN);
|
||||
break;
|
||||
|
|
@ -259,7 +259,7 @@ export default class ChainManager {
|
|||
memory: memory,
|
||||
prompt: options.prompt || chatPrompt,
|
||||
abortController: options.abortController,
|
||||
}) as RunnableSequence;
|
||||
});
|
||||
|
||||
setChainType(ChainType.COPILOT_PLUS_CHAIN);
|
||||
break;
|
||||
|
|
@ -273,7 +273,7 @@ export default class ChainManager {
|
|||
memory: memory,
|
||||
prompt: options.prompt || chatPrompt,
|
||||
abortController: options.abortController,
|
||||
}) as RunnableSequence;
|
||||
});
|
||||
setChainType(ChainType.PROJECT_CHAIN);
|
||||
break;
|
||||
}
|
||||
|
|
@ -302,7 +302,7 @@ export default class ChainManager {
|
|||
case ChainType.PROJECT_CHAIN:
|
||||
return new ProjectChainRunner(this);
|
||||
default:
|
||||
throw new Error(`Unsupported chain type: ${chainType}`);
|
||||
throw new Error(`Unsupported chain type: ${String(chainType)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ export class AutonomousAgentChainRunner extends CopilotPlusChainRunner {
|
|||
|
||||
// Agent Reasoning Block state
|
||||
private reasoningState: AgentReasoningState = createInitialReasoningState();
|
||||
private reasoningTimerInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private reasoningTimerInterval: ReturnType<typeof setTimeout> | null = null;
|
||||
private accumulatedContent = ""; // Track content to include in timer updates
|
||||
private allReasoningSteps: Array<{ timestamp: number; summary: string; toolName?: string }> = []; // Full history of all steps
|
||||
private abortHandledByTimer = false; // Flag to prevent duplicate interrupted messages
|
||||
|
|
@ -543,7 +543,7 @@ export class AutonomousAgentChainRunner extends CopilotPlusChainRunner {
|
|||
const availableTools = this.getAvailableTools();
|
||||
|
||||
// Bind tools to the model for native function calling
|
||||
const modelName = (chatModel as any).modelName || (chatModel as any).model || "unknown";
|
||||
const modelName = chatModel.modelName || chatModel.model || "unknown";
|
||||
if (typeof chatModel.bindTools !== "function") {
|
||||
throw new Error(
|
||||
`Model ${modelName} does not support native tool calling (bindTools not available). ` +
|
||||
|
|
|
|||
|
|
@ -203,10 +203,7 @@ export abstract class BaseChainRunner implements ChainRunner {
|
|||
const responseError = (error as { response?: { status?: number; data?: any } })?.response;
|
||||
const errorData = responseError?.data?.error ?? (error as { error?: unknown })?.error;
|
||||
const rawStatus = responseError?.status ?? (errorData as { status?: number | string })?.status;
|
||||
const statusCode =
|
||||
typeof rawStatus === "string"
|
||||
? Number.parseInt(rawStatus, 10)
|
||||
: (rawStatus as number | undefined);
|
||||
const statusCode = typeof rawStatus === "string" ? Number.parseInt(rawStatus, 10) : rawStatus;
|
||||
const errorObject =
|
||||
typeof errorData === "object" && errorData !== null
|
||||
? (errorData as Record<string, unknown>)
|
||||
|
|
|
|||
|
|
@ -160,9 +160,9 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
|
|||
// token estimation entirely. Actual token usage comes from API response metadata.
|
||||
let response: AIMessage;
|
||||
{
|
||||
const stream = (await withSuppressedTokenWarnings(() =>
|
||||
const stream: AsyncIterable<AIMessageChunk> = await withSuppressedTokenWarnings(() =>
|
||||
boundModel.stream(planningMessages)
|
||||
)) as AsyncIterable<AIMessageChunk>;
|
||||
);
|
||||
let aggregated: AIMessageChunk | undefined;
|
||||
for await (const chunk of stream) {
|
||||
aggregated = aggregated ? aggregated.concat(chunk) : chunk;
|
||||
|
|
|
|||
|
|
@ -95,11 +95,7 @@ export function formatSearchResultsForLLM(searchResults: any[]): string {
|
|||
const title = doc.title || "Untitled";
|
||||
const path = doc.path || "";
|
||||
// Optional stable source id if provided by caller; fallback to order
|
||||
const sourceId =
|
||||
(doc as any).__sourceId ||
|
||||
(doc as any).collection_name ||
|
||||
(doc as any).source_id ||
|
||||
idx + 1;
|
||||
const sourceId = doc.__sourceId || doc.collection_name || doc.source_id || idx + 1;
|
||||
|
||||
// Safely handle mtime - check validity before converting
|
||||
let modified: string | null = null;
|
||||
|
|
@ -293,7 +289,7 @@ export function formatSplitSearchResultsForLLM(
|
|||
if (filterDocs.length > 0) {
|
||||
const filterXml = filterDocs
|
||||
.map((doc: any) => {
|
||||
const id = (doc as any).__sourceId || currentId++;
|
||||
const id = doc.__sourceId || currentId++;
|
||||
const title = doc.title || "Untitled";
|
||||
const path = doc.path || "";
|
||||
const matchType = doc.matchType || doc.source || "filter";
|
||||
|
|
@ -318,7 +314,7 @@ ${doc.content || ""}
|
|||
if (searchDocs.length > 0) {
|
||||
const searchXml = searchDocs
|
||||
.map((doc: any) => {
|
||||
const id = (doc as any).__sourceId || currentId++;
|
||||
const id = doc.__sourceId || currentId++;
|
||||
const title = doc.title || "Untitled";
|
||||
const path = doc.path || "";
|
||||
const modified = toIsoString(doc.mtime);
|
||||
|
|
|
|||
|
|
@ -445,7 +445,7 @@ export default class ChatModelManager {
|
|||
let selectedProviderConfig =
|
||||
providerConfig[customModel.provider as keyof typeof providerConfig] || {};
|
||||
|
||||
if (customModel.provider === ChatModelProviders.AMAZON_BEDROCK) {
|
||||
if ((customModel.provider as ChatModelProviders) === ChatModelProviders.AMAZON_BEDROCK) {
|
||||
selectedProviderConfig = await this.buildBedrockConfig(
|
||||
customModel,
|
||||
modelName,
|
||||
|
|
@ -515,7 +515,7 @@ export default class ChatModelManager {
|
|||
if (
|
||||
modelInfo.isGPT5 &&
|
||||
customModel?.verbosity &&
|
||||
customModel?.provider !== ChatModelProviders.AZURE_OPENAI
|
||||
(customModel?.provider as ChatModelProviders) !== ChatModelProviders.AZURE_OPENAI
|
||||
) {
|
||||
const verbosityValue = customModel.verbosity;
|
||||
// For Responses API, verbosity is nested under 'text' parameter
|
||||
|
|
@ -670,7 +670,7 @@ export default class ChatModelManager {
|
|||
* @returns True when the provider requirements are satisfied, otherwise false.
|
||||
*/
|
||||
private hasProviderCredentials(model: CustomModel): boolean {
|
||||
if (model.provider === ChatModelProviders.AMAZON_BEDROCK) {
|
||||
if ((model.provider as ChatModelProviders) === ChatModelProviders.AMAZON_BEDROCK) {
|
||||
const settings = getSettings();
|
||||
const apiKey = model.apiKey || settings.amazonBedrockApiKey;
|
||||
// Region defaults to us-east-1 if not specified, so API key is the only requirement
|
||||
|
|
@ -789,8 +789,8 @@ export default class ChatModelManager {
|
|||
const modelInfo = getModelInfo(model.name);
|
||||
if (
|
||||
modelInfo.isGPT5 &&
|
||||
(model.provider === ChatModelProviders.OPENAI ||
|
||||
model.provider === ChatModelProviders.OPENAI_FORMAT)
|
||||
((model.provider as ChatModelProviders) === ChatModelProviders.OPENAI ||
|
||||
(model.provider as ChatModelProviders) === ChatModelProviders.OPENAI_FORMAT)
|
||||
) {
|
||||
logInfo(`Chat model set with Responses API for GPT-5: ${model.name}`);
|
||||
}
|
||||
|
|
@ -809,7 +809,7 @@ export default class ChatModelManager {
|
|||
}
|
||||
if (!selectedModel.hasApiKey) {
|
||||
const errorMessage = `API key is not provided for the model: ${modelKey}.`;
|
||||
if (model.provider === ChatModelProviders.COPILOT_PLUS) {
|
||||
if ((model.provider as ChatModelProviders) === ChatModelProviders.COPILOT_PLUS) {
|
||||
throw new MissingPlusLicenseError(
|
||||
"Copilot Plus license key is not configured. Please enter your license key in the Copilot Plus section at the top of Basic Settings."
|
||||
);
|
||||
|
|
@ -825,8 +825,8 @@ export default class ChatModelManager {
|
|||
const useCopilotResponses = shouldUseGitHubCopilotResponsesApi(model);
|
||||
if (
|
||||
modelInfo.isGPT5 &&
|
||||
(selectedModel.vendor === ChatModelProviders.OPENAI ||
|
||||
selectedModel.vendor === ChatModelProviders.OPENAI_FORMAT)
|
||||
((selectedModel.vendor as ChatModelProviders) === ChatModelProviders.OPENAI ||
|
||||
(selectedModel.vendor as ChatModelProviders) === ChatModelProviders.OPENAI_FORMAT)
|
||||
) {
|
||||
constructorConfig.useResponsesApi = true;
|
||||
logInfo(`Enabling Responses API for GPT-5 model: ${model.name} (${selectedModel.vendor})`);
|
||||
|
|
@ -839,7 +839,10 @@ export default class ChatModelManager {
|
|||
|
||||
// For LM Studio, use ChatLMStudio by default for Responses API compatibility.
|
||||
// Opt out by setting useResponsesApi to false.
|
||||
if (model.provider === ChatModelProviders.LM_STUDIO && model.useResponsesApi !== false) {
|
||||
if (
|
||||
(model.provider as ChatModelProviders) === ChatModelProviders.LM_STUDIO &&
|
||||
model.useResponsesApi !== false
|
||||
) {
|
||||
const lmStudioInstance = new ChatLMStudio(constructorConfig);
|
||||
logInfo(`[ChatModelManager] Using Responses API for LM Studio model: ${model.name}`);
|
||||
return lmStudioInstance;
|
||||
|
|
@ -913,8 +916,8 @@ export default class ChatModelManager {
|
|||
|
||||
if (
|
||||
modelInfo.isGPT5 &&
|
||||
(model.provider === ChatModelProviders.OPENAI ||
|
||||
model.provider === ChatModelProviders.OPENAI_FORMAT)
|
||||
((model.provider as ChatModelProviders) === ChatModelProviders.OPENAI ||
|
||||
(model.provider as ChatModelProviders) === ChatModelProviders.OPENAI_FORMAT)
|
||||
) {
|
||||
constructorConfig.useResponsesApi = true;
|
||||
}
|
||||
|
|
@ -926,7 +929,8 @@ export default class ChatModelManager {
|
|||
// For LM Studio with Responses API, ping via ChatLMStudio so the
|
||||
// connectivity check hits the same /v1/responses endpoint used in chats.
|
||||
const testModel =
|
||||
model.provider === ChatModelProviders.LM_STUDIO && model.useResponsesApi !== false
|
||||
(model.provider as ChatModelProviders) === ChatModelProviders.LM_STUDIO &&
|
||||
model.useResponsesApi !== false
|
||||
? new ChatLMStudio(constructorConfig)
|
||||
: useCopilotResponses
|
||||
? new GitHubCopilotResponsesModel(constructorConfig)
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ export default class EmbeddingManager {
|
|||
return emb.modelName as string;
|
||||
} else {
|
||||
throw new Error(
|
||||
`Embeddings instance missing model or modelName properties: ${embeddingsInstance}`
|
||||
`Embeddings instance missing model or modelName properties: ${JSON.stringify(embeddingsInstance)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -140,7 +140,7 @@ export default class EmbeddingManager {
|
|||
const settings = getSettings();
|
||||
const embeddingModelKey = settings.embeddingModelKey;
|
||||
|
||||
if (!EmbeddingManager.modelMap.hasOwnProperty(embeddingModelKey)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(EmbeddingManager.modelMap, embeddingModelKey)) {
|
||||
throw new CustomError(`No embedding model found for: ${embeddingModelKey}`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ export class GitHubCopilotChatModel extends ChatOpenAICompletions {
|
|||
const { fetchImplementation, configuration, apiKey, ...rest } = fields;
|
||||
|
||||
const provider = GitHubCopilotProvider.getInstance();
|
||||
// scorecard: streaming requires fetch — cannot use requestUrl
|
||||
const baseFetch = fetchImplementation ?? (configuration?.fetch as FetchImplementation) ?? fetch;
|
||||
const authedFetch = GitHubCopilotChatModel.buildAuthedFetch(provider, baseFetch);
|
||||
|
||||
|
|
@ -164,6 +165,7 @@ export class GitHubCopilotChatModel extends ChatOpenAICompletions {
|
|||
// each delta is a single-use streaming chunk.
|
||||
delta.content = normalizeDeltaContent(delta.content);
|
||||
|
||||
// scorecard: kept for backwards compat with ChatOpenAICompletions
|
||||
return super._convertCompletionsDeltaToBaseMessageChunk(delta, rawResponse, defaultRole);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -107,7 +107,9 @@ export default class ProjectManager {
|
|||
!nextProjects.some((p) => p.id === this.currentProjectId) &&
|
||||
getCurrentProject()?.id === this.currentProjectId
|
||||
) {
|
||||
logWarn(`[ProjectManager] Active project id="${this.currentProjectId}" no longer exists, clearing selection`);
|
||||
logWarn(
|
||||
`[ProjectManager] Active project id="${this.currentProjectId}" no longer exists, clearing selection`
|
||||
);
|
||||
void this.switchProject(null).catch((err) =>
|
||||
logError("[ProjectManager] Failed to switch away from removed project", err)
|
||||
);
|
||||
|
|
|
|||
19
src/__tests__/mockObsidian.ts
Normal file
19
src/__tests__/mockObsidian.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { TFile, TFolder } from "obsidian";
|
||||
|
||||
/**
|
||||
* Construct a TFile-typed mock from a partial spec. Uses the real TFile prototype
|
||||
* so `instanceof TFile` returns true. Test helper — keeps `as TFile` casts out of
|
||||
* test files so the `no-restricted-syntax` rule stays clean.
|
||||
*/
|
||||
export function mockTFile<T extends Partial<TFile>>(props: T): TFile {
|
||||
const file: TFile = Object.create(TFile.prototype);
|
||||
Object.assign(file, props);
|
||||
return file;
|
||||
}
|
||||
|
||||
/** TFolder counterpart to mockTFile. */
|
||||
export function mockTFolder<T extends Partial<TFolder>>(props: T): TFolder {
|
||||
const folder: TFolder = Object.create(TFolder.prototype);
|
||||
Object.assign(folder, props);
|
||||
return folder;
|
||||
}
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
import { CustomModel, useModelKey } from "@/aiParams";
|
||||
import { processCommandPrompt } from "@/commands/customCommandUtils";
|
||||
import { MenuCommandModal, type ContentState } from "@/components/command-ui";
|
||||
import { MODAL_MIN_HEIGHT_COMPACT, MODAL_MIN_HEIGHT_EXPANDED } from "@/components/command-ui/constants";
|
||||
import {
|
||||
MODAL_MIN_HEIGHT_COMPACT,
|
||||
MODAL_MIN_HEIGHT_EXPANDED,
|
||||
} from "@/components/command-ui/constants";
|
||||
import { SelectionHighlight } from "@/editor/selectionHighlight";
|
||||
import { createHighlightReplaceGuard, type ReplaceGuard } from "@/editor/replaceGuard";
|
||||
import { logError, logWarn } from "@/logger";
|
||||
|
|
@ -520,7 +523,11 @@ export class CustomCommandChatModal {
|
|||
* - Space checks use scrollRect (editor visible area), not window.
|
||||
* - Horizontal clamp to scrollRect first, then viewport as safety net.
|
||||
*/
|
||||
private getInitialPosition(activeView: MarkdownView | null): { x: number; y: number; anchorBottom?: number } {
|
||||
private getInitialPosition(activeView: MarkdownView | null): {
|
||||
x: number;
|
||||
y: number;
|
||||
anchorBottom?: number;
|
||||
} {
|
||||
const win = this.resolveWindow(activeView);
|
||||
const panelWidth = Math.min(500, win.innerWidth * 0.9);
|
||||
// Reason: The actual initial panel height depends on whether ContentArea is shown.
|
||||
|
|
@ -528,7 +535,9 @@ export class CustomCommandChatModal {
|
|||
// Custom Commands always show ContentArea (expanded).
|
||||
// Using the correct height prevents gaps (above) or overlaps (below).
|
||||
const hideContentAreaOnIdle = this.configs.behaviorConfig?.hideContentAreaOnIdle ?? false;
|
||||
const panelHeight = hideContentAreaOnIdle ? MODAL_MIN_HEIGHT_COMPACT : MODAL_MIN_HEIGHT_EXPANDED;
|
||||
const panelHeight = hideContentAreaOnIdle
|
||||
? MODAL_MIN_HEIGHT_COMPACT
|
||||
: MODAL_MIN_HEIGHT_EXPANDED;
|
||||
const margin = 12;
|
||||
const gap = 6;
|
||||
|
||||
|
|
@ -583,8 +592,11 @@ export class CustomCommandChatModal {
|
|||
(topCoords?.bottom ?? 0) - (topCoords?.top ?? 0),
|
||||
(bottomCoords?.bottom ?? 0) - (bottomCoords?.top ?? 0)
|
||||
);
|
||||
const isVisualMultiLine = !isCursor && topCoords && bottomCoords
|
||||
&& Math.abs(topCoords.top - bottomCoords.top) > Math.max(caretHeight / 2, 2);
|
||||
const isVisualMultiLine =
|
||||
!isCursor &&
|
||||
topCoords &&
|
||||
bottomCoords &&
|
||||
Math.abs(topCoords.top - bottomCoords.top) > Math.max(caretHeight / 2, 2);
|
||||
|
||||
// --- Vertical positioning (decides placement first) ---
|
||||
// Reason: Extracted to a pure helper (computeVerticalPlacement) so the
|
||||
|
|
|
|||
|
|
@ -56,11 +56,13 @@ export class CustomCommandManager {
|
|||
// Ensure nested folders are created cross-platform
|
||||
await ensureFolderExists(folderPath);
|
||||
|
||||
let commandFile = app.vault.getAbstractFileByPath(filePath) as TFile;
|
||||
if (!commandFile || !(commandFile instanceof TFile)) {
|
||||
commandFile = await app.vault.create(filePath, command.content);
|
||||
const existingFile = app.vault.getAbstractFileByPath(filePath);
|
||||
let commandFile: TFile;
|
||||
if (existingFile instanceof TFile) {
|
||||
await app.vault.modify(existingFile, command.content);
|
||||
commandFile = existingFile;
|
||||
} else {
|
||||
await app.vault.modify(commandFile, command.content);
|
||||
commandFile = await app.vault.create(filePath, command.content);
|
||||
}
|
||||
|
||||
await app.fileManager.processFrontMatter(commandFile, (frontmatter) => {
|
||||
|
|
|
|||
|
|
@ -38,8 +38,9 @@ export class CustomCommandRegister {
|
|||
|
||||
/**
|
||||
* Register all custom commands found in the custom commands folder.
|
||||
* Synchronous: iterates cached commands and registers each.
|
||||
*/
|
||||
private async registerCommands() {
|
||||
private registerCommands() {
|
||||
const commands = getCachedCustomCommands();
|
||||
commands.forEach((command) => {
|
||||
this.registerCommand(command);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { PromptSortStrategy } from "@/types";
|
|||
import { sortSlashCommands } from "@/commands/customCommandUtils";
|
||||
import { DEFAULT_SETTINGS } from "@/constants";
|
||||
import { logWarn } from "@/logger";
|
||||
import { mockTFile } from "@/__tests__/mockObsidian";
|
||||
|
||||
// Mock Obsidian
|
||||
jest.mock("obsidian", () => ({
|
||||
|
|
@ -64,10 +65,10 @@ describe("processedPrompt()", () => {
|
|||
}),
|
||||
},
|
||||
} as unknown as Vault;
|
||||
mockActiveNote = {
|
||||
mockActiveNote = mockTFile({
|
||||
path: "path/to/active/note.md",
|
||||
basename: "Active Note",
|
||||
} as TFile;
|
||||
});
|
||||
});
|
||||
|
||||
it("should add 1 context and selectedText", async () => {
|
||||
|
|
@ -84,7 +85,7 @@ describe("processedPrompt()", () => {
|
|||
|
||||
(getFileContent as jest.Mock).mockResolvedValueOnce("here is the note content for note0");
|
||||
(getFileName as jest.Mock).mockReturnValueOnce("Variable Note");
|
||||
(getNotesFromPath as jest.Mock).mockResolvedValueOnce([mockActiveNote]);
|
||||
(getNotesFromPath as jest.Mock).mockReturnValueOnce([mockActiveNote]);
|
||||
|
||||
const result = await processPrompt(doc.content, selectedText, mockVault, mockActiveNote);
|
||||
|
||||
|
|
@ -113,11 +114,11 @@ describe("processedPrompt()", () => {
|
|||
const { getFileName } = jest.requireMock("@/utils") as any;
|
||||
getFileName.mockReturnValueOnce("Variable1 Note").mockReturnValueOnce("Variable2 Note");
|
||||
|
||||
const mockNote1 = { path: "path/to/note1.md", basename: "Variable1 Note" } as TFile;
|
||||
const mockNote2 = { path: "path/to/note2.md", basename: "Variable2 Note" } as TFile;
|
||||
const mockNote1 = mockTFile({ path: "path/to/note1.md", basename: "Variable1 Note" });
|
||||
const mockNote2 = mockTFile({ path: "path/to/note2.md", basename: "Variable2 Note" });
|
||||
(getNotesFromPath as jest.Mock)
|
||||
.mockResolvedValueOnce([mockNote1])
|
||||
.mockResolvedValueOnce([mockNote2]);
|
||||
.mockReturnValueOnce([mockNote1])
|
||||
.mockReturnValueOnce([mockNote2]);
|
||||
|
||||
const result = await processPrompt(doc.content, selectedText, mockVault, mockActiveNote);
|
||||
|
||||
|
|
@ -228,13 +229,13 @@ describe("processedPrompt()", () => {
|
|||
const selectedText = "";
|
||||
|
||||
// Mock note file for the tag
|
||||
const mockNoteForTag = {
|
||||
const mockNoteForTag = mockTFile({
|
||||
path: "path/to/tagged/note.md",
|
||||
basename: "Tagged Note",
|
||||
} as TFile;
|
||||
});
|
||||
|
||||
// Mock getNotesFromTags to return our mock note
|
||||
(getNotesFromTags as jest.Mock).mockResolvedValue([mockNoteForTag]);
|
||||
(getNotesFromTags as jest.Mock).mockReturnValue([mockNoteForTag]);
|
||||
|
||||
// Mock getFileName to return the basename
|
||||
const { getFileName } = jest.requireMock("@/utils") as any;
|
||||
|
|
@ -256,17 +257,17 @@ describe("processedPrompt()", () => {
|
|||
const selectedText = "";
|
||||
|
||||
// Mock note files for the tags
|
||||
const mockNoteForTag1 = {
|
||||
const mockNoteForTag1 = mockTFile({
|
||||
basename: "Tagged Note 1",
|
||||
path: "path/to/tagged/note1.md",
|
||||
} as TFile;
|
||||
const mockNoteForTag2 = {
|
||||
});
|
||||
const mockNoteForTag2 = mockTFile({
|
||||
basename: "Tagged Note 2",
|
||||
path: "path/to/tagged/note2.md",
|
||||
} as TFile;
|
||||
});
|
||||
|
||||
// Mock getNotesFromTags to return our mock notes
|
||||
(getNotesFromTags as jest.Mock).mockResolvedValue([mockNoteForTag1, mockNoteForTag2]);
|
||||
(getNotesFromTags as jest.Mock).mockReturnValue([mockNoteForTag1, mockNoteForTag2]);
|
||||
|
||||
// Mock getFileName to return the basename
|
||||
const { getFileName } = jest.requireMock("@/utils") as any;
|
||||
|
|
@ -294,7 +295,7 @@ describe("processedPrompt()", () => {
|
|||
it("should process [[note title]] syntax correctly", async () => {
|
||||
const customPrompt = "Content of [[Test Note]] is important.";
|
||||
const selectedText = "";
|
||||
const mockTestNote = { basename: "Test Note", path: "Test Note.md" } as TFile;
|
||||
const mockTestNote = mockTFile({ basename: "Test Note", path: "Test Note.md" });
|
||||
|
||||
// Mock the necessary functions
|
||||
(extractTemplateNoteFiles as jest.Mock).mockReturnValue([mockTestNote]);
|
||||
|
|
@ -316,10 +317,10 @@ describe("processedPrompt()", () => {
|
|||
const selectedText = "";
|
||||
|
||||
// Mock the necessary functions
|
||||
const mockNoteFile = {
|
||||
const mockNoteFile = mockTFile({
|
||||
basename: "Test Note",
|
||||
path: "Test Note.md",
|
||||
} as TFile;
|
||||
});
|
||||
|
||||
(extractTemplateNoteFiles as jest.Mock).mockReturnValue([mockNoteFile]);
|
||||
|
||||
|
|
@ -329,7 +330,7 @@ describe("processedPrompt()", () => {
|
|||
(getFileContent as jest.Mock).mockResolvedValue("Test note content");
|
||||
|
||||
// Mock getNotesFromPath to return our mock note
|
||||
(getNotesFromPath as jest.Mock).mockResolvedValue([mockNoteFile]);
|
||||
(getNotesFromPath as jest.Mock).mockReturnValue([mockNoteFile]);
|
||||
|
||||
const result = await processPrompt(customPrompt, selectedText, mockVault, mockActiveNote);
|
||||
|
||||
|
|
@ -353,10 +354,10 @@ describe("processedPrompt()", () => {
|
|||
const selectedText = "";
|
||||
|
||||
// Mock the necessary functions
|
||||
const mockNote1 = {
|
||||
const mockNote1 = mockTFile({
|
||||
basename: "Note1",
|
||||
path: "Note1.md",
|
||||
} as TFile;
|
||||
});
|
||||
|
||||
// Only Note1 should be extracted since it's wrapped in {[[]]}
|
||||
(extractTemplateNoteFiles as jest.Mock).mockReturnValue([mockNote1]);
|
||||
|
|
@ -372,7 +373,7 @@ describe("processedPrompt()", () => {
|
|||
});
|
||||
|
||||
// Mock getNotesFromPath to return our mock note
|
||||
(getNotesFromPath as jest.Mock).mockResolvedValue([mockNote1]);
|
||||
(getNotesFromPath as jest.Mock).mockReturnValue([mockNote1]);
|
||||
|
||||
const result = await processPrompt(customPrompt, selectedText, mockVault, mockActiveNote);
|
||||
|
||||
|
|
@ -394,13 +395,13 @@ describe("processedPrompt()", () => {
|
|||
it("should handle multiple occurrences of {[[note title]]} syntax", async () => {
|
||||
const customPrompt = "{[[Note1]]} is related to {[[Note2]]} and {[[Note3]]}.";
|
||||
const selectedText = "";
|
||||
const mockNote1 = { basename: "Note1", path: "Note1.md" } as TFile;
|
||||
const mockNote2 = { basename: "Note2", path: "Note2.md" } as TFile;
|
||||
const mockNote3 = { basename: "Note3", path: "Note3.md" } as TFile;
|
||||
const mockNote1 = mockTFile({ basename: "Note1", path: "Note1.md" });
|
||||
const mockNote2 = mockTFile({ basename: "Note2", path: "Note2.md" });
|
||||
const mockNote3 = mockTFile({ basename: "Note3", path: "Note3.md" });
|
||||
|
||||
// Mock the necessary functions
|
||||
(extractTemplateNoteFiles as jest.Mock).mockReturnValue([mockNote1, mockNote2, mockNote3]);
|
||||
(getNotesFromPath as jest.Mock).mockResolvedValue([]);
|
||||
(getNotesFromPath as jest.Mock).mockReturnValue([]);
|
||||
(getFileContent as jest.Mock).mockImplementation((file: TFile) => {
|
||||
if (file.basename === "Note1") {
|
||||
return "Note1 content";
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@ export function sortCommandsByAlphabetical(commands: CustomCommand[]): CustomCom
|
|||
* Sort prompts of the slash commands based on the sort strategy.
|
||||
*/
|
||||
export function sortSlashCommands(commands: CustomCommand[]): CustomCommand[] {
|
||||
const sortStrategy = getSettings().promptSortStrategy;
|
||||
const sortStrategy: PromptSortStrategy = getSettings().promptSortStrategy as PromptSortStrategy;
|
||||
switch (sortStrategy) {
|
||||
case PromptSortStrategy.TIMESTAMP:
|
||||
return sortCommandsByRecency(commands);
|
||||
|
|
@ -295,7 +295,7 @@ async function extractVariablesFromPrompt(
|
|||
.slice(1)
|
||||
.split(",")
|
||||
.map((tag) => tag.trim());
|
||||
const noteFiles = await getNotesFromTags(vault, tagNames);
|
||||
const noteFiles = getNotesFromTags(vault, tagNames);
|
||||
const notesContent: string[] = [];
|
||||
for (const file of noteFiles) {
|
||||
const content = await getFileContent(file, vault);
|
||||
|
|
@ -309,7 +309,7 @@ async function extractVariablesFromPrompt(
|
|||
variableResult.content = notesContent.join("\n\n");
|
||||
} else {
|
||||
const processedVariableName = processVariableNameForNotePath(variableName);
|
||||
const noteFiles = await getNotesFromPath(vault, processedVariableName);
|
||||
const noteFiles = getNotesFromPath(vault, processedVariableName);
|
||||
const notesContent: string[] = [];
|
||||
for (const file of noteFiles) {
|
||||
const content = await getFileContent(file, vault);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { processPrompt } from "@/commands/customCommandUtils";
|
||||
import { TFile, Vault } from "obsidian";
|
||||
import { getFileContent, getFileName, getNotesFromPath } from "@/utils";
|
||||
import { mockTFile } from "@/__tests__/mockObsidian";
|
||||
|
||||
// Mock the dependencies
|
||||
jest.mock("@/utils", () => ({
|
||||
|
|
@ -35,10 +36,10 @@ describe("XML Escaping in processPrompt", () => {
|
|||
},
|
||||
} as unknown as Vault;
|
||||
|
||||
mockActiveNote = {
|
||||
mockActiveNote = mockTFile({
|
||||
path: "path/to/active/note.md",
|
||||
basename: "Active Note",
|
||||
} as TFile;
|
||||
});
|
||||
});
|
||||
|
||||
it("should NOT escape XML special characters in selected text", async () => {
|
||||
|
|
@ -59,12 +60,12 @@ describe("XML Escaping in processPrompt", () => {
|
|||
it("should NOT escape XML in variable names", async () => {
|
||||
const customPrompt = 'Use {my"variable<>}';
|
||||
|
||||
const mockNote = {
|
||||
const mockNote = mockTFile({
|
||||
basename: 'Note with <special> & "chars"',
|
||||
path: "special.md",
|
||||
} as TFile;
|
||||
});
|
||||
|
||||
(getNotesFromPath as jest.Mock).mockResolvedValue([mockNote]);
|
||||
(getNotesFromPath as jest.Mock).mockReturnValue([mockNote]);
|
||||
(getFileName as jest.Mock).mockReturnValue(mockNote.basename);
|
||||
(getFileContent as jest.Mock).mockResolvedValue('Content with <xml> & special "chars"');
|
||||
|
||||
|
|
@ -102,12 +103,12 @@ describe("XML Escaping in processPrompt", () => {
|
|||
it("should NOT escape XML in note paths and metadata", async () => {
|
||||
const customPrompt = "[[Special Note]]";
|
||||
|
||||
const mockNote = {
|
||||
const mockNote = mockTFile({
|
||||
basename: 'Special & "Note"',
|
||||
path: 'folder<with>/special&chars/"note".md',
|
||||
} as TFile;
|
||||
});
|
||||
|
||||
(getNotesFromPath as jest.Mock).mockResolvedValue([]);
|
||||
(getNotesFromPath as jest.Mock).mockReturnValue([]);
|
||||
jest.requireMock("@/utils").extractTemplateNoteFiles.mockReturnValue([mockNote]);
|
||||
(getFileContent as jest.Mock).mockResolvedValue("Content with & and < and >");
|
||||
|
||||
|
|
@ -126,13 +127,13 @@ describe("XML Escaping in processPrompt", () => {
|
|||
it("should NOT escape XML in tag variables", async () => {
|
||||
const customPrompt = "Notes for {#tag&special}";
|
||||
|
||||
const mockNote = {
|
||||
const mockNote = mockTFile({
|
||||
basename: 'Tagged & "Note"',
|
||||
path: "tagged.md",
|
||||
} as TFile;
|
||||
});
|
||||
|
||||
(getNotesFromPath as jest.Mock).mockResolvedValue([]);
|
||||
jest.requireMock("@/utils").getNotesFromTags.mockResolvedValue([mockNote]);
|
||||
(getNotesFromPath as jest.Mock).mockReturnValue([]);
|
||||
jest.requireMock("@/utils").getNotesFromTags.mockReturnValue([mockNote]);
|
||||
(getFileName as jest.Mock).mockReturnValue(mockNote.basename);
|
||||
(getFileContent as jest.Mock).mockResolvedValue("Content: <tag> & </tag>");
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ import { COMMAND_IDS, COMMAND_ICONS, COMMAND_NAMES, CommandId } from "../constan
|
|||
import { setSelectedTextContexts } from "@/aiParams";
|
||||
|
||||
/**
|
||||
* Add a command to the plugin.
|
||||
* Add a command to the plugin. Supports async callbacks; errors are logged.
|
||||
*/
|
||||
export function addCommand(plugin: CopilotPlugin, id: CommandId, callback: () => void) {
|
||||
plugin.addCommand({
|
||||
|
|
@ -83,7 +83,7 @@ export function registerCommands(
|
|||
next: CopilotSettings
|
||||
) {
|
||||
addEditorCommand(plugin, COMMAND_IDS.COUNT_WORD_AND_TOKENS_SELECTION, async (editor: Editor) => {
|
||||
const selectedText = await editor.getSelection();
|
||||
const selectedText = editor.getSelection();
|
||||
const wordCount = selectedText.split(" ").length;
|
||||
const tokenCount = await plugin.projectManager
|
||||
.getCurrentChainManager()
|
||||
|
|
@ -108,13 +108,13 @@ export function registerCommands(
|
|||
plugin.toggleView();
|
||||
});
|
||||
|
||||
addCommand(plugin, COMMAND_IDS.OPEN_COPILOT_CHAT_WINDOW, () => {
|
||||
plugin.activateView();
|
||||
addCommand(plugin, COMMAND_IDS.OPEN_COPILOT_CHAT_WINDOW, async () => {
|
||||
await plugin.activateView();
|
||||
});
|
||||
|
||||
addCommand(plugin, COMMAND_IDS.NEW_CHAT, () => {
|
||||
addCommand(plugin, COMMAND_IDS.NEW_CHAT, async () => {
|
||||
clearRecordedPromptPayload();
|
||||
plugin.newChat();
|
||||
await plugin.newChat();
|
||||
});
|
||||
|
||||
// Quick Command - opens a modal dialog for quick interactions
|
||||
|
|
@ -296,8 +296,8 @@ export function registerCommands(
|
|||
}
|
||||
});
|
||||
|
||||
addCommand(plugin, COMMAND_IDS.LOAD_COPILOT_CHAT_CONVERSATION, () => {
|
||||
plugin.loadCopilotChatHistory();
|
||||
addCommand(plugin, COMMAND_IDS.LOAD_COPILOT_CHAT_CONVERSATION, async () => {
|
||||
await plugin.loadCopilotChatHistory();
|
||||
});
|
||||
|
||||
addCommand(plugin, COMMAND_IDS.LIST_INDEXED_FILES, async () => {
|
||||
|
|
@ -377,16 +377,16 @@ export function registerCommands(
|
|||
await ensureFolderExists(folderPath);
|
||||
|
||||
const existingFile = plugin.app.vault.getAbstractFileByPath(filePath);
|
||||
if (existingFile) {
|
||||
await plugin.app.vault.modify(existingFile as TFile, content);
|
||||
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) {
|
||||
await plugin.app.workspace.getLeaf().openFile(file as TFile);
|
||||
if (file instanceof TFile) {
|
||||
await plugin.app.workspace.getLeaf().openFile(file);
|
||||
new Notice(`Listed ${indexedFiles.size} indexed files`);
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -449,15 +449,15 @@ export function registerCommands(
|
|||
await ensureFolderExists(folderPath);
|
||||
|
||||
const existingFile = plugin.app.vault.getAbstractFileByPath(filePath);
|
||||
if (existingFile) {
|
||||
await plugin.app.vault.modify(existingFile as TFile, content);
|
||||
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) {
|
||||
await plugin.app.workspace.getLeaf().openFile(file as TFile);
|
||||
if (file instanceof TFile) {
|
||||
await plugin.app.workspace.getLeaf().openFile(file);
|
||||
new Notice(`Embedding debug info for ${chunks.length} chunk(s)`);
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -546,7 +546,7 @@ export function registerCommands(
|
|||
setSelectedTextContexts([selectedTextContext]);
|
||||
|
||||
// Open chat window to show the context was added
|
||||
plugin.activateView();
|
||||
await plugin.activateView();
|
||||
});
|
||||
|
||||
// Add web selection to chat context command (manual)
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ export async function generateDefaultCommands(): Promise<void> {
|
|||
(command) => !existingCommands.some((c) => c.title === command.title)
|
||||
);
|
||||
const newCommands = [...existingCommands, ...defaultCommands];
|
||||
CustomCommandManager.getInstance().updateCommands(newCommands);
|
||||
await CustomCommandManager.getInstance().updateCommands(newCommands);
|
||||
}
|
||||
|
||||
/** Suggests the default commands if the user has not created any commands yet. */
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ export default class CopilotView extends ItemView {
|
|||
this.keyboardObserver?.disconnect();
|
||||
|
||||
const syncKeyboardClass = () => {
|
||||
const drawer = this.containerEl.closest(".workspace-drawer") as HTMLElement | null;
|
||||
const drawer = this.containerEl.closest<HTMLElement>(".workspace-drawer");
|
||||
|
||||
// Reason: If the view moved out of its previous drawer, clear the class on the old one
|
||||
// so drawer chrome (header/tab options) is restored.
|
||||
|
|
@ -138,7 +138,7 @@ export default class CopilotView extends ItemView {
|
|||
|
||||
this.drawerHideObserver?.disconnect();
|
||||
|
||||
const drawer = this.containerEl.closest(".workspace-drawer") as HTMLElement | null;
|
||||
const drawer = this.containerEl.closest<HTMLElement>(".workspace-drawer");
|
||||
if (!drawer) return;
|
||||
|
||||
let wasHidden = drawer.classList.contains("is-hidden");
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ jest.mock("@/LLMProviders/chainRunner/utils/citationUtils", () => ({
|
|||
}));
|
||||
|
||||
jest.mock("obsidian", () => {
|
||||
const renderMarkdown = jest.fn();
|
||||
const renderMarkdown = jest.fn().mockResolvedValue(undefined);
|
||||
return {
|
||||
MarkdownRenderer: {
|
||||
renderMarkdown,
|
||||
|
|
@ -93,6 +93,7 @@ describe("think block rendering — closing tags are not consumed by indented co
|
|||
|
||||
beforeEach(() => {
|
||||
renderMarkdownMock.mockReset();
|
||||
renderMarkdownMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
|
|
@ -122,7 +123,7 @@ describe("think block rendering — closing tags are not consumed by indented co
|
|||
const messageText = `<think>${thinkContent}</think>Here is my answer.`;
|
||||
|
||||
const capturedMarkdown: string[] = [];
|
||||
renderMarkdownMock.mockImplementation((md: string, el: HTMLElement) => {
|
||||
renderMarkdownMock.mockImplementation(async (md: string, el: HTMLElement) => {
|
||||
capturedMarkdown.push(md);
|
||||
el.innerHTML = "<p>rendered</p>";
|
||||
});
|
||||
|
|
@ -148,7 +149,7 @@ describe("think block rendering — closing tags are not consumed by indented co
|
|||
const messageText = `<think>${thinkContent}</think>Response text.`;
|
||||
|
||||
const capturedMarkdown: string[] = [];
|
||||
renderMarkdownMock.mockImplementation((md: string, el: HTMLElement) => {
|
||||
renderMarkdownMock.mockImplementation(async (md: string, el: HTMLElement) => {
|
||||
capturedMarkdown.push(md);
|
||||
el.innerHTML = "<p>rendered</p>";
|
||||
});
|
||||
|
|
@ -174,7 +175,7 @@ describe("think block rendering — closing tags are not consumed by indented co
|
|||
const messageText = "<think>Thinking:\n * Still streaming.";
|
||||
|
||||
const capturedMarkdown: string[] = [];
|
||||
renderMarkdownMock.mockImplementation((md: string, el: HTMLElement) => {
|
||||
renderMarkdownMock.mockImplementation(async (md: string, el: HTMLElement) => {
|
||||
capturedMarkdown.push(md);
|
||||
el.innerHTML = "<p>rendered</p>";
|
||||
});
|
||||
|
|
@ -199,6 +200,7 @@ describe("think block rendering — closing tags are not consumed by indented co
|
|||
describe("normalizeFootnoteRendering", () => {
|
||||
beforeEach(() => {
|
||||
renderMarkdownMock.mockReset();
|
||||
renderMarkdownMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("removes separator and backref while preserving non-footnote elements", () => {
|
||||
|
|
@ -263,6 +265,7 @@ describe("ChatSingleMessage", () => {
|
|||
|
||||
beforeEach(() => {
|
||||
renderMarkdownMock.mockReset();
|
||||
renderMarkdownMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
|
|
@ -270,7 +273,7 @@ describe("ChatSingleMessage", () => {
|
|||
});
|
||||
|
||||
it("normalizes rendered footnotes for assistant messages", async () => {
|
||||
renderMarkdownMock.mockImplementation((_markdown: string, el: HTMLElement) => {
|
||||
renderMarkdownMock.mockImplementation(async (_markdown: string, el: HTMLElement) => {
|
||||
el.innerHTML = `
|
||||
<p>Example <sup><a href="#fn-2">2-1</a></sup></p>
|
||||
<hr class="content-hr" />
|
||||
|
|
|
|||
|
|
@ -650,7 +650,7 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
|
|||
const reasoningBlockData = parseReasoningBlock(originMessage);
|
||||
if (reasoningBlockData?.hasReasoning && reasoningBlockData.status !== "idle") {
|
||||
setReasoningData({
|
||||
status: reasoningBlockData.status as "reasoning" | "collapsed" | "complete",
|
||||
status: reasoningBlockData.status,
|
||||
elapsedSeconds: reasoningBlockData.elapsedSeconds,
|
||||
steps: reasoningBlockData.steps,
|
||||
});
|
||||
|
|
@ -726,7 +726,7 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
|
|||
messageId.current,
|
||||
rootsRef.current,
|
||||
toolCallId,
|
||||
container as HTMLElement,
|
||||
container,
|
||||
"render refresh"
|
||||
);
|
||||
|
||||
|
|
@ -760,7 +760,7 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
|
|||
messageId.current,
|
||||
errorRootsRef.current,
|
||||
errorId,
|
||||
container as HTMLElement,
|
||||
container,
|
||||
"error render"
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -49,8 +49,8 @@ export class ChatViewLayout {
|
|||
|
||||
const syncClearance = () => {
|
||||
// Re-query each time to avoid stale references after theme reloads.
|
||||
const statusBar = document.querySelector(".status-bar") as HTMLElement | null;
|
||||
const viewContent = this.containerEl.querySelector(".view-content") as HTMLElement | null;
|
||||
const statusBar = document.querySelector<HTMLElement>(".status-bar");
|
||||
const viewContent = this.containerEl.querySelector<HTMLElement>(".view-content");
|
||||
if (!statusBar || !viewContent) return;
|
||||
|
||||
// Zero out clearance and force reflow to measure natural overlap.
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export const InlineMessageEditor: React.FC<InlineMessageEditorProps> = ({
|
|||
|
||||
// Convert initialContext to the format expected by ChatInput
|
||||
const [contextNotes, setContextNotes] = useState<TFile[]>(
|
||||
initialContext?.notes?.map((note) => note as TFile) || []
|
||||
initialContext?.notes?.filter((note): note is TFile => note instanceof TFile) || []
|
||||
);
|
||||
const [includeActiveNote, setIncludeActiveNote] = useState(false);
|
||||
const [includeActiveWebTab, setIncludeActiveWebTab] = useState(false);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React from "react";
|
|||
import { render } from "@testing-library/react";
|
||||
import { ChatMessage } from "@/types/message";
|
||||
import { TFile } from "obsidian";
|
||||
import { mockTFile } from "@/__tests__/mockObsidian";
|
||||
|
||||
// Mock Tooltip components
|
||||
jest.mock("@radix-ui/react-tooltip", () => ({
|
||||
|
|
@ -45,11 +46,11 @@ function MessageContext({ context }: { context: ChatMessage["context"] }) {
|
|||
|
||||
describe("MessageContext", () => {
|
||||
const createMockFile = (path: string, basename: string): TFile =>
|
||||
({
|
||||
mockTFile({
|
||||
path,
|
||||
basename,
|
||||
// Add other required TFile properties as needed
|
||||
}) as TFile;
|
||||
});
|
||||
|
||||
describe("Duplicate Notes Bug Prevention", () => {
|
||||
it("should render duplicate notes without React key conflicts", () => {
|
||||
|
|
|
|||
|
|
@ -141,7 +141,9 @@ function ProjectItem({
|
|||
if (fileInCache) {
|
||||
app.workspace.openLinkText(record.filePath, "", true);
|
||||
} else {
|
||||
new Notice("Project file is in a hidden folder and cannot be opened from the file explorer.");
|
||||
new Notice(
|
||||
"Project file is in a hidden folder and cannot be opened from the file explorer."
|
||||
);
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -31,8 +31,8 @@ export function KeyboardPlugin({ onSubmit, sendShortcut }: KeyboardPluginProps)
|
|||
|
||||
// Ignore Enter key during IME composition (e.g., Chinese, Japanese, Korean input).
|
||||
// event.isComposing is set by the browser while a composition session is active.
|
||||
// keyCode 229 is the legacy indicator used by some environments during IME input.
|
||||
if (event.isComposing || event.keyCode === 229) {
|
||||
// key "Process" is the standard indicator used during IME input.
|
||||
if (event.isComposing || event.key === "Process") {
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,18 +36,12 @@ export function PastePlugin({ enableURLPills = false, onImagePaste }: PastePlugi
|
|||
if (imageItems.length > 0) {
|
||||
event.preventDefault();
|
||||
|
||||
// Handle image processing asynchronously
|
||||
Promise.all(
|
||||
imageItems.map((item) => {
|
||||
const file = item.getAsFile();
|
||||
return file;
|
||||
})
|
||||
).then((files) => {
|
||||
const validFiles = files.filter((file) => file !== null);
|
||||
if (validFiles.length > 0) {
|
||||
onImagePaste(validFiles);
|
||||
}
|
||||
});
|
||||
// getAsFile returns synchronously; no Promise needed.
|
||||
const files = imageItems.map((item) => item.getAsFile());
|
||||
const validFiles = files.filter((file): file is File => file !== null);
|
||||
if (validFiles.length > 0) {
|
||||
onImagePaste(validFiles);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,8 +74,7 @@ export function $createPillNode(pillData: PillData) {
|
|||
case "webTabs":
|
||||
// WebTabContext has url, title, faviconUrl
|
||||
if (data && typeof data === "object" && "url" in data) {
|
||||
const webTab = data as WebTabContext;
|
||||
return $createWebTabPillNode(webTab.url, webTab.title, webTab.faviconUrl);
|
||||
return $createWebTabPillNode(data.url, data.title, data.faviconUrl);
|
||||
}
|
||||
break;
|
||||
case "activeWebTab":
|
||||
|
|
|
|||
|
|
@ -47,12 +47,7 @@ export function ActionButtons({
|
|||
{state === "result" && showInsertReplace && (
|
||||
<>
|
||||
{onCopy && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={onCopy}
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
<Button size="sm" variant="secondary" onClick={onCopy} title="Copy to clipboard">
|
||||
Copy
|
||||
</Button>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -55,10 +55,8 @@ export function ContentArea({
|
|||
const [isEditMode, setIsEditMode] = React.useState(false);
|
||||
|
||||
// Determine if we should show the markdown preview
|
||||
const isCompletedResult =
|
||||
state.type === "result" && !state.isStreaming;
|
||||
const showPreview =
|
||||
!!renderMarkdown && isCompletedResult && !isEditMode;
|
||||
const isCompletedResult = state.type === "result" && !state.isStreaming;
|
||||
const showPreview = !!renderMarkdown && isCompletedResult && !isEditMode;
|
||||
|
||||
// Reason: Reset to preview mode whenever a new generation starts.
|
||||
// This covers both type transitions (idle→loading) and same-type transitions
|
||||
|
|
@ -94,7 +92,8 @@ export function ContentArea({
|
|||
|
||||
// Markdown preview mode
|
||||
if (showPreview && renderMarkdown) {
|
||||
const previewContent = (editable && value !== undefined) ? value : (state as { text: string }).text;
|
||||
const previewContent =
|
||||
editable && value !== undefined ? value : (state as { text: string }).text;
|
||||
return (
|
||||
<div className={cn("tw-flex tw-min-h-0 tw-flex-1 tw-flex-col tw-px-4 tw-py-2", className)}>
|
||||
<div className="tw-relative tw-min-h-0 tw-flex-1 tw-overflow-auto tw-rounded-md tw-border tw-border-solid tw-px-3 tw-py-2">
|
||||
|
|
|
|||
|
|
@ -56,7 +56,13 @@ export function DraggableModal({
|
|||
closeOnEscapeFromOutside = false,
|
||||
anchorBottom,
|
||||
}: DraggableModalProps) {
|
||||
const { position, setPosition, dragRef, handleMouseDown: rawHandleMouseDown, isDragging } = useDraggable({
|
||||
const {
|
||||
position,
|
||||
setPosition,
|
||||
dragRef,
|
||||
handleMouseDown: rawHandleMouseDown,
|
||||
isDragging,
|
||||
} = useDraggable({
|
||||
initialPosition: initialPosition || {
|
||||
x: typeof window !== "undefined" ? (window.innerWidth - 500) / 2 : 100,
|
||||
y: typeof window !== "undefined" ? (window.innerHeight - 400) / 2 : 100,
|
||||
|
|
|
|||
|
|
@ -37,12 +37,11 @@ export function FollowUpInput({
|
|||
}: FollowUpInputProps) {
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Avoid submitting when Enter is used to confirm IME composition (e.g., Chinese/Japanese/Korean).
|
||||
// keyCode 229 is a legacy indicator for IME processing.
|
||||
// key "Process" is the standard indicator for IME processing.
|
||||
const nativeEvent = e.nativeEvent as KeyboardEvent & {
|
||||
isComposing?: boolean;
|
||||
keyCode?: number;
|
||||
};
|
||||
if (nativeEvent.isComposing || nativeEvent.keyCode === 229) {
|
||||
if (nativeEvent.isComposing || e.key === "Process") {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,11 +20,7 @@ import { ProjectContextBadgeList } from "@/components/project/ProjectContextBadg
|
|||
import { getModelKeyFromModel, useSettingsValue } from "@/settings/model";
|
||||
import { checkModelApiKey, err2String, randomUUID } from "@/utils";
|
||||
import { Settings } from "lucide-react";
|
||||
import {
|
||||
type UrlItem,
|
||||
parseProjectUrls,
|
||||
serializeProjectUrls,
|
||||
} from "@/utils/urlTagUtils";
|
||||
import { type UrlItem, parseProjectUrls, serializeProjectUrls } from "@/utils/urlTagUtils";
|
||||
import type CopilotPlugin from "@/main";
|
||||
import { App, Modal, Notice } from "obsidian";
|
||||
import React, { useMemo, useState } from "react";
|
||||
|
|
@ -91,8 +87,6 @@ function AddProjectModalContent({
|
|||
contextSource: formData.contextSource,
|
||||
});
|
||||
|
||||
|
||||
|
||||
const handleEditProjectContext = (projectDraft: ProjectConfig) => {
|
||||
const modal = new ContextManageModal(
|
||||
app,
|
||||
|
|
@ -211,9 +205,7 @@ function AddProjectModalContent({
|
|||
const saveData = { ...formData, name: trimmedName };
|
||||
|
||||
const requiredFields = ["name", "projectModelKey"];
|
||||
const missingFields = requiredFields.filter(
|
||||
(field) => !saveData[field as keyof ProjectConfig]
|
||||
);
|
||||
const missingFields = requiredFields.filter((field) => !saveData[field as keyof ProjectConfig]);
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
setTouched((prev) => ({
|
||||
|
|
|
|||
|
|
@ -1328,8 +1328,7 @@ function ContextManage({ initialProject, onSave, onCancel, app }: ContextManageP
|
|||
}
|
||||
onOpenCached={
|
||||
// Reason: only offer open for non-markdown files that have been processed
|
||||
!item.isIgnored &&
|
||||
item.id.split(".").pop()?.toLowerCase() !== "md"
|
||||
!item.isIgnored && item.id.split(".").pop()?.toLowerCase() !== "md"
|
||||
? () => {
|
||||
const name = item.name || item.id.split("/").pop() || item.id;
|
||||
openCachedProjectFile(app, projectCache, item.id, name);
|
||||
|
|
|
|||
|
|
@ -65,7 +65,11 @@ export function buildBadgeItems(value: string | undefined): BadgeItem[] {
|
|||
* Remove a pattern from a serialized pattern string.
|
||||
* Returns the new serialized string with the pattern removed.
|
||||
*/
|
||||
export function removePattern(value: string | undefined, pattern: string, type: PatternType): string {
|
||||
export function removePattern(
|
||||
value: string | undefined,
|
||||
pattern: string,
|
||||
type: PatternType
|
||||
): string {
|
||||
const patterns = [...new Set(getDecodedPatterns(value || ""))];
|
||||
const categorized = categorizePatterns(patterns);
|
||||
const categoryKey = CATEGORY_MAP[type];
|
||||
|
|
@ -171,9 +175,7 @@ export const ProjectContextBadgeList: React.FC<ProjectContextBadgeListProps> = (
|
|||
<div className="tw-rounded-md tw-border tw-border-dashed tw-border-border tw-py-4 tw-text-center">
|
||||
<span className="tw-text-sm tw-italic tw-text-muted">No file patterns configured</span>
|
||||
</div>
|
||||
{actionSlot && (
|
||||
<div className="tw-flex tw-justify-end">{actionSlot}</div>
|
||||
)}
|
||||
{actionSlot && <div className="tw-flex tw-justify-end">{actionSlot}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,11 +8,7 @@
|
|||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { TruncatedText } from "@/components/TruncatedText";
|
||||
import type { ProcessingItem } from "@/components/project/processingAdapter";
|
||||
|
|
@ -273,9 +269,7 @@ export function ProcessingStatus({
|
|||
key={item.id}
|
||||
item={item}
|
||||
onRetry={onRetry}
|
||||
onOpenCached={
|
||||
onOpenCachedItem ? () => onOpenCachedItem(item) : undefined
|
||||
}
|
||||
onOpenCached={onOpenCachedItem ? () => onOpenCachedItem(item) : undefined}
|
||||
/>
|
||||
))}
|
||||
</ScrollableList>
|
||||
|
|
@ -297,12 +291,8 @@ export function ProcessingStatus({
|
|||
key={item.id}
|
||||
item={item}
|
||||
onRetry={onRetry}
|
||||
onOpenCached={
|
||||
onOpenCachedItem ? () => onOpenCachedItem(item) : undefined
|
||||
}
|
||||
onRemove={
|
||||
onRemoveUrl ? () => onRemoveUrl(item) : undefined
|
||||
}
|
||||
onOpenCached={onOpenCachedItem ? () => onOpenCachedItem(item) : undefined}
|
||||
onRemove={onRemoveUrl ? () => onRemoveUrl(item) : undefined}
|
||||
/>
|
||||
))}
|
||||
</ScrollableList>
|
||||
|
|
@ -321,13 +311,7 @@ export function ProcessingStatus({
|
|||
* Scroll container with a bottom fade mask when content overflows.
|
||||
* Reuses the existing `.copilot-fade-mask-bottom` CSS class from PatternListEditor.
|
||||
*/
|
||||
function ScrollableList({
|
||||
maxHeight,
|
||||
children,
|
||||
}: {
|
||||
maxHeight: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
function ScrollableList({ maxHeight, children }: { maxHeight: string; children: React.ReactNode }) {
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isOverflowing, setIsOverflowing] = useState(false);
|
||||
|
||||
|
|
@ -379,10 +363,7 @@ function ProcessingItemRow({
|
|||
const isProcessing = item.status === "processing";
|
||||
const isFailed = item.status === "failed";
|
||||
// Reason: show open button for any item that is ready with actual content (file or URL)
|
||||
const canOpenCached =
|
||||
onOpenCached &&
|
||||
item.status === "ready" &&
|
||||
!item.contentEmpty;
|
||||
const canOpenCached = onOpenCached && item.status === "ready" && !item.contentEmpty;
|
||||
|
||||
return (
|
||||
<div className="tw-group tw-rounded-md tw-border tw-border-border tw-bg-primary tw-p-2.5">
|
||||
|
|
|
|||
|
|
@ -86,7 +86,18 @@ export interface BuildProcessingItemsOptions {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg", "tiff"]);
|
||||
const AUDIO_EXTENSIONS = new Set(["mp3", "wav", "m4a", "ogg", "flac", "aac", "mp4", "mpeg", "mpga", "webm"]);
|
||||
const AUDIO_EXTENSIONS = new Set([
|
||||
"mp3",
|
||||
"wav",
|
||||
"m4a",
|
||||
"ogg",
|
||||
"flac",
|
||||
"aac",
|
||||
"mp4",
|
||||
"mpeg",
|
||||
"mpga",
|
||||
"webm",
|
||||
]);
|
||||
|
||||
/** Infer file type from a file path's extension. */
|
||||
function inferFileType(path: string): ProcessingItem["fileType"] {
|
||||
|
|
@ -112,7 +123,8 @@ function extractName(key: string): string {
|
|||
const pathAndQuery = urlObj.pathname + urlObj.search;
|
||||
if (pathAndQuery && pathAndQuery !== "/") {
|
||||
const maxLen = 30;
|
||||
const shortPath = pathAndQuery.length > maxLen ? pathAndQuery.slice(0, maxLen) + "..." : pathAndQuery;
|
||||
const shortPath =
|
||||
pathAndQuery.length > maxLen ? pathAndQuery.slice(0, maxLen) + "..." : pathAndQuery;
|
||||
return hostname + shortPath;
|
||||
}
|
||||
return hostname;
|
||||
|
|
@ -131,12 +143,14 @@ function extractName(key: string): string {
|
|||
function parseUrlConfig(raw?: string): string[] {
|
||||
if (!raw) return [];
|
||||
// Reason: Deduplicate to prevent duplicate React keys and duplicate status rows
|
||||
return [...new Set(
|
||||
raw
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
)];
|
||||
return [
|
||||
...new Set(
|
||||
raw
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -237,15 +251,11 @@ function resolveNonCurrentProjectStatus(
|
|||
* This avoids depending on liveState.total which may be stale or empty for
|
||||
* non-current projects.
|
||||
*/
|
||||
export function buildProcessingItems(options: BuildProcessingItemsOptions): ProcessingAdapterResult {
|
||||
const {
|
||||
project,
|
||||
isCurrentProject,
|
||||
liveState,
|
||||
projectCache,
|
||||
projectFiles,
|
||||
supportedExtensions,
|
||||
} = options;
|
||||
export function buildProcessingItems(
|
||||
options: BuildProcessingItemsOptions
|
||||
): ProcessingAdapterResult {
|
||||
const { project, isCurrentProject, liveState, projectCache, projectFiles, supportedExtensions } =
|
||||
options;
|
||||
|
||||
// Pre-process live state sets for O(1) lookups
|
||||
const successSet = new Set(liveState.success);
|
||||
|
|
|
|||
|
|
@ -4,11 +4,7 @@ import { FailedItem, getCurrentProject, useProjectContextLoad } from "@/aiParams
|
|||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import {
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
|
|
@ -67,8 +63,7 @@ export default function ProgressCard({ plugin, setHiddenCard, onEditContext }: P
|
|||
|
||||
// Reason: determine whether the detail section has any content to show.
|
||||
// Without this, the "View Details" trigger would expand to empty space.
|
||||
const hasDetailContent =
|
||||
(processingData && processingData.items.length > 0) || hasMdItems;
|
||||
const hasDetailContent = (processingData && processingData.items.length > 0) || hasMdItems;
|
||||
|
||||
/** Retry a failed conversion item via processingData.failedItemMap. */
|
||||
const handleRetry = (itemId: string) => {
|
||||
|
|
@ -153,8 +148,7 @@ export default function ProgressCard({ plugin, setHiddenCard, onEditContext }: P
|
|||
<span className="tw-text-xs tw-text-muted">
|
||||
(Success:{" "}
|
||||
<span className="tw-font-medium tw-text-success">{successFiles.length}</span>,
|
||||
Failed:{" "}
|
||||
<span className="tw-font-medium tw-text-error">{failedFiles.length}</span>)
|
||||
Failed: <span className="tw-font-medium tw-text-error">{failedFiles.length}</span>)
|
||||
</span>
|
||||
</div>
|
||||
<span className="tw-font-medium">
|
||||
|
|
|
|||
|
|
@ -137,7 +137,14 @@ export function useProjectProcessingData(
|
|||
projectFiles,
|
||||
supportedExtensions,
|
||||
});
|
||||
}, [displayProject, isCurrentProject, contextLoadState, projectCache, projectFiles, supportedExtensions]);
|
||||
}, [
|
||||
displayProject,
|
||||
isCurrentProject,
|
||||
contextLoadState,
|
||||
projectCache,
|
||||
projectFiles,
|
||||
supportedExtensions,
|
||||
]);
|
||||
|
||||
return { processingData, projectCache, isCurrentProject };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -95,7 +95,9 @@ export const QuickAskMessageComponent = React.memo(function QuickAskMessageCompo
|
|||
if (message.role === "user") {
|
||||
return (
|
||||
<div className="tw-max-w-[85%] tw-self-end tw-rounded-lg tw-rounded-br-sm tw-bg-interactive-accent tw-px-3 tw-py-2 tw-text-on-accent">
|
||||
<div data-quick-ask-selectable className="tw-whitespace-pre-wrap tw-break-words tw-text-sm">{message.content}</div>
|
||||
<div data-quick-ask-selectable className="tw-whitespace-pre-wrap tw-break-words tw-text-sm">
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -104,7 +106,10 @@ export const QuickAskMessageComponent = React.memo(function QuickAskMessageCompo
|
|||
if (isStreaming) {
|
||||
return (
|
||||
<div className="tw-max-w-[95%] tw-self-start tw-rounded-lg tw-rounded-bl-sm tw-bg-secondary tw-px-3 tw-py-2">
|
||||
<div data-quick-ask-selectable className="tw-whitespace-pre-wrap tw-break-words tw-text-sm tw-text-normal">
|
||||
<div
|
||||
data-quick-ask-selectable
|
||||
className="tw-whitespace-pre-wrap tw-break-words tw-text-sm tw-text-normal"
|
||||
>
|
||||
{message.content}
|
||||
<span className="tw-animate-pulse tw-text-accent">▊</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -97,7 +97,11 @@ export class QuickAskOverlay {
|
|||
* @param bottomAnchorPos - Bottom anchor (normalized selection.to) for "place below"
|
||||
* @param topAnchorPos - Top anchor (selection.from) for "place above" flip target
|
||||
*/
|
||||
mount(bottomAnchorPos: number, topAnchorPos?: number | null, focusAnchorPos?: number | null): void {
|
||||
mount(
|
||||
bottomAnchorPos: number,
|
||||
topAnchorPos?: number | null,
|
||||
focusAnchorPos?: number | null
|
||||
): void {
|
||||
this.bottomAnchorPos = bottomAnchorPos;
|
||||
this.topAnchorPos = typeof topAnchorPos === "number" ? topAnchorPos : null;
|
||||
this.focusAnchorPos = typeof focusAnchorPos === "number" ? focusAnchorPos : null;
|
||||
|
|
@ -477,7 +481,7 @@ export class QuickAskOverlay {
|
|||
this.placementSide = "below";
|
||||
} else if (topRect) {
|
||||
const aboveY = topRect.top - hostRect.top - PANEL_OFFSET_Y - heightForClamp;
|
||||
const spaceAbove = (topRect.top - hostRect.top) - PANEL_OFFSET_Y - visibleTop;
|
||||
const spaceAbove = topRect.top - hostRect.top - PANEL_OFFSET_Y - visibleTop;
|
||||
|
||||
if (spaceAbove >= heightForClamp + PANEL_MARGIN) {
|
||||
top = aboveY;
|
||||
|
|
@ -497,7 +501,7 @@ export class QuickAskOverlay {
|
|||
} else if (topRect) {
|
||||
// Bottom anchor not visible (selection extends below viewport): place above topRect
|
||||
const aboveY = topRect.top - hostRect.top - PANEL_OFFSET_Y - heightForClamp;
|
||||
const spaceAbove = (topRect.top - hostRect.top) - PANEL_OFFSET_Y - visibleTop;
|
||||
const spaceAbove = topRect.top - hostRect.top - PANEL_OFFSET_Y - visibleTop;
|
||||
|
||||
if (spaceAbove >= heightForClamp + PANEL_MARGIN) {
|
||||
top = aboveY;
|
||||
|
|
|
|||
|
|
@ -218,7 +218,7 @@ export function ModelParametersEditor({
|
|||
(opt) =>
|
||||
opt.value !== ReasoningEffort.MINIMAL && opt.value !== ReasoningEffort.XHIGH
|
||||
),
|
||||
...(model.name.startsWith("gpt-5.5") && model.provider === "openai"
|
||||
...(model.name.startsWith("gpt-5.4") && model.provider === "openai"
|
||||
? [{ value: ReasoningEffort.XHIGH, label: "Extra High" }]
|
||||
: []),
|
||||
]}
|
||||
|
|
@ -235,8 +235,8 @@ export function ModelParametersEditor({
|
|||
<li>Low: Faster responses, basic reasoning (default)</li>
|
||||
<li>Medium: Balanced performance</li>
|
||||
<li>High: Thorough reasoning, slower responses</li>
|
||||
{model.name.startsWith("gpt-5.5") && model.provider === "openai" && (
|
||||
<li>Extra High: Maximum reasoning depth (GPT-5.5 only)</li>
|
||||
{model.name.startsWith("gpt-5.4") && model.provider === "openai" && (
|
||||
<li>Extra High: Maximum reasoning depth (GPT-5.4 only)</li>
|
||||
)}
|
||||
</ul>
|
||||
{!hasReasoningCapability && !isOpenAIReasoningModel && (
|
||||
|
|
|
|||
|
|
@ -10,14 +10,16 @@
|
|||
*/
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
type UrlItem,
|
||||
detectUrlType,
|
||||
isValidUrl,
|
||||
} from "@/utils/urlTagUtils";
|
||||
import { type UrlItem, detectUrlType, isValidUrl } from "@/utils/urlTagUtils";
|
||||
import { TruncatedText } from "@/components/TruncatedText";
|
||||
import { ClipboardPaste, Globe, Link, X, Youtube } from "lucide-react";
|
||||
import React, { useCallback, useRef, useState, type ClipboardEvent, type KeyboardEvent } from "react";
|
||||
import React, {
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
type ClipboardEvent,
|
||||
type KeyboardEvent,
|
||||
} from "react";
|
||||
|
||||
interface UrlTagInputProps {
|
||||
urls: UrlItem[];
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ export class PromptContextEngine {
|
|||
|
||||
const debugLabel =
|
||||
typeof params.metadata?.["debugLabel"] === "string"
|
||||
? (params.metadata["debugLabel"] as string)
|
||||
? params.metadata["debugLabel"]
|
||||
: undefined;
|
||||
|
||||
if (debugLabel) {
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ export class ContextProcessor {
|
|||
const match = matches[i];
|
||||
const queryType = match[1]; // 'dataview' or 'dataviewjs'
|
||||
const query = match[2].trim();
|
||||
const matchStart = match.index!;
|
||||
const matchStart = match.index;
|
||||
const matchEnd = matchStart + match[0].length;
|
||||
|
||||
try {
|
||||
|
|
@ -112,7 +112,8 @@ export class ContextProcessor {
|
|||
]);
|
||||
|
||||
// Replace block with structured output using slice (position-based, handles duplicates)
|
||||
const replacement = `\n\n<${DATAVIEW_BLOCK_TAG}>\n<query_type>${queryType}</query_type>\n<original_query>\n${query}\n</original_query>\n<executed_result>\n${result}\n</executed_result>\n</${DATAVIEW_BLOCK_TAG}>\n\n`;
|
||||
const resultStr = typeof result === "string" ? result : JSON.stringify(result);
|
||||
const replacement = `\n\n<${DATAVIEW_BLOCK_TAG}>\n<query_type>${queryType}</query_type>\n<original_query>\n${query}\n</original_query>\n<executed_result>\n${resultStr}\n</executed_result>\n</${DATAVIEW_BLOCK_TAG}>\n\n`;
|
||||
content = content.slice(0, matchStart) + replacement + content.slice(matchEnd);
|
||||
} catch (error) {
|
||||
logError(`Error executing Dataview query:`, error);
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ import { ContextManager } from "./ContextManager";
|
|||
import { ChainType } from "@/chainFactory";
|
||||
import { getWebViewerService } from "@/services/webViewerService/webViewerServiceSingleton";
|
||||
import { ChatMessage, MessageContext } from "@/types/message";
|
||||
import { TFile } from "obsidian";
|
||||
import { mockTFile } from "@/__tests__/mockObsidian";
|
||||
|
||||
const USER_SENDER = "user";
|
||||
const createContextResult = (content = "Hello with context") => ({
|
||||
|
|
@ -114,7 +114,7 @@ describe("ChatManager", () => {
|
|||
|
||||
mockChainManager = {
|
||||
memoryManager: {
|
||||
clearChatMemory: jest.fn(),
|
||||
clearChatMemory: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
runChain: jest.fn(),
|
||||
};
|
||||
|
|
@ -156,7 +156,7 @@ describe("ChatManager", () => {
|
|||
|
||||
describe("sendMessage", () => {
|
||||
it("should send a message with basic context", async () => {
|
||||
const mockActiveFile = { path: "active.md", basename: "active" } as TFile;
|
||||
const mockActiveFile = mockTFile({ path: "active.md", basename: "active" });
|
||||
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
|
||||
const context: MessageContext = {
|
||||
notes: [],
|
||||
|
|
@ -203,7 +203,7 @@ describe("ChatManager", () => {
|
|||
});
|
||||
|
||||
it("should include active note in context when includeActiveNote is true", async () => {
|
||||
const mockActiveFile = { path: "active.md", basename: "active" } as TFile;
|
||||
const mockActiveFile = mockTFile({ path: "active.md", basename: "active" });
|
||||
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
|
||||
const context: MessageContext = {
|
||||
notes: [],
|
||||
|
|
@ -283,7 +283,7 @@ describe("ChatManager", () => {
|
|||
|
||||
describe("editMessage", () => {
|
||||
it("should edit a message and reprocess context", async () => {
|
||||
const mockActiveFile = { path: "active.md", basename: "active" } as TFile;
|
||||
const mockActiveFile = mockTFile({ path: "active.md", basename: "active" });
|
||||
|
||||
mockPlugin.app.workspace.getActiveFile.mockReturnValue(mockActiveFile);
|
||||
mockMessageRepo.editMessage.mockReturnValue(true);
|
||||
|
|
@ -590,7 +590,7 @@ describe("ChatManager", () => {
|
|||
describe("Bug Prevention Tests", () => {
|
||||
describe("Context Badge Bug Prevention", () => {
|
||||
it("should include active note in context when includeActiveNote is true", async () => {
|
||||
const mockActiveFile = { path: "lesson4.md", basename: "Lesson 4" } as TFile;
|
||||
const mockActiveFile = mockTFile({ path: "lesson4.md", basename: "Lesson 4" });
|
||||
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
|
||||
const context: MessageContext = {
|
||||
notes: [],
|
||||
|
|
@ -641,7 +641,7 @@ describe("ChatManager", () => {
|
|||
|
||||
describe("Edit Message Bug Prevention", () => {
|
||||
it("should reprocess context after editing", async () => {
|
||||
const mockActiveFile = { path: "active.md", basename: "active" } as TFile;
|
||||
const mockActiveFile = mockTFile({ path: "active.md", basename: "active" });
|
||||
|
||||
mockPlugin.app.workspace.getActiveFile.mockReturnValue(mockActiveFile);
|
||||
mockMessageRepo.editMessage.mockReturnValue(true);
|
||||
|
|
@ -1240,7 +1240,7 @@ describe("ChatManager", () => {
|
|||
|
||||
describe("Template Skip Logic", () => {
|
||||
it("should not call processPrompt when user custom prompt has no template variables", async () => {
|
||||
const mockActiveFile = { path: "active.md", basename: "active" } as TFile;
|
||||
const mockActiveFile = mockTFile({ path: "active.md", basename: "active" });
|
||||
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
|
||||
const context: MessageContext = { notes: [], urls: [], selectedTextContexts: [] };
|
||||
|
||||
|
|
@ -1270,7 +1270,7 @@ describe("ChatManager", () => {
|
|||
});
|
||||
|
||||
it("should call processPrompt for JSON but preserve content (handled internally)", async () => {
|
||||
const mockActiveFile = { path: "active.md", basename: "active" } as TFile;
|
||||
const mockActiveFile = mockTFile({ path: "active.md", basename: "active" });
|
||||
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
|
||||
const context: MessageContext = { notes: [], urls: [], selectedTextContexts: [] };
|
||||
|
||||
|
|
@ -1308,7 +1308,7 @@ describe("ChatManager", () => {
|
|||
});
|
||||
|
||||
it("should treat {} as literal in system prompts (not expand to activeNote)", async () => {
|
||||
const mockActiveFile = { path: "test.md", basename: "Test Note" } as TFile;
|
||||
const mockActiveFile = mockTFile({ path: "test.md", basename: "Test Note" });
|
||||
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
|
||||
const context: MessageContext = { notes: [], urls: [], selectedTextContexts: [] };
|
||||
|
||||
|
|
@ -1350,7 +1350,7 @@ describe("ChatManager", () => {
|
|||
});
|
||||
|
||||
it("should preserve trailing whitespace for JSON content", async () => {
|
||||
const mockActiveFile = { path: "test.md", basename: "Test Note" } as TFile;
|
||||
const mockActiveFile = mockTFile({ path: "test.md", basename: "Test Note" });
|
||||
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
|
||||
const context: MessageContext = { notes: [], urls: [], selectedTextContexts: [] };
|
||||
|
||||
|
|
@ -1384,7 +1384,7 @@ describe("ChatManager", () => {
|
|||
});
|
||||
|
||||
it("should not call processPrompt when enableCustomPromptTemplating is false", async () => {
|
||||
const mockActiveFile = { path: "active.md", basename: "active" } as TFile;
|
||||
const mockActiveFile = mockTFile({ path: "active.md", basename: "active" });
|
||||
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
|
||||
const context: MessageContext = { notes: [], urls: [], selectedTextContexts: [] };
|
||||
|
||||
|
|
@ -1410,7 +1410,7 @@ describe("ChatManager", () => {
|
|||
});
|
||||
|
||||
it("should not call processPrompt when no user custom prompt is set", async () => {
|
||||
const mockActiveFile = { path: "active.md", basename: "active" } as TFile;
|
||||
const mockActiveFile = mockTFile({ path: "active.md", basename: "active" });
|
||||
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
|
||||
const context: MessageContext = { notes: [], urls: [], selectedTextContexts: [] };
|
||||
|
||||
|
|
@ -1431,7 +1431,7 @@ describe("ChatManager", () => {
|
|||
|
||||
describe("Template Processing", () => {
|
||||
it("should call processPrompt with correct arguments for {activeNote} template", async () => {
|
||||
const mockActiveFile = { path: "test.md", basename: "Test Note" } as TFile;
|
||||
const mockActiveFile = mockTFile({ path: "test.md", basename: "Test Note" });
|
||||
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
|
||||
const context: MessageContext = { notes: [], urls: [], selectedTextContexts: [] };
|
||||
|
||||
|
|
@ -1468,8 +1468,8 @@ describe("ChatManager", () => {
|
|||
});
|
||||
|
||||
it("should pass includedFiles to contextManager for deduplication", async () => {
|
||||
const mockActiveFile = { path: "test.md", basename: "Test Note" } as TFile;
|
||||
const mockIncludedFile = { path: "included.md", basename: "Included Note" } as TFile;
|
||||
const mockActiveFile = mockTFile({ path: "test.md", basename: "Test Note" });
|
||||
const mockIncludedFile = mockTFile({ path: "included.md", basename: "Included Note" });
|
||||
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
|
||||
const context: MessageContext = { notes: [], urls: [], selectedTextContexts: [] };
|
||||
|
||||
|
|
@ -1512,7 +1512,7 @@ describe("ChatManager", () => {
|
|||
|
||||
describe("Injection Logic", () => {
|
||||
it("should inject processed content into user_custom_instructions block", async () => {
|
||||
const mockActiveFile = { path: "test.md", basename: "Test Note" } as TFile;
|
||||
const mockActiveFile = mockTFile({ path: "test.md", basename: "Test Note" });
|
||||
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
|
||||
const context: MessageContext = { notes: [], urls: [], selectedTextContexts: [] };
|
||||
|
||||
|
|
@ -1550,7 +1550,7 @@ describe("ChatManager", () => {
|
|||
it("should preserve $ characters in user prompt without interpreting as replacement patterns", async () => {
|
||||
// Regression test: String.prototype.replace treats $&, $1, $$ etc. as special sequences.
|
||||
// Using function replacement avoids this issue.
|
||||
const mockActiveFile = { path: "test.md", basename: "Test Note" } as TFile;
|
||||
const mockActiveFile = mockTFile({ path: "test.md", basename: "Test Note" });
|
||||
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
|
||||
const context: MessageContext = { notes: [], urls: [], selectedTextContexts: [] };
|
||||
|
||||
|
|
@ -1597,7 +1597,7 @@ describe("ChatManager", () => {
|
|||
|
||||
it("should preserve $ characters when template processing is involved", async () => {
|
||||
// Regression test: Even when templates are processed, $ in output must not be interpreted
|
||||
const mockActiveFile = { path: "test.md", basename: "Test Note" } as TFile;
|
||||
const mockActiveFile = mockTFile({ path: "test.md", basename: "Test Note" });
|
||||
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
|
||||
const context: MessageContext = { notes: [], urls: [], selectedTextContexts: [] };
|
||||
|
||||
|
|
@ -1649,7 +1649,7 @@ describe("ChatManager", () => {
|
|||
|
||||
describe("Memory Preservation", () => {
|
||||
it("should preserve memory prefix and not process it for templates", async () => {
|
||||
const mockActiveFile = { path: "test.md", basename: "Test Note" } as TFile;
|
||||
const mockActiveFile = mockTFile({ path: "test.md", basename: "Test Note" });
|
||||
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
|
||||
const context: MessageContext = { notes: [], urls: [], selectedTextContexts: [] };
|
||||
|
||||
|
|
@ -1695,7 +1695,7 @@ describe("ChatManager", () => {
|
|||
|
||||
describe("Error Handling", () => {
|
||||
it("should return original prompt when processPrompt throws error", async () => {
|
||||
const mockActiveFile = { path: "test.md", basename: "Test Note" } as TFile;
|
||||
const mockActiveFile = mockTFile({ path: "test.md", basename: "Test Note" });
|
||||
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
|
||||
const context: MessageContext = { notes: [], urls: [], selectedTextContexts: [] };
|
||||
|
||||
|
|
@ -1729,7 +1729,7 @@ describe("ChatManager", () => {
|
|||
|
||||
describe("Builtin Disabled Branch", () => {
|
||||
it("should handle builtin disabled scenario (no user_custom_instructions block)", async () => {
|
||||
const mockActiveFile = { path: "test.md", basename: "Test Note" } as TFile;
|
||||
const mockActiveFile = mockTFile({ path: "test.md", basename: "Test Note" });
|
||||
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
|
||||
const context: MessageContext = { notes: [], urls: [], selectedTextContexts: [] };
|
||||
|
||||
|
|
@ -1762,7 +1762,7 @@ describe("ChatManager", () => {
|
|||
});
|
||||
|
||||
it("should preserve memory prefix when builtin is disabled", async () => {
|
||||
const mockActiveFile = { path: "test.md", basename: "Test Note" } as TFile;
|
||||
const mockActiveFile = mockTFile({ path: "test.md", basename: "Test Note" });
|
||||
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
|
||||
const context: MessageContext = { notes: [], urls: [], selectedTextContexts: [] };
|
||||
|
||||
|
|
@ -1797,7 +1797,7 @@ describe("ChatManager", () => {
|
|||
|
||||
describe("EndsWith Mismatch Fallback", () => {
|
||||
it("should fallback to original base prompt when endsWith check fails", async () => {
|
||||
const mockActiveFile = { path: "test.md", basename: "Test Note" } as TFile;
|
||||
const mockActiveFile = mockTFile({ path: "test.md", basename: "Test Note" });
|
||||
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
|
||||
const context: MessageContext = { notes: [], urls: [], selectedTextContexts: [] };
|
||||
|
||||
|
|
@ -1848,7 +1848,7 @@ describe("ChatManager", () => {
|
|||
});
|
||||
|
||||
it("should process project system prompt templates", async () => {
|
||||
const mockActiveFile = { path: "test.md", basename: "Test Note" } as TFile;
|
||||
const mockActiveFile = mockTFile({ path: "test.md", basename: "Test Note" });
|
||||
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
|
||||
const context: MessageContext = { notes: [], urls: [], selectedTextContexts: [] };
|
||||
|
||||
|
|
@ -1866,7 +1866,10 @@ describe("ChatManager", () => {
|
|||
getSystemPrompt.mockReturnValue("DEFAULT_SYSTEM_PROMPT");
|
||||
getSystemPromptWithMemory.mockResolvedValue("DEFAULT_SYSTEM_PROMPT");
|
||||
|
||||
const projectIncludedFile = { path: "project-note.md", basename: "Project Note" } as TFile;
|
||||
const projectIncludedFile = mockTFile({
|
||||
path: "project-note.md",
|
||||
basename: "Project Note",
|
||||
});
|
||||
processPrompt.mockResolvedValue({
|
||||
processedPrompt: "PROCESSED_PROJECT_PROMPT",
|
||||
includedFiles: [projectIncludedFile],
|
||||
|
|
@ -1904,7 +1907,7 @@ describe("ChatManager", () => {
|
|||
});
|
||||
|
||||
it("should merge includedFiles from both user and project prompts", async () => {
|
||||
const mockActiveFile = { path: "test.md", basename: "Test Note" } as TFile;
|
||||
const mockActiveFile = mockTFile({ path: "test.md", basename: "Test Note" });
|
||||
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
|
||||
const context: MessageContext = { notes: [], urls: [], selectedTextContexts: [] };
|
||||
|
||||
|
|
@ -1925,8 +1928,11 @@ describe("ChatManager", () => {
|
|||
`DEFAULT\n<user_custom_instructions>\n${userCustomPrompt}\n</user_custom_instructions>`
|
||||
);
|
||||
|
||||
const userIncludedFile = { path: "user-note.md", basename: "User Note" } as TFile;
|
||||
const projectIncludedFile = { path: "project-note.md", basename: "Project Note" } as TFile;
|
||||
const userIncludedFile = mockTFile({ path: "user-note.md", basename: "User Note" });
|
||||
const projectIncludedFile = mockTFile({
|
||||
path: "project-note.md",
|
||||
basename: "Project Note",
|
||||
});
|
||||
|
||||
// First call for user prompt, second call for project prompt
|
||||
processPrompt
|
||||
|
|
@ -1957,7 +1963,7 @@ describe("ChatManager", () => {
|
|||
});
|
||||
|
||||
it("should not add project_context block when context is null", async () => {
|
||||
const mockActiveFile = { path: "test.md", basename: "Test Note" } as TFile;
|
||||
const mockActiveFile = mockTFile({ path: "test.md", basename: "Test Note" });
|
||||
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
|
||||
const context: MessageContext = { notes: [], urls: [], selectedTextContexts: [] };
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { ChatMessage } from "@/types/message";
|
||||
import { Notice, TFile } from "obsidian";
|
||||
import { ChatPersistenceManager } from "./ChatPersistenceManager";
|
||||
import { mockTFile } from "@/__tests__/mockObsidian";
|
||||
|
||||
const USER_SENDER = "user";
|
||||
const AI_SENDER = "ai";
|
||||
|
|
@ -377,9 +378,9 @@ Nature's quiet song`);
|
|||
} as any;
|
||||
|
||||
persistenceManager = new ChatPersistenceManager(mockApp, mockMessageRepo, chainManager);
|
||||
const mockFile = {
|
||||
const mockFile = mockTFile({
|
||||
path: "test-folder/Summarize_weather_data@20240923_221800.md",
|
||||
} as unknown as TFile;
|
||||
});
|
||||
mockApp.vault.create.mockResolvedValue(mockFile);
|
||||
mockApp.vault.getAbstractFileByPath.mockReturnValue(mockFile);
|
||||
mockMessageRepo.getDisplayMessages.mockReturnValue(messages);
|
||||
|
|
@ -402,6 +403,12 @@ Nature's quiet song`);
|
|||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
// Topic generation is fire-and-forget via `void (async () => …)()`, so
|
||||
// we need additional microtask ticks to flush the chain of awaits inside.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
expect(invoke).toHaveBeenCalled();
|
||||
expect(mockApp.fileManager.processFrontMatter).toHaveBeenCalledWith(
|
||||
mockFile,
|
||||
|
|
@ -677,10 +684,12 @@ Nature's quiet song`);
|
|||
return Promise.reject(error);
|
||||
} else {
|
||||
// Second call: succeed with fallback filename
|
||||
return Promise.resolve({
|
||||
path,
|
||||
basename: path.split("/").pop(),
|
||||
} as TFile);
|
||||
return Promise.resolve(
|
||||
mockTFile({
|
||||
path,
|
||||
basename: path.split("/").pop(),
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -742,10 +751,12 @@ Nature's quiet song`);
|
|||
const error = new Error("ENAMETOOLONG: name too long");
|
||||
return Promise.reject(error);
|
||||
} else {
|
||||
return Promise.resolve({
|
||||
path,
|
||||
basename: path.split("/").pop(),
|
||||
} as TFile);
|
||||
return Promise.resolve(
|
||||
mockTFile({
|
||||
path,
|
||||
basename: path.split("/").pop(),
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -835,9 +846,9 @@ Nature's quiet song`);
|
|||
},
|
||||
];
|
||||
|
||||
const existingFile = {
|
||||
const existingFile = mockTFile({
|
||||
path: "test-folder/Hello@20240923_221800.md",
|
||||
} as unknown as TFile;
|
||||
});
|
||||
|
||||
const getFilesSpy = jest
|
||||
.spyOn(persistenceManager, "getChatHistoryFiles")
|
||||
|
|
@ -1018,7 +1029,7 @@ tags:
|
|||
**ai**: Hi there!
|
||||
[Timestamp: 2024/09/23 22:18:01]`;
|
||||
|
||||
const mockFile = { path: "test.md" } as TFile;
|
||||
const mockFile = mockTFile({ path: "test.md" });
|
||||
mockApp.vault.read.mockResolvedValue(fileContent);
|
||||
|
||||
const result = await persistenceManager.loadChat(mockFile);
|
||||
|
|
@ -1191,16 +1202,16 @@ ${formattedContent}`;
|
|||
|
||||
it("should resolve legacy basename-only context (backward compatibility)", async () => {
|
||||
// Create mock TFile for the note
|
||||
const mockTFile = {
|
||||
const file = mockTFile({
|
||||
basename: "typescript-guide.md",
|
||||
path: "docs/typescript-guide.md",
|
||||
extension: "md",
|
||||
name: "typescript-guide.md",
|
||||
} as TFile;
|
||||
});
|
||||
|
||||
// Mock vault to return the file for basename resolution (legacy format)
|
||||
mockApp.vault.getAbstractFileByPath.mockReturnValue(null); // Path lookup fails
|
||||
mockApp.vault.getMarkdownFiles.mockReturnValue([mockTFile]); // Basename lookup succeeds
|
||||
mockApp.vault.getMarkdownFiles.mockReturnValue([file]); // Basename lookup succeeds
|
||||
|
||||
// Old format: just basename, no path
|
||||
const content = `---
|
||||
|
|
@ -1228,19 +1239,19 @@ tags:
|
|||
|
||||
it("should handle ambiguous basename resolution gracefully", async () => {
|
||||
// Create two mock TFiles with the same basename
|
||||
const mockTFile1 = {
|
||||
const mockTFile1 = mockTFile({
|
||||
basename: "typescript-guide.md",
|
||||
path: "docs/typescript-guide.md",
|
||||
extension: "md",
|
||||
name: "typescript-guide.md",
|
||||
} as TFile;
|
||||
});
|
||||
|
||||
const mockTFile2 = {
|
||||
const mockTFile2 = mockTFile({
|
||||
basename: "typescript-guide.md",
|
||||
path: "archive/typescript-guide.md",
|
||||
extension: "md",
|
||||
name: "typescript-guide.md",
|
||||
} as TFile;
|
||||
});
|
||||
|
||||
// Mock vault to return multiple files with same basename
|
||||
mockApp.vault.getAbstractFileByPath.mockReturnValue(null);
|
||||
|
|
@ -1553,9 +1564,9 @@ tags:
|
|||
},
|
||||
];
|
||||
|
||||
const existingFile = {
|
||||
const existingFile = mockTFile({
|
||||
path: "test-folder/Hello@20240923_221800.md",
|
||||
} as unknown as TFile;
|
||||
});
|
||||
|
||||
const getFilesSpy = jest
|
||||
.spyOn(persistenceManager, "getChatHistoryFiles")
|
||||
|
|
@ -1599,9 +1610,11 @@ tags:
|
|||
|
||||
mockMessageRepo.getDisplayMessages.mockReturnValue(messages);
|
||||
mockApp.vault.getAbstractFileByPath.mockReturnValue(true);
|
||||
mockApp.vault.create.mockResolvedValue({
|
||||
path: "test-folder/New_chat@20240923_221800.md",
|
||||
} as TFile);
|
||||
mockApp.vault.create.mockResolvedValue(
|
||||
mockTFile({
|
||||
path: "test-folder/New_chat@20240923_221800.md",
|
||||
})
|
||||
);
|
||||
|
||||
await persistenceManager.saveChat("gpt-4");
|
||||
|
||||
|
|
|
|||
|
|
@ -726,9 +726,7 @@ ${conversationSummary}`;
|
|||
|
||||
// Prefix from an input project, global project, or empty if none
|
||||
const currentProject = project === undefined ? getCurrentProject() : project;
|
||||
const filePrefix = currentProject
|
||||
? `${sanitizeVaultPathSegment(currentProject.id)}__`
|
||||
: "";
|
||||
const filePrefix = currentProject ? `${sanitizeVaultPathSegment(currentProject.id)}__` : "";
|
||||
|
||||
// Calculate fixed components in bytes
|
||||
const extensionBytes = getUtf8ByteLength(".md");
|
||||
|
|
|
|||
|
|
@ -84,7 +84,9 @@ function buildEnvelopeWithL3Segments(segments: PromptLayerSegment[]): PromptCont
|
|||
/**
|
||||
* Create a mock MessageRepository that returns the given display messages.
|
||||
*/
|
||||
function createMockMessageRepo(messages: Array<{ id: string; sender: string; contextEnvelope?: PromptContextEnvelope }>) {
|
||||
function createMockMessageRepo(
|
||||
messages: Array<{ id: string; sender: string; contextEnvelope?: PromptContextEnvelope }>
|
||||
) {
|
||||
return {
|
||||
getDisplayMessages: () =>
|
||||
messages.map((msg) => ({
|
||||
|
|
|
|||
|
|
@ -673,7 +673,7 @@ export class ContextManager {
|
|||
vault: Vault,
|
||||
additionalNotes: TFile[] = []
|
||||
): Promise<TFile[]> {
|
||||
const extractedNotes = await extractNoteFiles(content, vault);
|
||||
const extractedNotes = extractNoteFiles(content, vault);
|
||||
|
||||
// Combine and deduplicate
|
||||
const allNotes = [...extractedNotes, ...additionalNotes];
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { AI_SENDER, USER_SENDER } from "@/constants";
|
|||
import { MessageContext } from "@/types/message";
|
||||
import { TFile } from "obsidian";
|
||||
import { MessageRepository } from "./MessageRepository";
|
||||
import { mockTFile } from "@/__tests__/mockObsidian";
|
||||
|
||||
// Mock the settings module
|
||||
jest.mock("@/settings/model", () => ({
|
||||
|
|
@ -22,12 +23,12 @@ describe("Message Lifecycle with Context Notes - Complete Example", () => {
|
|||
it("should demonstrate complete message lifecycle with context note", () => {
|
||||
// Step 1: User types a message and attaches a note
|
||||
const userDisplayText = "Please summarize the key points";
|
||||
const attachedNote: TFile = {
|
||||
const attachedNote: TFile = mockTFile({
|
||||
path: "meeting-notes-2024-01-15.md",
|
||||
name: "meeting-notes-2024-01-15.md",
|
||||
basename: "meeting-notes-2024-01-15",
|
||||
extension: "md",
|
||||
} as TFile;
|
||||
});
|
||||
|
||||
const context: MessageContext = {
|
||||
notes: [attachedNote],
|
||||
|
|
@ -166,12 +167,12 @@ The team appears to be taking a pragmatic approach with a focused MVP scope and
|
|||
it("should handle message edit with context reprocessing", () => {
|
||||
// Initial message with context
|
||||
const initialText = "List the attendees";
|
||||
const note: TFile = {
|
||||
const note: TFile = mockTFile({
|
||||
path: "meeting.md",
|
||||
name: "meeting.md",
|
||||
basename: "meeting",
|
||||
extension: "md",
|
||||
} as TFile;
|
||||
});
|
||||
|
||||
const context: MessageContext = {
|
||||
notes: [note],
|
||||
|
|
@ -234,12 +235,12 @@ Attendees: Alice (PM), Bob (Dev), Charlie (QA)
|
|||
// User asks initial question with context
|
||||
const context: MessageContext = {
|
||||
notes: [
|
||||
{
|
||||
mockTFile({
|
||||
path: "budget.md",
|
||||
name: "budget.md",
|
||||
basename: "budget",
|
||||
extension: "md",
|
||||
} as TFile,
|
||||
}),
|
||||
],
|
||||
urls: [],
|
||||
selectedTextContexts: [],
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { MessageRepository } from "./MessageRepository";
|
|||
import { USER_SENDER, SELECTED_TEXT_TAG, WEB_SELECTED_TEXT_TAG } from "@/constants";
|
||||
import { MessageContext } from "@/types/message";
|
||||
import { TFile } from "obsidian";
|
||||
import { mockTFile } from "@/__tests__/mockObsidian";
|
||||
|
||||
/**
|
||||
* Tests specifically for proper XML tag formatting in context processing
|
||||
|
|
@ -15,12 +16,12 @@ describe("Message Context XML Tag Formatting", () => {
|
|||
|
||||
it("should format note context with proper XML tags including metadata", () => {
|
||||
const userMessage = "Analyze this document";
|
||||
const note: TFile = {
|
||||
const note: TFile = mockTFile({
|
||||
path: "reports/quarterly-review.md",
|
||||
name: "quarterly-review.md",
|
||||
basename: "quarterly-review",
|
||||
extension: "md",
|
||||
} as TFile;
|
||||
});
|
||||
|
||||
const context: MessageContext = {
|
||||
notes: [note],
|
||||
|
|
@ -177,12 +178,12 @@ function fibonacci(n) {
|
|||
|
||||
it("should handle multiple contexts with proper XML structure", () => {
|
||||
const userMessage = "Compare these resources";
|
||||
const note: TFile = {
|
||||
const note: TFile = mockTFile({
|
||||
path: "design-patterns.md",
|
||||
name: "design-patterns.md",
|
||||
basename: "design-patterns",
|
||||
extension: "md",
|
||||
} as TFile;
|
||||
});
|
||||
|
||||
const context: MessageContext = {
|
||||
notes: [note],
|
||||
|
|
@ -261,12 +262,12 @@ The Single Responsibility Principle states that a class should have only one rea
|
|||
|
||||
it("should handle error cases with proper XML tags", () => {
|
||||
const userMessage = "Process this file";
|
||||
const corruptedNote: TFile = {
|
||||
const corruptedNote: TFile = mockTFile({
|
||||
path: "corrupted-file.pdf",
|
||||
name: "corrupted-file.pdf",
|
||||
basename: "corrupted-file",
|
||||
extension: "pdf",
|
||||
} as TFile;
|
||||
});
|
||||
|
||||
const context: MessageContext = {
|
||||
notes: [corruptedNote],
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { MessageRepository } from "./MessageRepository";
|
||||
import { ChatMessage, MessageContext, StoredMessage } from "@/types/message";
|
||||
import { formatDateTime } from "@/utils";
|
||||
import { TFile } from "obsidian";
|
||||
import { mockTFile } from "@/__tests__/mockObsidian";
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock("@/utils", () => ({
|
||||
|
|
@ -53,7 +53,7 @@ describe("MessageRepository", () => {
|
|||
});
|
||||
|
||||
it("should add a message with context", () => {
|
||||
const mockFile = { path: "test.md", basename: "test" } as TFile;
|
||||
const mockFile = mockTFile({ path: "test.md", basename: "test" });
|
||||
const context: MessageContext = {
|
||||
notes: [mockFile],
|
||||
urls: ["https://example.com"],
|
||||
|
|
@ -281,7 +281,7 @@ describe("MessageRepository", () => {
|
|||
describe("Bug Prevention Tests", () => {
|
||||
describe("Context Badge Bug Prevention", () => {
|
||||
it("should preserve context when creating display messages", () => {
|
||||
const mockFile = { path: "test.md", basename: "test" } as TFile;
|
||||
const mockFile = mockTFile({ path: "test.md", basename: "test" });
|
||||
const context: MessageContext = {
|
||||
notes: [mockFile],
|
||||
urls: ["https://example.com"],
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export class MessageRepository {
|
|||
* Generate a unique message ID
|
||||
*/
|
||||
private generateId(): string {
|
||||
return `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -108,8 +108,9 @@ export class ChatSelectionHighlightController {
|
|||
* Call once during plugin onload.
|
||||
*/
|
||||
initialize(): void {
|
||||
const leaf = this.plugin.app.workspace.activeLeaf ?? null;
|
||||
this.lastActiveLeafWasMarkdown = !!(leaf?.view instanceof MarkdownView);
|
||||
const markdownView = this.plugin.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
const leaf = markdownView?.leaf ?? null;
|
||||
this.lastActiveLeafWasMarkdown = !!markdownView;
|
||||
if (this.lastActiveLeafWasMarkdown && leaf) {
|
||||
this.lastActiveMarkdownLeaf = leaf;
|
||||
}
|
||||
|
|
@ -169,9 +170,8 @@ export class ChatSelectionHighlightController {
|
|||
* Does not use fallback - only works if MarkdownView is currently active.
|
||||
*/
|
||||
persistFromPointerDown(): void {
|
||||
// Early exit if already in Chat - no MarkdownView will be active
|
||||
const activeLeafType = this.plugin.app.workspace.activeLeaf?.getViewState().type;
|
||||
if (activeLeafType === CHAT_VIEWTYPE) {
|
||||
// Early exit if no MarkdownView is active (e.g., user is already in Chat).
|
||||
if (!this.plugin.app.workspace.getActiveViewOfType(MarkdownView)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ describe("createPersistentHighlight", () => {
|
|||
|
||||
view = new EditorView({
|
||||
state,
|
||||
parent: document.createElement("div"),
|
||||
parent: window.document.createElement("div"),
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -127,7 +127,7 @@ describe("createPersistentHighlight", () => {
|
|||
const bareState = EditorState.create({ doc: "Test" });
|
||||
const bareView = new EditorView({
|
||||
state: bareState,
|
||||
parent: document.createElement("div"),
|
||||
parent: window.document.createElement("div"),
|
||||
});
|
||||
|
||||
const effects = highlight.buildEffects(bareView, null);
|
||||
|
|
@ -202,7 +202,7 @@ describe("createPersistentHighlight", () => {
|
|||
|
||||
view = new EditorView({
|
||||
state,
|
||||
parent: document.createElement("div"),
|
||||
parent: window.document.createElement("div"),
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -94,7 +94,8 @@ function shouldSkipPersistOnAbort(signal: AbortSignal): boolean {
|
|||
// If abort() was called without a string reason, treat it as a "non-commit" cancel.
|
||||
if (typeof reason !== "string") return true;
|
||||
|
||||
return reason === ABORT_REASON.NEW_CHAT || reason === ABORT_REASON.UNMOUNT;
|
||||
const typedReason = reason as ABORT_REASON;
|
||||
return typedReason === ABORT_REASON.NEW_CHAT || typedReason === ABORT_REASON.UNMOUNT;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -86,12 +86,12 @@ const processColorObject =
|
|||
* bg-modifier-error/50
|
||||
* text-background-modifier-success/30
|
||||
*/
|
||||
export const colorOpacityPlugin = plugin(function ({ addUtilities, theme, e }) {
|
||||
export const colorOpacityPlugin = plugin(function (this: void, { addUtilities, theme, e }) {
|
||||
const opacityUtilities: Record<string, any> = {};
|
||||
|
||||
// 处理所有颜色相关的主题配置
|
||||
const processThemeColors = (themeKey: string, prefix?: string) => {
|
||||
const colors = theme(themeKey) as Record<string, string | ColorValue>;
|
||||
const colors: Record<string, string | ColorValue> = theme(themeKey);
|
||||
Object.entries(colors).forEach(([colorName, colorValue]) => {
|
||||
const baseName = prefix ? `${prefix}-${colorName}` : colorName;
|
||||
processColorObject(e, opacityUtilities)(colorValue, baseName);
|
||||
|
|
|
|||
|
|
@ -241,7 +241,8 @@ class LogFileManager {
|
|||
}
|
||||
|
||||
// Original buffer unchanged; open the file
|
||||
const file = app.vault.getAbstractFileByPath(path) as TFile | null;
|
||||
const abstract = app.vault.getAbstractFileByPath(path);
|
||||
const file = abstract instanceof TFile ? abstract : null;
|
||||
try {
|
||||
if (file) {
|
||||
const leaf = app.workspace.getLeaf(true);
|
||||
|
|
|
|||
|
|
@ -69,11 +69,7 @@ import {
|
|||
filterChatHistoryFiles,
|
||||
} from "@/utils/chatHistoryUtils";
|
||||
import { RecentUsageManager } from "@/utils/recentUsageManager";
|
||||
import {
|
||||
listMarkdownFiles,
|
||||
patchFrontmatter,
|
||||
resolveFileByPath,
|
||||
} from "@/utils/vaultAdapterUtils";
|
||||
import { listMarkdownFiles, patchFrontmatter, resolveFileByPath } from "@/utils/vaultAdapterUtils";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
// Removed unused FileTrackingState interface
|
||||
|
|
|
|||
|
|
@ -436,7 +436,7 @@ export class MiyoClient {
|
|||
if (getSettings().debug) {
|
||||
logInfo(`Miyo request ${options.method} ${url.toString()} succeeded`);
|
||||
}
|
||||
return parsed as T;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -86,7 +86,9 @@ export function isSelfHostModeValid(): boolean {
|
|||
|
||||
/** Check if the model key is a Copilot Plus model. */
|
||||
export function isPlusModel(modelKey: string): boolean {
|
||||
return modelKey.split("|")[1] === EmbeddingModelProviders.COPILOT_PLUS;
|
||||
return (
|
||||
(modelKey.split("|")[1] as EmbeddingModelProviders) === EmbeddingModelProviders.COPILOT_PLUS
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -67,13 +67,12 @@ jest.mock("@/projects/projectMigration", () => ({
|
|||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import {
|
||||
getCachedProjectRecords,
|
||||
getCachedProjectRecordById,
|
||||
} from "@/projects/state";
|
||||
import { getCachedProjectRecords, getCachedProjectRecordById } from "@/projects/state";
|
||||
|
||||
/** Minimal valid ProjectConfig for test use. */
|
||||
function makeConfig(overrides: { id: string; name: string } & Partial<ProjectConfig>): ProjectConfig {
|
||||
function makeConfig(
|
||||
overrides: { id: string; name: string } & Partial<ProjectConfig>
|
||||
): ProjectConfig {
|
||||
return {
|
||||
systemPrompt: "",
|
||||
projectModelKey: "",
|
||||
|
|
@ -88,7 +87,7 @@ function makeConfig(overrides: { id: string; name: string } & Partial<ProjectCon
|
|||
/** Build a minimal Vault mock. */
|
||||
function makeMockVault(): jest.Mocked<Vault> {
|
||||
return {
|
||||
create: jest.fn(async (path: string) => ({ path } as TFile)),
|
||||
create: jest.fn(async (path: string) => ({ path }) as TFile),
|
||||
// Reason: null = file does not exist yet, avoids collision error in createProject
|
||||
getAbstractFileByPath: jest.fn(() => null),
|
||||
adapter: { exists: jest.fn(async () => false) },
|
||||
|
|
@ -135,9 +134,9 @@ describe("ProjectFileManager.createProject", () => {
|
|||
it("rejects empty project id", async () => {
|
||||
const manager = ProjectFileManager.getInstance(vault);
|
||||
|
||||
await expect(
|
||||
manager.createProject(makeConfig({ id: "", name: "Valid Name" }))
|
||||
).rejects.toThrow(/cannot be empty/i);
|
||||
await expect(manager.createProject(makeConfig({ id: "", name: "Valid Name" }))).rejects.toThrow(
|
||||
/cannot be empty/i
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects whitespace-only project id", async () => {
|
||||
|
|
|
|||
|
|
@ -283,7 +283,8 @@ export class ProjectFileManager {
|
|||
}
|
||||
|
||||
const now = Date.now();
|
||||
const createdMs = Number.isFinite(project.created) && project.created > 0 ? project.created : now;
|
||||
const createdMs =
|
||||
Number.isFinite(project.created) && project.created > 0 ? project.created : now;
|
||||
const lastUsedMs =
|
||||
Number.isFinite(project.UsageTimestamps) && project.UsageTimestamps > 0
|
||||
? project.UsageTimestamps
|
||||
|
|
@ -321,7 +322,10 @@ export class ProjectFileManager {
|
|||
* @param nextProject - New ProjectConfig (id must match)
|
||||
* @returns Updated ProjectFileRecord
|
||||
*/
|
||||
public async updateProject(projectId: string, nextProject: ProjectConfig): Promise<ProjectFileRecord> {
|
||||
public async updateProject(
|
||||
projectId: string,
|
||||
nextProject: ProjectConfig
|
||||
): Promise<ProjectFileRecord> {
|
||||
const normalizedId = (projectId || "").trim();
|
||||
if (!normalizedId) throw new Error("Project id cannot be empty");
|
||||
if ((nextProject.id || "").trim() !== normalizedId) {
|
||||
|
|
@ -334,7 +338,9 @@ export class ProjectFileManager {
|
|||
const trimmedName = (nextProject.name || "").trim();
|
||||
if (trimmedName) {
|
||||
const nameConflict = getCachedProjectRecords().some(
|
||||
(r) => r.project.id !== normalizedId && r.project.name.trim().toLowerCase() === trimmedName.toLowerCase()
|
||||
(r) =>
|
||||
r.project.id !== normalizedId &&
|
||||
r.project.name.trim().toLowerCase() === trimmedName.toLowerCase()
|
||||
);
|
||||
if (nameConflict) {
|
||||
throw new Error(`A project with the name "${trimmedName}" already exists`);
|
||||
|
|
@ -369,9 +375,7 @@ export class ProjectFileManager {
|
|||
const isCaseOnlyRename =
|
||||
newFolderPath.toLowerCase() === getProjectFolderPath(existing.folderName).toLowerCase();
|
||||
if (!isCaseOnlyRename && (await this.vault.adapter.exists(newFolderPath))) {
|
||||
throw new Error(
|
||||
`Cannot rename project folder: "${newFolderPath}" already exists on disk`
|
||||
);
|
||||
throw new Error(`Cannot rename project folder: "${newFolderPath}" already exists on disk`);
|
||||
}
|
||||
|
||||
// Suppress vault events for both old and new project.md paths
|
||||
|
|
@ -447,9 +451,13 @@ export class ProjectFileManager {
|
|||
if (isInVaultCache(app, filePath)) {
|
||||
// Vault-cached file: use processFrontMatter for safe field-level updates
|
||||
try {
|
||||
await writeProjectFrontmatter(file, projectForWrite, folderName, { createdMs, lastUsedMs });
|
||||
await writeProjectFrontmatter(file, projectForWrite, folderName, {
|
||||
createdMs,
|
||||
lastUsedMs,
|
||||
});
|
||||
} catch (fmError) {
|
||||
if (materialized) await this.rollbackCreatedFile(filePath, getProjectFolderPath(folderName));
|
||||
if (materialized)
|
||||
await this.rollbackCreatedFile(filePath, getProjectFolderPath(folderName));
|
||||
throw fmError;
|
||||
}
|
||||
|
||||
|
|
@ -502,7 +510,10 @@ export class ProjectFileManager {
|
|||
`[Projects] Rolled back folder rename "${folderName}" → "${existing.folderName}" after write failure`
|
||||
);
|
||||
} catch (rollbackError) {
|
||||
logError(`[Projects] Failed to rollback folder rename for ${normalizedId}`, rollbackError);
|
||||
logError(
|
||||
`[Projects] Failed to rollback folder rename for ${normalizedId}`,
|
||||
rollbackError
|
||||
);
|
||||
}
|
||||
}
|
||||
throw writeError;
|
||||
|
|
@ -576,9 +587,7 @@ export class ProjectFileManager {
|
|||
|
||||
// Reason: rescan to re-admit any previously-ignored duplicate-id files.
|
||||
// The register won't fire (pending guard), so we trigger rescan here.
|
||||
void loadAllProjects().catch((err) =>
|
||||
logError("[Projects] Rescan after delete failed", err)
|
||||
);
|
||||
void loadAllProjects().catch((err) => logError("[Projects] Rescan after delete failed", err));
|
||||
} finally {
|
||||
removePendingFileWrite(existing.filePath);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,4 +4,7 @@ export * from "./projectUtils";
|
|||
export * from "./state";
|
||||
export { ProjectFileManager } from "./ProjectFileManager";
|
||||
export { ProjectRegister } from "./projectRegister";
|
||||
export { ensureProjectsMigratedIfNeeded, migrateProjectsFromSettingsToVault } from "./projectMigration";
|
||||
export {
|
||||
ensureProjectsMigratedIfNeeded,
|
||||
migrateProjectsFromSettingsToVault,
|
||||
} from "./projectMigration";
|
||||
|
|
|
|||
|
|
@ -1,14 +1,8 @@
|
|||
import { ProjectConfig } from "@/aiParams";
|
||||
import { ConfirmModal } from "@/components/modals/ConfirmModal";
|
||||
import { logError, logInfo, logWarn } from "@/logger";
|
||||
import {
|
||||
COPILOT_PROJECT_ID,
|
||||
PROJECTS_UNSUPPORTED_FOLDER_NAME,
|
||||
} from "@/projects/constants";
|
||||
import {
|
||||
deriveProjectFolderName,
|
||||
sanitizeVaultPathSegment,
|
||||
} from "@/projects/projectPaths";
|
||||
import { COPILOT_PROJECT_ID, PROJECTS_UNSUPPORTED_FOLDER_NAME } from "@/projects/constants";
|
||||
import { deriveProjectFolderName, sanitizeVaultPathSegment } from "@/projects/projectPaths";
|
||||
import {
|
||||
ensureProjectFrontmatter,
|
||||
getProjectConfigFilePath,
|
||||
|
|
@ -112,7 +106,11 @@ async function saveFailedProjectToUnsupported(
|
|||
* Best-effort rollback: delete a file and its parent folder if empty.
|
||||
* Logs errors but never throws.
|
||||
*/
|
||||
async function rollbackCreatedFile(vault: Vault, filePath: string, folderPath: string): Promise<void> {
|
||||
async function rollbackCreatedFile(
|
||||
vault: Vault,
|
||||
filePath: string,
|
||||
folderPath: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const file = vault.getAbstractFileByPath(filePath);
|
||||
if (file instanceof TFile) {
|
||||
|
|
@ -375,9 +373,7 @@ export async function migrateProjectsFromSettingsToVault(vault: Vault): Promise<
|
|||
continue;
|
||||
}
|
||||
|
||||
logWarn(
|
||||
`[Projects] Migration verify failed on existing file for id=${id} at ${filePath}`
|
||||
);
|
||||
logWarn(`[Projects] Migration verify failed on existing file for id=${id} at ${filePath}`);
|
||||
await saveFailedProjectToUnsupported(
|
||||
vault,
|
||||
project,
|
||||
|
|
@ -453,7 +449,9 @@ export async function migrateProjectsFromSettingsToVault(vault: Vault): Promise<
|
|||
};
|
||||
|
||||
if (failedCount === 0) {
|
||||
logInfo(`[Projects] Migration succeeded: all ${successCount} project(s) migrated to vault files`);
|
||||
logInfo(
|
||||
`[Projects] Migration succeeded: all ${successCount} project(s) migrated to vault files`
|
||||
);
|
||||
new ConfirmModal(
|
||||
app,
|
||||
() => revealFolderInExplorer(projectsFolder),
|
||||
|
|
@ -584,10 +582,7 @@ async function migrateProjectFolderNames(vault: Vault): Promise<void> {
|
|||
`for project "${name}" (id=${record.project.id})`
|
||||
);
|
||||
} catch (error) {
|
||||
logError(
|
||||
`[Projects] Naming migration failed for project id="${record.project.id}"`,
|
||||
error
|
||||
);
|
||||
logError(`[Projects] Naming migration failed for project id="${record.project.id}"`, error);
|
||||
} finally {
|
||||
removePendingFileWrite(oldFilePath);
|
||||
removePendingFileWrite(newFilePath);
|
||||
|
|
|
|||
|
|
@ -133,9 +133,11 @@ export class ProjectRegister {
|
|||
const cache = ProjectContextCache.getInstance();
|
||||
await Promise.all(
|
||||
oldRecords.map((old) =>
|
||||
cache.clearForProject(old.project).catch((err) =>
|
||||
logError("[Projects] Failed to clear context cache on folder switch", err)
|
||||
)
|
||||
cache
|
||||
.clearForProject(old.project)
|
||||
.catch((err) =>
|
||||
logError("[Projects] Failed to clear context cache on folder switch", err)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
|
|
@ -170,16 +172,20 @@ export class ProjectRegister {
|
|||
const cache = ProjectContextCache.getInstance();
|
||||
await Promise.all(
|
||||
oldRecords.map((old) =>
|
||||
cache.clearForProject(old.project).catch((err) =>
|
||||
logError("[Projects] Failed to clear context cache on folder switch failure", err)
|
||||
)
|
||||
cache
|
||||
.clearForProject(old.project)
|
||||
.catch((err) =>
|
||||
logError("[Projects] Failed to clear context cache on folder switch failure", err)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
updateCachedProjectRecords([]);
|
||||
|
||||
logError(`[Projects] Failed to reload after folder change: ${nextFolder}`, error);
|
||||
new Notice(`Failed to reload projects from "${nextFolder}". Projects cleared — reopen settings to retry.`);
|
||||
new Notice(
|
||||
`Failed to reload projects from "${nextFolder}". Projects cleared — reopen settings to retry.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -253,7 +259,9 @@ export class ProjectRegister {
|
|||
// fresh cache wiped by a stale async cleanup. Consistent with folder-switch path.
|
||||
await ProjectContextCache.getInstance()
|
||||
.clearForProject(record.project)
|
||||
.catch((err) => logError("[Projects] Failed to clear context cache on external delete", err));
|
||||
.catch((err) =>
|
||||
logError("[Projects] Failed to clear context cache on external delete", err)
|
||||
);
|
||||
|
||||
// Reason: rescan to re-admit any previously-ignored duplicate-id files
|
||||
// that were hidden while the deleted file was the "kept" entry.
|
||||
|
|
@ -378,7 +386,9 @@ export class ProjectRegister {
|
|||
if (staleRecord) {
|
||||
void ProjectContextCache.getInstance()
|
||||
.clearForProject(staleRecord.project)
|
||||
.catch((err) => logError("[Projects] Failed to clear context cache on invalid edit", err));
|
||||
.catch((err) =>
|
||||
logError("[Projects] Failed to clear context cache on invalid edit", err)
|
||||
);
|
||||
// Reason: rescan to re-admit previously-ignored duplicate-id files
|
||||
void loadAllProjects().catch((err) =>
|
||||
logError("[Projects] Rescan after invalid edit failed", err)
|
||||
|
|
@ -395,7 +405,9 @@ export class ProjectRegister {
|
|||
if (staleRecord) {
|
||||
void ProjectContextCache.getInstance()
|
||||
.clearForProject(staleRecord.project)
|
||||
.catch((err) => logError("[Projects] Failed to clear context cache on duplicate edit", err));
|
||||
.catch((err) =>
|
||||
logError("[Projects] Failed to clear context cache on duplicate edit", err)
|
||||
);
|
||||
// Reason: rescan to re-admit previously-ignored duplicate-id files
|
||||
void loadAllProjects().catch((err) =>
|
||||
logError("[Projects] Rescan after duplicate edit failed", err)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { TFile } from "obsidian";
|
||||
import { parseProjectConfigFile, sanitizeVaultPathSegment } from "@/projects/projectUtils";
|
||||
import { mockTFile } from "@/__tests__/mockObsidian";
|
||||
|
||||
// Mock deep dependencies to avoid transitive import chains
|
||||
jest.mock("@/settings/model", () => ({
|
||||
|
|
@ -21,7 +22,7 @@ jest.mock("@/logger", () => ({
|
|||
|
||||
// Helper: create a minimal TFile mock for a project config path
|
||||
function makeMockFile(path: string): TFile {
|
||||
return {
|
||||
return mockTFile({
|
||||
path,
|
||||
name: "project.md",
|
||||
basename: "project",
|
||||
|
|
@ -29,7 +30,7 @@ function makeMockFile(path: string): TFile {
|
|||
stat: { ctime: 1000, mtime: 1000, size: 0 },
|
||||
vault: {} as never,
|
||||
parent: null,
|
||||
} as unknown as TFile;
|
||||
});
|
||||
}
|
||||
|
||||
// Helper: set up the global `app` mock used by parseProjectConfigFile
|
||||
|
|
@ -122,12 +123,7 @@ describe("parseProjectConfigFile", () => {
|
|||
});
|
||||
|
||||
it("returns null when copilot-project-id is missing from frontmatter", async () => {
|
||||
const rawContent = [
|
||||
"---",
|
||||
"copilot-project-name: My Project",
|
||||
"---",
|
||||
"Body text",
|
||||
].join("\n");
|
||||
const rawContent = ["---", "copilot-project-name: My Project", "---", "Body text"].join("\n");
|
||||
|
||||
setupAppMock(rawContent, {
|
||||
"copilot-project-name": "My Project",
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ export class ChunkedStorage {
|
|||
|
||||
async saveDatabase(db: Orama<any>): Promise<void> {
|
||||
try {
|
||||
const rawData: RawData = await save(db);
|
||||
const rawData: RawData = save(db);
|
||||
const numPartitions = getSettings().numPartitions;
|
||||
|
||||
if (numPartitions === 1) {
|
||||
|
|
@ -234,7 +234,7 @@ export class ChunkedStorage {
|
|||
if (!legacyData?.schema) {
|
||||
throw new CustomError("Invalid legacy database format");
|
||||
}
|
||||
const newDb = await create({
|
||||
const newDb = create({
|
||||
schema: legacyData.schema,
|
||||
components: {
|
||||
tokenizer: {
|
||||
|
|
@ -243,13 +243,13 @@ export class ChunkedStorage {
|
|||
},
|
||||
},
|
||||
});
|
||||
await load(newDb, legacyData);
|
||||
load(newDb, legacyData);
|
||||
return newDb;
|
||||
}
|
||||
|
||||
// Load metadata
|
||||
const metadata = await this.loadMetadata();
|
||||
const newDb = await create({
|
||||
const newDb = create({
|
||||
schema: metadata.schema,
|
||||
components: {
|
||||
tokenizer: {
|
||||
|
|
@ -289,7 +289,7 @@ export class ChunkedStorage {
|
|||
// Find document in any chunk
|
||||
const doc = allChunks
|
||||
.flatMap((chunk) => Object.values(chunk.docs.docs))
|
||||
.find((doc: any) => (doc as any).id === internalId);
|
||||
.find((doc: any) => doc.id === internalId);
|
||||
|
||||
if (doc) {
|
||||
orderedDocs[nextDocId.toString()] = doc;
|
||||
|
|
@ -308,7 +308,7 @@ export class ChunkedStorage {
|
|||
// upsert cycles. These "ghost" IDs cause position mismatches after load,
|
||||
// where some user IDs point to wrong doc positions or undefined entries.
|
||||
mergedData.internalDocumentIDStore.internalIdToId = Object.values(orderedDocs).map(
|
||||
(doc: any) => (doc as any).id
|
||||
(doc: any) => doc.id
|
||||
);
|
||||
|
||||
// Merge vectors from all chunks
|
||||
|
|
@ -318,7 +318,7 @@ export class ChunkedStorage {
|
|||
);
|
||||
|
||||
// Load merged data into database
|
||||
await load(newDb, mergedData);
|
||||
load(newDb, mergedData);
|
||||
return newDb;
|
||||
} catch (error) {
|
||||
console.error(`Error loading database:`, error);
|
||||
|
|
|
|||
|
|
@ -275,7 +275,7 @@ export class DBOperations {
|
|||
|
||||
const schema = this.createDynamicSchema(vectorLength);
|
||||
|
||||
const db = await create({
|
||||
const db = create({
|
||||
schema,
|
||||
components: {
|
||||
tokenizer: {
|
||||
|
|
@ -496,8 +496,8 @@ export class DBOperations {
|
|||
oramaDocSample !== null &&
|
||||
"document" in oramaDocSample
|
||||
) {
|
||||
const document = oramaDocSample.document as { embeddingModel?: string };
|
||||
prevEmbeddingModel = document.embeddingModel;
|
||||
const doc = oramaDocSample.document as { embeddingModel?: string };
|
||||
prevEmbeddingModel = doc.embeddingModel;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ export class IndexOperations {
|
|||
this.chunkManager = getSharedChunkManager(app);
|
||||
|
||||
// Subscribe to settings changes
|
||||
subscribeToSettingsChange(async () => {
|
||||
subscribeToSettingsChange(() => {
|
||||
this.refreshRuntimeIndexingConfig();
|
||||
});
|
||||
}
|
||||
|
|
@ -187,7 +187,9 @@ export class IndexOperations {
|
|||
|
||||
// Skip documents with invalid embeddings
|
||||
if (!embedding || !Array.isArray(embedding) || embedding.length === 0) {
|
||||
logError(`Invalid embedding for document ${chunk.fileInfo.path}: ${embedding}`);
|
||||
logError(
|
||||
`Invalid embedding for document ${chunk.fileInfo.path}: ${JSON.stringify(embedding)}`
|
||||
);
|
||||
this.indexBackend.markFileMissingEmbeddings(chunk.fileInfo.path);
|
||||
continue;
|
||||
}
|
||||
|
|
@ -472,7 +474,7 @@ export class IndexOperations {
|
|||
const files = await this.getFilesToIndex();
|
||||
if (files.length === 0) {
|
||||
logInfo("No files to index after filter change, stopping indexing");
|
||||
this.cancelIndexing();
|
||||
await this.cancelIndexing();
|
||||
return;
|
||||
}
|
||||
// Keep progress denominator consistent after resume:
|
||||
|
|
|
|||
|
|
@ -292,7 +292,7 @@ export class FullTextEngine {
|
|||
};
|
||||
|
||||
for (const key of possibleKeys) {
|
||||
const rawValue = (frontmatter as Record<string, unknown>)[key];
|
||||
const rawValue = frontmatter[key];
|
||||
if (!rawValue) {
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -156,9 +156,7 @@ export class GraphBoostCalculator {
|
|||
*/
|
||||
private resolveFile(noteId: string): TFile | null {
|
||||
const file = this.metadataCache.getFirstLinkpathDest(noteId, "");
|
||||
// In tests, the file might be a plain object with the right shape
|
||||
// Check for the required properties instead of instanceof
|
||||
return file && typeof file === "object" && "path" in file ? (file as TFile) : null;
|
||||
return file instanceof TFile ? file : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -38,12 +38,12 @@ describe("ObsidianCliClient", () => {
|
|||
const platform = Platform as unknown as { isDesktopApp?: boolean; isDesktop?: boolean };
|
||||
platform.isDesktopApp = true;
|
||||
|
||||
const container = globalThis as unknown as TestRequireContainer;
|
||||
const container = window as unknown as TestRequireContainer;
|
||||
originalRequire = container.require;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
const container = globalThis as unknown as TestRequireContainer;
|
||||
const container = window as unknown as TestRequireContainer;
|
||||
if (originalRequire) {
|
||||
container.require = originalRequire;
|
||||
} else {
|
||||
|
|
@ -78,7 +78,7 @@ describe("ObsidianCliClient", () => {
|
|||
callback(null, "Daily note content", "")
|
||||
);
|
||||
|
||||
const container = globalThis as unknown as TestRequireContainer;
|
||||
const container = window as unknown as TestRequireContainer;
|
||||
container.require = jest.fn((id: string) => {
|
||||
if (id === "child_process") {
|
||||
return { execFile: execFileMock };
|
||||
|
|
@ -111,7 +111,7 @@ describe("ObsidianCliClient", () => {
|
|||
callback(error, "", "command not found");
|
||||
});
|
||||
|
||||
const container = globalThis as unknown as TestRequireContainer;
|
||||
const container = window as unknown as TestRequireContainer;
|
||||
container.require = jest.fn((id: string) => {
|
||||
if (id === "child_process") {
|
||||
return { execFile: execFileMock };
|
||||
|
|
@ -159,7 +159,7 @@ describe("ObsidianCliClient", () => {
|
|||
callback(error, "", "");
|
||||
});
|
||||
|
||||
const container = globalThis as unknown as TestRequireContainer;
|
||||
const container = window as unknown as TestRequireContainer;
|
||||
container.require = jest.fn((id: string) => {
|
||||
if (id === "child_process") {
|
||||
return { execFile: execFileMock };
|
||||
|
|
@ -185,7 +185,7 @@ describe("ObsidianCliClient", () => {
|
|||
callback(null, "Random note content", "")
|
||||
);
|
||||
|
||||
const container = globalThis as unknown as TestRequireContainer;
|
||||
const container = window as unknown as TestRequireContainer;
|
||||
container.require = jest.fn((id: string) => {
|
||||
if (id === "child_process") {
|
||||
return { execFile: execFileMock };
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ export function isDesktopRuntime(): boolean {
|
|||
* @throws If `require` is not available.
|
||||
*/
|
||||
function getRuntimeRequire(): (id: string) => unknown {
|
||||
const container = globalThis as unknown as RequireContainer;
|
||||
const container = window as unknown as RequireContainer;
|
||||
if (typeof container.require !== "function") {
|
||||
throw new Error(
|
||||
"Node require is unavailable in this runtime. Obsidian CLI commands require the desktop app."
|
||||
|
|
@ -171,7 +171,7 @@ function normalizeCliParameterValue(value: string): string {
|
|||
* @returns Ordered non-empty binary candidates from environment.
|
||||
*/
|
||||
function getCliBinaryCandidatesFromEnv(): string[] {
|
||||
const container = globalThis as unknown as ProcessContainer;
|
||||
const container = window as unknown as ProcessContainer;
|
||||
const env = container.process?.env;
|
||||
if (!env) {
|
||||
return [];
|
||||
|
|
|
|||
|
|
@ -45,6 +45,11 @@ export function formatCliFailureMessage(
|
|||
*/
|
||||
export function throwCliFailure(result: ObsidianCliProcessResult): never {
|
||||
throw new Error(
|
||||
formatCliFailureMessage(result.stderr, result.exitCode, result.errorCode, result.attemptedBinaries)
|
||||
formatCliFailureMessage(
|
||||
result.stderr,
|
||||
result.exitCode,
|
||||
result.errorCode,
|
||||
result.attemptedBinaries
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@ export class WebViewerService {
|
|||
|
||||
/** Get the currently active Web Viewer leaf (if any). */
|
||||
getActiveLeaf(): WebViewerLeaf | null {
|
||||
const leaf = this.app.workspace.activeLeaf;
|
||||
const leaf = this.app.workspace.getMostRecentLeaf();
|
||||
return isWebViewerLeaf(leaf) ? leaf : null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ export async function getReaderModeMarkdown(
|
|||
})
|
||||
.catch((err) => {
|
||||
signal.removeEventListener("abort", abortHandler);
|
||||
reject(err);
|
||||
reject(err instanceof Error ? err : new Error(String(err)));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ export function toStringSafe(value: unknown): string {
|
|||
if (typeof value === "string") return value;
|
||||
if (value === null || value === undefined) return "";
|
||||
try {
|
||||
if (typeof value === "object") return JSON.stringify(value);
|
||||
return String(value);
|
||||
} catch {
|
||||
return "";
|
||||
|
|
@ -276,7 +277,7 @@ export function getInternalWebViewerPluginApi(
|
|||
plugins instanceof Map
|
||||
? plugins.get(WEB_VIEWER_VIEW_TYPE)
|
||||
: isRecord(plugins)
|
||||
? (plugins as Record<string, unknown>)[WEB_VIEWER_VIEW_TYPE]
|
||||
? plugins[WEB_VIEWER_VIEW_TYPE]
|
||||
: null;
|
||||
|
||||
if (directEntry) {
|
||||
|
|
|
|||
|
|
@ -180,8 +180,7 @@ export function isWebViewerLeaf(leaf: WorkspaceLeaf | null): leaf is WebViewerLe
|
|||
if (!leaf) return false;
|
||||
const view = leaf.view as View | undefined;
|
||||
if (!view || typeof view !== "object") return false;
|
||||
const getViewType = view.getViewType;
|
||||
return typeof getViewType === "function" && leaf.view.getViewType() === WEB_VIEWER_VIEW_TYPE;
|
||||
return typeof view.getViewType === "function" && view.getViewType() === WEB_VIEWER_VIEW_TYPE;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -447,11 +447,7 @@ export function sanitizeSettings(settings: CopilotSettings): CopilotSettings {
|
|||
|
||||
// Ensure selfHostSearchProvider is a valid value
|
||||
const validSearchProviders = ["firecrawl", "perplexity"] as const;
|
||||
if (
|
||||
!validSearchProviders.includes(
|
||||
sanitizedSettings.selfHostSearchProvider as (typeof validSearchProviders)[number]
|
||||
)
|
||||
) {
|
||||
if (!validSearchProviders.includes(sanitizedSettings.selfHostSearchProvider)) {
|
||||
sanitizedSettings.selfHostSearchProvider = DEFAULT_SETTINGS.selfHostSearchProvider;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ export const ModelAddDialog: React.FC<ModelAddDialogProps> = ({
|
|||
|
||||
// 判断 Provider 是否有必填的额外设置
|
||||
const hasRequiredExtraSettings = (provider: string) => {
|
||||
return provider === ChatModelProviders.AZURE_OPENAI && !model.baseUrl;
|
||||
return (provider as ChatModelProviders) === ChatModelProviders.AZURE_OPENAI && !model.baseUrl;
|
||||
};
|
||||
|
||||
const [dialogElement, setDialogElement] = useState<HTMLDivElement | null>(null);
|
||||
|
|
@ -119,7 +119,7 @@ export const ModelAddDialog: React.FC<ModelAddDialogProps> = ({
|
|||
// does not consume baseUrl and still reads azureOpenAIApiInstanceName,
|
||||
// azureOpenAIApiEmbeddingDeploymentName, and azureOpenAIApiVersion directly.
|
||||
// Chat models may skip legacy fields when a full base URL is supplied instead.
|
||||
const isAzure = model.provider === ChatModelProviders.AZURE_OPENAI;
|
||||
const isAzure = (model.provider as ChatModelProviders) === ChatModelProviders.AZURE_OPENAI;
|
||||
const azureRequiresLegacyFields = isAzure && (isEmbeddingModel || !model.baseUrl?.trim());
|
||||
if (azureRequiresLegacyFields) {
|
||||
newErrors.instanceName = !model.azureOpenAIApiInstanceName;
|
||||
|
|
@ -138,7 +138,7 @@ export const ModelAddDialog: React.FC<ModelAddDialogProps> = ({
|
|||
}
|
||||
}
|
||||
|
||||
if (model.provider === ChatModelProviders.AMAZON_BEDROCK) {
|
||||
if ((model.provider as ChatModelProviders) === ChatModelProviders.AMAZON_BEDROCK) {
|
||||
newErrors.bedrockRegion = false;
|
||||
} else {
|
||||
newErrors.bedrockRegion = false;
|
||||
|
|
@ -324,7 +324,7 @@ export const ModelAddDialog: React.FC<ModelAddDialogProps> = ({
|
|||
|
||||
const renderProviderSpecificFields = () => {
|
||||
const fields = () => {
|
||||
switch (model.provider) {
|
||||
switch (model.provider as ChatModelProviders) {
|
||||
case ChatModelProviders.OPENAI:
|
||||
return (
|
||||
<FormField
|
||||
|
|
@ -496,7 +496,7 @@ export const ModelAddDialog: React.FC<ModelAddDialogProps> = ({
|
|||
};
|
||||
|
||||
const getPlaceholderUrl = () => {
|
||||
if (model.provider !== ChatModelProviders.AZURE_OPENAI) {
|
||||
if ((model.provider as ChatModelProviders) !== ChatModelProviders.AZURE_OPENAI) {
|
||||
return providerInfo.host;
|
||||
}
|
||||
|
||||
|
|
@ -535,7 +535,8 @@ export const ModelAddDialog: React.FC<ModelAddDialogProps> = ({
|
|||
error={errors.name}
|
||||
errorMessage="Model name is required"
|
||||
description={
|
||||
model.provider === ChatModelProviders.AMAZON_BEDROCK && !isEmbeddingModel
|
||||
(model.provider as ChatModelProviders) === ChatModelProviders.AMAZON_BEDROCK &&
|
||||
!isEmbeddingModel
|
||||
? "For Bedrock, use cross-region inference profile IDs (global., us., eu., or apac. prefix) for better reliability. Regional IDs without prefixes may fail."
|
||||
: undefined
|
||||
}
|
||||
|
|
@ -543,7 +544,8 @@ export const ModelAddDialog: React.FC<ModelAddDialogProps> = ({
|
|||
<Input
|
||||
type="text"
|
||||
placeholder={`Enter model name (e.g. ${
|
||||
model.provider === ChatModelProviders.AMAZON_BEDROCK && !isEmbeddingModel
|
||||
(model.provider as ChatModelProviders) === ChatModelProviders.AMAZON_BEDROCK &&
|
||||
!isEmbeddingModel
|
||||
? "global.anthropic.claude-sonnet-4-6-v1:0"
|
||||
: isEmbeddingModel
|
||||
? "text-embedding-3-small"
|
||||
|
|
@ -701,8 +703,8 @@ export const ModelAddDialog: React.FC<ModelAddDialogProps> = ({
|
|||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
{(model.provider === ChatModelProviders.OPENAI_FORMAT ||
|
||||
model.provider === ChatModelProviders.LM_STUDIO) && (
|
||||
{((model.provider as ChatModelProviders) === ChatModelProviders.OPENAI_FORMAT ||
|
||||
(model.provider as ChatModelProviders) === ChatModelProviders.LM_STUDIO) && (
|
||||
<div className="tw-flex tw-items-center tw-gap-2">
|
||||
<Checkbox
|
||||
id="stream-usage"
|
||||
|
|
|
|||
|
|
@ -44,7 +44,8 @@ export const ModelEditModalContent: React.FC<ModelEditModalContentProps> = ({
|
|||
const [originalModel, setOriginalModel] = useState<CustomModel>(model);
|
||||
const [providerInfo, setProviderInfo] = useState<ProviderMetadata>({} as ProviderMetadata);
|
||||
const settings = getSettings();
|
||||
const isBedrockProvider = localModel.provider === ChatModelProviders.AMAZON_BEDROCK;
|
||||
const isBedrockProvider =
|
||||
(localModel.provider as ChatModelProviders) === ChatModelProviders.AMAZON_BEDROCK;
|
||||
|
||||
useEffect(() => {
|
||||
setLocalModel(model);
|
||||
|
|
@ -120,7 +121,8 @@ export const ModelEditModalContent: React.FC<ModelEditModalContentProps> = ({
|
|||
localModel
|
||||
);
|
||||
const showOtherParameters =
|
||||
!isEmbeddingModel && localModel.provider !== EmbeddingModelProviders.COPILOT_PLUS_JINA;
|
||||
!isEmbeddingModel &&
|
||||
(localModel.provider as EmbeddingModelProviders) !== EmbeddingModelProviders.COPILOT_PLUS_JINA;
|
||||
|
||||
return (
|
||||
<div className="tw-space-y-3 tw-p-4">
|
||||
|
|
@ -208,7 +210,7 @@ export const ModelEditModalContent: React.FC<ModelEditModalContentProps> = ({
|
|||
</FormField>
|
||||
|
||||
{/* Prompt Caching Toggle for OpenRouter */}
|
||||
{localModel.provider === ChatModelProviders.OPENROUTERAI && (
|
||||
{(localModel.provider as ChatModelProviders) === ChatModelProviders.OPENROUTERAI && (
|
||||
<div className="tw-flex tw-items-center tw-gap-2">
|
||||
<Checkbox
|
||||
id="enable-prompt-caching"
|
||||
|
|
@ -271,8 +273,8 @@ export const ModelEditModalContent: React.FC<ModelEditModalContentProps> = ({
|
|||
</FormField>
|
||||
|
||||
{/* Stream Usage Toggle for OpenAI-format providers */}
|
||||
{(localModel.provider === ChatModelProviders.OPENAI_FORMAT ||
|
||||
localModel.provider === ChatModelProviders.LM_STUDIO) && (
|
||||
{((localModel.provider as ChatModelProviders) === ChatModelProviders.OPENAI_FORMAT ||
|
||||
(localModel.provider as ChatModelProviders) === ChatModelProviders.LM_STUDIO) && (
|
||||
<FormField label="Stream Options">
|
||||
<div className="tw-flex tw-items-center tw-gap-2">
|
||||
<Checkbox
|
||||
|
|
@ -297,7 +299,7 @@ export const ModelEditModalContent: React.FC<ModelEditModalContentProps> = ({
|
|||
)}
|
||||
|
||||
{/* Responses API Toggle for LM Studio */}
|
||||
{localModel.provider === ChatModelProviders.LM_STUDIO && (
|
||||
{(localModel.provider as ChatModelProviders) === ChatModelProviders.LM_STUDIO && (
|
||||
<FormField label="Responses API">
|
||||
<div className="tw-flex tw-items-center tw-gap-2">
|
||||
<Checkbox
|
||||
|
|
|
|||
|
|
@ -45,14 +45,24 @@ function renderVerificationMessage(message: string): React.ReactNode {
|
|||
const match = part.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
|
||||
if (match) {
|
||||
return (
|
||||
<a key={j} href={match[2]} target="_blank" rel="noopener noreferrer" className="tw-underline">
|
||||
<a
|
||||
key={j}
|
||||
href={match[2]}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="tw-underline"
|
||||
>
|
||||
{match[1]}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return part;
|
||||
});
|
||||
return <p key={i} className={i > 0 ? "tw-mt-1" : ""}>{nodes}</p>;
|
||||
return (
|
||||
<p key={i} className={i > 0 ? "tw-mt-1" : ""}>
|
||||
{nodes}
|
||||
</p>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -298,7 +298,7 @@ export const QASettings: React.FC = () => {
|
|||
<SettingItem
|
||||
type="switch"
|
||||
title="Enable Obsidian Sync for Copilot index"
|
||||
description="If enabled, store the semantic index in .obsidian so it syncs with Obsidian Sync. If disabled, store it under .copilot/ at the vault root."
|
||||
description={`If enabled, store the semantic index in ${app.vault.configDir} so it syncs with Obsidian Sync. If disabled, store it under .copilot/ at the vault root.`}
|
||||
checked={settings.enableIndexSync}
|
||||
onCheckedChange={(checked) => updateSetting("enableIndexSync", checked)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -156,7 +156,7 @@ export async function verifyAndAddModel(
|
|||
// hasn't enabled this model on their GitHub settings page. Append the policy
|
||||
// terms (which include an activation link) to guide the user.
|
||||
if (
|
||||
customModel.provider === ChatModelProviders.GITHUB_COPILOT &&
|
||||
(customModel.provider as ChatModelProviders) === ChatModelProviders.GITHUB_COPILOT &&
|
||||
verificationError.toLowerCase().includes("not supported")
|
||||
) {
|
||||
// Reason: policy cache is keyed by model.id, not customModel.name (display name)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import * as settingsModel from "@/settings/model";
|
|||
import * as systemPromptUtils from "@/system-prompts/systemPromptUtils";
|
||||
import * as logger from "@/logger";
|
||||
import * as utils from "@/utils";
|
||||
import { mockTFile } from "@/__tests__/mockObsidian";
|
||||
|
||||
// Mock Obsidian
|
||||
jest.mock("obsidian", () => ({
|
||||
|
|
@ -112,9 +113,11 @@ describe("migrateSystemPromptsFromSettings", () => {
|
|||
});
|
||||
(mockVault.getAbstractFileByPath as jest.Mock)
|
||||
.mockReturnValueOnce(null) // File does not exist
|
||||
.mockReturnValueOnce({
|
||||
path: "SystemPrompts/Migrated Custom System Prompt.md",
|
||||
} as TFile); // File created
|
||||
.mockReturnValueOnce(
|
||||
mockTFile({
|
||||
path: "SystemPrompts/Migrated Custom System Prompt.md",
|
||||
})
|
||||
); // File created
|
||||
|
||||
await migrateSystemPromptsFromSettings(mockVault);
|
||||
|
||||
|
|
@ -127,9 +130,11 @@ describe("migrateSystemPromptsFromSettings", () => {
|
|||
});
|
||||
(mockVault.getAbstractFileByPath as jest.Mock)
|
||||
.mockReturnValueOnce(null) // File does not exist
|
||||
.mockReturnValueOnce({
|
||||
path: "SystemPrompts/Migrated Custom System Prompt.md",
|
||||
} as TFile); // File created
|
||||
.mockReturnValueOnce(
|
||||
mockTFile({
|
||||
path: "SystemPrompts/Migrated Custom System Prompt.md",
|
||||
})
|
||||
); // File created
|
||||
|
||||
await migrateSystemPromptsFromSettings(mockVault);
|
||||
|
||||
|
|
@ -144,9 +149,11 @@ describe("migrateSystemPromptsFromSettings", () => {
|
|||
});
|
||||
(mockVault.getAbstractFileByPath as jest.Mock)
|
||||
.mockReturnValueOnce(null) // File does not exist
|
||||
.mockReturnValueOnce({
|
||||
path: "SystemPrompts/Migrated Custom System Prompt.md",
|
||||
} as TFile); // File created
|
||||
.mockReturnValueOnce(
|
||||
mockTFile({
|
||||
path: "SystemPrompts/Migrated Custom System Prompt.md",
|
||||
})
|
||||
); // File created
|
||||
|
||||
await migrateSystemPromptsFromSettings(mockVault);
|
||||
|
||||
|
|
@ -164,9 +171,11 @@ describe("migrateSystemPromptsFromSettings", () => {
|
|||
(mockVault.getAbstractFileByPath as jest.Mock)
|
||||
.mockReturnValueOnce(null)
|
||||
.mockReturnValueOnce(null)
|
||||
.mockReturnValueOnce({
|
||||
path: "SystemPrompts/Migrated Custom System Prompt.md",
|
||||
} as TFile);
|
||||
.mockReturnValueOnce(
|
||||
mockTFile({
|
||||
path: "SystemPrompts/Migrated Custom System Prompt.md",
|
||||
})
|
||||
);
|
||||
|
||||
await migrateSystemPromptsFromSettings(mockVault);
|
||||
|
||||
|
|
@ -179,9 +188,9 @@ describe("migrateSystemPromptsFromSettings", () => {
|
|||
|
||||
it("adds frontmatter to migrated file", async () => {
|
||||
const legacyPrompt = "This is a legacy system prompt.";
|
||||
const mockFile = {
|
||||
const mockFile = mockTFile({
|
||||
path: "SystemPrompts/Migrated Custom System Prompt.md",
|
||||
} as TFile;
|
||||
});
|
||||
|
||||
(settingsModel.getSettings as jest.Mock).mockReturnValue({
|
||||
userSystemPrompt: legacyPrompt,
|
||||
|
|
@ -205,9 +214,9 @@ describe("migrateSystemPromptsFromSettings", () => {
|
|||
|
||||
it("clears legacy userSystemPrompt from settings after migration", async () => {
|
||||
const legacyPrompt = "This is a legacy system prompt.";
|
||||
const mockFile = {
|
||||
const mockFile = mockTFile({
|
||||
path: "SystemPrompts/Migrated Custom System Prompt.md",
|
||||
} as TFile;
|
||||
});
|
||||
|
||||
(settingsModel.getSettings as jest.Mock).mockReturnValue({
|
||||
userSystemPrompt: legacyPrompt,
|
||||
|
|
@ -225,9 +234,9 @@ describe("migrateSystemPromptsFromSettings", () => {
|
|||
|
||||
it("sets migrated prompt as default", async () => {
|
||||
const legacyPrompt = "This is a legacy system prompt.";
|
||||
const mockFile = {
|
||||
const mockFile = mockTFile({
|
||||
path: "SystemPrompts/Migrated Custom System Prompt.md",
|
||||
} as TFile;
|
||||
});
|
||||
|
||||
(settingsModel.getSettings as jest.Mock).mockReturnValue({
|
||||
userSystemPrompt: legacyPrompt,
|
||||
|
|
@ -248,9 +257,9 @@ describe("migrateSystemPromptsFromSettings", () => {
|
|||
|
||||
it("reloads all prompts after migration", async () => {
|
||||
const legacyPrompt = "This is a legacy system prompt.";
|
||||
const mockFile = {
|
||||
const mockFile = mockTFile({
|
||||
path: "SystemPrompts/Migrated Custom System Prompt.md",
|
||||
} as TFile;
|
||||
});
|
||||
|
||||
(settingsModel.getSettings as jest.Mock).mockReturnValue({
|
||||
userSystemPrompt: legacyPrompt,
|
||||
|
|
@ -268,12 +277,12 @@ describe("migrateSystemPromptsFromSettings", () => {
|
|||
|
||||
it("generates unique name when default file already exists", async () => {
|
||||
const legacyPrompt = "This is a legacy system prompt.";
|
||||
const existingFile = {
|
||||
const existingFile = mockTFile({
|
||||
path: "SystemPrompts/Migrated Custom System Prompt.md",
|
||||
} as TFile;
|
||||
const newFile = {
|
||||
});
|
||||
const newFile = mockTFile({
|
||||
path: "SystemPrompts/Migrated Custom System Prompt 2.md",
|
||||
} as TFile;
|
||||
});
|
||||
|
||||
Object.setPrototypeOf(newFile, TFile.prototype);
|
||||
|
||||
|
|
@ -307,9 +316,9 @@ describe("migrateSystemPromptsFromSettings", () => {
|
|||
|
||||
it("generates incrementing unique names when multiple files exist", async () => {
|
||||
const legacyPrompt = "This is a legacy system prompt.";
|
||||
const newFile = {
|
||||
const newFile = mockTFile({
|
||||
path: "SystemPrompts/Migrated Custom System Prompt 3.md",
|
||||
} as TFile;
|
||||
});
|
||||
|
||||
Object.setPrototypeOf(newFile, TFile.prototype);
|
||||
|
||||
|
|
@ -334,9 +343,9 @@ describe("migrateSystemPromptsFromSettings", () => {
|
|||
|
||||
it("logs clearing message after migration", async () => {
|
||||
const legacyPrompt = "This is a legacy system prompt.";
|
||||
const mockFile = {
|
||||
const mockFile = mockTFile({
|
||||
path: "SystemPrompts/Migrated Custom System Prompt.md",
|
||||
} as TFile;
|
||||
});
|
||||
|
||||
(settingsModel.getSettings as jest.Mock).mockReturnValue({
|
||||
userSystemPrompt: legacyPrompt,
|
||||
|
|
@ -388,9 +397,9 @@ describe("migrateSystemPromptsFromSettings", () => {
|
|||
|
||||
it("sets correct timestamps for migrated prompt", async () => {
|
||||
const legacyPrompt = "This is a legacy system prompt.";
|
||||
const mockFile = {
|
||||
const mockFile = mockTFile({
|
||||
path: "SystemPrompts/Migrated Custom System Prompt.md",
|
||||
} as TFile;
|
||||
});
|
||||
|
||||
(settingsModel.getSettings as jest.Mock).mockReturnValue({
|
||||
userSystemPrompt: legacyPrompt,
|
||||
|
|
@ -425,9 +434,9 @@ describe("migrateSystemPromptsFromSettings", () => {
|
|||
describe("write-then-verify safety with unsupported folder", () => {
|
||||
it("clears userSystemPrompt and saves to unsupported when verification fails", async () => {
|
||||
const legacyPrompt = "This is a legacy system prompt.";
|
||||
const mockFile = {
|
||||
const mockFile = mockTFile({
|
||||
path: "SystemPrompts/Migrated Custom System Prompt.md",
|
||||
} as TFile;
|
||||
});
|
||||
|
||||
Object.setPrototypeOf(mockFile, TFile.prototype);
|
||||
|
||||
|
|
@ -478,9 +487,9 @@ describe("migrateSystemPromptsFromSettings", () => {
|
|||
|
||||
it("saves to unsupported and clears userSystemPrompt when vault.read throws", async () => {
|
||||
const legacyPrompt = "This is a legacy system prompt.";
|
||||
const mockFile = {
|
||||
const mockFile = mockTFile({
|
||||
path: "SystemPrompts/Migrated Custom System Prompt.md",
|
||||
} as TFile;
|
||||
});
|
||||
|
||||
Object.setPrototypeOf(mockFile, TFile.prototype);
|
||||
|
||||
|
|
@ -536,9 +545,9 @@ describe("migrateSystemPromptsFromSettings", () => {
|
|||
|
||||
it("clears userSystemPrompt and sets default on successful verification", async () => {
|
||||
const legacyPrompt = "This is a legacy system prompt.";
|
||||
const mockFile = {
|
||||
const mockFile = mockTFile({
|
||||
path: "SystemPrompts/Migrated Custom System Prompt.md",
|
||||
} as TFile;
|
||||
});
|
||||
|
||||
Object.setPrototypeOf(mockFile, TFile.prototype);
|
||||
|
||||
|
|
@ -563,9 +572,9 @@ describe("migrateSystemPromptsFromSettings", () => {
|
|||
|
||||
it("preserves whitespace and verifies exact content match", async () => {
|
||||
const legacyPrompt = " This is a legacy system prompt. \n\n";
|
||||
const mockFile = {
|
||||
const mockFile = mockTFile({
|
||||
path: "SystemPrompts/Migrated Custom System Prompt.md",
|
||||
} as TFile;
|
||||
});
|
||||
|
||||
Object.setPrototypeOf(mockFile, TFile.prototype);
|
||||
|
||||
|
|
@ -587,9 +596,9 @@ describe("migrateSystemPromptsFromSettings", () => {
|
|||
it("normalizes CRLF/LF differences in verification", async () => {
|
||||
// Legacy prompt uses CRLF line endings (Windows style)
|
||||
const legacyPrompt = "Line 1\r\nLine 2\r\nLine 3";
|
||||
const mockFile = {
|
||||
const mockFile = mockTFile({
|
||||
path: "SystemPrompts/Migrated Custom System Prompt.md",
|
||||
} as TFile;
|
||||
});
|
||||
|
||||
Object.setPrototypeOf(mockFile, TFile.prototype);
|
||||
|
||||
|
|
@ -614,9 +623,9 @@ describe("migrateSystemPromptsFromSettings", () => {
|
|||
it("handles double newline after frontmatter (Obsidian format)", async () => {
|
||||
// Obsidian's processFrontMatter may add an extra blank line after frontmatter
|
||||
const legacyPrompt = "This is a legacy system prompt.";
|
||||
const mockFile = {
|
||||
const mockFile = mockTFile({
|
||||
path: "SystemPrompts/Migrated Custom System Prompt.md",
|
||||
} as TFile;
|
||||
});
|
||||
|
||||
Object.setPrototypeOf(mockFile, TFile.prototype);
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { TFile, Vault } from "obsidian";
|
|||
import * as systemPromptUtils from "@/system-prompts/systemPromptUtils";
|
||||
import * as state from "@/system-prompts/state";
|
||||
import * as utils from "@/utils";
|
||||
import { mockTFile } from "@/__tests__/mockObsidian";
|
||||
|
||||
// Mock Obsidian
|
||||
jest.mock("obsidian", () => ({
|
||||
|
|
@ -128,7 +129,7 @@ describe("SystemPromptManager", () => {
|
|||
});
|
||||
|
||||
it("creates a new prompt file", async () => {
|
||||
const mockFile = { path: "SystemPrompts/New Prompt.md" } as TFile;
|
||||
const mockFile = mockTFile({ path: "SystemPrompts/New Prompt.md" });
|
||||
Object.setPrototypeOf(mockFile, TFile.prototype);
|
||||
(mockVault.getAbstractFileByPath as jest.Mock).mockReturnValue(mockFile);
|
||||
|
||||
|
|
@ -140,7 +141,7 @@ describe("SystemPromptManager", () => {
|
|||
});
|
||||
|
||||
it("updates cache after creation", async () => {
|
||||
const mockFile = { path: "SystemPrompts/New Prompt.md" } as TFile;
|
||||
const mockFile = mockTFile({ path: "SystemPrompts/New Prompt.md" });
|
||||
Object.setPrototypeOf(mockFile, TFile.prototype);
|
||||
(mockVault.getAbstractFileByPath as jest.Mock).mockReturnValue(mockFile);
|
||||
|
||||
|
|
@ -150,7 +151,7 @@ describe("SystemPromptManager", () => {
|
|||
});
|
||||
|
||||
it("skips cache update when skipStoreUpdate is true", async () => {
|
||||
const mockFile = { path: "SystemPrompts/New Prompt.md" } as TFile;
|
||||
const mockFile = mockTFile({ path: "SystemPrompts/New Prompt.md" });
|
||||
Object.setPrototypeOf(mockFile, TFile.prototype);
|
||||
(mockVault.getAbstractFileByPath as jest.Mock).mockReturnValue(mockFile);
|
||||
|
||||
|
|
@ -180,7 +181,7 @@ describe("SystemPromptManager", () => {
|
|||
});
|
||||
|
||||
it("manages pending file writes", async () => {
|
||||
const mockFile = { path: "SystemPrompts/New Prompt.md" } as TFile;
|
||||
const mockFile = mockTFile({ path: "SystemPrompts/New Prompt.md" });
|
||||
Object.setPrototypeOf(mockFile, TFile.prototype);
|
||||
(mockVault.getAbstractFileByPath as jest.Mock).mockReturnValue(mockFile);
|
||||
|
||||
|
|
@ -207,7 +208,7 @@ describe("SystemPromptManager", () => {
|
|||
});
|
||||
|
||||
it("updates prompt content without renaming", async () => {
|
||||
const mockFile = { path: "SystemPrompts/Updated Prompt.md" } as TFile;
|
||||
const mockFile = mockTFile({ path: "SystemPrompts/Updated Prompt.md" });
|
||||
Object.setPrototypeOf(mockFile, TFile.prototype);
|
||||
(mockVault.getAbstractFileByPath as jest.Mock).mockReturnValue(mockFile);
|
||||
|
||||
|
|
@ -218,8 +219,8 @@ describe("SystemPromptManager", () => {
|
|||
});
|
||||
|
||||
it("renames file when title changes", async () => {
|
||||
const oldFile = { path: "SystemPrompts/Old Prompt.md" } as TFile;
|
||||
const newFile = { path: "SystemPrompts/Updated Prompt.md" } as TFile;
|
||||
const oldFile = mockTFile({ path: "SystemPrompts/Old Prompt.md" });
|
||||
const newFile = mockTFile({ path: "SystemPrompts/Updated Prompt.md" });
|
||||
Object.setPrototypeOf(oldFile, TFile.prototype);
|
||||
Object.setPrototypeOf(newFile, TFile.prototype);
|
||||
|
||||
|
|
@ -237,7 +238,7 @@ describe("SystemPromptManager", () => {
|
|||
});
|
||||
|
||||
it("updates cache after modification", async () => {
|
||||
const mockFile = { path: "SystemPrompts/Updated Prompt.md" } as TFile;
|
||||
const mockFile = mockTFile({ path: "SystemPrompts/Updated Prompt.md" });
|
||||
Object.setPrototypeOf(mockFile, TFile.prototype);
|
||||
(mockVault.getAbstractFileByPath as jest.Mock).mockReturnValue(mockFile);
|
||||
|
||||
|
|
@ -247,8 +248,8 @@ describe("SystemPromptManager", () => {
|
|||
});
|
||||
|
||||
it("deletes old cache entry when renaming", async () => {
|
||||
const oldFile = { path: "SystemPrompts/Old Prompt.md" } as TFile;
|
||||
const newFile = { path: "SystemPrompts/Updated Prompt.md" } as TFile;
|
||||
const oldFile = mockTFile({ path: "SystemPrompts/Old Prompt.md" });
|
||||
const newFile = mockTFile({ path: "SystemPrompts/Updated Prompt.md" });
|
||||
Object.setPrototypeOf(oldFile, TFile.prototype);
|
||||
Object.setPrototypeOf(newFile, TFile.prototype);
|
||||
|
||||
|
|
@ -263,7 +264,7 @@ describe("SystemPromptManager", () => {
|
|||
});
|
||||
|
||||
it("skips cache update when skipStoreUpdate is true", async () => {
|
||||
const mockFile = { path: "SystemPrompts/Updated Prompt.md" } as TFile;
|
||||
const mockFile = mockTFile({ path: "SystemPrompts/Updated Prompt.md" });
|
||||
Object.setPrototypeOf(mockFile, TFile.prototype);
|
||||
(mockVault.getAbstractFileByPath as jest.Mock).mockReturnValue(mockFile);
|
||||
|
||||
|
|
@ -273,7 +274,7 @@ describe("SystemPromptManager", () => {
|
|||
});
|
||||
|
||||
it("manages pending file writes for update", async () => {
|
||||
const mockFile = { path: "SystemPrompts/Updated Prompt.md" } as TFile;
|
||||
const mockFile = mockTFile({ path: "SystemPrompts/Updated Prompt.md" });
|
||||
Object.setPrototypeOf(mockFile, TFile.prototype);
|
||||
(mockVault.getAbstractFileByPath as jest.Mock).mockReturnValue(mockFile);
|
||||
|
||||
|
|
@ -284,8 +285,8 @@ describe("SystemPromptManager", () => {
|
|||
});
|
||||
|
||||
it("manages pending file writes for rename", async () => {
|
||||
const oldFile = { path: "SystemPrompts/Old Prompt.md" } as TFile;
|
||||
const newFile = { path: "SystemPrompts/Updated Prompt.md" } as TFile;
|
||||
const oldFile = mockTFile({ path: "SystemPrompts/Old Prompt.md" });
|
||||
const newFile = mockTFile({ path: "SystemPrompts/Updated Prompt.md" });
|
||||
Object.setPrototypeOf(oldFile, TFile.prototype);
|
||||
Object.setPrototypeOf(newFile, TFile.prototype);
|
||||
|
||||
|
|
@ -310,7 +311,7 @@ describe("SystemPromptManager", () => {
|
|||
});
|
||||
|
||||
it("deletes the prompt file", async () => {
|
||||
const mockFile = { path: "SystemPrompts/Test Prompt.md" } as TFile;
|
||||
const mockFile = mockTFile({ path: "SystemPrompts/Test Prompt.md" });
|
||||
Object.setPrototypeOf(mockFile, TFile.prototype);
|
||||
(mockVault.getAbstractFileByPath as jest.Mock).mockReturnValue(mockFile);
|
||||
|
||||
|
|
@ -320,7 +321,7 @@ describe("SystemPromptManager", () => {
|
|||
});
|
||||
|
||||
it("removes prompt from cache", async () => {
|
||||
const mockFile = { path: "SystemPrompts/Test Prompt.md" } as TFile;
|
||||
const mockFile = mockTFile({ path: "SystemPrompts/Test Prompt.md" });
|
||||
Object.setPrototypeOf(mockFile, TFile.prototype);
|
||||
(mockVault.getAbstractFileByPath as jest.Mock).mockReturnValue(mockFile);
|
||||
|
||||
|
|
@ -339,7 +340,7 @@ describe("SystemPromptManager", () => {
|
|||
});
|
||||
|
||||
it("manages pending file writes", async () => {
|
||||
const mockFile = { path: "SystemPrompts/Test Prompt.md" } as TFile;
|
||||
const mockFile = mockTFile({ path: "SystemPrompts/Test Prompt.md" });
|
||||
Object.setPrototypeOf(mockFile, TFile.prototype);
|
||||
(mockVault.getAbstractFileByPath as jest.Mock).mockReturnValue(mockFile);
|
||||
|
||||
|
|
@ -371,7 +372,7 @@ describe("SystemPromptManager", () => {
|
|||
});
|
||||
|
||||
it("creates a duplicate with (copy) suffix", async () => {
|
||||
const mockFile = { path: "SystemPrompts/Original Prompt (copy).md" } as TFile;
|
||||
const mockFile = mockTFile({ path: "SystemPrompts/Original Prompt (copy).md" });
|
||||
Object.setPrototypeOf(mockFile, TFile.prototype);
|
||||
(mockVault.getAbstractFileByPath as jest.Mock).mockReturnValue(mockFile);
|
||||
|
||||
|
|
@ -385,7 +386,7 @@ describe("SystemPromptManager", () => {
|
|||
});
|
||||
|
||||
it("sets new timestamps for duplicated prompt", async () => {
|
||||
const mockFile = { path: "SystemPrompts/Original Prompt (copy).md" } as TFile;
|
||||
const mockFile = mockTFile({ path: "SystemPrompts/Original Prompt (copy).md" });
|
||||
Object.setPrototypeOf(mockFile, TFile.prototype);
|
||||
(mockVault.getAbstractFileByPath as jest.Mock).mockReturnValue(mockFile);
|
||||
|
||||
|
|
@ -401,7 +402,7 @@ describe("SystemPromptManager", () => {
|
|||
});
|
||||
|
||||
it("creates the duplicated file", async () => {
|
||||
const mockFile = { path: "SystemPrompts/Original Prompt (copy).md" } as TFile;
|
||||
const mockFile = mockTFile({ path: "SystemPrompts/Original Prompt (copy).md" });
|
||||
Object.setPrototypeOf(mockFile, TFile.prototype);
|
||||
(mockVault.getAbstractFileByPath as jest.Mock).mockReturnValue(mockFile);
|
||||
|
||||
|
|
@ -414,7 +415,7 @@ describe("SystemPromptManager", () => {
|
|||
});
|
||||
|
||||
it("returns the duplicated prompt object", async () => {
|
||||
const mockFile = { path: "SystemPrompts/Original Prompt (copy).md" } as TFile;
|
||||
const mockFile = mockTFile({ path: "SystemPrompts/Original Prompt (copy).md" });
|
||||
Object.setPrototypeOf(mockFile, TFile.prototype);
|
||||
(mockVault.getAbstractFileByPath as jest.Mock).mockReturnValue(mockFile);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { Notice, Plugin, TFile, Vault } from "obsidian";
|
||||
import { Notice, Plugin, Vault } from "obsidian";
|
||||
import { SystemPromptRegister } from "@/system-prompts/systemPromptRegister";
|
||||
import * as state from "@/system-prompts/state";
|
||||
import * as systemPromptUtils from "@/system-prompts/systemPromptUtils";
|
||||
import { mockTFile } from "@/__tests__/mockObsidian";
|
||||
|
||||
// Mock obsidian
|
||||
jest.mock("obsidian", () => ({
|
||||
|
|
@ -94,11 +95,11 @@ describe("SystemPromptRegister", () => {
|
|||
|
||||
describe("handleFileDeletion - selectedPromptTitle sync", () => {
|
||||
it("clears selectedPromptTitle when deleted file matches current selection", async () => {
|
||||
const mockFile = {
|
||||
const mockFile = mockTFile({
|
||||
path: "SystemPrompts/MyPrompt.md",
|
||||
basename: "MyPrompt",
|
||||
extension: "md",
|
||||
} as TFile;
|
||||
});
|
||||
|
||||
// Set up: current selection points to the file being deleted
|
||||
(state.getSelectedPromptTitle as jest.Mock).mockReturnValue("MyPrompt");
|
||||
|
|
@ -112,11 +113,11 @@ describe("SystemPromptRegister", () => {
|
|||
});
|
||||
|
||||
it("does not clear selectedPromptTitle when deleted file does not match", async () => {
|
||||
const mockFile = {
|
||||
const mockFile = mockTFile({
|
||||
path: "SystemPrompts/OtherPrompt.md",
|
||||
basename: "OtherPrompt",
|
||||
extension: "md",
|
||||
} as TFile;
|
||||
});
|
||||
|
||||
// Set up: current selection points to a different prompt
|
||||
(state.getSelectedPromptTitle as jest.Mock).mockReturnValue("MyPrompt");
|
||||
|
|
@ -130,11 +131,11 @@ describe("SystemPromptRegister", () => {
|
|||
});
|
||||
|
||||
it("does not clear selectedPromptTitle when no prompt is selected", async () => {
|
||||
const mockFile = {
|
||||
const mockFile = mockTFile({
|
||||
path: "SystemPrompts/MyPrompt.md",
|
||||
basename: "MyPrompt",
|
||||
extension: "md",
|
||||
} as TFile;
|
||||
});
|
||||
|
||||
// Set up: no prompt is currently selected
|
||||
(state.getSelectedPromptTitle as jest.Mock).mockReturnValue("");
|
||||
|
|
@ -150,11 +151,11 @@ describe("SystemPromptRegister", () => {
|
|||
|
||||
describe("handleFileRename - selectedPromptTitle sync", () => {
|
||||
it("updates selectedPromptTitle when renamed file matches current selection", async () => {
|
||||
const mockFile = {
|
||||
const mockFile = mockTFile({
|
||||
path: "SystemPrompts/NewName.md",
|
||||
basename: "NewName",
|
||||
extension: "md",
|
||||
} as TFile;
|
||||
});
|
||||
const oldPath = "SystemPrompts/OldName.md";
|
||||
|
||||
// Set up: current selection points to the old name
|
||||
|
|
@ -170,11 +171,11 @@ describe("SystemPromptRegister", () => {
|
|||
});
|
||||
|
||||
it("clears selectedPromptTitle when file is moved out of prompts folder", async () => {
|
||||
const mockFile = {
|
||||
const mockFile = mockTFile({
|
||||
path: "OtherFolder/MyPrompt.md",
|
||||
basename: "MyPrompt",
|
||||
extension: "md",
|
||||
} as TFile;
|
||||
});
|
||||
const oldPath = "SystemPrompts/MyPrompt.md";
|
||||
|
||||
// Set up: current selection points to the moved file
|
||||
|
|
@ -191,11 +192,11 @@ describe("SystemPromptRegister", () => {
|
|||
});
|
||||
|
||||
it("does not update selectedPromptTitle when renamed file does not match", async () => {
|
||||
const mockFile = {
|
||||
const mockFile = mockTFile({
|
||||
path: "SystemPrompts/NewName.md",
|
||||
basename: "NewName",
|
||||
extension: "md",
|
||||
} as TFile;
|
||||
});
|
||||
const oldPath = "SystemPrompts/OldName.md";
|
||||
|
||||
// Set up: current selection points to a different prompt
|
||||
|
|
@ -211,11 +212,11 @@ describe("SystemPromptRegister", () => {
|
|||
});
|
||||
|
||||
it("does not update selectedPromptTitle when no prompt is selected", async () => {
|
||||
const mockFile = {
|
||||
const mockFile = mockTFile({
|
||||
path: "SystemPrompts/NewName.md",
|
||||
basename: "NewName",
|
||||
extension: "md",
|
||||
} as TFile;
|
||||
});
|
||||
const oldPath = "SystemPrompts/OldName.md";
|
||||
|
||||
// Set up: no prompt is currently selected
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
import { UserSystemPrompt } from "@/system-prompts/type";
|
||||
import { TFile, TAbstractFile, normalizePath } from "obsidian";
|
||||
import * as settingsModel from "@/settings/model";
|
||||
import { mockTFile } from "@/__tests__/mockObsidian";
|
||||
|
||||
// Mock Obsidian
|
||||
jest.mock("obsidian", () => ({
|
||||
|
|
@ -158,10 +159,10 @@ describe("isSystemPromptFile", () => {
|
|||
});
|
||||
|
||||
it("returns true for valid system prompt file", () => {
|
||||
const mockFile = {
|
||||
const mockFile = mockTFile({
|
||||
path: "SystemPrompts/Test.md",
|
||||
extension: "md",
|
||||
} as TFile;
|
||||
});
|
||||
|
||||
// Mock instanceof check
|
||||
Object.setPrototypeOf(mockFile, TFile.prototype);
|
||||
|
|
@ -178,10 +179,10 @@ describe("isSystemPromptFile", () => {
|
|||
});
|
||||
|
||||
it("returns false for non-markdown files", () => {
|
||||
const mockFile = {
|
||||
const mockFile = mockTFile({
|
||||
path: "SystemPrompts/Test.txt",
|
||||
extension: "txt",
|
||||
} as TFile;
|
||||
});
|
||||
|
||||
Object.setPrototypeOf(mockFile, TFile.prototype);
|
||||
|
||||
|
|
@ -189,10 +190,10 @@ describe("isSystemPromptFile", () => {
|
|||
});
|
||||
|
||||
it("returns false for files outside system prompts folder", () => {
|
||||
const mockFile = {
|
||||
const mockFile = mockTFile({
|
||||
path: "OtherFolder/Test.md",
|
||||
extension: "md",
|
||||
} as TFile;
|
||||
});
|
||||
|
||||
Object.setPrototypeOf(mockFile, TFile.prototype);
|
||||
|
||||
|
|
@ -200,10 +201,10 @@ describe("isSystemPromptFile", () => {
|
|||
});
|
||||
|
||||
it("returns false for files in subfolders", () => {
|
||||
const mockFile = {
|
||||
const mockFile = mockTFile({
|
||||
path: "SystemPrompts/Subfolder/Test.md",
|
||||
extension: "md",
|
||||
} as TFile;
|
||||
});
|
||||
|
||||
Object.setPrototypeOf(mockFile, TFile.prototype);
|
||||
|
||||
|
|
@ -211,10 +212,10 @@ describe("isSystemPromptFile", () => {
|
|||
});
|
||||
|
||||
it("returns false for files in unsupported subfolder", () => {
|
||||
const mockFile = {
|
||||
const mockFile = mockTFile({
|
||||
path: "SystemPrompts/unsupported/Failed Migration.md",
|
||||
extension: "md",
|
||||
} as TFile;
|
||||
});
|
||||
|
||||
Object.setPrototypeOf(mockFile, TFile.prototype);
|
||||
|
||||
|
|
@ -226,15 +227,15 @@ describe("isSystemPromptFile", () => {
|
|||
userSystemPromptsFolder: "CustomFolder/MyPrompts",
|
||||
} as any);
|
||||
|
||||
const validFile = {
|
||||
const validFile = mockTFile({
|
||||
path: "CustomFolder/MyPrompts/Test.md",
|
||||
extension: "md",
|
||||
} as TFile;
|
||||
});
|
||||
|
||||
const unsupportedFile = {
|
||||
const unsupportedFile = mockTFile({
|
||||
path: "CustomFolder/MyPrompts/unsupported/Failed.md",
|
||||
extension: "md",
|
||||
} as TFile;
|
||||
});
|
||||
|
||||
Object.setPrototypeOf(validFile, TFile.prototype);
|
||||
Object.setPrototypeOf(unsupportedFile, TFile.prototype);
|
||||
|
|
@ -250,11 +251,11 @@ describe("parseSystemPromptFile", () => {
|
|||
|
||||
beforeEach(() => {
|
||||
originalApp = global.app;
|
||||
mockFile = {
|
||||
mockFile = mockTFile({
|
||||
basename: "Test Prompt",
|
||||
path: "SystemPrompts/Test Prompt.md",
|
||||
extension: "md",
|
||||
} as TFile;
|
||||
});
|
||||
|
||||
global.app = {
|
||||
vault: {
|
||||
|
|
|
|||
|
|
@ -57,12 +57,12 @@ async function show_preview(
|
|||
|
||||
if (file && (!activeFile || activeFile.path !== file_path)) {
|
||||
// If target file is not the active file, open the target file in the current leaf
|
||||
await app.workspace.getLeaf().openFile(file as TFile);
|
||||
await app.workspace.getLeaf().openFile(file);
|
||||
}
|
||||
|
||||
let originalContent = "";
|
||||
if (file) {
|
||||
originalContent = await app.vault.read(file as TFile);
|
||||
originalContent = await app.vault.read(file);
|
||||
}
|
||||
const changes = diffTrimmedLines(originalContent, content, {
|
||||
newlineIsToken: true,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { ToolManager } from "@/tools/toolManager";
|
|||
import { createGetTagListTool, enforceSizeLimit } from "./TagTools";
|
||||
|
||||
describe("TagTools", () => {
|
||||
const originalApp = (globalThis as any).app;
|
||||
const originalApp = (window as any).app;
|
||||
|
||||
const parsePayload = (result: string) => {
|
||||
const startIndex = result.indexOf('{"');
|
||||
|
|
@ -22,7 +22,7 @@ describe("TagTools", () => {
|
|||
};
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as any).app = {
|
||||
(window as any).app = {
|
||||
metadataCache: {
|
||||
getTags: jest.fn().mockReturnValue({
|
||||
"#project": 5,
|
||||
|
|
@ -38,7 +38,7 @@ describe("TagTools", () => {
|
|||
});
|
||||
|
||||
afterEach(() => {
|
||||
(globalThis as any).app = originalApp;
|
||||
(window as any).app = originalApp;
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
|
|
@ -48,8 +48,8 @@ describe("TagTools", () => {
|
|||
|
||||
const payload = parsePayload(result);
|
||||
|
||||
expect((globalThis as any).app.metadataCache.getTags).toHaveBeenCalled();
|
||||
expect((globalThis as any).app.metadataCache.getFrontmatterTags).toHaveBeenCalled();
|
||||
expect((window as any).app.metadataCache.getTags).toHaveBeenCalled();
|
||||
expect((window as any).app.metadataCache.getFrontmatterTags).toHaveBeenCalled();
|
||||
expect(payload.totalUniqueTags).toBe(3);
|
||||
expect(payload.returnedTagCount).toBe(3);
|
||||
expect(payload.totalOccurrences).toBe(10);
|
||||
|
|
@ -83,8 +83,8 @@ describe("TagTools", () => {
|
|||
|
||||
const payload = parsePayload(result);
|
||||
|
||||
expect((globalThis as any).app.metadataCache.getTags).not.toHaveBeenCalled();
|
||||
expect((globalThis as any).app.metadataCache.getFrontmatterTags).toHaveBeenCalled();
|
||||
expect((window as any).app.metadataCache.getTags).not.toHaveBeenCalled();
|
||||
expect((window as any).app.metadataCache.getFrontmatterTags).toHaveBeenCalled();
|
||||
expect(payload.totalUniqueTags).toBe(2);
|
||||
expect(payload.includedSources).toEqual(["frontmatter"]);
|
||||
expect(payload.tags).toEqual([
|
||||
|
|
@ -118,8 +118,8 @@ describe("TagTools", () => {
|
|||
});
|
||||
|
||||
it("handles empty vault gracefully", async () => {
|
||||
(globalThis as any).app.metadataCache.getTags.mockReturnValue({});
|
||||
(globalThis as any).app.metadataCache.getFrontmatterTags.mockReturnValue({});
|
||||
(window as any).app.metadataCache.getTags.mockReturnValue({});
|
||||
(window as any).app.metadataCache.getFrontmatterTags.mockReturnValue({});
|
||||
|
||||
const tool = createGetTagListTool();
|
||||
const result = await ToolManager.callTool(tool, {});
|
||||
|
|
@ -132,11 +132,11 @@ describe("TagTools", () => {
|
|||
});
|
||||
|
||||
it("normalizes malformed tags correctly", async () => {
|
||||
(globalThis as any).app.metadataCache.getTags.mockReturnValue({
|
||||
(window as any).app.metadataCache.getTags.mockReturnValue({
|
||||
project: 5,
|
||||
"##Weird/Tag": 4,
|
||||
});
|
||||
(globalThis as any).app.metadataCache.getFrontmatterTags.mockReturnValue({
|
||||
(window as any).app.metadataCache.getFrontmatterTags.mockReturnValue({
|
||||
" #Project ": 3,
|
||||
"##Weird/Tag": 1,
|
||||
});
|
||||
|
|
@ -163,10 +163,10 @@ describe("TagTools", () => {
|
|||
});
|
||||
|
||||
it("falls back when inline counts omit frontmatter occurrences", async () => {
|
||||
(globalThis as any).app.metadataCache.getTags.mockReturnValue({
|
||||
(window as any).app.metadataCache.getTags.mockReturnValue({
|
||||
"#project": 1,
|
||||
});
|
||||
(globalThis as any).app.metadataCache.getFrontmatterTags.mockReturnValue({
|
||||
(window as any).app.metadataCache.getFrontmatterTags.mockReturnValue({
|
||||
"#project": 3,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -254,8 +254,8 @@ export class ToolResultFormatter {
|
|||
private static parseSearchResults(result: any): any[] {
|
||||
// Only support the new structured format or pre-formatted XML flow
|
||||
if (typeof result === "object" && result !== null) {
|
||||
if ((result as any).type === "local_search" && Array.isArray((result as any).documents)) {
|
||||
return (result as any).documents;
|
||||
if (result.type === "local_search" && Array.isArray(result.documents)) {
|
||||
return result.documents;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -496,7 +496,9 @@ function getNoteTitleAndTags(noteWithTag: {
|
|||
function getChatContextStr(chatNoteContextPath: string, chatNoteContextTags: string[]): string {
|
||||
const pathStr = chatNoteContextPath ? `\nChat context by path: ${chatNoteContextPath}` : "";
|
||||
const tagsStr =
|
||||
chatNoteContextTags?.length > 0 ? `\nChat context by tags: ${chatNoteContextTags}` : "";
|
||||
chatNoteContextTags?.length > 0
|
||||
? `\nChat context by tags: ${chatNoteContextTags.join(",")}`
|
||||
: "";
|
||||
return pathStr + tagsStr;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,7 @@
|
|||
export function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return (globalThis as any)["btoa"](binary);
|
||||
return Buffer.from(buffer).toString("base64");
|
||||
}
|
||||
|
||||
export function base64ToArrayBuffer(base64: string): ArrayBuffer {
|
||||
const binaryString = (globalThis as any)["atob"](base64);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
return new Uint8Array(Buffer.from(base64, "base64")).buffer;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,10 +39,7 @@ function coerceProjectId(projectId: unknown): string | undefined {
|
|||
* Read the projectId from a chat file's frontmatter.
|
||||
* Tries metadataCache first, falls back to adapter read for hidden-directory files.
|
||||
*/
|
||||
export async function readChatFileProjectId(
|
||||
app: App,
|
||||
file: TFile
|
||||
): Promise<string | undefined> {
|
||||
export async function readChatFileProjectId(app: App, file: TFile): Promise<string | undefined> {
|
||||
const fm = app.metadataCache.getFileCache(file)?.frontmatter;
|
||||
let projectId: unknown = fm?.projectId;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { TFile, Vault } from "obsidian";
|
||||
import { saveConvertedDocOutput } from "./convertedDocOutput";
|
||||
import { mockTFile } from "@/__tests__/mockObsidian";
|
||||
|
||||
jest.mock("@/utils", () => ({
|
||||
ensureFolderExists: jest.fn(),
|
||||
|
|
@ -21,7 +22,7 @@ function makeTFile(path: string): TFile {
|
|||
const filename = parts[parts.length - 1];
|
||||
const basename = filename.replace(/\.[^.]+$/, "");
|
||||
const extension = filename.split(".").pop() ?? "";
|
||||
return { path, basename, extension } as unknown as TFile;
|
||||
return mockTFile({ path, basename, extension });
|
||||
}
|
||||
|
||||
function makeVaultAdapter() {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue