Support image and chat context menu in free modes (#1746)

This commit is contained in:
Logan Yang 2025-08-25 19:59:57 -07:00 committed by GitHub
parent b446a68b02
commit eb830dfa6f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 246 additions and 123 deletions

View file

@ -45,11 +45,18 @@ export class LLMChainRunner extends BaseChainRunner {
messages.push({ role: entry.role, content: entry.content });
}
// Add current user message
messages.push({
role: "user",
content: userMessage.message,
});
// Add current user message - support multimodal content if available
if (userMessage.content && Array.isArray(userMessage.content)) {
messages.push({
role: "user",
content: userMessage.content,
});
} else {
messages.push({
role: "user",
content: userMessage.message,
});
}
logInfo("==== Final Request to AI ====\n", messages);

View file

@ -99,11 +99,18 @@ export class VaultQAChainRunner extends BaseChainRunner {
messages.push({ role: entry.role, content: entry.content });
}
// Add current user question
messages.push({
role: "user",
content: userMessage.message,
});
// Add current user question - support multimodal content if available
if (userMessage.content && Array.isArray(userMessage.content)) {
messages.push({
role: "user",
content: userMessage.content,
});
} else {
messages.push({
role: "user",
content: userMessage.message,
});
}
logInfo("==== Final Request to AI ====\n", messages);

View file

@ -18,7 +18,13 @@ import ChatMessages from "@/components/chat-components/ChatMessages";
import { NewVersionBanner } from "@/components/chat-components/NewVersionBanner";
import { ProjectList } from "@/components/chat-components/ProjectList";
import ProgressCard from "@/components/project/progress-card";
import { ABORT_REASON, EVENT_NAMES, LOADING_MESSAGES, USER_SENDER } from "@/constants";
import {
ABORT_REASON,
EVENT_NAMES,
LOADING_MESSAGES,
USER_SENDER,
RESTRICTION_MESSAGES,
} from "@/constants";
import { AppContext, EventTargetContext } from "@/context";
import { useChatManager } from "@/hooks/useChatManager";
import { getAIResponse } from "@/langchainStream";
@ -29,7 +35,7 @@ import { useIsPlusUser } from "@/plusUtils";
import { updateSetting, useSettingsValue } from "@/settings/model";
import { ChatUIState } from "@/state/ChatUIState";
import { FileParserManager } from "@/tools/FileParserManager";
import { err2String } from "@/utils";
import { err2String, isPlusChain } from "@/utils";
import { Buffer } from "buffer";
import { Notice, TFile } from "obsidian";
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
@ -152,6 +158,15 @@ const Chat: React.FC<ChatProps> = ({
} = {}) => {
if (!inputMessage && selectedImages.length === 0) return;
// Check for URL restrictions in non-Plus chains and show notice, but continue processing
const hasUrlsInContext = urls && urls.length > 0;
const hasUrlsInMessage = inputMessage && mention.extractAllUrls(inputMessage).length > 0;
if ((hasUrlsInContext || hasUrlsInMessage) && !isPlusChain(currentChain)) {
// Show notice but continue processing the message without URL context
new Notice(RESTRICTION_MESSAGES.URL_PROCESSING_RESTRICTED);
}
try {
// Create message content array
const content: any[] = [];
@ -190,10 +205,10 @@ const Chat: React.FC<ChatProps> = ({
displayText += " " + toolCalls.join("\n");
}
// Create message context
// Create message context - filter out URLs for non-Plus chains
const context = {
notes,
urls: urls || [],
urls: isPlusChain(currentChain) ? urls || [] : [],
selectedTextContexts,
};

View file

@ -15,10 +15,12 @@ import { AddImageModal } from "@/components/modals/AddImageModal";
import { ListPromptModal } from "@/components/modals/ListPromptModal";
import { Button } from "@/components/ui/button";
import { ModelSelector } from "@/components/ui/ModelSelector";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { ContextProcessor } from "@/contextProcessor";
import { cn } from "@/lib/utils";
import { COPILOT_TOOL_NAMES } from "@/LLMProviders/intentAnalyzer";
import { Mention } from "@/mentions/Mention";
import { isPlusChain } from "@/utils";
import { updateSetting, useSettingsValue } from "@/settings/model";
import { SelectedTextContext } from "@/types/message";
@ -110,8 +112,7 @@ const ChatInput = forwardRef<{ focus: () => void }, ChatInputProps>(
return isAllowedFileForContext(activeFile) ? activeFile : null;
});
const [selectedProject, setSelectedProject] = useState<ProjectConfig | null>(null);
const isCopilotPlus =
currentChain === ChainType.COPILOT_PLUS_CHAIN || currentChain === ChainType.PROJECT_CHAIN;
const isCopilotPlus = isPlusChain(currentChain);
// Toggle states for vault, web search, composer, and autonomous agent
const [vaultToggle, setVaultToggle] = useState(false);
@ -225,7 +226,8 @@ const ChatInput = forwardRef<{ focus: () => void }, ChatInputProps>(
// Update URLs in context, ensuring uniqueness
const newUrls = urls.filter((url) => !contextUrls.includes(url));
if (newUrls.length > 0) {
if (newUrls.length > 0 && isPlusChain(currentChain)) {
// Only add URLs to context for Plus chains
// Use Set to ensure uniqueness
setContextUrls((prev) => Array.from(new Set([...prev, ...newUrls])));
}
@ -379,7 +381,7 @@ const ChatInput = forwardRef<{ focus: () => void }, ChatInputProps>(
const handlePaste = useCallback(
async (e: React.ClipboardEvent) => {
const items = e.clipboardData?.items;
if (!items || !isCopilotPlus) return;
if (!items) return;
const imageItems = Array.from(items).filter((item) => item.type.indexOf("image") !== -1);
@ -400,7 +402,7 @@ const ChatInput = forwardRef<{ focus: () => void }, ChatInputProps>(
}
}
},
[onAddImage, isCopilotPlus]
[onAddImage]
);
useEffect(() => {
@ -447,8 +449,22 @@ const ChatInput = forwardRef<{ focus: () => void }, ChatInputProps>(
);
// Remove any URLs that are no longer present in the input
setContextUrls((prev) => prev.filter((url) => currentUrls.includes(url)));
}, [inputMessage, includeActiveNote, currentActiveNote, mention, setContextNotes, app.vault]);
// Only keep URLs if URL processing is supported for the current chain
if (isPlusChain(currentChain)) {
setContextUrls((prev) => prev.filter((url) => currentUrls.includes(url)));
} else {
// Clear all URLs for non-Plus chains
setContextUrls([]);
}
}, [
inputMessage,
includeActiveNote,
currentActiveNote,
mention,
setContextNotes,
app.vault,
currentChain,
]);
// Update the current active note whenever it changes
useEffect(() => {
@ -535,7 +551,7 @@ const ChatInput = forwardRef<{ focus: () => void }, ChatInputProps>(
</div>
)}
<div className="tw-relative" {...(isCopilotPlus ? getRootProps() : {})}>
<div className="tw-relative" {...getRootProps()}>
{isProjectLoading && (
<div className="tw-absolute tw-inset-0 tw-z-modal tw-flex tw-items-center tw-justify-center tw-bg-primary tw-opacity-80 tw-backdrop-blur-sm">
<div className="tw-flex tw-items-center tw-gap-2">
@ -557,16 +573,12 @@ const ChatInput = forwardRef<{ focus: () => void }, ChatInputProps>(
onPaste={handlePaste}
disabled={isProjectLoading}
/>
{isCopilotPlus && (
<>
<input {...getInputProps()} />
{/* Overlay that appears when dragging */}
{isDragActive && (
<div className="tw-absolute tw-inset-0 tw-flex tw-items-center tw-justify-center tw-rounded-md tw-border tw-border-dashed tw-bg-primary">
<span>Drop images here...</span>
</div>
)}
</>
<input {...getInputProps()} />
{/* Overlay that appears when dragging */}
{isDragActive && (
<div className="tw-absolute tw-inset-0 tw-flex tw-items-center tw-justify-center tw-rounded-md tw-border tw-border-dashed tw-bg-primary">
<span>Drop images here...</span>
</div>
)}
</div>
@ -604,85 +616,111 @@ const ChatInput = forwardRef<{ focus: () => void }, ChatInputProps>(
Stop
</Button>
) : (
<>
<TooltipProvider delayDuration={0}>
{/* Autonomous Agent button - only show in Copilot Plus mode and NOT in Projects mode */}
{isCopilotPlus && currentChain !== ChainType.PROJECT_CHAIN && (
<Button
variant="ghost2"
size="fit"
onClick={() => {
const newValue = !autonomousAgentToggle;
setAutonomousAgentToggle(newValue);
updateSetting("enableAutonomousAgent", newValue);
}}
className={cn(
"tw-mr-2 tw-text-muted hover:tw-text-accent",
autonomousAgentToggle && "tw-text-accent tw-bg-accent/10"
)}
title="Toggle autonomous agent mode"
>
<Brain className="tw-size-4" />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost2"
size="fit"
onClick={() => {
const newValue = !autonomousAgentToggle;
setAutonomousAgentToggle(newValue);
updateSetting("enableAutonomousAgent", newValue);
}}
className={cn(
"tw-mr-2 tw-text-muted hover:tw-text-accent",
autonomousAgentToggle && "tw-text-accent tw-bg-accent/10"
)}
>
<Brain className="tw-size-4" />
</Button>
</TooltipTrigger>
<TooltipContent className="tw-px-1 tw-py-0.5">
Toggle autonomous agent mode
</TooltipContent>
</Tooltip>
)}
{/* Toggle buttons for vault, web search, and composer - show when Autonomous Agent is off */}
{!autonomousAgentToggle && isCopilotPlus && (
<>
<Button
variant="ghost2"
size="fit"
onClick={() => setVaultToggle(!vaultToggle)}
className={cn(
"tw-mr-2 tw-text-muted hover:tw-text-accent",
vaultToggle && "tw-text-accent tw-bg-accent/10"
)}
title="Toggle vault search"
>
<Database className="tw-size-4" />
</Button>
<Button
variant="ghost2"
size="fit"
onClick={() => setWebToggle(!webToggle)}
className={cn(
"tw-mr-2 tw-text-muted hover:tw-text-accent",
webToggle && "tw-text-accent tw-bg-accent/10"
)}
title="Toggle web search"
>
<Globe className="tw-size-4" />
</Button>
<Button
variant="ghost2"
size="fit"
onClick={() => setComposerToggle(!composerToggle)}
className={cn(
"tw-mr-2 tw-text-muted hover:tw-text-accent",
composerToggle && "tw-text-accent tw-bg-accent/10"
)}
title="Toggle composer (note editing)"
>
<span className="tw-flex tw-items-center tw-gap-0.5">
<Sparkles className="tw-size-2" />
<Pen className="tw-size-3" />
</span>
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost2"
size="fit"
onClick={() => setVaultToggle(!vaultToggle)}
className={cn(
"tw-mr-2 tw-text-muted hover:tw-text-accent",
vaultToggle && "tw-text-accent tw-bg-accent/10"
)}
>
<Database className="tw-size-4" />
</Button>
</TooltipTrigger>
<TooltipContent className="tw-px-1 tw-py-0.5">
Toggle vault search
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost2"
size="fit"
onClick={() => setWebToggle(!webToggle)}
className={cn(
"tw-mr-2 tw-text-muted hover:tw-text-accent",
webToggle && "tw-text-accent tw-bg-accent/10"
)}
>
<Globe className="tw-size-4" />
</Button>
</TooltipTrigger>
<TooltipContent className="tw-px-1 tw-py-0.5">
Toggle web search
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost2"
size="fit"
onClick={() => setComposerToggle(!composerToggle)}
className={cn(
"tw-mr-2 tw-text-muted hover:tw-text-accent",
composerToggle && "tw-text-accent tw-bg-accent/10"
)}
>
<span className="tw-flex tw-items-center tw-gap-0.5">
<Sparkles className="tw-size-2" />
<Pen className="tw-size-3" />
</span>
</Button>
</TooltipTrigger>
<TooltipContent className="tw-px-1 tw-py-0.5">
Toggle composer (note editing)
</TooltipContent>
</Tooltip>
</>
)}
{isCopilotPlus && (
<Button
variant="ghost2"
size="fit"
className="tw-text-muted hover:tw-text-accent"
onClick={() => {
new AddImageModal(app, onAddImage).open();
}}
title="Add image(s)"
>
<Image className="tw-size-4" />
</Button>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost2"
size="fit"
className="tw-text-muted hover:tw-text-accent"
onClick={() => {
new AddImageModal(app, onAddImage).open();
}}
>
<Image className="tw-size-4" />
</Button>
</TooltipTrigger>
<TooltipContent className="tw-px-1 tw-py-0.5">Add image(s)</TooltipContent>
</Tooltip>
<Button
variant="ghost2"
size="fit"
@ -692,7 +730,7 @@ const ChatInput = forwardRef<{ focus: () => void }, ChatInputProps>(
<CornerDownLeft className="!tw-size-3" />
<span>chat</span>
</Button>
</>
</TooltipProvider>
)}
</div>
</div>

View file

@ -1,8 +1,7 @@
import { useChainType } from "@/aiParams";
import { App } from "obsidian";
import React from "react";
import { ChainType } from "@/chainFactory";
import { useChainType } from "@/aiParams";
import { AddContextNoteModal } from "@/components/modals/AddContextNoteModal";
import { SelectedTextContext } from "@/types/message";
import { TFile } from "obsidian";
@ -38,7 +37,6 @@ const ContextControl: React.FC<ChatControlsProps> = ({
showProgressCard,
}) => {
const [selectedChain] = useChainType();
const handleAddContext = () => {
new AddContextNoteModal({
app,
@ -53,6 +51,7 @@ const ContextControl: React.FC<ChatControlsProps> = ({
}
},
excludeNotePaths,
chainType: selectedChain,
}).open();
};
@ -74,9 +73,7 @@ const ContextControl: React.FC<ChatControlsProps> = ({
}
};
if (selectedChain !== ChainType.COPILOT_PLUS_CHAIN && selectedChain !== ChainType.PROJECT_CHAIN) {
return null;
}
// Context menu is now available for all chain types
return (
<ChatContextMenu

View file

@ -1,11 +1,15 @@
import { App, FuzzyMatch, TFile } from "obsidian";
import { App, FuzzyMatch, TFile, Notice } from "obsidian";
import { BaseNoteModal } from "./BaseNoteModal";
import { ChainType } from "@/chainFactory";
import { isAllowedFileForChainContext } from "@/utils";
import { RESTRICTION_MESSAGES } from "@/constants";
interface AddContextNoteModalProps {
app: App;
onNoteSelect: (note: TFile) => void;
excludeNotePaths: string[];
titleOnly?: boolean;
chainType?: ChainType;
}
export class AddContextNoteModal extends BaseNoteModal<TFile> {
@ -17,8 +21,9 @@ export class AddContextNoteModal extends BaseNoteModal<TFile> {
onNoteSelect,
excludeNotePaths,
titleOnly = false,
chainType = ChainType.COPILOT_PLUS_CHAIN,
}: AddContextNoteModalProps) {
super(app);
super(app, chainType);
this.onNoteSelect = onNoteSelect;
this.availableNotes = this.getOrderedNotes(excludeNotePaths);
this.titleOnly = titleOnly;
@ -42,6 +47,12 @@ export class AddContextNoteModal extends BaseNoteModal<TFile> {
}
onChooseItem(note: TFile, evt: MouseEvent | KeyboardEvent) {
// Check if the file is allowed for the current chain type
if (!isAllowedFileForChainContext(note, this.chainType)) {
new Notice(RESTRICTION_MESSAGES.NON_MARKDOWN_FILES_RESTRICTED);
return;
}
this.onNoteSelect(note);
}

View file

@ -1,13 +1,16 @@
import { App, FuzzySuggestModal, TFile } from "obsidian";
import { isAllowedFileForContext } from "@/utils";
import { isAllowedFileForChainContext } from "@/utils";
import { ChainType } from "@/chainFactory";
export abstract class BaseNoteModal<T> extends FuzzySuggestModal<T> {
protected activeNote: TFile | null;
protected availableNotes: T[];
protected chainType: ChainType;
constructor(app: App) {
constructor(app: App, chainType: ChainType = ChainType.COPILOT_PLUS_CHAIN) {
super(app);
this.activeNote = app.workspace.getActiveFile();
this.chainType = chainType;
}
protected getOrderedNotes(excludeNotePaths: string[] = []): TFile[] {
@ -18,13 +21,15 @@ export abstract class BaseNoteModal<T> extends FuzzySuggestModal<T> {
.filter(
(file): file is TFile =>
file instanceof TFile &&
isAllowedFileForContext(file) &&
isAllowedFileForChainContext(file, this.chainType) &&
!excludeNotePaths.includes(file.path) &&
file.path !== this.activeNote?.path
);
// Get all other files that weren't recently opened
const allFiles = this.app.vault.getFiles().filter((file) => isAllowedFileForContext(file));
const allFiles = this.app.vault
.getFiles()
.filter((file) => isAllowedFileForChainContext(file, this.chainType));
const otherFiles = allFiles.filter(
(file) =>
@ -35,7 +40,9 @@ export abstract class BaseNoteModal<T> extends FuzzySuggestModal<T> {
// Combine active note (if exists and is allowed type) with recent files and other files
const activeNoteArray =
this.activeNote && isAllowedFileForContext(this.activeNote) ? [this.activeNote] : [];
this.activeNote && isAllowedFileForChainContext(this.activeNote, this.chainType)
? [this.activeNote]
: [];
return [...activeNoteArray, ...recentFiles, ...otherFiles];
}

View file

@ -99,7 +99,7 @@ export const EMPTY_INDEX_ERROR_MESSAGE =
export const CHUNK_SIZE = 6000;
export const TEXT_WEIGHT = 0.4;
export const MAX_CHARS_FOR_LOCAL_SEARCH_CONTEXT = 448000;
export const LLM_TIMEOUT_MS = 10000; // 10 seconds timeout for LLM operations
export const LLM_TIMEOUT_MS = 30000; // 30 seconds timeout for LLM operations
export const LOADING_MESSAGES = {
DEFAULT: "",
READING_FILES: "Reading files",
@ -658,6 +658,15 @@ export const AUTOCOMPLETE_CONFIG = {
KEYBIND: "Tab" as AcceptKeyOption,
} as const;
export const RESTRICTION_MESSAGES = {
NON_MARKDOWN_FILES_RESTRICTED:
"Non-markdown files are only available in Copilot Plus mode. Please upgrade to access this file type.",
URL_PROCESSING_RESTRICTED:
"URL processing is only available in Copilot Plus mode. URLs will not be processed for context.",
UNSUPPORTED_FILE_TYPE: (extension: string) =>
`${extension.toUpperCase()} files are not supported in the current mode.`,
} as const;
export const DEFAULT_SETTINGS: CopilotSettings = {
userId: uuidv4(),
isPlusUser: false,

View file

@ -1,7 +1,9 @@
import { getSelectedTextContexts } from "@/aiParams";
import { ChainType } from "@/chainFactory";
import { RESTRICTION_MESSAGES } from "@/constants";
import { FileParserManager } from "@/tools/FileParserManager";
import { TFile, Vault } from "obsidian";
import { isPlusChain } from "@/utils";
import { TFile, Vault, Notice } from "obsidian";
import { NOTE_CONTEXT_PROMPT_TAG, EMBEDDED_PDF_TAG, SELECTED_TEXT_TAG } from "./constants";
export class ContextProcessor {
@ -93,15 +95,13 @@ export class ContextProcessor {
}
// 2. Apply chain restrictions only to supported files that are NOT md or canvas
if (
currentChain !== ChainType.COPILOT_PLUS_CHAIN &&
note.extension !== "md" &&
note.extension !== "canvas"
) {
if (!isPlusChain(currentChain) && note.extension !== "md" && note.extension !== "canvas") {
// This file type is supported, but requires Plus mode (e.g., PDF)
console.warn(
`File type ${note.extension} requires Copilot Plus mode for context processing.`
);
// Show user-facing notice about the restriction
new Notice(RESTRICTION_MESSAGES.NON_MARKDOWN_FILES_RESTRICTED);
return;
}
@ -109,7 +109,7 @@ export class ContextProcessor {
let content = await fileParserManager.parseFile(note, vault);
// Special handling for embedded PDFs within markdown (only in Plus mode)
if (note.extension === "md" && currentChain === ChainType.COPILOT_PLUS_CHAIN) {
if (note.extension === "md" && isPlusChain(currentChain)) {
content = await this.processEmbeddedPDFs(content, vault, fileParserManager);
}

View file

@ -290,6 +290,38 @@ export function isAllowedFileForContext(file: TFile | null): boolean {
return file.extension === "md" || file.extension === "pdf" || file.extension === "canvas";
}
/**
* Checks if a chain type is a Plus mode chain (Copilot Plus or Project Chain).
* Plus mode chains have access to premium features like PDF processing and URL processing.
* @param chainType The chain type to check
* @returns true if this is a Plus mode chain, false otherwise
*/
export function isPlusChain(chainType: ChainType): boolean {
return chainType === ChainType.COPILOT_PLUS_CHAIN || chainType === ChainType.PROJECT_CHAIN;
}
/**
* Checks if a file extension is allowed for context based on the chain type.
* All chains support markdown and canvas files.
* Plus chains support all file types (PDF, EPUB, PPT, DOCX, etc.).
* Free chains only support markdown and canvas files.
* @param file The file to check
* @param chainType The current chain type
* @returns true if the file is allowed for this chain type, false otherwise
*/
export function isAllowedFileForChainContext(file: TFile | null, chainType: ChainType): boolean {
if (!file) return false;
// All chains support markdown and canvas files
if (file.extension === "md" || file.extension === "canvas") {
return true;
}
// Plus chains support all other file types (PDF, EPUB, PPT, DOCX, etc.)
// Free chains only support markdown and canvas
return isPlusChain(chainType);
}
export async function getAllNotesContent(vault: Vault): Promise<string> {
const vaultNotes: string[] = [];