mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
Refactor Composer: Implement @composer tool. (#1399)
This commit is contained in:
parent
b35290568a
commit
88599dc113
12 changed files with 480 additions and 214 deletions
|
|
@ -83,27 +83,6 @@ export interface AutocompleteResponse {
|
|||
elapsed_time_ms: number;
|
||||
}
|
||||
|
||||
export interface ComposerPromptResponse {
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
export interface ComposerApplyResponse {
|
||||
content: string;
|
||||
}
|
||||
|
||||
// Define interface for the composerApply request
|
||||
export interface ComposerApplyRequest {
|
||||
target_note: {
|
||||
title: string;
|
||||
content: string;
|
||||
};
|
||||
chat_history: Array<{
|
||||
role: string;
|
||||
content: string;
|
||||
}>;
|
||||
markdown_block: string;
|
||||
}
|
||||
|
||||
export class BrevilabsClient {
|
||||
private static instance: BrevilabsClient;
|
||||
private pluginVersion: string = "Unknown";
|
||||
|
|
@ -299,33 +278,4 @@ export class BrevilabsClient {
|
|||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async composerPrompt(): Promise<ComposerPromptResponse> {
|
||||
const { data, error } = await this.makeRequest<ComposerPromptResponse>(
|
||||
"/composer/prompt",
|
||||
{},
|
||||
"GET"
|
||||
);
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
if (!data) {
|
||||
throw new Error("No data returned from composerPrompt");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async composerApply(request: ComposerApplyRequest): Promise<ComposerApplyResponse> {
|
||||
const { data, error } = await this.makeRequest<ComposerApplyResponse>(
|
||||
"/composer/apply",
|
||||
request
|
||||
);
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
if (!data) {
|
||||
throw new Error("No data returned from composerApply");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { getCurrentProject } from "@/aiParams";
|
||||
import { getStandaloneQuestion } from "@/chainUtils";
|
||||
import { getComposerSystemPrompt } from "@/composerUtils";
|
||||
import { Composer } from "./composer";
|
||||
import { getSystemPrompt } from "@/settings/model";
|
||||
import {
|
||||
ABORT_REASON,
|
||||
AI_SENDER,
|
||||
|
|
@ -88,10 +89,6 @@ abstract class BaseChainRunner implements ChainRunner {
|
|||
this.chainManager = chainManager;
|
||||
}
|
||||
|
||||
protected async getSystemPrompt(): Promise<string> {
|
||||
return getComposerSystemPrompt();
|
||||
}
|
||||
|
||||
abstract run(
|
||||
userMessage: ChatMessage,
|
||||
abortController: AbortController,
|
||||
|
|
@ -437,7 +434,6 @@ class CopilotPlusChainRunner extends BaseChainRunner {
|
|||
const messages: any[] = [];
|
||||
|
||||
// Add system message if available
|
||||
|
||||
let fullSystemMessage = await this.getSystemPrompt();
|
||||
|
||||
// Add chat history context to system message if exists
|
||||
|
|
@ -458,9 +454,8 @@ class CopilotPlusChainRunner extends BaseChainRunner {
|
|||
}
|
||||
|
||||
// Add chat history
|
||||
for (const [human, ai] of chatHistory) {
|
||||
messages.push({ role: "user", content: human });
|
||||
messages.push({ role: "assistant", content: ai });
|
||||
for (const entry of chatHistory) {
|
||||
messages.push({ role: entry.role, content: entry.content });
|
||||
}
|
||||
|
||||
// Get the current chat model
|
||||
|
|
@ -553,9 +548,9 @@ class CopilotPlusChainRunner extends BaseChainRunner {
|
|||
|
||||
if (debug) console.log("==== Step 1: Analyzing intent ====");
|
||||
let toolCalls;
|
||||
// Use the original message for intent analysis
|
||||
const messageForAnalysis = userMessage.originalMessage || userMessage.message;
|
||||
try {
|
||||
// Use the original message for intent analysis
|
||||
const messageForAnalysis = userMessage.originalMessage || userMessage.message;
|
||||
toolCalls = await IntentAnalyzer.analyzeIntent(messageForAnalysis);
|
||||
} catch (error: any) {
|
||||
return this.handleResponse(
|
||||
|
|
@ -580,15 +575,15 @@ class CopilotPlusChainRunner extends BaseChainRunner {
|
|||
(output) => output.tool === "localSearch" && output.output && output.output.length > 0
|
||||
);
|
||||
|
||||
// Format chat history from memory
|
||||
const memory = this.chainManager.memoryManager.getMemory();
|
||||
const memoryVariables = await memory.loadMemoryVariables({});
|
||||
const chatHistory = extractChatHistory(memoryVariables);
|
||||
|
||||
if (localSearchResult) {
|
||||
if (debug) console.log("==== Step 2: Processing local search results ====");
|
||||
const documents = JSON.parse(localSearchResult.output);
|
||||
|
||||
// Format chat history from memory
|
||||
const memory = this.chainManager.memoryManager.getMemory();
|
||||
const memoryVariables = await memory.loadMemoryVariables({});
|
||||
const chatHistory = extractChatHistory(memoryVariables);
|
||||
|
||||
if (debug) console.log("==== Step 3: Condensing Question ====");
|
||||
const standaloneQuestion = await getStandaloneQuestion(cleanedUserMessage, chatHistory);
|
||||
if (debug) console.log("Condensed standalone question: ", standaloneQuestion);
|
||||
|
|
@ -607,7 +602,7 @@ class CopilotPlusChainRunner extends BaseChainRunner {
|
|||
if (debug) console.log("==== Step 5: Invoking QA Chain ====");
|
||||
const qaPrompt = await this.chainManager.promptManager.getQAPrompt({
|
||||
question: enhancedQuestion,
|
||||
context: context,
|
||||
context,
|
||||
systemMessage: "", // System prompt is added separately in streamMultimodalResponse
|
||||
});
|
||||
|
||||
|
|
@ -622,16 +617,23 @@ class CopilotPlusChainRunner extends BaseChainRunner {
|
|||
// Append sources to the response
|
||||
sources = this.getSources(documents);
|
||||
} else {
|
||||
const enhancedUserMessage = this.prepareEnhancedUserMessage(
|
||||
cleanedUserMessage,
|
||||
toolOutputs
|
||||
);
|
||||
// Enhance with tool outputs.
|
||||
let enhancedUserMessage = this.prepareEnhancedUserMessage(cleanedUserMessage, toolOutputs);
|
||||
// If no results, default to LLM Chain
|
||||
if (debug) {
|
||||
console.log("No local search results. Using standard LLM Chain.");
|
||||
console.log("Enhanced user message:", enhancedUserMessage);
|
||||
}
|
||||
|
||||
// Enhance with composer output.
|
||||
if (messageForAnalysis.includes("@composer")) {
|
||||
enhancedUserMessage = await Composer.getInstance().prepareUserMessageWithComposerOutput(
|
||||
messageForAnalysis,
|
||||
enhancedUserMessage,
|
||||
chatHistory,
|
||||
debug
|
||||
);
|
||||
}
|
||||
fullAIResponse = await this.streamMultimodalResponse(
|
||||
enhancedUserMessage,
|
||||
userMessage,
|
||||
|
|
@ -765,6 +767,25 @@ class CopilotPlusChainRunner extends BaseChainRunner {
|
|||
? `Local Search Result for ${timeExpression}:\n${formattedDocs}`
|
||||
: `Local Search Result:\n${formattedDocs}`;
|
||||
}
|
||||
|
||||
protected async getSystemPrompt(): Promise<string> {
|
||||
// get current project
|
||||
const projectConfig = getCurrentProject();
|
||||
|
||||
if (!projectConfig) {
|
||||
return getSystemPrompt();
|
||||
}
|
||||
|
||||
// Get cached context synchronously
|
||||
const context = ProjectManager.instance.getProjectContext(projectConfig.id);
|
||||
let finalPrompt = projectConfig.systemPrompt;
|
||||
|
||||
if (context) {
|
||||
finalPrompt = `${finalPrompt}\n\n${context}`;
|
||||
}
|
||||
|
||||
return finalPrompt;
|
||||
}
|
||||
}
|
||||
|
||||
class ProjectChainRunner extends CopilotPlusChainRunner {
|
||||
|
|
|
|||
251
src/LLMProviders/composer.ts
Normal file
251
src/LLMProviders/composer.ts
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
import { ChatHistoryEntry } from "@/utils";
|
||||
import { BaseChatModelCallOptions } from "@langchain/core/language_models/chat_models";
|
||||
import ProjectManager from "./projectManager";
|
||||
import { Change, diffTrimmedLines } from "diff";
|
||||
import { App, TFile } from "obsidian";
|
||||
import { StructuredOutputParser } from "langchain/output_parsers";
|
||||
import { z } from "zod";
|
||||
|
||||
interface ComposerNote {
|
||||
note_path: string;
|
||||
note_content: string;
|
||||
}
|
||||
|
||||
interface ComposerResponse {
|
||||
notes?: ComposerNote[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class Composer {
|
||||
private static instance: Composer;
|
||||
private static changesMap: { [key: string]: Change[] } = {};
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): Composer {
|
||||
if (!Composer.instance) {
|
||||
Composer.instance = new Composer();
|
||||
}
|
||||
return Composer.instance;
|
||||
}
|
||||
|
||||
public static getChanges(notePath: string): Change[] {
|
||||
return Composer.changesMap[notePath] || [];
|
||||
}
|
||||
|
||||
// Group changes into blocks for better UI presentation
|
||||
public static getChangeBlocks(changes: Change[]): Change[][] {
|
||||
const blocks: Change[][] = [];
|
||||
let currentBlock: Change[] = [];
|
||||
|
||||
changes.forEach((change) => {
|
||||
if (change.added || change.removed) {
|
||||
currentBlock.push(change);
|
||||
} else {
|
||||
if (currentBlock.length > 0) {
|
||||
blocks.push(currentBlock);
|
||||
currentBlock = [];
|
||||
}
|
||||
blocks.push([change]);
|
||||
}
|
||||
});
|
||||
if (currentBlock.length > 0) {
|
||||
blocks.push(currentBlock);
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
private async composerResponse(
|
||||
content: string,
|
||||
chatHistory: ChatHistoryEntry[],
|
||||
debug: boolean
|
||||
): Promise<ComposerResponse> {
|
||||
const composerSystemPrompt = `
|
||||
You are a helpful assistant that creates markdown notes for obsidian users.
|
||||
|
||||
# Task
|
||||
Your task is to generate one or more markdown notes:
|
||||
1. For editing existing notes - Return the updated markdown note content and the original note path.
|
||||
2. For creating new notes - Return the new markdown note content and the new note path based on user's request.
|
||||
3. If user's request is not clear, such as the note path is not provided, return an error message.
|
||||
4. Do no include the title to the note content.
|
||||
5. You can return multiple notes if the user's request involves creating multiple notes.
|
||||
|
||||
# Important JSON Formatting Rules
|
||||
1. All newlines in string values must be escaped as \\n
|
||||
2. The response must be valid JSON that can be parsed
|
||||
|
||||
Below is the chat history the user and the previous assistant have had. You should continue the conversation if necessary but respond in a different format.
|
||||
<CHAT_HISTORY>
|
||||
${chatHistory.map((entry) => `${entry.role}: ${entry.content}`).join("\n")}
|
||||
</CHAT_HISTORY>`;
|
||||
|
||||
// Define the schema for the output
|
||||
const schema = z.object({
|
||||
notes: z
|
||||
.array(
|
||||
z.object({
|
||||
note_path: z.string().refine((val) => val.endsWith(".md"), {
|
||||
message: "note_path must end with .md",
|
||||
}),
|
||||
note_content: z.string(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
// Create the output parser
|
||||
const parser = StructuredOutputParser.fromZodSchema(schema);
|
||||
|
||||
const messages: any[] = [
|
||||
{
|
||||
role: "system",
|
||||
content: composerSystemPrompt + "\n\n" + parser.getFormatInstructions(),
|
||||
},
|
||||
];
|
||||
|
||||
// Get the current chat model
|
||||
const chatModel = ProjectManager.instance
|
||||
.getCurrentChainManager()
|
||||
.chatModelManager.getChatModel()
|
||||
.bind({
|
||||
temperature: 0,
|
||||
maxTokens: 16000,
|
||||
} as BaseChatModelCallOptions);
|
||||
|
||||
// Add current user message
|
||||
messages.push({
|
||||
role: "user",
|
||||
content: content,
|
||||
});
|
||||
|
||||
if (debug) {
|
||||
console.log("==== Composer Request ====\n", messages);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await chatModel.invoke(messages);
|
||||
const responseContent =
|
||||
typeof response.content === "string" ? response.content : JSON.stringify(response.content);
|
||||
console.log("==== Composer Response ====\n", responseContent);
|
||||
return await parser.parse(responseContent);
|
||||
} catch (error) {
|
||||
console.error("Error parsing composer response:", error);
|
||||
return {
|
||||
error: `Error parsing composer response: ${error.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async prepareUserMessageWithComposerOutput(
|
||||
originalMessage: string,
|
||||
messageWithContext: string,
|
||||
chatHistory: ChatHistoryEntry[],
|
||||
debug: boolean
|
||||
): Promise<string> {
|
||||
const composerOutput = await this.composerResponse(messageWithContext, chatHistory, debug);
|
||||
if (debug) {
|
||||
console.log("==== Composer Output ====\n", composerOutput);
|
||||
}
|
||||
if (!composerOutput.error) {
|
||||
const notes = composerOutput.notes || [];
|
||||
const changesMarkdownPromises = notes.map(async (note: ComposerNote) => {
|
||||
const changesMarkdown = await this.getChangesMarkdown(
|
||||
app,
|
||||
note.note_path,
|
||||
note.note_content,
|
||||
debug
|
||||
);
|
||||
return `\`\`\`markdown
|
||||
<!-- path=${note.note_path} -->
|
||||
${changesMarkdown}
|
||||
\`\`\``;
|
||||
});
|
||||
const changesMarkdownBlocks = await Promise.all(changesMarkdownPromises);
|
||||
|
||||
return `User message: ${originalMessage}
|
||||
|
||||
The user is calling @composer tool to generate note content. Below are the markdown blocks representing the changes from the @composer output:
|
||||
|
||||
${changesMarkdownBlocks.join("\n\n")}
|
||||
|
||||
Return the markdown blocks above directly and add a brief summary of the changes at the end.`;
|
||||
} else {
|
||||
return `User message: ${originalMessage}
|
||||
|
||||
The user is calling @composer tool to generate note content. However, the note content was not created successfully. Below is the output
|
||||
|
||||
${composerOutput.error}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Break content into lines and wrap each line in ~~
|
||||
private strikeThrough(content: string): string {
|
||||
const lines = content.trim().split("\n");
|
||||
return lines.map((line) => "- " + line).join("\n");
|
||||
}
|
||||
|
||||
// Get relevant changes only and combine them into a single markdown block
|
||||
private getRelevantChangesMarkdown(blocks: Change[][]): string {
|
||||
const renderedChanges = blocks
|
||||
.map((block) => {
|
||||
const hasAddedChanges = block.some((change) => change.added);
|
||||
const hasRemovedChanges = block.some((change) => change.removed);
|
||||
let blockChange = "";
|
||||
if (hasAddedChanges) {
|
||||
blockChange = block.map((change) => (change.added ? change.value : "")).join("\n");
|
||||
} else if (hasRemovedChanges) {
|
||||
blockChange = block
|
||||
.map((change) => (change.removed ? this.strikeThrough(change.value) : ""))
|
||||
.join("\n");
|
||||
} else {
|
||||
blockChange = "...";
|
||||
}
|
||||
return blockChange;
|
||||
})
|
||||
.join("\n");
|
||||
return renderedChanges;
|
||||
}
|
||||
|
||||
private async getChangesMarkdown(
|
||||
app: App,
|
||||
path: string,
|
||||
newContent: string,
|
||||
debug: boolean = false
|
||||
): Promise<string> {
|
||||
try {
|
||||
// Get the file from the path
|
||||
const file = app.vault.getAbstractFileByPath(path);
|
||||
if (!file) {
|
||||
// If the file does not exist, return the newContent directly
|
||||
return newContent;
|
||||
}
|
||||
|
||||
if (!(file instanceof TFile)) {
|
||||
throw new Error(`Path is not a file: ${path}`);
|
||||
}
|
||||
|
||||
// Read the original content
|
||||
const originalContent = await app.vault.read(file);
|
||||
// Get the diff
|
||||
const changes = diffTrimmedLines(originalContent, newContent, { newlineIsToken: true });
|
||||
// Cache the changes to be used by the Apply view.
|
||||
Composer.changesMap[path] = changes;
|
||||
|
||||
if (debug) {
|
||||
console.log("==== Changes ====\n", changes);
|
||||
}
|
||||
|
||||
// Group changes into blocks
|
||||
const blocks = Composer.getChangeBlocks(changes);
|
||||
|
||||
// Process blocks into markdown
|
||||
const markdownChanges = this.getRelevantChangesMarkdown(blocks);
|
||||
|
||||
return markdownChanges;
|
||||
} catch (error) {
|
||||
console.error("Error getting changes markdown:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ import { Vault } from "obsidian";
|
|||
import ProjectManager from "@/LLMProviders/projectManager";
|
||||
|
||||
// TODO: Add @index with explicit pdf files in chat context menu
|
||||
export const COPILOT_TOOL_NAMES = ["@vault", "@web", "@youtube", "@pomodoro"];
|
||||
export const COPILOT_TOOL_NAMES = ["@vault", "@composer", "@web", "@youtube", "@pomodoro"];
|
||||
|
||||
type ToolCall = {
|
||||
tool: any;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { removeThinkTags } from "@/utils";
|
||||
import { removeThinkTags, ChatHistoryEntry } from "@/utils";
|
||||
import { BaseChatModelCallOptions } from "@langchain/core/language_models/chat_models";
|
||||
import ProjectManager from "@/LLMProviders/projectManager";
|
||||
|
||||
export async function getStandaloneQuestion(
|
||||
question: string,
|
||||
chatHistory: [string, string][]
|
||||
chatHistory: ChatHistoryEntry[]
|
||||
): Promise<string> {
|
||||
const condenseQuestionTemplate = `Given the following conversation and a follow up question,
|
||||
summarize the conversation as context and keep the follow up question unchanged, in its original language.
|
||||
|
|
@ -19,7 +19,7 @@ export async function getStandaloneQuestion(
|
|||
Standalone question:`;
|
||||
|
||||
const formattedChatHistory = chatHistory
|
||||
.map(([human, ai]) => `Human: ${human}\nAssistant: ${ai}`)
|
||||
.map(({ role, content }) => `${role}: ${content}`)
|
||||
.join("\n");
|
||||
|
||||
const chatModel = ProjectManager.instance
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import { ChatControls } from "@/components/chat-components/ChatControls";
|
|||
import ChatInput from "@/components/chat-components/ChatInput";
|
||||
import ChatMessages from "@/components/chat-components/ChatMessages";
|
||||
import { ProjectList } from "@/components/chat-components/ProjectList";
|
||||
import { resetComposerPromptCache } from "@/composerUtils";
|
||||
import { ABORT_REASON, COMMAND_IDS, EVENT_NAMES, LOADING_MESSAGES, USER_SENDER } from "@/constants";
|
||||
import { AppContext, EventTargetContext } from "@/context";
|
||||
import { ContextProcessor } from "@/contextProcessor";
|
||||
|
|
@ -600,8 +599,6 @@ ${chatContent}`;
|
|||
}
|
||||
clearMessages();
|
||||
chainManager.memoryManager.clearChatMemory();
|
||||
// Reset the composer prompt cache when starting a new chat
|
||||
resetComposerPromptCache();
|
||||
setCurrentAiMessage("");
|
||||
setContextNotes([]);
|
||||
setIncludeActiveNote(false);
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { Bot, User } from "lucide-react";
|
|||
import { App, Component, MarkdownRenderer, MarkdownView, TFile } from "obsidian";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createRoot, Root } from "react-dom/client";
|
||||
import { CodeBlock } from "./CodeBlock";
|
||||
import { ComposerCodeBlock } from "./ComposerCodeBlock";
|
||||
|
||||
function MessageContext({ context }: { context: ChatMessage["context"] }) {
|
||||
if (!context || (context.notes.length === 0 && context.urls.length === 0)) {
|
||||
|
|
@ -255,13 +255,7 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
|
|||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
if (!isUnmounting) {
|
||||
root.render(
|
||||
<CodeBlock
|
||||
code={cleanedCode}
|
||||
path={path}
|
||||
onApply={isStreaming ? undefined : handleApplyCode}
|
||||
/>
|
||||
);
|
||||
root.render(<ComposerCodeBlock path={path} code={cleanedCode} />);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
105
src/components/chat-components/ComposerCodeBlock.tsx
Normal file
105
src/components/chat-components/ComposerCodeBlock.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Check } from "lucide-react";
|
||||
import { APPLY_VIEW_TYPE } from "@/components/composer/ApplyView";
|
||||
import { Composer } from "@/LLMProviders/composer";
|
||||
import { logError } from "@/logger";
|
||||
import { Notice } from "obsidian";
|
||||
import { TFile } from "obsidian";
|
||||
|
||||
interface ComposerCodeBlockProps {
|
||||
code: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export const ComposerCodeBlock: React.FC<ComposerCodeBlockProps> = ({ path, code }) => {
|
||||
const handleApply = async () => {
|
||||
if (!path) return;
|
||||
|
||||
try {
|
||||
const changes = Composer.getChanges(path);
|
||||
let file = app.vault.getAbstractFileByPath(path);
|
||||
|
||||
let isNewFile = false;
|
||||
|
||||
// If file doesn't exist, create it
|
||||
if (!file) {
|
||||
try {
|
||||
// Create the folder if it doesn't exist
|
||||
if (path.includes("/")) {
|
||||
const folderPath = path.split("/").slice(0, -1).join("/");
|
||||
const folder = app.vault.getAbstractFileByPath(folderPath);
|
||||
if (!folder) {
|
||||
await app.vault.createFolder(folderPath);
|
||||
}
|
||||
}
|
||||
file = await app.vault.create(path, code);
|
||||
if (file) {
|
||||
new Notice(`Created new file: ${path}`);
|
||||
isNewFile = true;
|
||||
} else {
|
||||
new Notice(`Failed to create file: ${path}`);
|
||||
return;
|
||||
}
|
||||
|
||||
isNewFile = true;
|
||||
} catch (createError) {
|
||||
logError("Error creating file:", createError);
|
||||
new Notice(`Failed to create file: ${createError.message}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!(file instanceof TFile)) {
|
||||
new Notice(`Path is not a file: ${path}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the current active note is the same as the target note
|
||||
const activeFile = app.workspace.getActiveFile();
|
||||
if (!activeFile || activeFile.path !== path) {
|
||||
// If not, open the target file in the current leaf
|
||||
await app.workspace.getLeaf().openFile(file);
|
||||
new Notice(`Switched to ${file.name}`);
|
||||
}
|
||||
|
||||
// If the file is newly created, don't show the apply view
|
||||
if (isNewFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Open the Apply View in a new leaf with the processed content
|
||||
const leaf = app.workspace.getLeaf(true);
|
||||
await leaf.setViewState({
|
||||
type: APPLY_VIEW_TYPE,
|
||||
active: true,
|
||||
state: {
|
||||
changes,
|
||||
path,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logError("Error calling composer apply:", error);
|
||||
new Notice(`Error processing code: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border border-border border-solid rounded-md my-2 flex flex-col overflow-hidden">
|
||||
{path && (
|
||||
<div className="flex justify-between items-center border-[0px] border-b border-border border-solid gap-2 p-2 overflow-hidden">
|
||||
<div className="text-xs p-1 text-muted-foreground truncate flex-1">{path}</div>
|
||||
{
|
||||
<Button className="text-muted" variant="ghost2" size="fit" onClick={handleApply}>
|
||||
<Check className="h-4 w-4" />
|
||||
Apply
|
||||
</Button>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
<pre className="m-0 border-none">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,20 +1,19 @@
|
|||
import { ApplyChangesConfirmModal } from "@/components/modals/ApplyChangesConfirmModal";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { logError } from "@/logger";
|
||||
import { Change, diffLines } from "diff";
|
||||
import { Change } from "diff";
|
||||
import { Check, X as XIcon } from "lucide-react";
|
||||
import { App, ItemView, Notice, TFile, WorkspaceLeaf } from "obsidian";
|
||||
import React, { useMemo, useRef, useState } from "react";
|
||||
import React, { useRef } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Button } from "../ui/button";
|
||||
import { Composer } from "@/LLMProviders/composer";
|
||||
import { useState } from "react";
|
||||
|
||||
export const APPLY_VIEW_TYPE = "obsidian-copilot-apply-view";
|
||||
|
||||
export interface ApplyViewState {
|
||||
file: TFile;
|
||||
originalContent: string;
|
||||
newContent: string;
|
||||
path?: string;
|
||||
changes: Change[];
|
||||
path: string;
|
||||
}
|
||||
|
||||
// Extended Change interface to track user decisions
|
||||
|
|
@ -80,64 +79,25 @@ interface ApplyViewRootProps {
|
|||
}
|
||||
|
||||
const ApplyViewRoot: React.FC<ApplyViewRootProps> = ({ app, state, close }) => {
|
||||
// Initialize diff with extended properties - moved before conditional
|
||||
const [diff, setDiff] = useState<ExtendedChange[]>(() => {
|
||||
if (!state?.originalContent || !state?.newContent) {
|
||||
return [];
|
||||
}
|
||||
const initialDiff = diffLines(state.originalContent, state.newContent);
|
||||
return initialDiff.map((change) => ({
|
||||
return state.changes.map((change) => ({
|
||||
...change,
|
||||
accepted: null, // Start with null (undecided)
|
||||
}));
|
||||
});
|
||||
|
||||
const undecidedChanges = diff.filter(
|
||||
(change) => (change.added || change.removed) && change.accepted === null
|
||||
);
|
||||
const hasAnyDecidedChanges = diff.some((change) => change.accepted !== null);
|
||||
|
||||
// Group changes into blocks for better UI presentation
|
||||
const changeBlocks = useMemo(() => {
|
||||
if (!diff.length) return;
|
||||
|
||||
const blocks: ExtendedChange[][] = [];
|
||||
let currentBlock: ExtendedChange[] = [];
|
||||
let inChangeBlock = false;
|
||||
|
||||
diff.forEach((change) => {
|
||||
if (change.added || change.removed) {
|
||||
if (!inChangeBlock) {
|
||||
inChangeBlock = true;
|
||||
currentBlock = [];
|
||||
}
|
||||
currentBlock.push(change);
|
||||
} else {
|
||||
if (inChangeBlock) {
|
||||
blocks.push([...currentBlock]);
|
||||
currentBlock = [];
|
||||
inChangeBlock = false;
|
||||
}
|
||||
blocks.push([change]);
|
||||
}
|
||||
});
|
||||
|
||||
if (currentBlock.length > 0) {
|
||||
blocks.push(currentBlock);
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}, [diff]);
|
||||
const changeBlocks = Composer.getChangeBlocks(diff);
|
||||
|
||||
// Add refs to track change blocks
|
||||
const blockRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
|
||||
// Add defensive check for state after hooks
|
||||
if (!state || !state.originalContent || !state.newContent) {
|
||||
if (!state || !state.changes) {
|
||||
logError("Invalid state:", state);
|
||||
return (
|
||||
<div className="flex flex-col h-full items-center justify-center">
|
||||
<div className="text-error">Error: Invalid state - missing content</div>
|
||||
<div className="text-error">Error: Invalid state - missing changes</div>
|
||||
<Button onClick={close} className="mt-4">
|
||||
Close
|
||||
</Button>
|
||||
|
|
@ -145,52 +105,15 @@ const ApplyViewRoot: React.FC<ApplyViewRootProps> = ({ app, state, close }) => {
|
|||
);
|
||||
}
|
||||
|
||||
// Handle applying changes that have been marked as accepted
|
||||
const handleApply = async () => {
|
||||
try {
|
||||
const applyChanges = async () => {
|
||||
const newContent = diff
|
||||
.filter((change) => {
|
||||
if (change.added) return change.accepted === true;
|
||||
else if (change.removed) return change.accepted === false;
|
||||
return true; // Unchanged lines are always included
|
||||
})
|
||||
.map((change) => change.value)
|
||||
.join("");
|
||||
|
||||
await app.vault.modify(state.file, newContent);
|
||||
new Notice("Changes applied successfully");
|
||||
close();
|
||||
};
|
||||
|
||||
if (undecidedChanges.length > 0) {
|
||||
// Divide by 2 because each change has a pair of added and removed lines
|
||||
const modal = new ApplyChangesConfirmModal(app, undecidedChanges.length / 2, () => {
|
||||
applyChanges();
|
||||
});
|
||||
modal.open();
|
||||
return;
|
||||
}
|
||||
|
||||
applyChanges();
|
||||
} catch (error) {
|
||||
logError("Error applying changes:", error);
|
||||
new Notice(`Error applying changes: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Apply all changes regardless of whether they have been marked as accepted
|
||||
const handleAcceptAll = async () => {
|
||||
const handleAccept = async () => {
|
||||
try {
|
||||
const newContent = diff
|
||||
.filter((change) => {
|
||||
return change.added || !change.removed;
|
||||
})
|
||||
.map((change) => change.value)
|
||||
.join("");
|
||||
await app.vault.modify(state.file, newContent);
|
||||
new Notice("Changes applied successfully");
|
||||
close();
|
||||
// Mark all undecided changes as accepted
|
||||
const updatedDiff = diff.map((change) =>
|
||||
change.accepted === null ? { ...change, accepted: true } : change
|
||||
);
|
||||
|
||||
await applyDecidedChangesToFile(updatedDiff);
|
||||
} catch (error) {
|
||||
logError("Error applying changes:", error);
|
||||
new Notice(`Error applying changes: ${error.message}`);
|
||||
|
|
@ -198,7 +121,41 @@ const ApplyViewRoot: React.FC<ApplyViewRootProps> = ({ app, state, close }) => {
|
|||
};
|
||||
|
||||
// Handle rejecting all changes
|
||||
const handleReject = () => {
|
||||
const handleReject = async () => {
|
||||
try {
|
||||
// Mark all undecided changes as rejected
|
||||
const updatedDiff = diff.map((change) =>
|
||||
change.accepted === null ? { ...change, accepted: false } : change
|
||||
);
|
||||
|
||||
await applyDecidedChangesToFile(updatedDiff);
|
||||
} catch (error) {
|
||||
logError("Error applying changes:", error);
|
||||
new Notice(`Error applying changes: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Shared function to apply changes to file
|
||||
const applyDecidedChangesToFile = async (updatedDiff: ExtendedChange[]) => {
|
||||
// Apply changes based on their accepted status
|
||||
const newContent = updatedDiff
|
||||
.filter((change) => {
|
||||
if (change.added) return change.accepted === true; // Include if accepted
|
||||
if (change.removed) return change.accepted === false; // Include if rejected
|
||||
return true; // Keep unchanged lines
|
||||
})
|
||||
.map((change) => change.value)
|
||||
.join("");
|
||||
|
||||
const file = app.vault.getAbstractFileByPath(state.path);
|
||||
if (!file || !(file instanceof TFile)) {
|
||||
new Notice("File not found:" + state.path);
|
||||
close();
|
||||
return;
|
||||
}
|
||||
|
||||
await app.vault.modify(file, newContent);
|
||||
new Notice("Changes applied successfully");
|
||||
close();
|
||||
};
|
||||
|
||||
|
|
@ -212,7 +169,7 @@ const ApplyViewRoot: React.FC<ApplyViewRootProps> = ({ app, state, close }) => {
|
|||
const block = changeBlocks[i];
|
||||
const hasChanges = block.some((change) => change.added || change.removed);
|
||||
const isUndecided = block.some(
|
||||
(change) => (change.added || change.removed) && change.accepted === null
|
||||
(change) => (change.added || change.removed) && (change as ExtendedChange).accepted === null
|
||||
);
|
||||
|
||||
if (hasChanges && isUndecided) {
|
||||
|
|
@ -284,23 +241,15 @@ const ApplyViewRoot: React.FC<ApplyViewRootProps> = ({ app, state, close }) => {
|
|||
<div className="flex z-[1] gap-2 fixed bottom-2 left-1/2 -translate-x-1/2 p-2 border border-solid border-border rounded-md bg-secondary">
|
||||
<Button variant="destructive" size="sm" onClick={handleReject}>
|
||||
<XIcon className="size-4" />
|
||||
Reject All
|
||||
Reject
|
||||
</Button>
|
||||
<Button variant="success" size="sm" onClick={handleAcceptAll}>
|
||||
<Button variant="success" size="sm" onClick={handleAccept}>
|
||||
<Check className="size-4" />
|
||||
Accept All
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!hasAnyDecidedChanges}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleApply}
|
||||
>
|
||||
Apply Changes
|
||||
Accept
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center p-2 border-[0px] border-solid border-b border-border text-sm font-medium">
|
||||
{state.path || state.file.path}
|
||||
{state.path}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-2">
|
||||
|
|
@ -311,11 +260,14 @@ const ApplyViewRoot: React.FC<ApplyViewRootProps> = ({ app, state, close }) => {
|
|||
// Get the decision status for this block
|
||||
const blockStatus = hasChanges
|
||||
? block.every(
|
||||
(change) => (!change.added && !change.removed) || change.accepted === true
|
||||
(change) =>
|
||||
(!change.added && !change.removed) || (change as ExtendedChange).accepted === true
|
||||
)
|
||||
? "accepted"
|
||||
: block.every(
|
||||
(change) => (!change.added && !change.removed) || change.accepted === false
|
||||
(change) =>
|
||||
(!change.added && !change.removed) ||
|
||||
(change as ExtendedChange).accepted === false
|
||||
)
|
||||
? "rejected"
|
||||
: "undecided"
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
import { App } from "obsidian";
|
||||
import { ConfirmModal } from "./ConfirmModal";
|
||||
|
||||
export class ApplyChangesConfirmModal extends ConfirmModal {
|
||||
constructor(app: App, unDecidedChanges: number, onConfirm: () => void) {
|
||||
super(
|
||||
app,
|
||||
onConfirm,
|
||||
`There are ${unDecidedChanges} changes that have not been decided. Are you sure you want to skip these changes?`,
|
||||
"Apply Changes",
|
||||
"Confirm",
|
||||
"Back to Edit"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,8 @@ export const getToolDescription = (tool: string): string => {
|
|||
return "Get the transcript of a YouTube video. Example: @youtube <video_url>";
|
||||
case "@pomodoro":
|
||||
return "Start a pomodoro timer. Example: @pomodoro 25m";
|
||||
case "@composer":
|
||||
return "Edit existing notes or create new notes.";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
|
|
|||
15
src/utils.ts
15
src/utils.ts
|
|
@ -354,14 +354,23 @@ export function getSendChatContextNotesPrompt(
|
|||
);
|
||||
}
|
||||
|
||||
export function extractChatHistory(memoryVariables: MemoryVariables): [string, string][] {
|
||||
const chatHistory: [string, string][] = [];
|
||||
export interface ChatHistoryEntry {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function extractChatHistory(memoryVariables: MemoryVariables): ChatHistoryEntry[] {
|
||||
const chatHistory: ChatHistoryEntry[] = [];
|
||||
const { history } = memoryVariables;
|
||||
|
||||
for (let i = 0; i < history.length; i += 2) {
|
||||
const userMessage = history[i]?.content || "";
|
||||
const aiMessage = history[i + 1]?.content || "";
|
||||
chatHistory.push([userMessage, aiMessage]);
|
||||
|
||||
chatHistory.push(
|
||||
{ role: "user", content: userMessage },
|
||||
{ role: "assistant", content: aiMessage }
|
||||
);
|
||||
}
|
||||
|
||||
return chatHistory;
|
||||
|
|
|
|||
Loading…
Reference in a new issue