diff --git a/src/adapters/acp/acp-type-converter.ts b/src/adapters/acp/acp-type-converter.ts index ad36a58..88a6bea 100644 --- a/src/adapters/acp/acp-type-converter.ts +++ b/src/adapters/acp/acp-type-converter.ts @@ -1,5 +1,6 @@ import * as acp from "@agentclientprotocol/sdk"; import type { ToolCallContent } from "../../domain/models/chat-message"; +import type { PromptContent } from "../../domain/models/prompt-content"; /** * Type converter between ACP Protocol types and Domain types. @@ -44,4 +45,26 @@ export class AcpTypeConverter { return converted.length > 0 ? converted : undefined; } + + /** + * Convert domain PromptContent to ACP ContentBlock. + * + * This converts our domain-layer prompt content to the ACP protocol format + * for sending to the agent. + * + * @param content - Domain prompt content (text or image) + * @returns ACP ContentBlock for use with the prompt API + */ + static toAcpContentBlock(content: PromptContent): acp.ContentBlock { + switch (content.type) { + case "text": + return { type: "text", text: content.text }; + case "image": + return { + type: "image", + data: content.data, + mimeType: content.mimeType, + }; + } + } } diff --git a/src/adapters/acp/acp.adapter.ts b/src/adapters/acp/acp.adapter.ts index 3de70bf..20a97d2 100644 --- a/src/adapters/acp/acp.adapter.ts +++ b/src/adapters/acp/acp.adapter.ts @@ -13,6 +13,7 @@ import type { PermissionOption, } from "../../domain/models/chat-message"; import type { SessionUpdate } from "../../domain/models/session-update"; +import type { PromptContent } from "../../domain/models/prompt-content"; import type { AgentError } from "../../domain/models/agent-error"; import { AcpTypeConverter } from "./acp-type-converter"; import { TerminalManager } from "../../shared/terminal-manager"; @@ -544,7 +545,10 @@ export class AcpAdapter implements IAgentClient, IAcpClient { /** * Send a message to the agent in a specific session. */ - async sendMessage(sessionId: string, message: string): Promise { + async sendPrompt( + sessionId: string, + content: PromptContent[], + ): Promise { if (!this.connection) { throw new Error( "Connection not initialized. Call initialize() first.", @@ -555,20 +559,22 @@ export class AcpAdapter implements IAgentClient, IAcpClient { this.resetCurrentMessage(); try { - this.logger.log(`[AcpAdapter] ✅ Sending Message...: ${message}`); + // Convert domain PromptContent to ACP ContentBlock + const acpContent = content.map((c) => + AcpTypeConverter.toAcpContentBlock(c), + ); + + this.logger.log( + `[AcpAdapter] Sending prompt with ${content.length} content blocks`, + ); const promptResult = await this.connection.prompt({ sessionId: sessionId, - prompt: [ - { - type: "text", - text: message, - }, - ], + prompt: acpContent, }); this.logger.log( - `[AcpAdapter] ✅ Agent completed with: ${promptResult.stopReason}`, + `[AcpAdapter] Agent completed with: ${promptResult.stopReason}`, ); } catch (error: unknown) { this.logger.error("[AcpAdapter] Prompt Error:", error); diff --git a/src/components/chat/ChatInput.tsx b/src/components/chat/ChatInput.tsx index 2949b46..8b3de96 100644 --- a/src/components/chat/ChatInput.tsx +++ b/src/components/chat/ChatInput.tsx @@ -1,6 +1,6 @@ import * as React from "react"; const { useRef, useState, useEffect, useCallback, useMemo } = React; -import { setIcon, DropdownComponent } from "obsidian"; +import { setIcon, DropdownComponent, Notice } from "obsidian"; import type AgentClientPlugin from "../../plugin"; import type { ChatView } from "./ChatView"; @@ -10,12 +10,37 @@ import type { SessionModeState, SessionModelState, } from "../../domain/models/chat-session"; +import type { ImagePromptContent } from "../../domain/models/prompt-content"; import type { UseMentionsReturn } from "../../hooks/useMentions"; import type { UseSlashCommandsReturn } from "../../hooks/useSlashCommands"; import type { UseAutoMentionReturn } from "../../hooks/useAutoMention"; import { SuggestionDropdown } from "./SuggestionDropdown"; +import { ImagePreviewStrip, type AttachedImage } from "./ImagePreviewStrip"; import { Logger } from "../../shared/logger"; +// ============================================================================ +// Image Constants +// ============================================================================ + +/** Maximum image size in MB */ +const MAX_IMAGE_SIZE_MB = 5; + +/** Maximum image size in bytes */ +const MAX_IMAGE_SIZE_BYTES = MAX_IMAGE_SIZE_MB * 1024 * 1024; + +/** Maximum number of images per message */ +const MAX_IMAGE_COUNT = 10; + +/** Supported image MIME types (whitelist) */ +const SUPPORTED_IMAGE_TYPES = [ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", +] as const; + +type SupportedImageType = (typeof SUPPORTED_IMAGE_TYPES)[number]; + /** * Props for ChatInput component */ @@ -42,8 +67,11 @@ export interface ChatInputProps { plugin: AgentClientPlugin; /** View instance for event registration */ view: ChatView; - /** Callback to send a message */ - onSendMessage: (content: string) => Promise; + /** Callback to send a message with optional images */ + onSendMessage: ( + content: string, + images?: ImagePromptContent[], + ) => Promise; /** Callback to stop the current generation */ onStopGeneration: () => Promise; /** Callback when restored message has been consumed */ @@ -96,6 +124,7 @@ export function ChatInput({ const [inputValue, setInputValue] = useState(""); const [hintText, setHintText] = useState(null); const [commandText, setCommandText] = useState(""); + const [attachedImages, setAttachedImages] = useState([]); // Refs const textareaRef = useRef(null); @@ -105,6 +134,112 @@ export function ChatInput({ const modelDropdownRef = useRef(null); const modelDropdownInstance = useRef(null); + /** + * Add an image to the attached images list. + * Simple addition - validation is done in handlePaste. + */ + const addImage = useCallback((image: AttachedImage) => { + setAttachedImages((prev) => { + // Safety check for race conditions + if (prev.length >= MAX_IMAGE_COUNT) { + return prev; + } + return [...prev, image]; + }); + }, []); + + /** + * Remove an image from the attached images list. + */ + const removeImage = useCallback((id: string) => { + setAttachedImages((prev) => prev.filter((img) => img.id !== id)); + }, []); + + /** + * Convert a File to Base64 string. + */ + const fileToBase64 = useCallback(async (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as string; + // Extract base64 part from "data:image/png;base64,..." + const base64 = result.split(",")[1]; + resolve(base64); + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); + }, []); + + /** + * Handle paste event for image attachment. + * + * v3 design: + * - Check file size before Base64 conversion (memory efficiency) + * - Use MIME type whitelist (security) + * - Track added count locally for multiple image paste + */ + const handlePaste = useCallback( + async (e: React.ClipboardEvent) => { + const items = e.clipboardData?.items; + if (!items) return; + + // Track added count for multiple image paste + let addedCount = 0; + + // Convert DataTransferItemList to array for iteration + const itemsArray = Array.from(items); + + for (const item of itemsArray) { + // Step 1: Check MIME type whitelist + if ( + !SUPPORTED_IMAGE_TYPES.includes( + item.type as SupportedImageType, + ) + ) { + continue; + } + + e.preventDefault(); + + const file = item.getAsFile(); + if (!file) continue; + + // Step 2: Check image count (before conversion) + if (attachedImages.length + addedCount >= MAX_IMAGE_COUNT) { + new Notice( + `[Agent Client] Maximum ${MAX_IMAGE_COUNT} images allowed`, + ); + break; + } + + // Step 3: Check file size (before conversion - key v3 improvement) + if (file.size > MAX_IMAGE_SIZE_BYTES) { + new Notice( + `[Agent Client] Image too large (max ${MAX_IMAGE_SIZE_MB}MB)`, + ); + continue; + } + + // Step 4: Convert to Base64 after validation passes + try { + const base64 = await fileToBase64(file); + addImage({ + id: crypto.randomUUID(), + data: base64, + mimeType: file.type, + }); + addedCount++; + } catch (error) { + console.error("Failed to convert image:", error); + new Notice("[Agent Client] Failed to attach image"); + } + } + }, + [attachedImages.length, addImage, fileToBase64], + ); + /** * Common logic for setting cursor position after text replacement. */ @@ -242,18 +377,36 @@ export function ChatInput({ return; } - if (!inputValue.trim()) return; + // Allow sending if there's text OR images + if (!inputValue.trim() && attachedImages.length === 0) return; - // Save input value before clearing + // Save input value and images before clearing const messageToSend = inputValue; + const imagesToSend: ImagePromptContent[] = attachedImages.map( + (img) => ({ + type: "image", + data: img.data, + mimeType: img.mimeType, + }), + ); - // Clear input and hint state immediately + // Clear input, images, and hint state immediately setInputValue(""); + setAttachedImages([]); setHintText(null); setCommandText(""); - await onSendMessage(messageToSend); - }, [isSending, inputValue, onSendMessage, onStopGeneration]); + await onSendMessage( + messageToSend, + imagesToSend.length > 0 ? imagesToSend : undefined, + ); + }, [ + isSending, + inputValue, + attachedImages, + onSendMessage, + onStopGeneration, + ]); /** * Handle dropdown keyboard navigation. @@ -570,9 +723,11 @@ export function ChatInput({ } }, [currentModelId]); - // Button disabled state + // Button disabled state - also allow sending if images are attached const isButtonDisabled = - !isSending && (inputValue.trim() === "" || !isSessionReady); + !isSending && + ((inputValue.trim() === "" && attachedImages.length === 0) || + !isSessionReady); // Placeholder text const placeholder = `Message ${agentLabel} - @ to mention notes${availableCommands.length > 0 ? ", / for commands" : ""}`; @@ -660,6 +815,7 @@ export function ChatInput({ value={inputValue} onChange={handleInputChange} onKeyDown={handleKeyPress} + onPaste={(e) => void handlePaste(e)} placeholder={placeholder} className={`chat-input-textarea ${autoMentionEnabled && autoMention.activeNote ? "has-auto-mention" : ""}`} rows={1} @@ -672,6 +828,12 @@ export function ChatInput({ )} + {/* Image Preview Strip */} + + {/* Input Actions (Mode Selector + Model Selector + Send Button) */}
{/* Mode Selector */} diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx index 46daaa0..72f948a 100644 --- a/src/components/chat/ChatView.tsx +++ b/src/components/chat/ChatView.tsx @@ -251,13 +251,17 @@ function ChatComponent({ }, [plugin]); const handleSendMessage = useCallback( - async (content: string) => { + async ( + content: string, + images?: import("../../domain/models/prompt-content").ImagePromptContent[], + ) => { await chat.sendMessage(content, { activeNote: autoMention.activeNote, vaultBasePath: (plugin.app.vault.adapter as VaultAdapterWithBasePath) .basePath || "", isAutoMentionDisabled: autoMention.isDisabled, + images, }); }, [chat, autoMention, plugin], diff --git a/src/components/chat/ImagePreviewStrip.tsx b/src/components/chat/ImagePreviewStrip.tsx new file mode 100644 index 0000000..539dda9 --- /dev/null +++ b/src/components/chat/ImagePreviewStrip.tsx @@ -0,0 +1,48 @@ +import * as React from "react"; + +/** + * Attached image with unique ID for React key stability + */ +export interface AttachedImage { + id: string; + data: string; + mimeType: string; +} + +interface ImagePreviewStripProps { + images: AttachedImage[]; + onRemove: (id: string) => void; +} + +/** + * Horizontal strip of image thumbnails with remove buttons. + * Displays attached images before sending. + */ +export function ImagePreviewStrip({ + images, + onRemove, +}: ImagePreviewStripProps) { + if (images.length === 0) return null; + + return ( +
+ {images.map((image) => ( +
+ Attached image + +
+ ))} +
+ ); +} diff --git a/src/components/chat/MessageContentRenderer.tsx b/src/components/chat/MessageContentRenderer.tsx index 1682c48..6356385 100644 --- a/src/components/chat/MessageContentRenderer.tsx +++ b/src/components/chat/MessageContentRenderer.tsx @@ -93,6 +93,17 @@ export function MessageContentRenderer({ /> ); + case "image": + return ( +
+ Attached image +
+ ); + default: return Unsupported content type; } diff --git a/src/components/chat/MessageRenderer.tsx b/src/components/chat/MessageRenderer.tsx index 822ba5c..c6675f7 100644 --- a/src/components/chat/MessageRenderer.tsx +++ b/src/components/chat/MessageRenderer.tsx @@ -1,5 +1,8 @@ import * as React from "react"; -import type { ChatMessage } from "../../domain/models/chat-message"; +import type { + ChatMessage, + MessageContent, +} from "../../domain/models/chat-message"; import type { IAcpClient } from "../../adapters/acp/acp.adapter"; import type AgentClientPlugin from "../../plugin"; import { MessageContentRenderer } from "./MessageContentRenderer"; @@ -15,28 +18,90 @@ interface MessageRendererProps { ) => Promise; } +/** + * Group consecutive image contents together for horizontal scrolling display. + * Non-image contents are wrapped individually. + */ +function groupContent( + contents: MessageContent[], +): Array< + | { type: "images"; items: MessageContent[] } + | { type: "single"; item: MessageContent } +> { + const groups: Array< + | { type: "images"; items: MessageContent[] } + | { type: "single"; item: MessageContent } + > = []; + + let currentImageGroup: MessageContent[] = []; + + for (const content of contents) { + if (content.type === "image") { + currentImageGroup.push(content); + } else { + // Flush any pending image group + if (currentImageGroup.length > 0) { + groups.push({ type: "images", items: currentImageGroup }); + currentImageGroup = []; + } + groups.push({ type: "single", item: content }); + } + } + + // Flush remaining images + if (currentImageGroup.length > 0) { + groups.push({ type: "images", items: currentImageGroup }); + } + + return groups; +} + export function MessageRenderer({ message, plugin, acpClient, onApprovePermission, }: MessageRendererProps) { + const groups = groupContent(message.content); + return (
- {message.content.map((content, idx) => ( -
- -
- ))} + {groups.map((group, idx) => { + if (group.type === "images") { + // Render images in horizontal scroll container + return ( +
+ {group.items.map((content, imgIdx) => ( + + ))} +
+ ); + } else { + // Render single non-image content + return ( +
+ +
+ ); + } + })}
); } diff --git a/src/domain/models/prompt-content.ts b/src/domain/models/prompt-content.ts new file mode 100644 index 0000000..177319c --- /dev/null +++ b/src/domain/models/prompt-content.ts @@ -0,0 +1,34 @@ +/** + * Prompt Content Types + * + * Types representing content that can be included in a prompt sent to the agent. + * These correspond to ACP ContentBlock types but are defined independently + * to maintain domain layer isolation. + */ + +/** + * Text content in a prompt + */ +export interface TextPromptContent { + type: "text"; + text: string; +} + +/** + * Image content in a prompt + * + * Images are sent as Base64-encoded data with their MIME type. + * Supported MIME types: image/png, image/jpeg, image/gif, image/webp + */ +export interface ImagePromptContent { + type: "image"; + /** Base64-encoded image data (without data: prefix) */ + data: string; + /** MIME type of the image */ + mimeType: string; +} + +/** + * Union type for all prompt content types + */ +export type PromptContent = TextPromptContent | ImagePromptContent; diff --git a/src/domain/ports/agent-client.port.ts b/src/domain/ports/agent-client.port.ts index 581c45d..27c000a 100644 --- a/src/domain/ports/agent-client.port.ts +++ b/src/domain/ports/agent-client.port.ts @@ -19,6 +19,7 @@ import type { } from "../models/chat-session"; import type { SessionUpdate } from "../models/session-update"; import type { AgentError } from "../models/agent-error"; +import type { PromptContent } from "../models/prompt-content"; /** * Runtime configuration for launching an AI agent process. @@ -151,17 +152,18 @@ export interface IAgentClient { authenticate(methodId: string): Promise; /** - * Send a message to the agent. + * Send a prompt to the agent. * - * The agent will process the message and respond via the onMessage callback. - * May also trigger permission requests via onPermissionRequest callback. + * The prompt can contain multiple content blocks (text, images). + * The agent will process the prompt and respond via the onSessionUpdate callback. + * May also trigger permission requests. * * @param sessionId - Session identifier - * @param message - Message text to send + * @param content - Array of content blocks to send (text and/or images) * @returns Promise resolving when agent completes processing * @throws AgentError if sending fails */ - sendMessage(sessionId: string, message: string): Promise; + sendPrompt(sessionId: string, content: PromptContent[]): Promise; /** * Cancel ongoing agent operations. diff --git a/src/hooks/useChat.ts b/src/hooks/useChat.ts index 23b0a71..3ae1831 100644 --- a/src/hooks/useChat.ts +++ b/src/hooks/useChat.ts @@ -9,8 +9,9 @@ import type { IVaultAccess } from "../domain/ports/vault-access.port"; import type { NoteMetadata } from "../domain/ports/vault-access.port"; import type { AuthenticationMethod } from "../domain/models/chat-session"; import type { ErrorInfo } from "../domain/models/agent-error"; +import type { ImagePromptContent } from "../domain/models/prompt-content"; import type { IMentionService } from "../shared/mention-utils"; -import { prepareMessage, sendPreparedMessage } from "../shared/message-service"; +import { preparePrompt, sendPreparedPrompt } from "../shared/message-service"; import { Platform } from "obsidian"; // ============================================================================ @@ -30,6 +31,8 @@ export interface SendMessageOptions { vaultBasePath: string; /** Whether auto-mention is temporarily disabled */ isAutoMentionDisabled?: boolean; + /** Attached images */ + images?: ImagePromptContent[]; } /** @@ -433,10 +436,11 @@ export function useChat( return; } - // Phase 1: Prepare message using message-service - const prepared = await prepareMessage( + // Phase 1: Prepare prompt using message-service + const prepared = await preparePrompt( { message: content, + images: options.images, activeNote: options.activeNote, vaultBasePath: options.vaultBasePath, isAutoMentionDisabled: options.isAutoMentionDisabled, @@ -446,24 +450,38 @@ export function useChat( mentionService, ); - // Phase 2: Add user message to UI immediately + // Phase 2: Build user message for UI + const userMessageContent: MessageContent[] = []; + + // Text part (with or without auto-mention context) + if (prepared.autoMentionContext) { + userMessageContent.push({ + type: "text_with_context", + text: content, + autoMentionContext: prepared.autoMentionContext, + }); + } else { + userMessageContent.push({ + type: "text", + text: content, + }); + } + + // Image parts + if (options.images && options.images.length > 0) { + for (const img of options.images) { + userMessageContent.push({ + type: "image", + data: img.data, + mimeType: img.mimeType, + }); + } + } + const userMessage: ChatMessage = { id: crypto.randomUUID(), role: "user", - content: prepared.autoMentionContext - ? [ - { - type: "text_with_context", - text: prepared.displayMessage, - autoMentionContext: prepared.autoMentionContext, - }, - ] - : [ - { - type: "text", - text: prepared.displayMessage, - }, - ], + content: userMessageContent, timestamp: new Date(), }; addMessage(userMessage); @@ -472,13 +490,13 @@ export function useChat( setIsSending(true); setLastUserMessage(content); - // Phase 4: Send prepared message to agent using message-service + // Phase 4: Send prepared prompt to agent using message-service try { - const result = await sendPreparedMessage( + const result = await sendPreparedPrompt( { sessionId: sessionContext.sessionId, - agentMessage: prepared.agentMessage, - displayMessage: prepared.displayMessage, + agentContent: prepared.agentContent, + displayContent: prepared.displayContent, authMethods: sessionContext.authMethods, }, agentClient, diff --git a/src/shared/message-service.ts b/src/shared/message-service.ts index b701196..5d3863e 100644 --- a/src/shared/message-service.ts +++ b/src/shared/message-service.ts @@ -1,14 +1,14 @@ /** * Message Service * - * Pure functions for message preparation and sending. + * Pure functions for prompt preparation and sending. * Extracted from SendMessageUseCase for better separation of concerns. * * Responsibilities: * - Process mentions (@[[note]] syntax) * - Add auto-mention for active note * - Convert mentions to file paths - * - Send message to agent via IAgentClient + * - Send prompt to agent via IAgentClient * - Handle authentication errors with retry logic */ @@ -20,6 +20,10 @@ import type { } from "../domain/ports/vault-access.port"; import type { AgentError } from "../domain/models/agent-error"; import type { AuthenticationMethod } from "../domain/models/chat-session"; +import type { + PromptContent, + ImagePromptContent, +} from "../domain/models/prompt-content"; import { extractMentionedNotes, type IMentionService } from "./mention-utils"; import { convertWindowsPathToWsl } from "./wsl-utils"; @@ -28,12 +32,15 @@ import { convertWindowsPathToWsl } from "./wsl-utils"; // ============================================================================ /** - * Input for preparing a message + * Input for preparing a prompt */ -export interface PrepareMessageInput { +export interface PreparePromptInput { /** User's message text (may contain @mentions) */ message: string; + /** Attached images */ + images?: ImagePromptContent[]; + /** Currently active note (for auto-mention feature) */ activeNote?: NoteMetadata | null; @@ -48,14 +55,14 @@ export interface PrepareMessageInput { } /** - * Result of preparing a message + * Result of preparing a prompt */ -export interface PrepareMessageResult { - /** The processed message text (without auto-mention syntax in text) */ - displayMessage: string; +export interface PreparePromptResult { + /** Content for UI display (original text + images) */ + displayContent: PromptContent[]; - /** The message text to send to agent (with mentions converted to paths) */ - agentMessage: string; + /** Content to send to agent (processed text + images) */ + agentContent: PromptContent[]; /** Auto-mention context metadata (if auto-mention is active) */ autoMentionContext?: { @@ -69,34 +76,34 @@ export interface PrepareMessageResult { } /** - * Input for sending a prepared message + * Input for sending a prepared prompt */ -export interface SendPreparedMessageInput { +export interface SendPreparedPromptInput { /** Current session ID */ sessionId: string; - /** The prepared agent message (from prepareMessage) */ - agentMessage: string; + /** The prepared agent content (from preparePrompt) */ + agentContent: PromptContent[]; - /** The display message (for error reporting) */ - displayMessage: string; + /** The display content (for error reporting) */ + displayContent: PromptContent[]; /** Available authentication methods */ authMethods: AuthenticationMethod[]; } /** - * Result of sending a message + * Result of sending a prompt */ -export interface SendMessageResult { - /** Whether the message was sent successfully */ +export interface SendPromptResult { + /** Whether the prompt was sent successfully */ success: boolean; - /** The processed message text (with auto-mention added if applicable) */ - displayMessage: string; + /** The display content */ + displayContent: PromptContent[]; - /** The message text sent to agent (with mentions converted to paths) */ - agentMessage: string; + /** The agent content sent */ + agentContent: PromptContent[]; /** Error information if sending failed */ error?: AgentError; @@ -104,7 +111,7 @@ export interface SendMessageResult { /** Whether authentication is required */ requiresAuth?: boolean; - /** Whether the message was successfully sent after retry */ + /** Whether the prompt was successfully sent after retry */ retriedSuccessfully?: boolean; } @@ -116,22 +123,22 @@ const MAX_NOTE_LENGTH = 10000; // Maximum characters per note const MAX_SELECTION_LENGTH = 10000; // Maximum characters for selection // ============================================================================ -// Message Preparation Functions +// Prompt Preparation Functions // ============================================================================ /** - * Prepare a message for sending to the agent. + * Prepare a prompt for sending to the agent. * * Processes the message by: * - Building context blocks for mentioned notes * - Adding auto-mention context for active note - * - Creating agent message with context + user message + * - Creating agent content with context + user message + images */ -export async function prepareMessage( - input: PrepareMessageInput, +export async function preparePrompt( + input: PreparePromptInput, vaultAccess: IVaultAccess, mentionService: IMentionService, -): Promise { +): Promise { // Step 1: Extract all mentioned notes from the message const mentionedNotes = extractMentionedNotes(input.message, mentionService); @@ -181,13 +188,24 @@ export async function prepareMessage( contextBlocks.push(autoMentionContextBlock); } - // Step 4: Build agent message (context blocks + original message) - const agentMessage = + // Step 4: Build agent message text (context blocks + original message) + const agentMessageText = contextBlocks.length > 0 ? contextBlocks.join("\n") + "\n\n" + input.message : input.message; - // Step 5: Build auto-mention context metadata + // Step 5: Build content arrays + const displayContent: PromptContent[] = [ + { type: "text", text: input.message }, + ...(input.images || []), + ]; + + const agentContent: PromptContent[] = [ + { type: "text", text: agentMessageText }, + ...(input.images || []), + ]; + + // Step 6: Build auto-mention context metadata const autoMentionContext = input.activeNote && !input.isAutoMentionDisabled ? { @@ -204,8 +222,8 @@ export async function prepareMessage( : undefined; return { - displayMessage: input.message, - agentMessage, + displayContent, + agentContent, autoMentionContext, }; } @@ -265,30 +283,30 @@ This is what the user is currently focusing on. } // ============================================================================ -// Message Sending Functions +// Prompt Sending Functions // ============================================================================ /** - * Send a prepared message to the agent. + * Send a prepared prompt to the agent. */ -export async function sendPreparedMessage( - input: SendPreparedMessageInput, +export async function sendPreparedPrompt( + input: SendPreparedPromptInput, agentClient: IAgentClient, -): Promise { +): Promise { try { - await agentClient.sendMessage(input.sessionId, input.agentMessage); + await agentClient.sendPrompt(input.sessionId, input.agentContent); return { success: true, - displayMessage: input.displayMessage, - agentMessage: input.agentMessage, + displayContent: input.displayContent, + agentContent: input.agentContent, }; } catch (error) { return await handleSendError( error, input.sessionId, - input.agentMessage, - input.displayMessage, + input.agentContent, + input.displayContent, input.authMethods, agentClient, ); @@ -300,22 +318,22 @@ export async function sendPreparedMessage( // ============================================================================ /** - * Handle errors that occur during message sending. + * Handle errors that occur during prompt sending. */ async function handleSendError( error: unknown, sessionId: string, - agentMessage: string, - displayMessage: string, + agentContent: PromptContent[], + displayContent: PromptContent[], authMethods: AuthenticationMethod[], agentClient: IAgentClient, -): Promise { +): Promise { // Check for "empty response text" error - ignore silently if (isEmptyResponseError(error)) { return { success: true, - displayMessage, - agentMessage, + displayContent, + agentContent, }; } @@ -335,8 +353,8 @@ async function handleSendError( return { success: false, - displayMessage, - agentMessage, + displayContent, + agentContent, error: { id: crypto.randomUUID(), category: "rate_limit", @@ -356,8 +374,8 @@ async function handleSendError( if (!authMethods || authMethods.length === 0) { return { success: false, - displayMessage, - agentMessage, + displayContent, + agentContent, error: { id: crypto.randomUUID(), category: "authentication", @@ -377,8 +395,8 @@ async function handleSendError( if (authMethods.length === 1) { const retryResult = await retryWithAuthentication( sessionId, - agentMessage, - displayMessage, + agentContent, + displayContent, authMethods[0].id, agentClient, ); @@ -391,8 +409,8 @@ async function handleSendError( // Multiple auth methods or retry failed return { success: false, - displayMessage, - agentMessage, + displayContent, + agentContent, requiresAuth: true, error: { id: crypto.randomUUID(), @@ -444,15 +462,15 @@ function isEmptyResponseError(error: unknown): boolean { } /** - * Retry sending message after authentication. + * Retry sending prompt after authentication. */ async function retryWithAuthentication( sessionId: string, - agentMessage: string, - displayMessage: string, + agentContent: PromptContent[], + displayContent: PromptContent[], authMethodId: string, agentClient: IAgentClient, -): Promise { +): Promise { try { const authSuccess = await agentClient.authenticate(authMethodId); @@ -460,19 +478,19 @@ async function retryWithAuthentication( return null; } - await agentClient.sendMessage(sessionId, agentMessage); + await agentClient.sendPrompt(sessionId, agentContent); return { success: true, - displayMessage, - agentMessage, + displayContent, + agentContent, retriedSuccessfully: true, }; } catch (retryError) { return { success: false, - displayMessage, - agentMessage, + displayContent, + agentContent, error: { id: crypto.randomUUID(), category: "communication", diff --git a/styles.css b/styles.css index 62b0ae8..e43272f 100644 --- a/styles.css +++ b/styles.css @@ -1119,3 +1119,112 @@ If your plugin does not need CSS, delete this file. word-wrap: break-word; word-break: break-word; } + +/* ===== Image Preview Strip ===== */ +.image-preview-strip { + display: flex; + gap: 8px; + padding: 8px 12px; + border-top: 1px solid var(--background-modifier-border); + background-color: var(--background-primary); + overflow-x: auto; + scrollbar-width: thin; + -ms-overflow-style: none; +} + +.image-preview-strip::-webkit-scrollbar { + height: 4px; +} + +.image-preview-strip::-webkit-scrollbar-track { + background: transparent; +} + +.image-preview-strip::-webkit-scrollbar-thumb { + background-color: var(--background-modifier-border); + border-radius: 2px; +} + +.image-preview-item { + position: relative; + flex-shrink: 0; + width: 64px; + height: 64px; +} + +.image-preview-thumbnail { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; + border-radius: 8px; + border: 1px solid var(--background-modifier-border); +} + +.image-preview-remove { + position: absolute; + top: -6px; + right: -6px; + width: 18px; + height: 18px; + padding: 0; + border: none; + border-radius: 50%; + background-color: var(--background-modifier-error); + color: white; + font-size: 12px; + font-weight: bold; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.15s ease; +} + +.image-preview-item:hover .image-preview-remove { + opacity: 1; +} + +/* ===== Message Image (in chat history) ===== */ +.message-image { + display: inline-block; + margin: 4px 4px 4px 0; +} + +.message-image-thumbnail { + width: 120px; + height: 120px; + object-fit: cover; + object-position: center; + border-radius: 8px; + border: 1px solid var(--background-modifier-border); +} + +/* ===== Message Images Strip (horizontal scroll in chat history) ===== */ +.message-images-strip { + display: flex; + gap: 8px; + padding: 8px 0; + overflow-x: auto; + scrollbar-width: thin; + -ms-overflow-style: none; +} + +.message-images-strip::-webkit-scrollbar { + height: 4px; +} + +.message-images-strip::-webkit-scrollbar-track { + background: transparent; +} + +.message-images-strip::-webkit-scrollbar-thumb { + background-color: var(--background-modifier-border); + border-radius: 2px; +} + +.message-images-strip .message-image { + flex-shrink: 0; + margin: 0; +}