mirror of
https://github.com/rait-09/obsidian-agent-client.git
synced 2026-07-22 06:43:37 +00:00
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:
parent
19b2c95c59
commit
3cc8e8e4e5
12 changed files with 628 additions and 128 deletions
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
48
src/components/chat/ImagePreviewStrip.tsx
Normal file
48
src/components/chat/ImagePreviewStrip.tsx
Normal 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"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
34
src/domain/models/prompt-content.ts
Normal file
34
src/domain/models/prompt-content.ts
Normal 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;
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
109
styles.css
109
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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue