Implement getFileTree intent (#1249)

* Add FileTreeTools.ts
* Output file tree in JSON format
* Optimize file tree output
This commit is contained in:
Wenzheng Jiang 2025-02-14 16:32:25 +09:00 committed by GitHub
parent be36c964fa
commit 79b3dcdf39
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 200 additions and 10 deletions

View file

@ -632,6 +632,8 @@ class CopilotPlusChainRunner extends BaseChainRunner {
updateLoadingMessage?.(LOADING_MESSAGES.READING_FILES);
} else if (toolCall.tool.name === "webSearch") {
updateLoadingMessage?.(LOADING_MESSAGES.SEARCHING_WEB);
} else if (toolCall.tool.name === "getFileTree") {
updateLoadingMessage?.(LOADING_MESSAGES.READING_FILE_TREE);
}
const output = await ToolManager.callTool(toolCall.tool, toolCall.args);
toolOutputs.push({ tool: toolCall.tool.name, output });

View file

@ -6,11 +6,13 @@ import {
pomodoroTool,
TimeInfo,
} from "@/tools/TimeTools";
import { createGetFileTreeTool } from "@/tools/FileTreeTools";
import { simpleYoutubeTranscriptionTool } from "@/tools/YoutubeTools";
import { ToolManager } from "@/tools/toolManager";
import { extractChatHistory, extractYoutubeUrl } from "@/utils";
import { BrevilabsClient } from "./brevilabsClient";
import MemoryManager from "./memoryManager";
import { Vault } from "obsidian";
// TODO: Add @index with explicit pdf files in chat context menu
export const COPILOT_TOOL_NAMES = ["@vault", "@web", "@youtube", "@pomodoro"];
@ -21,16 +23,23 @@ type ToolCall = {
};
export class IntentAnalyzer {
private static tools = [
getCurrentTimeTool,
getTimeInfoByEpochTool,
getTimeRangeMsTool,
localSearchTool,
indexTool,
pomodoroTool,
webSearchTool,
simpleYoutubeTranscriptionTool,
];
private static tools: any[] = [];
static initTools(vault: Vault) {
if (this.tools.length === 0) {
this.tools = [
getCurrentTimeTool,
getTimeInfoByEpochTool,
getTimeRangeMsTool,
localSearchTool,
indexTool,
pomodoroTool,
webSearchTool,
simpleYoutubeTranscriptionTool,
createGetFileTreeTool(vault.getRoot()),
];
}
}
static async analyzeIntent(originalMessage: string): Promise<ToolCall[]> {
try {

View file

@ -29,6 +29,7 @@ export const LOADING_MESSAGES = {
DEFAULT: "",
READING_FILES: "Reading files",
SEARCHING_WEB: "Searching the web",
READING_FILE_TREE: "Reading file tree",
};
export const PLUS_UTM_MEDIUMS = {
SETTINGS: "settings",

View file

@ -31,6 +31,7 @@ import {
TFolder,
WorkspaceLeaf,
} from "obsidian";
import { IntentAnalyzer } from "./LLMProviders/intentAnalyzer";
export default class CopilotPlugin extends Plugin {
// A chat history that stores the messages sent and received
@ -80,6 +81,8 @@ export default class CopilotPlugin extends Plugin {
registerBuiltInCommands(this);
IntentAnalyzer.initTools(this.app.vault);
this.registerEvent(
this.app.workspace.on("editor-menu", (menu: Menu, editor: Editor) => {
const selectedText = editor.getSelection().trim();

View file

@ -0,0 +1,120 @@
import { TFolder } from "obsidian";
import { createGetFileTreeTool } from "./FileTreeTools";
// Mock TFile class
class MockTFile {
vault: any;
stat: { ctime: number; mtime: number; size: number };
basename: string;
extension: string;
name: string;
parent: TFolder;
path: string;
constructor(path: string, parent: TFolder) {
this.path = path;
this.name = path.split("/").pop() || "";
this.basename = this.name.split(".")[0];
this.extension = this.name.split(".")[1] || "";
this.parent = parent;
this.vault = {};
this.stat = {
ctime: Date.now(),
mtime: Date.now(),
size: 0,
};
}
}
// Mock TFolder class
class MockTFolder {
vault: any;
name: string;
path: string;
parent: TFolder | null;
children: Array<MockTFile | MockTFolder>;
constructor(path: string, parent: TFolder | null) {
this.path = path;
this.name = path.split("/").pop() || "";
this.parent = parent;
this.vault = {};
this.children = [];
}
isRoot(): boolean {
return this.parent === null;
}
}
describe("FileTreeTools", () => {
let root: MockTFolder;
beforeEach(() => {
root = new MockTFolder("", null);
// Create a mock file structure
const docs = new MockTFolder("docs", root);
const projects = new MockTFolder("docs/projects", docs);
const notes = new MockTFolder("docs/notes", docs);
docs.children = [projects, notes, new MockTFile("docs/readme.md", docs)];
projects.children = [
new MockTFile("docs/projects/project1.md", projects),
new MockTFile("docs/projects/project2.md", projects),
];
notes.children = [
new MockTFile("docs/notes/note1.md", notes),
new MockTFile("docs/notes/note2.md", notes),
];
root.children = [docs];
});
it("should generate correct JSON file tree representation", async () => {
const tool = createGetFileTreeTool(root);
const result = await tool.invoke({});
const parsedResult = JSON.parse(result);
const expected = {
path: "",
children: [
{
path: "docs",
children: [
{
path: "docs/projects",
children: [
{ path: "docs/projects/project1.md" },
{ path: "docs/projects/project2.md" },
],
},
{
path: "docs/notes",
children: [{ path: "docs/notes/note1.md" }, { path: "docs/notes/note2.md" }],
},
{ path: "docs/readme.md" },
],
},
],
};
expect(parsedResult).toEqual(expected);
});
it("should handle empty folder", async () => {
const emptyRoot = new MockTFolder("", null);
const tool = createGetFileTreeTool(emptyRoot);
const result = await tool.invoke({});
const parsedResult = JSON.parse(result);
const expected = {
path: "",
children: [],
};
expect(parsedResult).toEqual(expected);
});
});

View file

@ -0,0 +1,55 @@
import { tool } from "@langchain/core/tools";
import { TFile, TFolder } from "obsidian";
import { z } from "zod";
interface FileTreeNode {
path: string;
children?: FileTreeNode[];
}
function isTFolder(item: any): item is TFolder {
return "children" in item && "path" in item;
}
function isTFile(item: any): item is TFile {
return "path" in item && !("children" in item);
}
function buildFileTree(folder: TFolder): FileTreeNode {
const node: FileTreeNode = {
path: folder.path,
children: [],
};
// Add folders first
for (const child of folder.children) {
if (isTFolder(child)) {
node.children?.push(buildFileTree(child));
}
}
// Then add files
for (const child of folder.children) {
if (isTFile(child)) {
node.children?.push({
path: child.path,
});
}
}
return node;
}
const createGetFileTreeTool = (root: TFolder) =>
tool(
async () => {
return JSON.stringify(buildFileTree(root), null, 0);
},
{
name: "getFileTree",
description: "Get the complete file tree structure of the folder as JSON",
schema: z.object({}),
}
);
export { createGetFileTreeTool, type FileTreeNode };