mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
* 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>
843 lines
31 KiB
TypeScript
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`);
|
|
}
|
|
}
|