Add image attachment and preview support in chat

This update introduces support for attaching images to chat messages, including image validation, preview strip in the input area, and rendering of images in chat history. The domain model is extended with PromptContent types for text and images, and the message sending pipeline is refactored to handle multi-part prompts. UI components and styles are updated to support image previews and horizontal scrolling for image groups.
This commit is contained in:
RAIT-09 2025-12-16 00:55:04 +09:00
parent 19b2c95c59
commit 3cc8e8e4e5
12 changed files with 628 additions and 128 deletions

View file

@ -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,
};
}
}
}

View file

@ -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<void> {
async sendPrompt(
sessionId: string,
content: PromptContent[],
): Promise<void> {
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);

View file

@ -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<void>;
/** Callback to send a message with optional images */
onSendMessage: (
content: string,
images?: ImagePromptContent[],
) => Promise<void>;
/** Callback to stop the current generation */
onStopGeneration: () => Promise<void>;
/** Callback when restored message has been consumed */
@ -96,6 +124,7 @@ export function ChatInput({
const [inputValue, setInputValue] = useState("");
const [hintText, setHintText] = useState<string | null>(null);
const [commandText, setCommandText] = useState<string>("");
const [attachedImages, setAttachedImages] = useState<AttachedImage[]>([]);
// Refs
const textareaRef = useRef<HTMLTextAreaElement>(null);
@ -105,6 +134,112 @@ export function ChatInput({
const modelDropdownRef = useRef<HTMLDivElement>(null);
const modelDropdownInstance = useRef<DropdownComponent | null>(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<string> => {
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({
)}
</div>
{/* Image Preview Strip */}
<ImagePreviewStrip
images={attachedImages}
onRemove={removeImage}
/>
{/* Input Actions (Mode Selector + Model Selector + Send Button) */}
<div className="chat-input-actions">
{/* Mode Selector */}

View file

@ -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],

View file

@ -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 (
<div className="image-preview-strip">
{images.map((image) => (
<div key={image.id} className="image-preview-item">
<img
src={`data:${image.mimeType};base64,${image.data}`}
alt="Attached image"
className="image-preview-thumbnail"
/>
<button
className="image-preview-remove"
onClick={() => onRemove(image.id)}
title="Remove image"
type="button"
>
&times;
</button>
</div>
))}
</div>
);
}

View file

@ -93,6 +93,17 @@ export function MessageContentRenderer({
/>
);
case "image":
return (
<div className="message-image">
<img
src={`data:${content.mimeType};base64,${content.data}`}
alt="Attached image"
className="message-image-thumbnail"
/>
</div>
);
default:
return <span>Unsupported content type</span>;
}

View file

@ -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<void>;
}
/**
* 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 (
<div
className={`message-renderer ${message.role === "user" ? "message-user" : "message-assistant"}`}
>
{message.content.map((content, idx) => (
<div key={idx}>
<MessageContentRenderer
content={content}
plugin={plugin}
messageId={message.id}
messageRole={message.role}
acpClient={acpClient}
onApprovePermission={onApprovePermission}
/>
</div>
))}
{groups.map((group, idx) => {
if (group.type === "images") {
// Render images in horizontal scroll container
return (
<div key={idx} className="message-images-strip">
{group.items.map((content, imgIdx) => (
<MessageContentRenderer
key={imgIdx}
content={content}
plugin={plugin}
messageId={message.id}
messageRole={message.role}
acpClient={acpClient}
onApprovePermission={onApprovePermission}
/>
))}
</div>
);
} else {
// Render single non-image content
return (
<div key={idx}>
<MessageContentRenderer
content={group.item}
plugin={plugin}
messageId={message.id}
messageRole={message.role}
acpClient={acpClient}
onApprovePermission={onApprovePermission}
/>
</div>
);
}
})}
</div>
);
}

View file

@ -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;

View file

@ -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<boolean>;
/**
* 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<void>;
sendPrompt(sessionId: string, content: PromptContent[]): Promise<void>;
/**
* Cancel ongoing agent operations.

View file

@ -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,

View file

@ -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<PrepareMessageResult> {
): Promise<PreparePromptResult> {
// 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<SendMessageResult> {
): Promise<SendPromptResult> {
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<SendMessageResult> {
): Promise<SendPromptResult> {
// 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<SendMessageResult | null> {
): Promise<SendPromptResult | null> {
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",

View file

@ -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;
}