Refactor Composer: Implement @composer tool. (#1399)

This commit is contained in:
Wenzheng Jiang 2025-04-09 03:44:51 +09:00 committed by GitHub
parent b35290568a
commit 88599dc113
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 480 additions and 214 deletions

View file

@ -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;
}
}

View file

@ -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 {

View 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;
}
}
}

View file

@ -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;

View file

@ -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

View file

@ -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);

View file

@ -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} />);
}
}
});

View 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>
);
};

View file

@ -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"

View file

@ -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"
);
}
}

View file

@ -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 "";
}

View file

@ -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;