feat: add web viewer content retrieval tool for AI agents

Add new get_web_viewer_content tool that allows AI agents to retrieve
text content or screenshots from open web views. Refactor AIToolResponse
to use AIToolResponsePayload class for structured responses with attachment
support. Update tool service to handle binary file attachments consistently
across read_vault_files and new web viewer tool.
This commit is contained in:
Andrew Beal 2026-04-08 21:00:23 +01:00
parent ae321f382a
commit 3bc2b34aa2
20 changed files with 336 additions and 177 deletions

View file

@ -51,6 +51,12 @@ export const ListVaultFilesArgsSchema = z.object({
user_message: z.string()
});
export const GetWebViewerContentSchema = z.object({
url_hint: z.string().optional(),
format: z.enum(["text", "screenshot"]),
user_message: z.string()
});
export const ExecuteWorkflowArgsSchema = z.object({
goal: z.string(),
context: z.string().optional(),
@ -135,8 +141,9 @@ export type WriteVaultFileArgs = z.infer<typeof WriteVaultFileArgsSchema>;
export type PatchVaultFileArgs = z.infer<typeof PatchVaultFileArgsSchema>;
export type DeleteVaultFilesArgs = z.infer<typeof DeleteVaultFilesArgsSchema>;
export type MoveVaultFilesArgs = z.infer<typeof MoveVaultFilesArgsSchema>;
export type ListVaultFilesArgs = z.infer<typeof ListVaultFilesArgsSchema>;
export type CreateVaultFolder = z.infer<typeof CreateVaultFolderSchema>;
export type ListVaultFilesArgs = z.infer<typeof ListVaultFilesArgsSchema>;
export type GetWebViewerContent = z.infer<typeof GetWebViewerContentSchema>;
export type ExecuteWorkflowArgs = z.infer<typeof ExecuteWorkflowArgsSchema>;
export type AskUserQuestionPlanningArgs = z.infer<typeof AskUserQuestionPlanningArgsSchema>;
export type AskUserQuestionExecutionArgs = z.infer<typeof AskUserQuestionExecutionArgsSchema>;

View file

@ -19,13 +19,15 @@ import { SkipStep } from "./Tools/SkipStep";
import { ReadMemories } from "./Tools/ReadMemories";
import { UpdateMemories } from "./Tools/UpdateMemories";
import { CreateVaultFolder } from "./Tools/CreateVaultFolder";
import { GetWebViewerContent } from "./Tools/GetWebViewerContent";
export abstract class AIToolDefinitions {
public static isGated: boolean = false;
// Definitions list provides a list of function definitions that does not include any planning functions (used as reference in planning agent prompt)
private static readonly definitionsList = [SearchVaultFiles, ReadVaultFiles, ListVaultFiles, WriteVaultFile, PatchVaultFile, DeleteVaultFiles, MoveVaultFiles, CreateVaultFolder];
private static readonly definitionsList = [SearchVaultFiles, ReadVaultFiles, ListVaultFiles, GetWebViewerContent,
WriteVaultFile, PatchVaultFile, DeleteVaultFiles, MoveVaultFiles, CreateVaultFolder];
public static agentDefinitions(destructive: boolean, planningMode: boolean, memories: boolean, updateMemories: boolean): IAIToolDefinition[] {
this.isGated = false;
@ -37,7 +39,8 @@ export abstract class AIToolDefinitions {
let actions = [
SearchVaultFiles,
ReadVaultFiles,
ListVaultFiles
ListVaultFiles,
GetWebViewerContent
];
if (memories) {
@ -66,6 +69,7 @@ export abstract class AIToolDefinitions {
SearchVaultFiles,
ReadVaultFiles,
ListVaultFiles,
GetWebViewerContent,
CompleteStep,
ReviseStep,
RevisePlan,
@ -76,7 +80,7 @@ export abstract class AIToolDefinitions {
}
public static planningAgentDefinitions(memories: boolean): IAIToolDefinition[] {
let actions = [SearchVaultFiles, ReadVaultFiles, ListVaultFiles, AskUserQuestionPlanning, SubmitPlan];
let actions = [SearchVaultFiles, ReadVaultFiles, ListVaultFiles, GetWebViewerContent, AskUserQuestionPlanning, SubmitPlan];
if (memories) {
actions = actions.concat([ReadMemories]);

View file

@ -1,18 +1,19 @@
// Platform agnostic class for function responses
import type { AITool } from "Enums/AITool";
import type { AIToolResponsePayload } from "./AIToolResponsePayload";
// Used by AI providers to format function execution results for API calls
export class AIToolResponse {
public readonly name: AITool;
public readonly response: object;
public readonly payload: AIToolResponsePayload;
public readonly toolId?: string;
public static readonly UserRejectionMessage: string = `The user has explicitly rejected this change.
They may have changed their mind about the requested change.
**CRITICAL:** Immediately stop all further actions and consult with the user`;
public static readonly UserSuggestionMessage: string = `**USER MODIFICATION REQUEST:**
The user has reviewed your proposed action and provided a modification or alternative direction.
@ -29,9 +30,9 @@ export class AIToolResponse {
**User's Suggestion:**`;
constructor(name: AITool, response: object, toolId?: string) {
constructor(name: AITool, payload: AIToolResponsePayload, toolId?: string) {
this.name = name;
this.response = response;
this.payload = payload;
this.toolId = toolId;
}
@ -40,7 +41,7 @@ export class AIToolResponse {
id: this.toolId,
functionResponse: {
name: this.name,
response: { result: this.response }
response: { result: this.payload.response }
}
});
}

View file

@ -0,0 +1,11 @@
import type { Attachment } from "Conversations/Attachment";
export class AIToolResponsePayload {
public readonly response: object;
public readonly attachments: Attachment[];
constructor(response: object, attachments: Attachment[] = []) {
this.response = response;
this.attachments = attachments;
}
}

View file

@ -0,0 +1,31 @@
import { AITool } from "Enums/AITool";
import type { IAIToolDefinition } from "../IAIToolDefinition";
export const GetWebViewerContent: IAIToolDefinition = {
name: AITool.GetWebViewerContent,
description: `Retrieves the text content from a currently open Web Viewer tab in Obsidian.
Call this function:
- When the user asks you to read, summarise, or act on the contents of an open web page
- When you need to extract information from a URL that is already open in the Web Viewer
- When the user refers to "this page", "the current tab", or similar`,
parameters: {
type: "object",
properties: {
url_hint: {
type: "string",
description: "Optional. A partial URL or domain to identify which Web Viewer tab to read from, if multiple tabs are open. If omitted, the active or most recently opened Web Viewer tab will be used. Example: 'github.com'"
},
format: {
type: "string",
enum: ["text", "screenshot"],
description: "The format in which to return the page content. Use 'text' for plain readable content (equivalent to document.body.innerText), or 'screenshot' for an image of the webpage. Prefer 'text' unless you need to inspect the page visually."
},
user_message: {
type: "string",
description: "A short message to be displayed to the user explaining what you're reading and why. Example: 'Reading the open page to summarise its key points'"
}
},
required: ["format", "user_message"]
}
}

View file

@ -478,6 +478,10 @@ After multi-step execution:
- Problem-solving and explanations across any domain
- Programming, writing, and creative tasks with vault context
**Web Viewer**
- When the user asks about a web page or its contents, immediately attempt to retrieve the open web view do not ask for a URL first
- Retrieving web view content is always safe: if no page is open, the tool will tell you there is no risk in trying
**Interactive Capabilities**
- Asking clarifying questions during planning to shape the approach
- Consulting the user during execution when decisions require their input

View file

@ -1,12 +1,7 @@
import { StringTools } from "Helpers/StringTools";
import { ConversationContent } from "./ConversationContent";
import { Attachment } from "./Attachment";
import type { AIToolResponse } from "AIClasses/ToolDefinitions/AIToolResponse";
import { Role } from "Enums/Role";
import { AITool } from "Enums/AITool";
import { isTextFile, toFileType } from "Enums/FileType";
import { FileTypeToMimeType } from "Enums/FileTypeMimeTypeMapping";
import { isDocumentMimeType, MimeType } from "Enums/MimeType";
export class Conversation {
@ -28,95 +23,17 @@ export class Conversation {
}
public addFunctionResponse(functionResponse: AIToolResponse): void {
if (functionResponse.name !== AITool.ReadVaultFiles) {
const functionResponseString = functionResponse.toConversationString();
this.contents.push(new ConversationContent({
role: Role.User,
functionResponse: functionResponseString,
shouldDisplayContent: false,
toolId: functionResponse.toolId
}));
return;
}
const responseData = functionResponse.response as
{results?: Array<{type?: string, path: string, contents?: string, error?: string}>};
const results = responseData.results;
if (!results) {
// Handle case where results array is missing (general error response)
const functionResponseString = functionResponse.toConversationString();
this.contents.push(new ConversationContent({
role: Role.User,
functionResponse: functionResponseString,
shouldDisplayContent: false,
toolId: functionResponse.toolId
}));
return;
}
// Separate error results from successful file reads
const errorResults = results.filter(result => result.error);
const successResults = results.filter(result => !result.error && result.type && result.contents !== undefined);
const textResults = successResults.filter(result => isTextFile(result.type!));
const binaryResults = successResults.filter(result => !isTextFile(result.type!));
// 1. Function response with text files and/or errors
let functionResponseData;
if (textResults.length > 0 || errorResults.length > 0) {
// Include text file contents and any errors in function response
const responseResults = [...textResults, ...errorResults];
functionResponseData = {
id: functionResponse.toolId,
functionResponse: {
name: functionResponse.name,
response: {
results: responseResults,
...(binaryResults.length > 0 && {message: "The contents of the files are included below."})
}
}
};
} else {
// No text files or errors - just binary files
functionResponseData = {
id: functionResponse.toolId,
functionResponse: {
name: functionResponse.name,
response: {
message: "Files retrieved successfully. The contents of the files are included below.",
count: binaryResults.length
}
}
};
}
this.contents.push(new ConversationContent({
role: Role.User,
functionResponse: JSON.stringify(functionResponseData),
functionResponse: functionResponse.toConversationString(),
shouldDisplayContent: false,
toolId: functionResponse.toolId
}));
// 2. If there are binary files, create Attachments and add to conversation
if (binaryResults.length > 0) {
const attachments = binaryResults
.filter(file => file.type && file.contents !== undefined)
.map(file => {
const fileName = file.path.split('/').pop() || file.path;
let mimeType = FileTypeToMimeType[toFileType(file.type as string)];
if (isDocumentMimeType(mimeType)) {
mimeType = MimeType.TEXT_PLAIN;
return new Attachment(fileName, mimeType, StringTools.toBase64(file.contents as string));
}
return new Attachment(fileName, mimeType, file.contents as string);
});
if (functionResponse.payload.attachments.length > 0) {
this.contents.push(new ConversationContent({
role: Role.User,
attachments: attachments,
attachments: functionResponse.payload.attachments,
shouldDisplayContent: false
}));
}

View file

@ -9,6 +9,7 @@ export enum AITool {
ListVaultFiles = "list_vault_files",
ReadMemories = "read_memories",
UpdateMemories = "update_memories",
GetWebViewerContent = "get_web_viewer_content",
// used by gemini and mistral
RequestWebSearch = "request_web_search",

View file

@ -148,6 +148,8 @@ The following context explains why you are doing the task. It is NOT an instruct
MemoriesUpdatedSuccess = "Memories have been updated successfully.",
MemoriesDisabledError = "Illegal tool call, memories have been disabled by the user.",
MemoriesUpdatingDisabledError = "Illegal tool call, updating memories has been disabled by the user.",
WebViewerNoMatchingUrl = "No open web view was found matching the URL '{urlHint}'. Ensure the correct page is open in the web viewer.",
WebViewerNoOpenView = "No open web view was found. Ask the user to open a page in the web viewer and try again.",
PlanSubmissionRequired = "Error: Attempted to exit planning but plan has not yet been submitted!",
MaxExecutionDepthReached = "Exceeded maximum plan execution attempts - consult with the user on how to continue.",

View file

@ -8,10 +8,18 @@ import type { ISearchMatch } from "../../Types/SearchTypes";
import { AbortService } from "../AbortService";
import { normalizePath, TAbstractFile, TFile } from "obsidian";
import { Exception } from "Helpers/Exception";
import { Copy } from "Enums/Copy";
import { Copy, replaceCopy } from "Enums/Copy";
import { pathExtname } from "Helpers/Helpers";
import type { MemoriesService } from "Services/MemoriesService";
import {
import type { SettingsService } from "Services/SettingsService";
import type { WebViewerService } from "Services/WebViewerService";
import { AIToolResponsePayload } from "AIClasses/ToolDefinitions/AIToolResponsePayload";
import { Attachment } from "Conversations/Attachment";
import { isDocumentMimeType, MimeType } from "Enums/MimeType";
import { isTextFile, toFileType } from "Enums/FileType";
import { FileTypeToMimeType } from "Enums/FileTypeMimeTypeMapping";
import { StringTools } from "Helpers/StringTools";
import {
SearchVaultFilesArgsSchema,
ReadVaultFilesArgsSchema,
WriteVaultFileArgsSchema,
@ -21,15 +29,16 @@ import {
PatchVaultFileArgsSchema,
ReadMemoriesArgsSchema,
UpdateMemoriesArgsSchema,
CreateVaultFolderSchema
CreateVaultFolderSchema,
GetWebViewerContentSchema
} from "AIClasses/Schemas/AIToolSchemas";
import type { SettingsService } from "Services/SettingsService";
export class AIToolService {
private readonly fileSystemService: FileSystemService;
private readonly memoriesService: MemoriesService;
private readonly settingsService: SettingsService;
private readonly webViewerService: WebViewerService;
private readonly abortService: AbortService;
private lastToolReadMemories: boolean = false;
@ -38,6 +47,7 @@ export class AIToolService {
this.fileSystemService = Resolve<FileSystemService>(Services.FileSystemService);
this.memoriesService = Resolve<MemoriesService>(Services.MemoriesService);
this.settingsService = Resolve<SettingsService>(Services.SettingsService);
this.webViewerService = Resolve<WebViewerService>(Services.WebViewerService);
this.abortService = Resolve<AbortService>(Services.AbortService);
}
@ -54,7 +64,7 @@ export class AIToolService {
if (!parseResult.success) {
return new AIToolResponse(
toolCall.name,
{ error: `Invalid arguments for ${AITool.SearchVaultFiles}: ${parseResult.error.message}` },
new AIToolResponsePayload({ error: `Invalid arguments for ${AITool.SearchVaultFiles}: ${parseResult.error.message}` }),
toolCall.toolId
);
}
@ -66,7 +76,7 @@ export class AIToolService {
if (!parseResult.success) {
return new AIToolResponse(
toolCall.name,
{ error: `Invalid arguments for ${AITool.ReadVaultFiles}: ${parseResult.error.message}` },
new AIToolResponsePayload({ error: `Invalid arguments for ${AITool.ReadVaultFiles}: ${parseResult.error.message}` }),
toolCall.toolId
);
}
@ -78,7 +88,7 @@ export class AIToolService {
if (!parseResult.success) {
return new AIToolResponse(
toolCall.name,
{ error: `Invalid arguments for ${AITool.WriteVaultFile}: ${parseResult.error.message}` },
new AIToolResponsePayload({ error: `Invalid arguments for ${AITool.WriteVaultFile}: ${parseResult.error.message}` }),
toolCall.toolId
);
}
@ -90,7 +100,7 @@ export class AIToolService {
if (!parseResult.success) {
return new AIToolResponse(
toolCall.name,
{ error: `Invalid arguments for ${AITool.PatchVaultFile}: ${parseResult.error.message}` },
new AIToolResponsePayload({ error: `Invalid arguments for ${AITool.PatchVaultFile}: ${parseResult.error.message}` }),
toolCall.toolId
);
}
@ -102,7 +112,7 @@ export class AIToolService {
if (!parseResult.success) {
return new AIToolResponse(
toolCall.name,
{ error: `Invalid arguments for ${AITool.DeleteVaultFiles}: ${parseResult.error.message}` },
new AIToolResponsePayload({ error: `Invalid arguments for ${AITool.DeleteVaultFiles}: ${parseResult.error.message}` }),
toolCall.toolId
);
}
@ -114,7 +124,7 @@ export class AIToolService {
if (!parseResult.success) {
return new AIToolResponse(
toolCall.name,
{ error: `Invalid arguments for ${AITool.MoveVaultFiles}: ${parseResult.error.message}` },
new AIToolResponsePayload({ error: `Invalid arguments for ${AITool.MoveVaultFiles}: ${parseResult.error.message}` }),
toolCall.toolId
);
}
@ -126,7 +136,7 @@ export class AIToolService {
if (!parseResult.success) {
return new AIToolResponse(
toolCall.name,
{ error: `Invalid arguments for ${AITool.CreateVaultFolder}: ${parseResult.error.message}` },
new AIToolResponsePayload({ error: `Invalid arguments for ${AITool.CreateVaultFolder}: ${parseResult.error.message}` }),
toolCall.toolId
);
}
@ -138,19 +148,31 @@ export class AIToolService {
if (!parseResult.success) {
return new AIToolResponse(
toolCall.name,
{ error: `Invalid arguments for ${AITool.ListVaultFiles}: ${parseResult.error.message}` },
new AIToolResponsePayload({ error: `Invalid arguments for ${AITool.ListVaultFiles}: ${parseResult.error.message}` }),
toolCall.toolId
);
}
return new AIToolResponse(toolCall.name, await this.listVaultFiles(parseResult.data.path, parseResult.data.recursive), toolCall.toolId);
}
case AITool.GetWebViewerContent: {
const parseResult = GetWebViewerContentSchema.safeParse(toolCall.arguments);
if (!parseResult.success) {
return new AIToolResponse(
toolCall.name,
new AIToolResponsePayload({ error: `Invalid arguments for ${AITool.GetWebViewerContent}: ${parseResult.error.message}` }),
toolCall.toolId
);
}
return new AIToolResponse(toolCall.name, await this.getWebViewerContent(parseResult.data.format, parseResult.data.url_hint), toolCall.toolId);
}
case AITool.ReadMemories: {
const parseResult = ReadMemoriesArgsSchema.safeParse(toolCall.arguments);
if (!parseResult.success) {
return new AIToolResponse(
toolCall.name,
{ error: `Invalid arguments for ${AITool.ReadMemories}: ${parseResult.error.message}` },
new AIToolResponsePayload({ error: `Invalid arguments for ${AITool.ReadMemories}: ${parseResult.error.message}` }),
toolCall.toolId
);
}
@ -162,7 +184,7 @@ export class AIToolService {
if (!parseResult.success) {
return new AIToolResponse(
toolCall.name,
{ error: `Invalid arguments for ${AITool.UpdateMemories}: ${parseResult.error.message}` },
new AIToolResponsePayload({ error: `Invalid arguments for ${AITool.UpdateMemories}: ${parseResult.error.message}` }),
toolCall.toolId
);
}
@ -171,7 +193,7 @@ export class AIToolService {
// This is only used by gemini
case AITool.RequestWebSearch:
return new AIToolResponse(toolCall.name, {}, toolCall.toolId)
return new AIToolResponse(toolCall.name, new AIToolResponsePayload({}), toolCall.toolId)
// multi-agent functions are handled elsewhere - this shouldn't really ever get hit
case AITool.ExecuteWorkflow:
@ -189,7 +211,7 @@ export class AIToolService {
Exception.log(`Multi-agent function ${toolCall.name} should not be handled by AIToolService`);
return new AIToolResponse(
toolCall.name,
{ error: `Failed to execute ${toolCall.name}.` },
new AIToolResponsePayload({ error: `Failed to execute ${toolCall.name}.` }),
toolCall.toolId
);
}
@ -201,7 +223,7 @@ export class AIToolService {
Exception.log(error);
return new AIToolResponse(
toolCallName,
{ error: error },
new AIToolResponsePayload({ error: error }),
toolCall.toolId
);
}
@ -209,7 +231,7 @@ export class AIToolService {
});
}
private async searchVaultFiles(searchTerms: string[]): Promise<object> {
private async searchVaultFiles(searchTerms: string[]): Promise<AIToolResponsePayload> {
const results: { searchTerm: string, results: object[] }[] = [];
for (const searchTerm of searchTerms) {
@ -227,10 +249,10 @@ export class AIToolService {
});
}
return results;
return new AIToolResponsePayload(results);
}
private async readVaultFiles(filePaths: string[]): Promise<object> {
private async readVaultFiles(filePaths: string[]): Promise<AIToolResponsePayload> {
const results = await Promise.all(
filePaths.map(async (filePath) => {
const result = await this.fileSystemService.readFile(filePath);
@ -244,28 +266,56 @@ export class AIToolService {
};
})
);
return { results };
const errorResults = results.filter(result => result.error);
const successResults = results.filter(result => !result.error && result.type && result.contents !== undefined) as
Array<{ type: string; path: string; contents: string }>;
const textResults = successResults.filter(result => isTextFile(result.type));
const binaryResults = successResults.filter(result => !isTextFile(result.type));
const attachments = binaryResults.map(file => {
const fileName = file.path.split('/').pop() || file.path;
let mimeType = FileTypeToMimeType[toFileType(file.type)];
if (isDocumentMimeType(mimeType)) {
mimeType = MimeType.TEXT_PLAIN;
return new Attachment(fileName, mimeType, StringTools.toBase64(file.contents));
}
return new Attachment(fileName, mimeType, file.contents);
});
const response = textResults.length > 0 || errorResults.length > 0
? {
results: [...textResults, ...errorResults],
...(binaryResults.length > 0 && { message: "The contents of the files are included below." })
}
: {
message: "Files retrieved successfully. The contents of the files are included below.",
count: binaryResults.length
};
return new AIToolResponsePayload(response, attachments);
}
private async writeVaultFile(filePath: string, content: string): Promise<object> {
private async writeVaultFile(filePath: string, content: string): Promise<AIToolResponsePayload> {
const result = await this.fileSystemService.writeFile(normalizePath(filePath), content);
if (result instanceof Error) {
return { success: false, error: result.message };
return new AIToolResponsePayload({ success: false, error: result.message });
}
return { success: true };
return new AIToolResponsePayload({ success: true });
}
private async patchVaultFile(filePath: string, oldContent: string[], newContent: string[]): Promise<object> {
private async patchVaultFile(filePath: string, oldContent: string[], newContent: string[]): Promise<AIToolResponsePayload> {
const result = await this.fileSystemService.patchFile(normalizePath(filePath), oldContent, newContent);
if (result instanceof Error) {
return { success: false, error: result.message };
return new AIToolResponsePayload({ success: false, error: result.message });
}
return { success: true };
return new AIToolResponsePayload({ success: true });
}
private async deleteVaultFiles(filePaths: string[], confirmation: boolean): Promise<object> {
private async deleteVaultFiles(filePaths: string[], confirmation: boolean): Promise<AIToolResponsePayload> {
if (!confirmation) {
return { error: "Confirmation was false, no action taken" };
return new AIToolResponsePayload({ error: "Confirmation was false, no action taken" });
}
const results = await Promise.all(filePaths.map(async filePath => {
@ -276,12 +326,12 @@ export class AIToolService {
return { path: filePath, success: true };
}));
return { results };
return new AIToolResponsePayload({ results });
}
private async moveVaultFiles(sourcePaths: string[], destinationPaths: string[]): Promise<object> {
private async moveVaultFiles(sourcePaths: string[], destinationPaths: string[]): Promise<AIToolResponsePayload> {
if (sourcePaths.length !== destinationPaths.length) {
return { error: "Source paths array length does not equal destination paths array length" };
return new AIToolResponsePayload({ error: "Source paths array length does not equal destination paths array length" });
}
const results = await Promise.all(sourcePaths.map(async (sourcePath, index) => {
@ -293,45 +343,60 @@ export class AIToolService {
return { path: destinationPath, success: true };
}));
return { results };
return new AIToolResponsePayload({ results });
}
private async createVaultFolder(path: string): Promise<object> {
private async createVaultFolder(path: string): Promise<AIToolResponsePayload> {
const result = await this.fileSystemService.createFolder(path);
if (result instanceof Error) {
return { path: path, success: false, error: result.message };
return new AIToolResponsePayload({ path: path, success: false, error: result.message });
}
return { path: path, success: true };
return new AIToolResponsePayload({ path: path, success: true });
}
private async listVaultFiles(path: string, recursive: boolean): Promise<object> {
private async listVaultFiles(path: string, recursive: boolean): Promise<AIToolResponsePayload> {
const files: TAbstractFile[] = await this.fileSystemService.listDirectoryContents(path, recursive);
return files.map(file => ({
return new AIToolResponsePayload(files.map(file => ({
type: file instanceof TFile ? "file" : "directory",
path: file.path
}));
})));
}
private async readMemories(): Promise<object> {
private async getWebViewerContent(format: "text" | "screenshot", urlHint?: string): Promise<AIToolResponsePayload> {
const result = format === "text"
? await this.webViewerService.getWebViewContent(urlHint)
: await this.webViewerService.takeScreenshot(urlHint, true);
if (urlHint && !result) {
return new AIToolResponsePayload({ error: replaceCopy(Copy.WebViewerNoMatchingUrl, [urlHint]) });
}
if (!result) {
return new AIToolResponsePayload({ error: Copy.WebViewerNoOpenView });
}
return format === "text"
? new AIToolResponsePayload({ content: result })
: new AIToolResponsePayload({ success: true }, [new Attachment("screenshot.png", MimeType.IMAGE_PNG, result)]);
}
private async readMemories(): Promise<AIToolResponsePayload> {
if (!this.settingsService.settings.enableMemories) {
return { error: Copy.MemoriesDisabledError }
return new AIToolResponsePayload({ error: Copy.MemoriesDisabledError });
}
this.lastToolReadMemories = true;
return { memories: await this.memoriesService.readMemories() };
return new AIToolResponsePayload({ memories: await this.memoriesService.readMemories() });
}
private async updateMemories(content: string): Promise<object> {
private async updateMemories(content: string): Promise<AIToolResponsePayload> {
if (!this.settingsService.settings.allowUpdatingMemories) {
return { error: Copy.MemoriesUpdatingDisabledError };
return new AIToolResponsePayload({ error: Copy.MemoriesUpdatingDisabledError });
}
if (!this.lastToolReadMemories) {
return {
error: Copy.UpdateMemoriesWithoutReadError
};
return new AIToolResponsePayload({ error: Copy.UpdateMemoriesWithoutReadError });
}
this.lastToolReadMemories = false;
return { result: await this.memoriesService.updateMemories(content) };
return new AIToolResponsePayload({ result: await this.memoriesService.updateMemories(content) });
}
}

View file

@ -13,6 +13,7 @@ import { AIToolDefinitions } from "AIClasses/ToolDefinitions/AIToolDefinitions";
import { Copy, replaceCopy } from "Enums/Copy";
import { DebugColor } from "Enums/DebugColor";
import { AIToolUsageMode } from "Enums/AIToolUsageMode";
import { AIToolResponsePayload } from "AIClasses/ToolDefinitions/AIToolResponsePayload";
export class ExecutionAgent extends BaseAgent {
@ -53,7 +54,7 @@ export class ExecutionAgent extends BaseAgent {
if (!parseResult.success) {
this.conversation.addFunctionResponse(new AIToolResponse(
toolCallName,
{ error: `Invalid arguments for ${AITool.CompleteTask}: ${parseResult.error.message}` },
new AIToolResponsePayload({ error: `Invalid arguments for ${AITool.CompleteTask}: ${parseResult.error.message}` }),
toolCall.toolId
));
return { shouldExit: false };
@ -61,7 +62,7 @@ export class ExecutionAgent extends BaseAgent {
this.debugService?.log("ExecutionAgent", `Task completed (success: ${parseResult.data.success}): ${parseResult.data.description}`);
this.conversation.addFunctionResponse(new AIToolResponse(
toolCallName,
{ result: parseResult.data.success ? "Task completed successfully." : "Task failed, attempting recovery..." },
new AIToolResponsePayload({ result: parseResult.data.success ? "Task completed successfully." : "Task failed, attempting recovery..." }),
toolCall.toolId
));
this.updateThought(toolCall, callbacks);

View file

@ -13,6 +13,7 @@ import { ConversationContent } from "Conversations/ConversationContent";
import { Role } from "Enums/Role";
import { DebugColor } from "Enums/DebugColor";
import { AIToolUsageMode } from "Enums/AIToolUsageMode";
import { AIToolResponsePayload } from "AIClasses/ToolDefinitions/AIToolResponsePayload";
export class MainAgent extends BaseAgent {
@ -64,7 +65,7 @@ export class MainAgent extends BaseAgent {
if (!parseResult.success) {
conversation.addFunctionResponse(new AIToolResponse(
toolCallName,
{ error: `Invalid arguments for ${AITool.ExecuteWorkflow}: ${parseResult.error.message}` },
new AIToolResponsePayload({ error: `Invalid arguments for ${AITool.ExecuteWorkflow}: ${parseResult.error.message}` }),
toolCall.toolId
));
return { shouldExit: false };

View file

@ -15,6 +15,7 @@ import { AITool, isAITool } from "Enums/AITool";
import { AIToolResponse } from "AIClasses/ToolDefinitions/AIToolResponse";
import { DebugColor } from "Enums/DebugColor";
import { AIToolUsageMode } from "Enums/AIToolUsageMode";
import { AIToolResponsePayload } from "AIClasses/ToolDefinitions/AIToolResponsePayload";
export class OrchestrationAgent extends BaseAgent {
@ -22,7 +23,7 @@ export class OrchestrationAgent extends BaseAgent {
private orchestrationDepth: number = 0;
public async runPlannedWorkflow(planRequest: ExecuteWorkflowArgs, callbacks: IChatServiceCallbacks): Promise<object> {
public async runPlannedWorkflow(planRequest: ExecuteWorkflowArgs, callbacks: IChatServiceCallbacks): Promise<AIToolResponsePayload> {
this.debugService?.log("OrchestrationAgent", `Starting planned workflow: ${planRequest.goal}`);
const planningConversation: Conversation = new Conversation();
planningConversation.contents.push(new ConversationContent({
@ -41,7 +42,7 @@ export class OrchestrationAgent extends BaseAgent {
if (!executionPlan) {
this.debugService?.log("OrchestrationAgent", "Planning failed - no execution plan generated");
return { message: Copy.PlanningFailedNoSteps };
return new AIToolResponsePayload({ message: Copy.PlanningFailedNoSteps });
}
this.debugService?.log("OrchestrationAgent", `Execution plan received with ${executionPlan.executionSteps.length} steps`);
callbacks.onPlanUpdate(executionPlan);
@ -61,7 +62,7 @@ export class OrchestrationAgent extends BaseAgent {
if (!executionResult) {
this.debugService?.log("OrchestrationAgent", `Step ${stepIndex + 1} failed to execute - workflow aborted`);
return { message: replaceCopy(Copy.WorkflowFailedAtStep, [step.description]) };
return new AIToolResponsePayload({ message: replaceCopy(Copy.WorkflowFailedAtStep, [step.description]) });
}
if (executionResult.success) {
@ -127,7 +128,7 @@ export class OrchestrationAgent extends BaseAgent {
if (orchestrationResult.abort) {
this.debugService?.log("OrchestrationAgent", `Orchestration decision: ABORT — ${orchestrationResult.abortContext}`);
return { message: replaceCopy(Copy.WorkflowAborted, [orchestrationResult.abortContext]) };
return new AIToolResponsePayload({ message: replaceCopy(Copy.WorkflowAborted, [orchestrationResult.abortContext]) });
}
if (orchestrationResult.complete) {
@ -144,7 +145,7 @@ export class OrchestrationAgent extends BaseAgent {
}));
const orchestrationSummary = await this.requestAgentResponse(AgentType.Orchestration, planningConversation, callbacks);
return { planExecutionSummary: orchestrationSummary };
return new AIToolResponsePayload({ planExecutionSummary: orchestrationSummary });
}
private async runOrchestrationAgentLoop(planningConversation: Conversation, callbacks: IChatServiceCallbacks): Promise<OrchestrationResult> {
@ -166,7 +167,7 @@ export class OrchestrationAgent extends BaseAgent {
this.debugService?.log("Orchestration", `Invalid tool call denied: ${toolCallName}`);
planningConversation.addFunctionResponse(new AIToolResponse(
toolCallName,
{ message: Copy.OrchestrationToolDenial },
new AIToolResponsePayload({ message: Copy.OrchestrationToolDenial }),
toolCall.toolId
));
return Promise.resolve({ shouldExit: false });
@ -188,7 +189,7 @@ export class OrchestrationAgent extends BaseAgent {
if (!parseResult.success) {
planningConversation.addFunctionResponse(new AIToolResponse(
toolCallName,
{ error: `Invalid arguments for ${AITool.CompleteStep}: ${parseResult.error.message}` },
new AIToolResponsePayload({ error: `Invalid arguments for ${AITool.CompleteStep}: ${parseResult.error.message}` }),
toolCall.toolId
));
return Promise.resolve({ shouldExit: false });
@ -196,7 +197,7 @@ export class OrchestrationAgent extends BaseAgent {
if (!parseResult.data.confirm_completion) {
planningConversation.addFunctionResponse(new AIToolResponse(
toolCallName,
{ error: "Confirmation was false, no action taken" },
new AIToolResponsePayload({ error: "Confirmation was false, no action taken" }),
toolCall.toolId
));
return Promise.resolve({ shouldExit: false });
@ -205,7 +206,7 @@ export class OrchestrationAgent extends BaseAgent {
this.updateThought(toolCall, callbacks);
planningConversation.addFunctionResponse(new AIToolResponse(
toolCallName,
{ message: "Step completed" },
new AIToolResponsePayload({ message: "Step completed" }),
toolCall.toolId
));
orchestrationResult = new OrchestrationResult({ continue: true, continueContext: parseResult.data.context_for_next_step });
@ -217,7 +218,7 @@ export class OrchestrationAgent extends BaseAgent {
if (!parseResult.success) {
planningConversation.addFunctionResponse(new AIToolResponse(
toolCallName,
{ error: `Invalid arguments for ${AITool.ReviseStep}: ${parseResult.error.message}` },
new AIToolResponsePayload({ error: `Invalid arguments for ${AITool.ReviseStep}: ${parseResult.error.message}` }),
toolCall.toolId
));
return Promise.resolve({ shouldExit: false });
@ -226,7 +227,7 @@ export class OrchestrationAgent extends BaseAgent {
this.updateThought(toolCall, callbacks);
planningConversation.addFunctionResponse(new AIToolResponse(
toolCallName,
{ message: "Step revision accepted — retrying" },
new AIToolResponsePayload({ message: "Step revision accepted — retrying" }),
toolCall.toolId
));
orchestrationResult = new OrchestrationResult({
@ -243,7 +244,7 @@ export class OrchestrationAgent extends BaseAgent {
if (!parseResult.success) {
planningConversation.addFunctionResponse(new AIToolResponse(
toolCallName,
{ error: `Invalid arguments for ${AITool.RevisePlan}: ${parseResult.error.message}` },
new AIToolResponsePayload({ error: `Invalid arguments for ${AITool.RevisePlan}: ${parseResult.error.message}` }),
toolCall.toolId
));
return Promise.resolve({ shouldExit: false });
@ -252,7 +253,7 @@ export class OrchestrationAgent extends BaseAgent {
this.updateThought(toolCall, callbacks);
planningConversation.addFunctionResponse(new AIToolResponse(
toolCallName,
{ message: `Plan revised with ${parseResult.data.steps.length} remaining step(s)` },
new AIToolResponsePayload({ message: `Plan revised with ${parseResult.data.steps.length} remaining step(s)` }),
toolCall.toolId
));
orchestrationResult = new OrchestrationResult({
@ -267,7 +268,7 @@ export class OrchestrationAgent extends BaseAgent {
if (!parseResult.success) {
planningConversation.addFunctionResponse(new AIToolResponse(
toolCallName,
{ error: `Invalid arguments for ${AITool.SkipStep}: ${parseResult.error.message}` },
new AIToolResponsePayload({ error: `Invalid arguments for ${AITool.SkipStep}: ${parseResult.error.message}` }),
toolCall.toolId
));
return Promise.resolve({ shouldExit: false });
@ -276,7 +277,7 @@ export class OrchestrationAgent extends BaseAgent {
this.updateThought(toolCall, callbacks);
planningConversation.addFunctionResponse(new AIToolResponse(
toolCallName,
{ message: "Step skipped" },
new AIToolResponsePayload({ message: "Step skipped" }),
toolCall.toolId
));
orchestrationResult = new OrchestrationResult({
@ -291,7 +292,7 @@ export class OrchestrationAgent extends BaseAgent {
if (!parseResult.success) {
planningConversation.addFunctionResponse(new AIToolResponse(
toolCallName,
{ error: `Invalid arguments for ${AITool.CompletePlan}: ${parseResult.error.message}` },
new AIToolResponsePayload({ error: `Invalid arguments for ${AITool.CompletePlan}: ${parseResult.error.message}` }),
toolCall.toolId
));
return Promise.resolve({ shouldExit: false });
@ -299,7 +300,7 @@ export class OrchestrationAgent extends BaseAgent {
if (!parseResult.data.confirm_completion) {
planningConversation.addFunctionResponse(new AIToolResponse(
toolCallName,
{ error: "Confirmation was false, no action taken" },
new AIToolResponsePayload({ error: "Confirmation was false, no action taken" }),
toolCall.toolId
));
return Promise.resolve({ shouldExit: false });
@ -308,7 +309,7 @@ export class OrchestrationAgent extends BaseAgent {
this.updateThought(toolCall, callbacks);
planningConversation.addFunctionResponse(new AIToolResponse(
toolCallName,
{ message: "Plan completed" },
new AIToolResponsePayload({ message: "Plan completed" }),
toolCall.toolId
));
orchestrationResult = new OrchestrationResult({ complete: true });
@ -320,7 +321,7 @@ export class OrchestrationAgent extends BaseAgent {
if (!parseResult.success) {
planningConversation.addFunctionResponse(new AIToolResponse(
toolCallName,
{ error: `Invalid arguments for ${AITool.CancelPlan}: ${parseResult.error.message}` },
new AIToolResponsePayload({ error: `Invalid arguments for ${AITool.CancelPlan}: ${parseResult.error.message}` }),
toolCall.toolId
));
return Promise.resolve({ shouldExit: false });
@ -329,7 +330,7 @@ export class OrchestrationAgent extends BaseAgent {
this.updateThought(toolCall, callbacks);
planningConversation.addFunctionResponse(new AIToolResponse(
toolCallName,
{ message: "Plan cancelled" },
new AIToolResponsePayload({ message: "Plan cancelled" }),
toolCall.toolId
));
orchestrationResult = new OrchestrationResult({ abort: true, abortContext: parseResult.data.context });

View file

@ -13,6 +13,7 @@ import { ConversationContent } from "Conversations/ConversationContent";
import { Role } from "Enums/Role";
import { DebugColor } from "Enums/DebugColor";
import { AIToolUsageMode } from "Enums/AIToolUsageMode";
import { AIToolResponsePayload } from "AIClasses/ToolDefinitions/AIToolResponsePayload";
export class PlanningAgent extends BaseAgent {
@ -39,7 +40,7 @@ export class PlanningAgent extends BaseAgent {
this.debugService?.log("PlanningAgent", `Invalid tool call denied: ${toolCallName}`);
conversation.addFunctionResponse(new AIToolResponse(
toolCallName,
{ message: Copy.PlanningToolDenial },
new AIToolResponsePayload({ message: Copy.PlanningToolDenial }),
toolCall.toolId
));
return { shouldExit: false };
@ -50,7 +51,7 @@ export class PlanningAgent extends BaseAgent {
if (!parseResult.success) {
conversation.addFunctionResponse(new AIToolResponse(
toolCallName,
{ error: `Invalid arguments for ${AITool.AskUserQuestionPlanning}: ${parseResult.error.message}` },
new AIToolResponsePayload({ error: `Invalid arguments for ${AITool.AskUserQuestionPlanning}: ${parseResult.error.message}` }),
toolCall.toolId
));
return { shouldExit: false };
@ -61,7 +62,7 @@ export class PlanningAgent extends BaseAgent {
this.debugService?.log("PlanningAgent", "User answer received");
conversation.addFunctionResponse(new AIToolResponse(
toolCallName,
{ answer: answer },
new AIToolResponsePayload({ answer: answer }),
toolCall.toolId
));
return { shouldExit: false };
@ -72,7 +73,7 @@ export class PlanningAgent extends BaseAgent {
if (!parseResult.success) {
conversation.addFunctionResponse(new AIToolResponse(
toolCallName,
{ error: `Invalid arguments for ${AITool.SubmitPlan}: ${parseResult.error.message}` },
new AIToolResponsePayload({ error: `Invalid arguments for ${AITool.SubmitPlan}: ${parseResult.error.message}` }),
toolCall.toolId
));
return { shouldExit: false };
@ -81,7 +82,7 @@ export class PlanningAgent extends BaseAgent {
capturedPlan = new ExecutionPlan(parseResult.data);
conversation.addFunctionResponse(new AIToolResponse(
toolCallName,
{ message: Copy.PlanReceived },
new AIToolResponsePayload({ message: Copy.PlanReceived }),
toolCall.toolId
));
return { shouldExit: true };

View file

@ -27,6 +27,7 @@ import { UserInputService } from "./UserInputService";
import { VaultCacheService } from "./VaultCacheService";
import { VaultService } from "./VaultService";
import { WorkSpaceService } from "./WorkSpaceService";
import { WebViewerService } from "./WebViewerService";
// Stores
import { ExecutionPlanStore } from "Stores/ExecutionPlanStore";
@ -81,7 +82,9 @@ export function RegisterDependencies() {
RegisterSingleton<MemoriesService>(Services.MemoriesService, new MemoriesService());
RegisterSingleton<ConversationFileSystemService>(Services.ConversationFileSystemService, new ConversationFileSystemService());
RegisterSingleton<ConversationNamingService>(Services.ConversationNamingService, new ConversationNamingService());
RegisterTransient<WebViewerService>(Services.WebViewerService, () => new WebViewerService());
RegisterSingleton<IPrompt>(Services.IPrompt, new AIPrompt());
RegisterSingleton<AIToolService>(Services.AIToolService, new AIToolService());
RegisterSingleton<MainAgent>(Services.MainAgent, new MainAgent());

View file

@ -19,6 +19,7 @@ export class Services {
static ChatService = Symbol("ChatService");
static SanitiserService = Symbol("SanitiserService");
static InputService = Symbol("InputService");
static WebViewerService = Symbol("WebViewerService");
static DiffService = Symbol("DiffService");
static MemoriesService = Symbol("MemoriesService");
static DebugService = Symbol("DebugService");

View file

@ -0,0 +1,86 @@
import type VaultkeeperAIPlugin from "main";
import { Resolve } from "./DependencyService";
import { Services } from "./Services";
import type { WebviewElement } from "Types/WebviewElement";
export class WebViewerService {
private readonly plugin: VaultkeeperAIPlugin;
public constructor() {
this.plugin = Resolve<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin);
}
public async getWebViewContent(urlHint?: string): Promise<string | null> {
const webviewElement = this.getWebviewElement(urlHint);
if (!webviewElement) {
return null;
}
if (await this.waitForLoad(webviewElement)) {
const pageContent = await webviewElement.executeJavaScript(
'document.body.innerText'
);
return pageContent as string ?? "Failed to retrieve page content";
}
return "";
}
public async takeScreenshot(urlHint?: string, stripDataUrl: boolean = false): Promise<string | null> {
const webviewElement = this.getWebviewElement(urlHint);
if (!webviewElement) {
return null;
}
if (await this.waitForLoad(webviewElement)) {
const screenshot = await webviewElement.capturePage();
const dataUrl = screenshot.toDataURL();
return stripDataUrl ? dataUrl.split(',')[1] : dataUrl;
}
return null;
}
private getWebviewElement(urlHint?: string): WebviewElement | null {
const leaves = this.plugin.app.workspace.getLeavesOfType('webviewer');
if (leaves.length <= 0) {
return null;
}
let webviewElement: WebviewElement | null = null;
if (urlHint) {
for (const leaf of leaves) {
const element = leaf.view.containerEl.querySelector('webview');
if (this.isWebviewElement(element) && element.getURL().contains(urlHint)) {
webviewElement = element;
}
}
} else {
const leaf = leaves[0];
const element = leaf.view.containerEl.querySelector('webview');
if (this.isWebviewElement(element)) {
webviewElement = element;
}
}
return webviewElement;
}
private async waitForLoad(webviewElement: WebviewElement, timeoutMs = 10000): Promise<boolean> {
const start = Date.now();
while (webviewElement.isLoading()) {
if (Date.now() - start > timeoutMs) {
return false;
}
await new Promise(r => setTimeout(r, 200));
}
return true;
}
private isWebviewElement(element: Element | null): element is WebviewElement {
return element !== null
&& typeof (element as WebviewElement).isLoading === 'function'
&& typeof (element as WebviewElement).executeJavaScript === 'function';
}
}

View file

@ -57,14 +57,18 @@
resize: none;
}
.setting-item-memories-disabled .checkbox-container {
background-color: var(--interactive-accent-disabled);
.setting-item-memories-disabled-accent .checkbox-container {
background-color: var(--interactive-accent-disabled-accent);
}
.setting-item-memories-disabled .setting-item-info {
.setting-item-memories-disabled-accent .setting-item-info {
font-weight: var(--font-light);
}
.setting-item-memories-disabled .checkbox-container {
background-color: var(--interactive-accent-disabled);
}
/* ============================== */
/* CSS Variables for Common Components */
/* ============================== */
@ -213,11 +217,17 @@ body {
var(--background-primary) 75%,
hsl(0 0% 0%) 25%
);
--interactive-accent-disabled: color-mix(
--interactive-accent-disabled-accent: color-mix(
in hsl shorter hue,
var(--background-modifier-border-hover) 50%,
var(--interactive-accent) 40%
);
--interactive-accent-disabled: color-mix(
in hsl shorter hue,
var(--background-modifier-border-hover) 50%,
var(--background-primary) 40%
);
}
/* ============================== */

10
Types/WebviewElement.ts Normal file
View file

@ -0,0 +1,10 @@
export interface WebviewElement extends Element {
getURL(): string;
isLoading(): boolean;
capturePage(): Promise<NativeImage>
executeJavaScript(code: string): Promise<unknown>;
}
export interface NativeImage {
toDataURL(): string;
}

View file

@ -305,8 +305,10 @@ export class VaultkeeperAISettingTab extends PluginSettingTab {
private updateAllowUpdatingMemoriesSetting() {
if (this.allowUpdatingMemoriesToggleComponent && this.allowUpdatingMemoriesSetting) {
const enabled = this.settingsService.settings.enableMemories;
const updateEnabled = this.settingsService.settings.allowUpdatingMemories;
this.allowUpdatingMemoriesToggleComponent.disabled = !enabled;
this.allowUpdatingMemoriesSetting.settingEl.toggleClass("setting-item-memories-disabled", !enabled);
this.allowUpdatingMemoriesSetting.settingEl.toggleClass("setting-item-memories-disabled-accent", !enabled && updateEnabled);
this.allowUpdatingMemoriesSetting.settingEl.toggleClass("setting-item-memories-disabled", !enabled && !updateEnabled);
}
}