mirror of
https://github.com/rait-09/obsidian-agent-client.git
synced 2026-07-22 06:43:37 +00:00
511 lines
15 KiB
TypeScript
511 lines
15 KiB
TypeScript
/**
|
|
* Sub-hook for managing chat messages, streaming, and permissions.
|
|
*
|
|
* Handles message state, RAF batching for streaming updates,
|
|
* send/receive operations, and permission approve/reject.
|
|
*/
|
|
|
|
import * as React from "react";
|
|
const { useState, useCallback, useMemo, useRef, useEffect } = React;
|
|
|
|
import type {
|
|
ChatMessage,
|
|
MessageContent,
|
|
ActivePermission,
|
|
ImagePromptContent,
|
|
ResourceLinkPromptContent,
|
|
} from "../types/chat";
|
|
import type { ChatSession, SessionUpdate } from "../types/session";
|
|
import type { AcpClient } from "../acp/acp-client";
|
|
import type { IVaultAccess, NoteMetadata } from "../services/vault-service";
|
|
import type { ISettingsAccess } from "../services/settings-service";
|
|
import type { ErrorInfo } from "../types/errors";
|
|
import type { IMentionService } from "../utils/mention-parser";
|
|
import { preparePrompt, sendPreparedPrompt } from "../services/message-sender";
|
|
import { extractErrorMessage } from "../utils/error-utils";
|
|
import { Platform } from "obsidian";
|
|
import {
|
|
rebuildToolCallIndex,
|
|
applySingleUpdate,
|
|
findActivePermission,
|
|
selectOption,
|
|
} from "../services/message-state";
|
|
|
|
// ============================================================================
|
|
// Types
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Options for sending a message.
|
|
*/
|
|
export interface SendMessageOptions {
|
|
/** Currently active note for auto-mention */
|
|
activeNote: NoteMetadata | null;
|
|
/** Vault base path for mention resolution */
|
|
vaultBasePath: string;
|
|
/** Whether auto-mention is temporarily disabled */
|
|
isAutoMentionDisabled?: boolean;
|
|
/** Attached images (Base64 embedded) */
|
|
images?: ImagePromptContent[];
|
|
/** Attached file references (resource links) */
|
|
resourceLinks?: ResourceLinkPromptContent[];
|
|
/** Whether this is the first message in the session */
|
|
isFirstMessage?: boolean;
|
|
}
|
|
|
|
export interface UseAgentMessagesReturn {
|
|
// Message state
|
|
messages: ChatMessage[];
|
|
isSending: boolean;
|
|
lastUserMessage: string | null;
|
|
|
|
// Message operations
|
|
sendMessage: (
|
|
content: string,
|
|
options: SendMessageOptions,
|
|
) => Promise<void>;
|
|
clearMessages: () => void;
|
|
setInitialMessages: (
|
|
history: Array<{
|
|
role: string;
|
|
content: Array<{ type: string; text: string }>;
|
|
timestamp?: string;
|
|
}>,
|
|
) => void;
|
|
setMessagesFromLocal: (localMessages: ChatMessage[]) => void;
|
|
clearError: () => void;
|
|
setIgnoreUpdates: (ignore: boolean) => void;
|
|
/** Discard any pending RAF updates and reset streaming state (call after stop/cancel). */
|
|
clearPendingUpdates: () => void;
|
|
|
|
// Permission
|
|
activePermission: ActivePermission | null;
|
|
hasActivePermission: boolean;
|
|
approvePermission: (requestId: string, optionId: string) => Promise<void>;
|
|
approveActivePermission: () => Promise<boolean>;
|
|
rejectActivePermission: () => Promise<boolean>;
|
|
|
|
/** Enqueue a message-level update (used by useAgent for unified handler) */
|
|
enqueueUpdate: (update: SessionUpdate) => void;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Hook Implementation
|
|
// ============================================================================
|
|
|
|
export function useAgentMessages(
|
|
agentClient: AcpClient,
|
|
settingsAccess: ISettingsAccess,
|
|
vaultAccess: IVaultAccess & IMentionService,
|
|
session: ChatSession,
|
|
setErrorInfo: (error: ErrorInfo | null) => void,
|
|
): UseAgentMessagesReturn {
|
|
// ============================================================
|
|
// Message State
|
|
// ============================================================
|
|
|
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
const [isSending, setIsSending] = useState(false);
|
|
const [lastUserMessage, setLastUserMessage] = useState<string | null>(null);
|
|
|
|
// Tool call index: toolCallId → message index for O(1) lookup
|
|
const toolCallIndexRef = useRef<Map<string, number>>(new Map());
|
|
|
|
// Ignore updates flag (used during session/load to skip history replay)
|
|
const ignoreUpdatesRef = useRef(false);
|
|
|
|
// Generation counter to prevent stale async callbacks from overwriting
|
|
// state after cancel/stop followed by a new send. Each sendMessage()
|
|
// increments this; completion handlers only update state if the
|
|
// generation hasn't changed (fixes Issue #200).
|
|
const generationRef = useRef(0);
|
|
|
|
// Track the current send promise so a new sendMessage() can wait for
|
|
// the previous one to settle before starting (avoids interleaved sends).
|
|
const sendPromiseRef = useRef<Promise<void> | null>(null);
|
|
|
|
// ============================================================
|
|
// Streaming Update Batching
|
|
// ============================================================
|
|
|
|
const pendingUpdatesRef = useRef<SessionUpdate[]>([]);
|
|
const flushScheduledRef = useRef(false);
|
|
|
|
const flushPendingUpdates = useCallback(() => {
|
|
flushScheduledRef.current = false;
|
|
const updates = pendingUpdatesRef.current;
|
|
if (updates.length === 0) return;
|
|
pendingUpdatesRef.current = [];
|
|
|
|
setMessages((prev) => {
|
|
let result = prev;
|
|
for (const update of updates) {
|
|
result = applySingleUpdate(
|
|
result,
|
|
update,
|
|
toolCallIndexRef.current,
|
|
);
|
|
}
|
|
return result;
|
|
});
|
|
}, []);
|
|
|
|
const enqueueUpdate = useCallback(
|
|
(update: SessionUpdate) => {
|
|
if (ignoreUpdatesRef.current) return;
|
|
pendingUpdatesRef.current.push(update);
|
|
if (!flushScheduledRef.current) {
|
|
flushScheduledRef.current = true;
|
|
window.requestAnimationFrame(flushPendingUpdates);
|
|
}
|
|
},
|
|
[flushPendingUpdates],
|
|
);
|
|
|
|
// Clean up on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
pendingUpdatesRef.current = [];
|
|
flushScheduledRef.current = false;
|
|
toolCallIndexRef.current.clear();
|
|
};
|
|
}, []);
|
|
|
|
// ============================================================
|
|
// Message Operations
|
|
// ============================================================
|
|
|
|
const addMessage = useCallback((message: ChatMessage): void => {
|
|
setMessages((prev) => [...prev, message]);
|
|
}, []);
|
|
|
|
const setIgnoreUpdates = useCallback((ignore: boolean): void => {
|
|
ignoreUpdatesRef.current = ignore;
|
|
}, []);
|
|
|
|
/**
|
|
* Cancel-time cleanup. Discards in-flight streaming updates so they don't
|
|
* bleed into the next reply (#200), but still applies any queued permission
|
|
* lifecycle updates (cancel/response) so the permission banner clears
|
|
* instead of staying stuck (#326).
|
|
*/
|
|
const clearPendingUpdates = useCallback((): void => {
|
|
const queued = pendingUpdatesRef.current;
|
|
pendingUpdatesRef.current = [];
|
|
flushScheduledRef.current = false;
|
|
|
|
// Streaming deltas are dropped; only terminal permission updates
|
|
// (cancelled or answered) are applied so `findActivePermission` stops
|
|
// returning the request. Active/pending permission updates are not
|
|
// replayed, so a cancel can never re-surface an active banner.
|
|
const permissionUpdates = queued.filter(
|
|
(u) =>
|
|
(u.type === "tool_call" || u.type === "tool_call_update") &&
|
|
(u.permissionRequest?.isCancelled === true ||
|
|
u.permissionRequest?.selectedOptionId !== undefined),
|
|
);
|
|
if (permissionUpdates.length > 0) {
|
|
setMessages((prev) => {
|
|
let result = prev;
|
|
for (const update of permissionUpdates) {
|
|
result = applySingleUpdate(
|
|
result,
|
|
update,
|
|
toolCallIndexRef.current,
|
|
);
|
|
}
|
|
return result;
|
|
});
|
|
}
|
|
|
|
setIsSending(false);
|
|
}, []);
|
|
|
|
const clearMessages = useCallback((): void => {
|
|
setMessages([]);
|
|
toolCallIndexRef.current.clear();
|
|
setLastUserMessage(null);
|
|
setIsSending(false);
|
|
setErrorInfo(null);
|
|
}, [setErrorInfo]);
|
|
|
|
const setInitialMessages = useCallback(
|
|
(
|
|
history: Array<{
|
|
role: string;
|
|
content: Array<{ type: string; text: string }>;
|
|
timestamp?: string;
|
|
}>,
|
|
): void => {
|
|
const chatMessages: ChatMessage[] = history.map((msg) => ({
|
|
id: crypto.randomUUID(),
|
|
role: msg.role as "user" | "assistant",
|
|
content: msg.content.map((c) => ({
|
|
type: c.type as "text",
|
|
text: c.text,
|
|
})),
|
|
timestamp: msg.timestamp ? new Date(msg.timestamp) : new Date(),
|
|
}));
|
|
|
|
setMessages(chatMessages);
|
|
rebuildToolCallIndex(chatMessages, toolCallIndexRef.current);
|
|
setIsSending(false);
|
|
setErrorInfo(null);
|
|
},
|
|
[setErrorInfo],
|
|
);
|
|
|
|
const setMessagesFromLocal = useCallback(
|
|
(localMessages: ChatMessage[]): void => {
|
|
setMessages(localMessages);
|
|
rebuildToolCallIndex(localMessages, toolCallIndexRef.current);
|
|
setIsSending(false);
|
|
setErrorInfo(null);
|
|
},
|
|
[setErrorInfo],
|
|
);
|
|
|
|
const clearError = useCallback((): void => {
|
|
setErrorInfo(null);
|
|
}, [setErrorInfo]);
|
|
|
|
const shouldConvertToWsl = useMemo(() => {
|
|
const settings = settingsAccess.getSnapshot();
|
|
return Platform.isWin && settings.windowsWslMode;
|
|
}, [settingsAccess]);
|
|
|
|
const sendMessage = useCallback(
|
|
async (content: string, options: SendMessageOptions): Promise<void> => {
|
|
if (!session.sessionId) {
|
|
setErrorInfo({
|
|
title: "Cannot Send Message",
|
|
message: "No active session. Please wait for connection.",
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Wait for any in-flight send to settle (e.g. after cancel/stop)
|
|
// before starting a new one to avoid interleaved state updates.
|
|
if (sendPromiseRef.current) {
|
|
try { await sendPromiseRef.current; } catch { /* ignore */ }
|
|
}
|
|
|
|
const currentSessionId = session.sessionId;
|
|
const generation = ++generationRef.current;
|
|
const settings = settingsAccess.getSnapshot();
|
|
|
|
const prepared = await preparePrompt(
|
|
{
|
|
message: content,
|
|
images: options.images,
|
|
resourceLinks: options.resourceLinks,
|
|
activeNote: options.activeNote,
|
|
vaultBasePath: options.vaultBasePath,
|
|
isAutoMentionDisabled: options.isAutoMentionDisabled,
|
|
convertToWsl: shouldConvertToWsl,
|
|
supportsEmbeddedContext:
|
|
session.promptCapabilities?.embeddedContext ?? false,
|
|
maxNoteLength: settings.displaySettings.maxNoteLength,
|
|
maxSelectionLength:
|
|
settings.displaySettings.maxSelectionLength,
|
|
isFirstMessage: options.isFirstMessage,
|
|
promptInjection: settings.promptInjection.enabled
|
|
? {
|
|
latex: settings.promptInjection.latex,
|
|
wikiLinks: settings.promptInjection.wikiLinks,
|
|
tables: settings.promptInjection.tables,
|
|
}
|
|
: undefined,
|
|
},
|
|
vaultAccess,
|
|
vaultAccess, // IMentionService (same object)
|
|
);
|
|
|
|
const userMessageContent: MessageContent[] = [];
|
|
|
|
if (prepared.autoMentionContext) {
|
|
userMessageContent.push({
|
|
type: "text_with_context",
|
|
text: content,
|
|
autoMentionContext: prepared.autoMentionContext,
|
|
});
|
|
} else {
|
|
userMessageContent.push({
|
|
type: "text",
|
|
text: content,
|
|
});
|
|
}
|
|
|
|
if (options.images && options.images.length > 0) {
|
|
for (const img of options.images) {
|
|
userMessageContent.push({
|
|
type: "image",
|
|
data: img.data,
|
|
mimeType: img.mimeType,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (options.resourceLinks && options.resourceLinks.length > 0) {
|
|
for (const link of options.resourceLinks) {
|
|
userMessageContent.push({
|
|
type: "resource_link",
|
|
uri: link.uri,
|
|
name: link.name,
|
|
mimeType: link.mimeType,
|
|
size: link.size,
|
|
});
|
|
}
|
|
}
|
|
|
|
const userMessage: ChatMessage = {
|
|
id: crypto.randomUUID(),
|
|
role: "user",
|
|
content: userMessageContent,
|
|
timestamp: new Date(),
|
|
};
|
|
addMessage(userMessage);
|
|
|
|
setIsSending(true);
|
|
setLastUserMessage(content);
|
|
|
|
const sendPromise = (async () => {
|
|
try {
|
|
const result = await sendPreparedPrompt(
|
|
{
|
|
sessionId: currentSessionId,
|
|
agentContent: prepared.agentContent,
|
|
displayContent: prepared.displayContent,
|
|
authMethods: session.authMethods,
|
|
},
|
|
agentClient,
|
|
);
|
|
|
|
// Discard results if a newer send has started
|
|
if (generationRef.current !== generation) return;
|
|
|
|
if (result.success) {
|
|
setIsSending(false);
|
|
setLastUserMessage(null);
|
|
} else {
|
|
setIsSending(false);
|
|
setErrorInfo(
|
|
result.error
|
|
? {
|
|
title: result.error.title,
|
|
message: result.error.message,
|
|
suggestion: result.error.suggestion,
|
|
}
|
|
: {
|
|
title: "Send Message Failed",
|
|
message: "Failed to send message",
|
|
},
|
|
);
|
|
}
|
|
} catch (error) {
|
|
if (generationRef.current !== generation) return;
|
|
setIsSending(false);
|
|
setErrorInfo({
|
|
title: "Send Message Failed",
|
|
message: `Failed to send message: ${extractErrorMessage(error)}`,
|
|
});
|
|
}
|
|
})();
|
|
|
|
sendPromiseRef.current = sendPromise;
|
|
try {
|
|
await sendPromise;
|
|
} catch {
|
|
// Error already handled inside sendPromise
|
|
} finally {
|
|
sendPromiseRef.current = null;
|
|
}
|
|
},
|
|
[
|
|
agentClient,
|
|
vaultAccess,
|
|
settingsAccess,
|
|
session.sessionId,
|
|
session.authMethods,
|
|
session.promptCapabilities,
|
|
shouldConvertToWsl,
|
|
addMessage,
|
|
setErrorInfo,
|
|
],
|
|
);
|
|
|
|
// ============================================================
|
|
// Permission State & Operations
|
|
// ============================================================
|
|
|
|
const activePermission = useMemo(
|
|
() => findActivePermission(messages),
|
|
[messages],
|
|
);
|
|
|
|
const hasActivePermission = activePermission !== null;
|
|
|
|
const approvePermission = useCallback(
|
|
async (requestId: string, optionId: string): Promise<void> => {
|
|
try {
|
|
await agentClient.respondToPermission(requestId, optionId);
|
|
} catch (error) {
|
|
setErrorInfo({
|
|
title: "Permission Error",
|
|
message: `Failed to respond to permission request: ${extractErrorMessage(error)}`,
|
|
});
|
|
}
|
|
},
|
|
[agentClient, setErrorInfo],
|
|
);
|
|
|
|
const approveActivePermission = useCallback(async (): Promise<boolean> => {
|
|
if (!activePermission || activePermission.options.length === 0)
|
|
return false;
|
|
const option = selectOption(activePermission.options, [
|
|
"allow_once",
|
|
"allow_always",
|
|
]);
|
|
if (!option) return false;
|
|
await approvePermission(activePermission.requestId, option.optionId);
|
|
return true;
|
|
}, [activePermission, approvePermission]);
|
|
|
|
const rejectActivePermission = useCallback(async (): Promise<boolean> => {
|
|
if (!activePermission || activePermission.options.length === 0)
|
|
return false;
|
|
const option = selectOption(
|
|
activePermission.options,
|
|
["reject_once", "reject_always"],
|
|
(opt) =>
|
|
opt.name.toLowerCase().includes("reject") ||
|
|
opt.name.toLowerCase().includes("deny"),
|
|
);
|
|
if (!option) return false;
|
|
await approvePermission(activePermission.requestId, option.optionId);
|
|
return true;
|
|
}, [activePermission, approvePermission]);
|
|
|
|
// ============================================================
|
|
// Return
|
|
// ============================================================
|
|
|
|
return {
|
|
messages,
|
|
isSending,
|
|
lastUserMessage,
|
|
sendMessage,
|
|
clearMessages,
|
|
setInitialMessages,
|
|
setMessagesFromLocal,
|
|
clearError,
|
|
setIgnoreUpdates,
|
|
clearPendingUpdates,
|
|
activePermission,
|
|
hasActivePermission,
|
|
approvePermission,
|
|
approveActivePermission,
|
|
rejectActivePermission,
|
|
enqueueUpdate,
|
|
};
|
|
}
|