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

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

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

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

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

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

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

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

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

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

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

---------

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

843 lines
31 KiB
TypeScript

import { getSettings } from "@/settings/model";
import {
getEffectiveUserPrompt,
getSystemPrompt,
getSystemPromptWithMemory,
} from "@/system-prompts/systemPromptBuilder";
import { ChainType } from "@/chainType";
import { getChainType, getCurrentProject } from "@/aiParams";
import { logError, logInfo, logWarn } from "@/logger";
import { ChatMessage, MessageContext, WebTabContext } from "@/types/message";
import { processPrompt, type ProcessedPromptResult } from "@/commands/customCommandUtils";
import { FileParserManager } from "@/tools/FileParserManager";
import ChainManager from "@/LLMProviders/chainManager";
import ProjectManager from "@/LLMProviders/projectManager";
import { updateChatMemory } from "@/chatUtils";
import CopilotPlugin from "@/main";
import { ContextManager } from "./ContextManager";
import { MessageRepository } from "./MessageRepository";
import { ChatPersistenceManager } from "./ChatPersistenceManager";
import { ACTIVE_WEB_TAB_MARKER, USER_SENDER } from "@/constants";
import { MessageContent } from "@/imageProcessing/imageProcessor";
import { TFile, Vault } from "obsidian";
import { getWebViewerService } from "@/services/webViewerService/webViewerServiceSingleton";
import {
normalizeUrlForMatching,
normalizeUrlString,
sanitizeWebTabContexts,
} from "@/utils/urlNormalization";
/**
* ChatManager - Central business logic coordinator
*
* This is the main business logic hub that coordinates all chat operations.
* It orchestrates MessageRepository, ContextManager, and LLM chain operations
* while providing a clean API for UI components.
*/
export class ChatManager {
private contextManager: ContextManager;
private projectMessageRepos: Map<string, MessageRepository> = new Map();
private defaultProjectKey = "defaultProjectKey";
private lastKnownProjectId: string | null = null;
private persistenceManager: ChatPersistenceManager;
private onMessageCreatedCallback?: (messageId: string) => void;
constructor(
private messageRepo: MessageRepository,
private chainManager: ChainManager,
private fileParserManager: FileParserManager,
private plugin: CopilotPlugin
) {
this.contextManager = ContextManager.getInstance();
// Initialize default project repository
this.projectMessageRepos.set(this.defaultProjectKey, messageRepo);
// Initialize persistence manager with default repository
this.persistenceManager = new ChatPersistenceManager(plugin.app, messageRepo, chainManager);
}
/**
* Get the current project's message repository
* Automatically detects project changes and handles repository switching
*/
private getCurrentMessageRepo(): MessageRepository {
const currentProjectId = this.plugin.projectManager.getCurrentProjectId();
const projectKey = currentProjectId ?? this.defaultProjectKey;
// Detect if project has changed
if (this.lastKnownProjectId !== currentProjectId) {
logInfo(
`[ChatManager] Project changed from ${this.lastKnownProjectId} to ${currentProjectId}`
);
this.lastKnownProjectId = currentProjectId;
}
// Create a new repository for this project if it doesn't exist
if (!this.projectMessageRepos.has(projectKey)) {
logInfo(`[ChatManager] Creating new message repository for project: ${projectKey}`);
const newRepo = new MessageRepository();
this.projectMessageRepos.set(projectKey, newRepo);
}
const currentRepo = this.projectMessageRepos.get(projectKey)!;
// Update persistence manager to use current repository
this.persistenceManager = new ChatPersistenceManager(
this.plugin.app,
currentRepo,
this.chainManager
);
return currentRepo;
}
/**
* Set callback for when a message is created (before context processing)
*/
setOnMessageCreatedCallback(callback: (messageId: string) => void): void {
this.onMessageCreatedCallback = callback;
}
/**
* Process system prompt template variables using the same engine as custom commands.
* Supports: {activeNote}, {[[Note Title]]}, {#tag1, #tag2}, {folder/path}
*
* Note: JSON-like content (e.g., {"foo": "bar"}) is handled by processPrompt internally,
* which checks if the variable name starts with '"' and skips processing.
* Empty braces `{}` are treated as literals in system prompts.
*
* @param prompt - Raw system prompt text
* @param vault - Vault used to resolve note/tag templates
* @param activeNote - Active note used for {activeNote} resolution
* @returns Processed prompt and included files discovered during processing
*/
private async processSystemPromptTemplates(
prompt: string,
vault: Vault,
activeNote: TFile | null
): Promise<ProcessedPromptResult> {
// Quick skip if no curly braces present
if (!prompt.includes("{") || !prompt.includes("}")) {
return { processedPrompt: prompt, includedFiles: [] };
}
// Respect enableCustomPromptTemplating setting
const settings = getSettings();
if (!settings.enableCustomPromptTemplating) {
return { processedPrompt: prompt, includedFiles: [] };
}
try {
// Reason: selectedText is empty because system prompts don't use selection context
// skipEmptyBraces is true to treat {} as literal in system prompts
const result = await processPrompt(prompt, "", vault, activeNote, true);
// Only trim when the template engine actually ran to avoid mutating user-provided whitespace
return {
processedPrompt: result.processedPrompt.trimEnd(),
includedFiles: result.includedFiles,
};
} catch (error) {
logWarn("[ChatManager] Error processing system prompt templates:", error);
// Return original prompt on error to avoid breaking the chat
return { processedPrompt: prompt, includedFiles: [] };
}
}
/**
* Inject a processed user custom prompt into the system prompt returned by `getSystemPrompt()`.
*
* - If `<user_custom_instructions>` block exists, replaces its inner content.
* - If builtin system prompt is disabled (i.e., system prompt equals `userCustomPrompt`),
* replaces the entire system prompt with the processed user prompt.
* - Otherwise, returns the original system prompt unchanged.
*
* This ensures DEFAULT_SYSTEM_PROMPT is never template-processed.
*/
private injectProcessedUserCustomPromptIntoSystemPrompt(params: {
systemPromptWithoutMemory: string;
userCustomPrompt: string;
processedUserCustomPrompt: string;
}): string {
const { systemPromptWithoutMemory, userCustomPrompt, processedUserCustomPrompt } = params;
// Note: trimEnd() is now done in processSystemPromptTemplates only when templates are processed
const userInstructionsBlockRegex =
/<user_custom_instructions>\n[\s\S]*?\n<\/user_custom_instructions>/;
if (userInstructionsBlockRegex.test(systemPromptWithoutMemory)) {
// Use function replacement to avoid $ being interpreted as special replacement patterns
return systemPromptWithoutMemory.replace(
userInstructionsBlockRegex,
() =>
`<user_custom_instructions>\n${processedUserCustomPrompt}\n</user_custom_instructions>`
);
}
// disableBuiltin === true -> getSystemPrompt() returns userCustomPrompt as-is
if (systemPromptWithoutMemory === userCustomPrompt) {
return processedUserCustomPrompt;
}
logInfo(
"[ChatManager] Could not locate <user_custom_instructions> block for injection; returning original system prompt."
);
return systemPromptWithoutMemory;
}
/**
* Replace the trailing `getSystemPrompt()` portion inside a `getSystemPromptWithMemory()` result.
* This preserves the memory prompt prefix exactly and ensures memory content is not template-processed.
*/
private replaceSystemPromptWithoutMemoryInBasePrompt(params: {
basePromptWithMemory: string;
systemPromptWithoutMemory: string;
processedSystemPromptWithoutMemory: string;
}): string {
const { basePromptWithMemory, systemPromptWithoutMemory, processedSystemPromptWithoutMemory } =
params;
if (!basePromptWithMemory.endsWith(systemPromptWithoutMemory)) {
logInfo(
"[ChatManager] basePromptWithMemory does not end with systemPromptWithoutMemory; returning original base prompt."
);
return basePromptWithMemory;
}
const prefix = basePromptWithMemory.slice(
0,
basePromptWithMemory.length - systemPromptWithoutMemory.length
);
return `${prefix}${processedSystemPromptWithoutMemory}`;
}
/**
* Build system prompt for the current message, including project context if in project mode.
* Also expands template variables (e.g., {activeNote}, {[[Note Title]]}, {#tag}) using the
* same behavior as custom commands.
*
* IMPORTANT: Only the user-defined custom prompt portion (from `getEffectiveSystemPromptContent()`)
* is processed for template variables. DEFAULT_SYSTEM_PROMPT and memory prompts are never processed.
*
* This method preserves the behavior of `getSystemPromptWithMemory()` by:
* 1) Calling it to obtain the canonical base prompt (memory + system prompt)
* 2) Replacing only the user custom prompt content within the trailing `getSystemPrompt()` portion
*
* @param chainType - The chain type being used
* @param vault - Vault used to resolve note/tag templates
* @param activeNote - Active note used for {activeNote} resolution
* @returns Processed system prompt and included files (for deduplication)
*/
private async getSystemPromptForMessage(
chainType: ChainType,
vault: Vault,
activeNote: TFile | null
): Promise<ProcessedPromptResult> {
// Use getEffectiveUserPrompt to ensure consistency with getSystemPrompt (includes legacy fallback)
const userCustomPrompt = getEffectiveUserPrompt();
const allIncludedFiles: TFile[] = [];
// Preserve original behavior (memory + system prompt) via settings/model helpers
const basePromptWithMemory = await getSystemPromptWithMemory(
this.chainManager.userMemoryManager
);
const systemPromptWithoutMemory = getSystemPrompt();
let processedBasePromptWithMemory = basePromptWithMemory;
// Process templates only on user-defined custom prompt content
if (userCustomPrompt) {
const userPromptResult = await this.processSystemPromptTemplates(
userCustomPrompt,
vault,
activeNote
);
const processedSystemPromptWithoutMemory =
this.injectProcessedUserCustomPromptIntoSystemPrompt({
systemPromptWithoutMemory,
userCustomPrompt,
processedUserCustomPrompt: userPromptResult.processedPrompt,
});
const nextProcessedBasePromptWithMemory = this.replaceSystemPromptWithoutMemoryInBasePrompt({
basePromptWithMemory,
systemPromptWithoutMemory,
processedSystemPromptWithoutMemory,
});
// Only add includedFiles if the processed prompt was actually injected
// This prevents context deduplication from skipping files that weren't actually included
if (nextProcessedBasePromptWithMemory !== basePromptWithMemory) {
allIncludedFiles.push(...userPromptResult.includedFiles);
}
processedBasePromptWithMemory = nextProcessedBasePromptWithMemory;
}
// Special case: Add project context for project chain
if (chainType === ChainType.PROJECT_CHAIN) {
const project = getCurrentProject();
if (project) {
const context = await ProjectManager.instance.getProjectContext(project.id);
// Process project system prompt templates too
const projectPromptResult = await this.processSystemPromptTemplates(
project.systemPrompt,
vault,
activeNote
);
allIncludedFiles.push(...projectPromptResult.includedFiles);
let result = `${processedBasePromptWithMemory}\n\n<project_system_prompt>\n${projectPromptResult.processedPrompt}\n</project_system_prompt>`;
// Only add project_context block if context exists
if (context) {
// TODO: Remove this temporary hard cap once proper token budget enforcement
// is implemented (see designdocs/todo/TOKEN_BUDGET_ENFORCEMENT.md Phase 1).
// Hard cap to prevent total payload from exceeding model context windows.
// 600k tokens ≈ 2.4M chars leaves room for L2+L3+L4+L5 within ~1M total.
const MAX_PROJECT_CONTEXT_CHARS = 600_000 * 4;
let projectContext = context;
if (context.length > MAX_PROJECT_CONTEXT_CHARS) {
projectContext = context.substring(0, MAX_PROJECT_CONTEXT_CHARS);
logWarn(
`Project context truncated from ${Math.round(context.length / 4000)}k to ${Math.round(MAX_PROJECT_CONTEXT_CHARS / 4000)}k estimated tokens to stay within token budget`
);
}
result += `\n\n<project_context>\n${projectContext}\n</project_context>`;
}
return {
processedPrompt: result,
includedFiles: allIncludedFiles,
};
}
}
return {
processedPrompt: processedBasePromptWithMemory,
includedFiles: allIncludedFiles,
};
}
/**
* Build webTabs array with Active Web Tab snapshot injected.
*
* This implements snapshot semantics:
* - Active Web Tab URL is resolved at message creation time
* - The URL is stored in message.context.webTabs with isActive: true
* - Edit/reprocess will use the stored URL, not the current active tab
*
* @param existingWebTabs - Existing webTabs from context
* @param shouldIncludeActiveWebTab - Pre-computed flag for whether to include active web tab
* @returns Updated webTabs array with active tab snapshot
*/
private buildWebTabsWithActiveSnapshot(
existingWebTabs: WebTabContext[],
shouldIncludeActiveWebTab: boolean
): WebTabContext[] {
// Always sanitize existing webTabs (normalize URLs, dedupe, ensure single isActive)
const sanitizedTabs = sanitizeWebTabContexts(existingWebTabs);
if (!shouldIncludeActiveWebTab) {
return sanitizedTabs;
}
try {
// Get active web tab from WebViewerService
// Use activeWebTabForMentions to match UI behavior:
// - Preserved only when switching directly to chat panel
// - Cleared when switching to other views (e.g., note tab)
const service = getWebViewerService(this.plugin.app);
const state = service.getActiveWebTabState();
const activeTab = state.activeWebTabForMentions;
const activeUrl = normalizeUrlForMatching(activeTab?.url);
if (!activeUrl) {
// No active web tab available, return sanitized tabs unchanged
return sanitizedTabs;
}
// Clear any existing isActive flags to ensure only one active tab
const clearedTabs: WebTabContext[] = sanitizedTabs.map((tab) => {
if (tab.isActive) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { isActive: _unused, ...rest } = tab;
return rest;
}
return tab;
});
// Check if active URL already exists in the list (using normalized matching)
const existingIndex = clearedTabs.findIndex(
(tab) => normalizeUrlForMatching(tab.url) === activeUrl
);
if (existingIndex >= 0) {
// Merge metadata and mark as active
// Prefer activeTab.url to preserve hash fragments for SPA routing
// Use normalizeUrlString to trim whitespace while keeping hash/query intact
const existing = clearedTabs[existingIndex];
clearedTabs[existingIndex] = {
...existing,
url: normalizeUrlString(activeTab?.url) ?? existing.url,
title: activeTab?.title ?? existing.title,
faviconUrl: activeTab?.faviconUrl ?? existing.faviconUrl,
isActive: true,
};
return clearedTabs;
}
// Add new active tab entry
// Store the raw URL to preserve hash fragments and query params for SPA routing
// Use normalizeUrlString to trim whitespace while keeping hash/query intact
// (activeUrl is only used for comparison/deduplication above)
return [
...clearedTabs,
{
url: normalizeUrlString(activeTab?.url) ?? activeUrl,
title: activeTab?.title,
faviconUrl: activeTab?.faviconUrl,
isActive: true,
},
];
} catch (error) {
// Web Viewer not available (e.g., mobile platform) - don't fail the message
logWarn("[ChatManager] Failed to resolve active web tab:", error);
return sanitizedTabs;
}
}
/**
* Send a new message with context processing
*/
async sendMessage(
displayText: string,
context: MessageContext,
chainType: ChainType,
includeActiveNote: boolean = false,
includeActiveWebTab: boolean = false,
content?: MessageContent[],
updateLoadingMessage?: (message: string) => void
): Promise<string> {
try {
logInfo(`[ChatManager] Sending message: "${displayText}"`);
// Get active note
const activeNote = this.plugin.app.workspace.getActiveFile();
// If includeActiveNote is true and there's an active note, add it to context
const updatedContext = { ...context };
if (includeActiveNote && activeNote) {
const existingNotes = context.notes || [];
// Only add activeNote if it's not already in the context
const hasActiveNote = existingNotes.some((note) => note.path === activeNote.path);
updatedContext.notes = hasActiveNote ? existingNotes : [...existingNotes, activeNote];
}
// Inject Active Web Tab snapshot if requested (快照语义)
// This resolves the active web tab URL at message creation time
// Compute shouldIncludeActiveWebTab: either explicitly requested or via marker in text
// BUT: any selection takes priority and suppresses active tab to avoid redundant context
const hasAnySelection = (updatedContext.selectedTextContexts || []).length > 0;
const shouldIncludeActiveWebTab =
!hasAnySelection && (includeActiveWebTab || displayText.includes(ACTIVE_WEB_TAB_MARKER));
updatedContext.webTabs = this.buildWebTabsWithActiveSnapshot(
updatedContext.webTabs || [],
shouldIncludeActiveWebTab
);
// Create the message with initial content
const currentRepo = this.getCurrentMessageRepo();
const messageId = currentRepo.addMessage(
displayText,
displayText, // Will be updated with processed content
USER_SENDER,
updatedContext,
content
);
// Notify that message was created (for immediate UI update)
if (this.onMessageCreatedCallback) {
this.onMessageCreatedCallback(messageId);
}
// Get the message for context processing
const message = currentRepo.getMessage(messageId);
if (!message) {
throw new Error(`Failed to retrieve message ${messageId}`);
}
// Get system prompt for L1 layer (includes project context if in project mode)
const { processedPrompt: systemPrompt, includedFiles: systemPromptIncludedFiles } =
await this.getSystemPromptForMessage(chainType, this.plugin.app.vault, activeNote);
// Process context to generate LLM content
const { processedContent, contextEnvelope } = await this.contextManager.processMessageContext(
message,
this.fileParserManager,
this.plugin.app.vault,
chainType,
includeActiveNote,
activeNote,
currentRepo, // Pass MessageRepository for L2 building
systemPrompt,
systemPromptIncludedFiles,
updateLoadingMessage
);
// Update the processed content
currentRepo.updateProcessedText(messageId, processedContent, contextEnvelope);
logInfo(`[ChatManager] Successfully sent message ${messageId}`);
return messageId;
} catch (error) {
logInfo(`[ChatManager] Error sending message:`, error);
throw error;
}
}
/**
* Edit an existing message
*
* Design note: This method only updates the message text, not the context (notes/urls/webTabs/etc).
* The original context is preserved because:
* 1. Context represents the state at message creation time (which files/URLs were referenced)
* 2. Allowing context modification would require complex UI for editing pills/references
* 3. The reprocessMessageContext() call below re-fetches content using the ORIGINAL context,
* ensuring the LLM sees fresh content from the same sources
*/
async editMessage(
messageId: string,
newText: string,
chainType: ChainType,
includeActiveNote: boolean = false
): Promise<boolean> {
try {
logInfo(`[ChatManager] Editing message ${messageId}: "${newText}"`);
// Edit the message text only - context remains unchanged (see design note above)
const currentRepo = this.getCurrentMessageRepo();
const editSuccess = currentRepo.editMessage(messageId, newText);
if (!editSuccess) {
return false;
}
// Reprocess context for the edited message
const activeNote = this.plugin.app.workspace.getActiveFile();
const { processedPrompt: systemPrompt, includedFiles: systemPromptIncludedFiles } =
await this.getSystemPromptForMessage(chainType, this.plugin.app.vault, activeNote);
await this.contextManager.reprocessMessageContext(
messageId,
currentRepo,
this.fileParserManager,
this.plugin.app.vault,
chainType,
includeActiveNote,
activeNote,
systemPrompt,
systemPromptIncludedFiles
);
// Update chain memory with fresh LLM messages
await this.updateChainMemory();
logInfo(`[ChatManager] Successfully edited message ${messageId}`);
return true;
} catch (error) {
logInfo(`[ChatManager] Error editing message ${messageId}:`, error);
return false;
}
}
/**
* Regenerate an AI response
*/
async regenerateMessage(
messageId: string,
onUpdateCurrentMessage: (message: string) => void,
onAddMessage: (message: ChatMessage) => void,
onTruncate?: () => void
): Promise<boolean> {
try {
logInfo(`[ChatManager] Regenerating message ${messageId}`);
// Find the message to regenerate
const currentRepo = this.getCurrentMessageRepo();
const message = currentRepo.getMessage(messageId);
if (!message) {
logInfo(`[ChatManager] Message not found: ${messageId}`);
return false;
}
// Find the corresponding user message (should be the previous message)
const displayMessages = currentRepo.getDisplayMessages();
const messageIndex = displayMessages.findIndex((msg) => msg.id === messageId);
if (messageIndex <= 0) {
logInfo(`[ChatManager] Cannot regenerate first message or no user message found`);
return false;
}
const userMessage = displayMessages[messageIndex - 1];
if (userMessage.sender !== USER_SENDER) {
logInfo(`[ChatManager] Previous message is not from user`);
return false;
}
// Truncate messages after the user message
currentRepo.truncateAfter(messageIndex - 1);
// Notify that truncation happened
if (onTruncate) {
onTruncate();
}
// Update chain memory after truncation
await this.updateChainMemory();
// Get the LLM version of the user message for regeneration
if (!userMessage.id) {
logInfo(`[ChatManager] User message has no ID for regeneration`);
return false;
}
let llmMessage = currentRepo.getLLMMessage(userMessage.id);
if (!llmMessage) {
logInfo(`[ChatManager] LLM message not found for regeneration`);
return false;
}
// Lazy reprocess: if contextEnvelope is missing (e.g., loaded from disk),
// reprocess context before running the chain
if (!llmMessage.contextEnvelope) {
logInfo(`[ChatManager] Context envelope missing, reprocessing context for regeneration`);
const chainType = getChainType();
const activeNote = this.plugin.app.workspace.getActiveFile();
const { processedPrompt: systemPrompt, includedFiles: systemPromptIncludedFiles } =
await this.getSystemPromptForMessage(chainType, this.plugin.app.vault, activeNote);
await this.contextManager.reprocessMessageContext(
userMessage.id,
currentRepo,
this.fileParserManager,
this.plugin.app.vault,
chainType,
false,
activeNote,
systemPrompt,
systemPromptIncludedFiles
);
// Re-fetch the LLM message with the newly created envelope
llmMessage = currentRepo.getLLMMessage(userMessage.id)!;
}
// Run the chain to regenerate the response
const abortController = new AbortController();
await this.chainManager.runChain(
llmMessage,
abortController,
onUpdateCurrentMessage,
onAddMessage,
{ debug: getSettings().debug }
);
logInfo(`[ChatManager] Successfully regenerated message ${messageId}`);
return true;
} catch (error) {
logInfo(`[ChatManager] Error regenerating message ${messageId}:`, error);
return false;
}
}
/**
* Delete a message
*/
async deleteMessage(messageId: string): Promise<boolean> {
try {
logInfo(`[ChatManager] Deleting message ${messageId}`);
const currentRepo = this.getCurrentMessageRepo();
const deleteSuccess = currentRepo.deleteMessage(messageId);
if (!deleteSuccess) {
return false;
}
// Update chain memory after deletion
await this.updateChainMemory();
logInfo(`[ChatManager] Successfully deleted message ${messageId}`);
return true;
} catch (error) {
logInfo(`[ChatManager] Error deleting message ${messageId}:`, error);
return false;
}
}
/**
* Add a message
*/
addMessage(message: ChatMessage): string {
const currentRepo = this.getCurrentMessageRepo();
const messageId = currentRepo.addMessage(message);
return messageId;
}
/**
* Clear all messages
*/
clearMessages(): void {
const currentRepo = this.getCurrentMessageRepo();
currentRepo.clear();
// Clear chain memory directly (fire-and-forget; errors are logged but do not block UI)
void this.chainManager.memoryManager
.clearChatMemory()
.catch((err) => logError("clearChatMemory failed", err));
logInfo(`[ChatManager] Cleared all messages`);
}
/**
* Truncate messages after a specific message ID
*/
async truncateAfterMessageId(messageId: string): Promise<void> {
const currentRepo = this.getCurrentMessageRepo();
currentRepo.truncateAfterMessageId(messageId);
// Update chain memory after truncation
await this.updateChainMemory();
logInfo(`[ChatManager] Truncated messages after ${messageId}`);
}
/**
* Get display messages for UI
*/
getDisplayMessages(): ChatMessage[] {
const currentRepo = this.getCurrentMessageRepo();
return currentRepo.getDisplayMessages();
}
/**
* Get LLM messages for AI communication
*/
getLLMMessages(): ChatMessage[] {
const currentRepo = this.getCurrentMessageRepo();
return currentRepo.getLLMMessages();
}
/**
* Get a specific message by ID (display version)
*/
getMessage(id: string): ChatMessage | undefined {
const currentRepo = this.getCurrentMessageRepo();
return currentRepo.getMessage(id);
}
/**
* Get a specific message for LLM processing
*/
getLLMMessage(id: string): ChatMessage | undefined {
const currentRepo = this.getCurrentMessageRepo();
return currentRepo.getLLMMessage(id);
}
/**
* Sync chain memory with the current message repository.
*/
private async updateChainMemory(): Promise<void> {
try {
const currentRepo = this.getCurrentMessageRepo();
const llmMessages = currentRepo.getLLMMessages();
await updateChatMemory(llmMessages, this.chainManager.memoryManager);
logInfo(`[ChatManager] Updated chain memory with ${llmMessages.length} messages`);
} catch (error) {
logInfo(`[ChatManager] Error updating chain memory:`, error);
}
}
/**
* Load messages from saved chat
*/
async loadMessages(messages: ChatMessage[]): Promise<void> {
const currentRepo = this.getCurrentMessageRepo();
currentRepo.clear();
messages.forEach((msg) => {
currentRepo.addMessage(msg);
});
// Update chain memory with loaded messages
await this.updateChainMemory();
logInfo(`[ChatManager] Loaded ${messages.length} messages`);
}
/**
* Save current chat history
*/
async saveChat(modelKey: string): Promise<void> {
await this.persistenceManager.saveChat(modelKey);
}
/**
* Get debug information
*/
getDebugInfo() {
const currentRepo = this.getCurrentMessageRepo();
return {
...currentRepo.getDebugInfo(),
currentProject: this.plugin.projectManager.getCurrentProjectId(),
totalProjects: this.projectMessageRepos.size,
};
}
/**
* Force a project switch refresh
* This ensures the UI gets the correct messages when switching projects
*/
async handleProjectSwitch(): Promise<void> {
const currentProjectId = this.plugin.projectManager.getCurrentProjectId();
logInfo(`[ChatManager] Handling project switch to: ${currentProjectId}`);
// Force detection of project change
this.lastKnownProjectId = null; // Reset to force change detection
const currentRepo = this.getCurrentMessageRepo();
// Sync chain memory with the new project's messages
await this.updateChainMemory();
logInfo(
`[ChatManager] Project switch complete. Messages: ${currentRepo.getDisplayMessages().length}`
);
}
/**
* Load chat history from a file
*
* Design note: This method does NOT reprocess context (URLs/webTabs content fetching) for loaded messages.
* This is intentional because:
* 1. Historical messages represent a past conversation state - refetching would alter the original context
* 2. URL content may have changed since the original conversation
* 3. WebTabs are ephemeral - the tabs referenced in history are likely closed
* 4. Performance - loading history should be fast, not trigger network requests
*
* The persisted chat only stores references (file paths, URLs) not the fetched content.
* If the user wants fresh content, they should start a new conversation or regenerate specific messages.
*/
async loadChatHistory(file: TFile): Promise<void> {
// Clear current messages first
this.clearMessages();
// Load messages from file - only restores message text and context references (not fetched content)
const messages = await this.persistenceManager.loadChat(file);
// Add messages to the current repository
const currentRepo = this.getCurrentMessageRepo();
for (const message of messages) {
currentRepo.addMessage(message);
}
// Update chain memory with loaded messages
await this.updateChainMemory();
logInfo(`[ChatManager] Loaded ${messages.length} messages from chat history`);
}
}