mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
Compare commits
99 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbf59ff911 | ||
|
|
cd6a74ae1c | ||
|
|
79ab34e92d | ||
|
|
6ac8a62f4a | ||
|
|
56f3ed57c1 | ||
|
|
82dab77d74 | ||
|
|
6b5d51df39 | ||
|
|
0b54cdcc10 | ||
|
|
f4c3b5b826 | ||
|
|
cc45949eba | ||
|
|
7757800127 | ||
|
|
6b31e3d4e9 | ||
|
|
10ddb1da28 | ||
|
|
bfa8360037 | ||
|
|
1662a7c671 | ||
|
|
329052e032 | ||
|
|
7b22c26718 | ||
|
|
bcc7ce12ed | ||
|
|
1e02b2d703 | ||
|
|
4811e89535 | ||
|
|
1c5834e858 | ||
|
|
f57438dd54 | ||
|
|
fa92e15b8c | ||
|
|
4f57798295 | ||
|
|
69d7a1df16 | ||
|
|
afaa530f8b | ||
|
|
f74286f3c8 | ||
|
|
13a09cdb89 | ||
|
|
fc435caae2 | ||
|
|
0d21c1e3e0 | ||
|
|
938837c9c1 | ||
|
|
d31c92b35c | ||
|
|
0372b9a182 | ||
|
|
2131ad0edd | ||
|
|
78644a0958 | ||
|
|
6835c9167d | ||
|
|
cf037a09de | ||
|
|
9457fa5b44 | ||
|
|
9f6994e30d | ||
|
|
1390fafe86 | ||
|
|
d500214e2e | ||
|
|
31812c80ad | ||
|
|
c36e99540c | ||
|
|
2d40fac44f | ||
|
|
212c782c55 | ||
|
|
e5a9e4a24b | ||
|
|
ca00242cd3 | ||
|
|
e3c66bd4af | ||
|
|
569d938bdf | ||
|
|
7e13e00fde | ||
|
|
ba364c2d81 | ||
|
|
3b9d5e2c3c | ||
|
|
a77c5b3339 | ||
|
|
72bf43a8ea | ||
|
|
68a918338e | ||
|
|
4763199f16 | ||
|
|
84001a88d4 | ||
|
|
e14c711837 | ||
|
|
9ed0646b4a | ||
|
|
c0e1a4e03e | ||
|
|
e8e3dcd363 | ||
|
|
713d82d5bf | ||
|
|
1a0c1ccb89 | ||
|
|
060ce4964f | ||
|
|
9cf8f1dbd7 | ||
|
|
945d0d00ee | ||
|
|
5fc1cfba60 | ||
|
|
23dbf5e5a2 | ||
|
|
5f10b236cd | ||
|
|
35dfd3f9cc | ||
|
|
be5711f6a4 | ||
|
|
3fec768488 | ||
|
|
4887a10764 | ||
|
|
aed4caf347 | ||
|
|
857adbbf1c | ||
|
|
d3294d3870 | ||
|
|
ad850ce5e7 | ||
|
|
e0e91bb879 | ||
|
|
868a74cdff | ||
|
|
5778b3df65 | ||
|
|
6ed8bc8357 | ||
|
|
89dceda21c | ||
|
|
3e1b52fe1c | ||
|
|
6c23df562b | ||
|
|
8823e3cf23 | ||
|
|
31f0484114 | ||
|
|
da2d777087 | ||
|
|
d2edd167dc | ||
|
|
145c4c817f | ||
|
|
0107878a10 | ||
|
|
9ca4f6f4f8 | ||
|
|
b21207adcf | ||
|
|
f46f738ca7 | ||
|
|
955d711ba1 | ||
|
|
bbb3630a8f | ||
|
|
841b3af5a4 | ||
|
|
7be9b3f321 | ||
|
|
5762826b06 | ||
|
|
1402456660 |
155 changed files with 10015 additions and 7730 deletions
50
.github/workflows/release.yml
vendored
Normal file
50
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
name: Release Obsidian plugin
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20.x"
|
||||
|
||||
- name: Build plugin
|
||||
run: |
|
||||
npm install --legacy-peer-deps
|
||||
npm run build
|
||||
|
||||
- name: Attest main.js
|
||||
uses: actions/attest-build-provenance@v2
|
||||
with:
|
||||
subject-path: main.js
|
||||
|
||||
- name: Attest styles.css
|
||||
uses: actions/attest-build-provenance@v2
|
||||
with:
|
||||
subject-path: styles.css
|
||||
|
||||
- name: Create release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
tag="${GITHUB_REF#refs/tags/}"
|
||||
gh release create "$tag" \
|
||||
--title="$tag" \
|
||||
--draft \
|
||||
main.js manifest.json styles.css
|
||||
|
|
@ -16,11 +16,13 @@ import type { AbortService } from "Services/AbortService";
|
|||
import type { IAIFileService } from "./IAIFileService";
|
||||
import { AgentType } from "Enums/AgentType";
|
||||
import { AIToolUsageMode } from "Enums/AIToolUsageMode";
|
||||
import type { MimeType } from "Enums/MimeType";
|
||||
|
||||
export abstract class BaseAIClass implements IAIClass {
|
||||
|
||||
protected apiKey: string;
|
||||
|
||||
protected readonly provider: AIProvider;
|
||||
protected readonly apiKey: string;
|
||||
protected readonly abortService: AbortService;
|
||||
protected readonly aiFileService: IAIFileService;
|
||||
protected readonly settingsService: SettingsService;
|
||||
|
|
@ -88,12 +90,109 @@ export abstract class BaseAIClass implements IAIClass {
|
|||
|
||||
public abstract streamRequest(conversation: Conversation): AsyncGenerator<IStreamChunk, void, unknown>;
|
||||
|
||||
protected abstract formatBinaryFiles(attachments: Attachment[]): string;
|
||||
protected abstract formatBinaryFiles(attachments: Attachment[]): Promise<string>;
|
||||
|
||||
protected abstract parseStreamChunk(chunk: string): IStreamChunk;
|
||||
protected abstract extractContents(conversationContent: ConversationContent[]): unknown;
|
||||
protected abstract mapFunctionDefinitions(aiToolDefinitions: IAIToolDefinition[]): object;
|
||||
|
||||
/** The MIME types this provider can accept as attachments. */
|
||||
protected abstract get supportedMimeTypes(): MimeType[];
|
||||
|
||||
protected isSupportedMimeType(mimeType: MimeType): boolean {
|
||||
return this.supportedMimeTypes.includes(mimeType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the current AIToolUsageMode to a provider-specific tool-choice value.
|
||||
* The branching is identical across providers; only the returned shape differs
|
||||
* (a bare string for Chat Completions/Responses, an object for Claude/Gemini),
|
||||
* so callers supply the three concrete values.
|
||||
*/
|
||||
protected buildToolChoice<T>(choices: { auto: T; enabled: T; disabled: T }): T {
|
||||
if (this.aiToolDefinitions.length === 0) {
|
||||
return choices.auto;
|
||||
}
|
||||
|
||||
switch (this.aiToolUsageMode) {
|
||||
case AIToolUsageMode.Auto:
|
||||
return choices.auto;
|
||||
case AIToolUsageMode.Enabled:
|
||||
return choices.enabled;
|
||||
case AIToolUsageMode.Disabled:
|
||||
return choices.disabled;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a retry delay (in seconds) from a rate-limit error's HTTP response headers.
|
||||
* Shared by providers that surface rate limits via the standard `Retry-After` header
|
||||
* (Claude, OpenAI, Mistral). Providers that signal retry timing in the response body
|
||||
* (e.g. Gemini's RetryInfo) override this instead.
|
||||
*/
|
||||
protected extractRetryDelay(error: ApiError): number | undefined {
|
||||
if (error.info.type !== ApiErrorType.RATE_LIMIT || !error.info.responseHeaders) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const headers = error.info.responseHeaders;
|
||||
|
||||
// 1. Prefer the standard Retry-After header (seconds or HTTP-date)
|
||||
const retryAfter = headers.get('retry-after');
|
||||
if (retryAfter) {
|
||||
const seconds = Number(retryAfter);
|
||||
if (!Number.isNaN(seconds)) {
|
||||
return Math.max(0, seconds);
|
||||
}
|
||||
|
||||
const date = new Date(retryAfter);
|
||||
if (!Number.isNaN(date.getTime())) {
|
||||
const delayMs = date.getTime() - Date.now();
|
||||
return Math.max(0, Math.ceil(delayMs / 1000));
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fall back to provider-specific reset headers (e.g. OpenAI)
|
||||
const resetHeader =
|
||||
headers.get('x-ratelimit-reset-requests') ??
|
||||
headers.get('x-ratelimit-reset-tokens');
|
||||
|
||||
if (resetHeader) {
|
||||
return this.parseDurationToSeconds(resetHeader);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses duration strings (e.g. "15s", "600ms", "2m", "1h") into seconds.
|
||||
* Returns undefined if parsing fails.
|
||||
*/
|
||||
protected parseDurationToSeconds(value: string): number | undefined {
|
||||
const trimmed = value.trim();
|
||||
const numericValue = parseFloat(trimmed);
|
||||
|
||||
if (Number.isNaN(numericValue)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (trimmed.endsWith('ms')) {
|
||||
return Math.max(0, Math.ceil(numericValue / 1000));
|
||||
}
|
||||
if (trimmed.endsWith('s')) {
|
||||
return Math.max(0, numericValue);
|
||||
}
|
||||
if (trimmed.endsWith('m')) {
|
||||
return Math.max(0, numericValue * 60);
|
||||
}
|
||||
if (trimmed.endsWith('h')) {
|
||||
return Math.max(0, numericValue * 3600);
|
||||
}
|
||||
|
||||
// Fallback: treat as raw seconds
|
||||
return Math.max(0, numericValue);
|
||||
}
|
||||
|
||||
protected model(): string {
|
||||
switch (this._agentType) {
|
||||
case AgentType.Main:
|
||||
|
|
@ -176,7 +275,7 @@ export abstract class BaseAIClass implements IAIClass {
|
|||
|
||||
protected async processAttachments<T>(
|
||||
attachments: Attachment[],
|
||||
formatBinaryFiles: (attachments: Attachment[]) => string
|
||||
formatBinaryFiles: (attachments: Attachment[]) => Promise<string>
|
||||
): Promise<{ formattedParts: T[], uploadErrors: Error[] }> {
|
||||
const uploadErrors: Error[] = [];
|
||||
|
||||
|
|
@ -194,7 +293,7 @@ export abstract class BaseAIClass implements IAIClass {
|
|||
}
|
||||
}
|
||||
|
||||
const formattedContent = formatBinaryFiles(attachments);
|
||||
const formattedContent = await formatBinaryFiles(attachments);
|
||||
const formattedParts = JSON.parse(formattedContent) as T[];
|
||||
|
||||
return { formattedParts, uploadErrors };
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ export abstract class BaseAIFileService implements IAIFileService {
|
|||
return `----FormBoundary${Date.now()}${Math.random().toString(36).substring(2)}`;
|
||||
}
|
||||
|
||||
protected createFormData(displayName: string | undefined, mimeType: string, boundary: string, bytes: Uint8Array<ArrayBuffer>, additionalFields?: Record<string, string>): ArrayBuffer {
|
||||
protected createFormData(displayName: string | undefined, mimeType: string, boundary: string, bytes: Uint8Array, additionalFields?: Record<string, string>): ArrayBuffer {
|
||||
const parts: Uint8Array[] = [];
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
|
|
@ -146,20 +146,17 @@ export abstract class BaseAIFileService implements IAIFileService {
|
|||
|
||||
// Calculate total length and concatenate
|
||||
const totalLength = parts.reduce((sum, part) => sum + part.byteLength, 0);
|
||||
const result = new Uint8Array(totalLength);
|
||||
const buffer = new ArrayBuffer(totalLength);
|
||||
const result = new Uint8Array(buffer);
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
result.set(part, offset);
|
||||
offset += part.byteLength;
|
||||
}
|
||||
|
||||
return this.bytesToBuffer(result);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
protected bytesToBuffer(bytes: Uint8Array<ArrayBuffer>): ArrayBuffer {
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
||||
}
|
||||
|
||||
protected getHeader(headerName: string, headers: Record<string, string>): string | undefined {
|
||||
const header = Object.keys(headers).find(header => header.toLowerCase() === headerName.toLowerCase());
|
||||
return header ? headers[header] : undefined;
|
||||
|
|
|
|||
348
AIClasses/ChatCompletions/ChatCompletionsAIClass.ts
Normal file
348
AIClasses/ChatCompletions/ChatCompletionsAIClass.ts
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
import { BaseAIClass } from "AIClasses/BaseAIClass";
|
||||
import type { IStreamChunk } from "Services/StreamingService";
|
||||
import type { Conversation } from "Conversations/Conversation";
|
||||
import { AIToolCall } from "AIClasses/AIToolCall";
|
||||
import { fromString as aiToolFromString } from "Enums/AITool";
|
||||
import type { IAIToolDefinition } from "AIClasses/ToolDefinitions/IAIToolDefinition";
|
||||
import type { ConversationContent } from "Conversations/ConversationContent";
|
||||
import { Exception } from "Helpers/Exception";
|
||||
import { parseToolCall, parseFunctionResponse } from "Helpers/ResponseHelper";
|
||||
import type {
|
||||
ChatCompletionStreamChunk,
|
||||
ChatCompletionToolDefinition,
|
||||
ChatCompletionMessage,
|
||||
ChatCompletionContentPart
|
||||
} from "./ChatCompletionsTypes";
|
||||
|
||||
export abstract class ChatCompletionsAIClass extends BaseAIClass {
|
||||
|
||||
private readonly STOP_REASON_TOOL_CALLS: string = "tool_calls";
|
||||
|
||||
/** The Chat Completions endpoint for this provider. */
|
||||
protected abstract get apiUrl(): string;
|
||||
|
||||
/** Max output tokens to request. Subclasses may override. */
|
||||
protected get maxTokens(): number {
|
||||
return 16384;
|
||||
}
|
||||
|
||||
// Accumulation state for streaming tool calls
|
||||
private accumulatedToolCalls: Map<number, { id: string; name: string; args: string }> = new Map();
|
||||
// Indices for which toolCallStarted has already been emitted this stream
|
||||
private startedToolCalls: Set<number> = new Set();
|
||||
|
||||
public async* streamRequest(conversation: Conversation): AsyncGenerator<IStreamChunk, void, unknown> {
|
||||
const messages = await this.buildMessages(conversation);
|
||||
const tools = this.getTools();
|
||||
|
||||
const requestBody: Record<string, unknown> = {
|
||||
model: this.model(),
|
||||
max_tokens: this.maxTokens,
|
||||
messages: messages,
|
||||
stream: true
|
||||
};
|
||||
|
||||
// Only include tools if there are definitions
|
||||
if (tools.length > 0) {
|
||||
requestBody.tools = tools;
|
||||
requestBody.tool_choice = this.buildChatCompletionsToolChoice();
|
||||
}
|
||||
|
||||
const headers = {
|
||||
"Authorization": `Bearer ${this.apiKey}`,
|
||||
"Content-Type": "application/json"
|
||||
};
|
||||
|
||||
yield* this.streamingService.streamRequest(
|
||||
this.apiUrl,
|
||||
requestBody,
|
||||
(chunk: string) => this.parseStreamChunk(chunk),
|
||||
headers,
|
||||
(error) => this.extractRetryDelay(error)
|
||||
);
|
||||
}
|
||||
|
||||
private async buildMessages(conversation: Conversation): Promise<ChatCompletionMessage[]> {
|
||||
this.accumulatedToolCalls.clear();
|
||||
this.startedToolCalls.clear();
|
||||
|
||||
// Refresh file cache only if conversation has attachments
|
||||
if (conversation.hasAttachments()) {
|
||||
await this.aiFileService.refreshCache();
|
||||
}
|
||||
|
||||
const systemPrompt = `${this.systemPrompt}\n\n${this.userInstruction}`;
|
||||
const messages = await this.extractContents(conversation.contents);
|
||||
|
||||
return [
|
||||
{ role: "system", content: systemPrompt },
|
||||
...messages
|
||||
];
|
||||
}
|
||||
|
||||
protected parseStreamChunk(chunk: string): IStreamChunk {
|
||||
try {
|
||||
// Chat Completions sends "[DONE]" as the final message
|
||||
if (chunk.trim() === "[DONE]") {
|
||||
return { content: "", isComplete: true };
|
||||
}
|
||||
|
||||
const data = JSON.parse(chunk) as ChatCompletionStreamChunk;
|
||||
|
||||
let text = "";
|
||||
let toolCall: AIToolCall | undefined = undefined;
|
||||
let isComplete = false;
|
||||
let shouldContinue = false;
|
||||
let toolCallStarted: string | undefined = undefined;
|
||||
|
||||
if (!data.choices || data.choices.length === 0) {
|
||||
return { content: "", isComplete: false };
|
||||
}
|
||||
|
||||
const choice = data.choices[0];
|
||||
|
||||
// Handle text content
|
||||
if (choice.delta.content) {
|
||||
text = choice.delta.content;
|
||||
}
|
||||
|
||||
// Handle tool call deltas
|
||||
if (choice.delta.tool_calls) {
|
||||
for (const tc of choice.delta.tool_calls) {
|
||||
const index = tc.index ?? 0;
|
||||
|
||||
if (!this.accumulatedToolCalls.has(index)) {
|
||||
// New tool call starting
|
||||
this.accumulatedToolCalls.set(index, {
|
||||
id: tc.id || "",
|
||||
name: tc.function?.name || "",
|
||||
args: tc.function?.arguments || ""
|
||||
});
|
||||
} else {
|
||||
// Accumulate arguments for existing tool call
|
||||
const existing = this.accumulatedToolCalls.get(index)!;
|
||||
if (tc.id) existing.id = tc.id;
|
||||
if (tc.function?.name) existing.name += tc.function.name;
|
||||
if (tc.function?.arguments) existing.args += tc.function.arguments;
|
||||
}
|
||||
|
||||
// Some providers stream the function name fragmented across multiple deltas rather than
|
||||
// whole in the first one, so fire "started" the moment the accumulated name first becomes non-empty.
|
||||
const accumulated = this.accumulatedToolCalls.get(index)!;
|
||||
if (!this.startedToolCalls.has(index) && accumulated.name) {
|
||||
this.startedToolCalls.add(index);
|
||||
toolCallStarted = accumulated.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle completion
|
||||
if (choice.finish_reason) {
|
||||
isComplete = true;
|
||||
|
||||
if (choice.finish_reason === this.STOP_REASON_TOOL_CALLS) {
|
||||
shouldContinue = true;
|
||||
|
||||
// Finalize the first accumulated tool call
|
||||
// (additional tool calls in a single response are not supported by this plugin's architecture)
|
||||
const firstToolCall = this.accumulatedToolCalls.get(0);
|
||||
if (firstToolCall && firstToolCall.name && firstToolCall.args) {
|
||||
try {
|
||||
const args = JSON.parse(firstToolCall.args) as Record<string, unknown>;
|
||||
toolCall = new AIToolCall(
|
||||
aiToolFromString(firstToolCall.name),
|
||||
args,
|
||||
firstToolCall.id || undefined,
|
||||
undefined
|
||||
);
|
||||
} catch (error) {
|
||||
Exception.log(error);
|
||||
}
|
||||
}
|
||||
|
||||
this.accumulatedToolCalls.clear();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: text,
|
||||
isComplete: isComplete,
|
||||
toolCall: toolCall,
|
||||
shouldContinue: shouldContinue,
|
||||
toolCallStarted: toolCallStarted
|
||||
};
|
||||
} catch (error) {
|
||||
return this.createErrorChunk(error);
|
||||
}
|
||||
}
|
||||
|
||||
protected async extractContents(conversationContent: ConversationContent[]): Promise<ChatCompletionMessage[]> {
|
||||
const results: ChatCompletionMessage[] = [];
|
||||
|
||||
for (const content of this.filterConversationContents(conversationContent)) {
|
||||
const contentToExtract = content.content ?? "";
|
||||
|
||||
// Case 1: Assistant message with tool call
|
||||
if (content.toolCall) {
|
||||
const parsedContent = parseToolCall(content.toolCall);
|
||||
|
||||
if (parsedContent) {
|
||||
// A native tool-call id means the call originated from this provider and
|
||||
// can be replayed in the structured format. Otherwise (a call from another
|
||||
// provider, or no id) fall back to legacy text so history stays coherent.
|
||||
const isNative = parsedContent.toolCall.id
|
||||
&& parsedContent.toolCall.id.trim() !== ""
|
||||
&& this.isNativeToolCallId(parsedContent.toolCall.id);
|
||||
|
||||
if (isNative) {
|
||||
// Native function call - use proper function call format
|
||||
results.push({
|
||||
role: content.role,
|
||||
content: contentToExtract || "",
|
||||
tool_calls: [{
|
||||
id: parsedContent.toolCall.id,
|
||||
type: "function",
|
||||
function: {
|
||||
name: parsedContent.toolCall.name,
|
||||
arguments: JSON.stringify(parsedContent.toolCall.args)
|
||||
}
|
||||
}]
|
||||
});
|
||||
} else {
|
||||
// Cross-provider function call (from Claude/OpenAI) or no id - use legacy text format
|
||||
const legacyText = this.convertToolCallToText(parsedContent);
|
||||
const combinedContent = contentToExtract.trim() !== ""
|
||||
? `${contentToExtract}\n\n${legacyText}`
|
||||
: legacyText;
|
||||
|
||||
results.push({
|
||||
role: content.role,
|
||||
content: combinedContent
|
||||
});
|
||||
}
|
||||
} else {
|
||||
results.push({
|
||||
role: content.role,
|
||||
content: contentToExtract.trim() !== "" ? contentToExtract : "Error parsing function call"
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Case 2: Binary file attachments
|
||||
if (content.attachments && content.attachments.length > 0) {
|
||||
const { formattedParts, uploadErrors } = await this.processAttachments<ChatCompletionContentPart>(
|
||||
content.attachments,
|
||||
(attachments) => this.formatBinaryFiles(attachments)
|
||||
);
|
||||
|
||||
const contentParts: ChatCompletionContentPart[] = [];
|
||||
|
||||
if (contentToExtract.trim() !== "") {
|
||||
contentParts.push({ type: "text", text: contentToExtract });
|
||||
}
|
||||
|
||||
contentParts.push(...formattedParts);
|
||||
|
||||
for (const uploadError of uploadErrors) {
|
||||
contentParts.push({
|
||||
type: "text",
|
||||
text: Exception.messageFrom(uploadError)
|
||||
});
|
||||
}
|
||||
|
||||
if (contentParts.length > 0) {
|
||||
results.push({
|
||||
role: content.role,
|
||||
content: contentParts
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Case 3: Function call response (tool result)
|
||||
if (content.functionResponse) {
|
||||
const parsedContent = parseFunctionResponse(content.functionResponse);
|
||||
|
||||
if (parsedContent) {
|
||||
const isNative = parsedContent.id
|
||||
&& parsedContent.id.trim() !== ""
|
||||
&& this.isNativeToolCallId(parsedContent.id);
|
||||
|
||||
if (isNative) {
|
||||
// Native function response - use proper format
|
||||
results.push({
|
||||
role: "tool",
|
||||
content: JSON.stringify(parsedContent.functionResponse.response),
|
||||
tool_call_id: parsedContent.id,
|
||||
name: parsedContent.functionResponse.name
|
||||
});
|
||||
} else {
|
||||
// Cross-provider function response (from Claude/OpenAI) or no id - use legacy text format
|
||||
const legacyText = this.convertFunctionResponseToText(parsedContent);
|
||||
results.push({
|
||||
role: content.role,
|
||||
content: legacyText
|
||||
});
|
||||
}
|
||||
} else {
|
||||
results.push({
|
||||
role: content.role,
|
||||
content: content.functionResponse
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Case 4: Regular text message
|
||||
if (contentToExtract.trim() !== "") {
|
||||
results.push({
|
||||
role: content.role,
|
||||
content: contentToExtract
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
protected mapFunctionDefinitions(aiToolDefinitions: IAIToolDefinition[]): ChatCompletionToolDefinition[] {
|
||||
return aiToolDefinitions.map((functionDefinition) => ({
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: functionDefinition.name,
|
||||
description: functionDefinition.description,
|
||||
parameters: {
|
||||
type: "object" as const,
|
||||
properties: functionDefinition.parameters.properties,
|
||||
required: functionDefinition.parameters.required
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private buildChatCompletionsToolChoice(): string {
|
||||
return this.buildToolChoice<string>({
|
||||
auto: "auto",
|
||||
enabled: "any",
|
||||
disabled: "none"
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The tools advertised to the model. Defaults to the mapped function definitions.
|
||||
* Subclasses override to inject provider-specific built-in tools (e.g. web search).
|
||||
*/
|
||||
protected getTools(): ChatCompletionToolDefinition[] {
|
||||
return this.mapFunctionDefinitions(this.aiToolDefinitions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a stored tool-call id is a native id for this provider (vs. one carried
|
||||
* over from another provider in cross-provider conversation history). Default treats
|
||||
* any non-empty id as native; providers with a known id format override this.
|
||||
*/
|
||||
protected isNativeToolCallId(id: string): boolean {
|
||||
return id.trim() !== "";
|
||||
}
|
||||
}
|
||||
|
|
@ -1,29 +1,40 @@
|
|||
import { Resolve } from "Services/DependencyService";
|
||||
import { Services } from "Services/Services";
|
||||
import type { IConversationNamingService } from "AIClasses/IConversationNamingService";
|
||||
import { AIProvider, AIProviderURL, AIProviderModel } from "Enums/ApiProvider";
|
||||
import type { IConversationNamingAgent } from "AIClasses/IConversationNamingAgent";
|
||||
import type { AIProvider } from "Enums/ApiProvider";
|
||||
import { Role } from "Enums/Role";
|
||||
import { NamePrompt } from "AIPrompts/NamePrompt";
|
||||
import type { SettingsService } from "Services/SettingsService";
|
||||
import { Exception } from "Helpers/Exception";
|
||||
import type { AbortService } from "Services/AbortService";
|
||||
import type { MistralChatResponse } from "./MistralTypes";
|
||||
import type { ChatCompletionResponse } from "./ChatCompletionsTypes";
|
||||
|
||||
export class MistralConversationNamingService implements IConversationNamingService {
|
||||
/**
|
||||
* Base conversation-naming agent for providers speaking the OpenAI-compatible
|
||||
* Chat Completions protocol. Subclasses supply only the endpoint and namer model;
|
||||
* the request shape and response parsing are identical across such providers.
|
||||
*/
|
||||
export abstract class ChatCompletionsConversationNamingAgent implements IConversationNamingAgent {
|
||||
|
||||
private readonly apiKey: string;
|
||||
private readonly abortService: AbortService;
|
||||
|
||||
public constructor() {
|
||||
protected constructor(provider: AIProvider) {
|
||||
const settingsService = Resolve<SettingsService>(Services.SettingsService);
|
||||
this.apiKey = settingsService.getApiKeyForProvider(AIProvider.Mistral);
|
||||
this.apiKey = settingsService.getApiKeyForProvider(provider);
|
||||
this.abortService = Resolve<AbortService>(Services.AbortService);
|
||||
}
|
||||
|
||||
/** The Chat Completions endpoint for this provider. */
|
||||
protected abstract get apiUrl(): string;
|
||||
|
||||
/** The (typically small/fast) model used to generate conversation names. */
|
||||
protected abstract get namerModel(): string;
|
||||
|
||||
public async generateName(userPrompt: string): Promise<string> {
|
||||
return await this.abortService.abortableOperation(async () => {
|
||||
const requestBody = {
|
||||
model: AIProviderModel.MistralNamer,
|
||||
model: this.namerModel,
|
||||
max_tokens: 100,
|
||||
messages: [
|
||||
{
|
||||
|
|
@ -37,7 +48,7 @@ export class MistralConversationNamingService implements IConversationNamingServ
|
|||
]
|
||||
};
|
||||
|
||||
const response = await fetch(AIProviderURL.Mistral, {
|
||||
const response = await fetch(this.apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
|
|
@ -48,10 +59,10 @@ export class MistralConversationNamingService implements IConversationNamingServ
|
|||
});
|
||||
|
||||
if (!response.ok) {
|
||||
Exception.throw(`Mistral API error: ${response.status} ${response.statusText} - ${await response.text()}`);
|
||||
Exception.throw(`Chat Completions API error: ${response.status} ${response.statusText} - ${await response.text()}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as MistralChatResponse;
|
||||
const data = await response.json() as ChatCompletionResponse;
|
||||
const firstChoice = data.choices?.[0];
|
||||
|
||||
if (!firstChoice || !firstChoice.message?.content) {
|
||||
97
AIClasses/ChatCompletions/ChatCompletionsTypes.ts
Normal file
97
AIClasses/ChatCompletions/ChatCompletionsTypes.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// Shared OpenAI-compatible Chat Completions API types.
|
||||
// Spoken by Mistral and any future Chat Completions provider (Groq, DeepSeek,
|
||||
// OpenRouter, Ollama, LM Studio, llama.cpp, ...). Provider-specific extensions
|
||||
// (e.g. Mistral's Agents/File APIs) live in the provider's own *Types.ts.
|
||||
|
||||
export interface ChatCompletionStreamChunk {
|
||||
id: string;
|
||||
object: "chat.completion.chunk";
|
||||
created: number;
|
||||
model: string;
|
||||
choices: ChatCompletionStreamChoice[];
|
||||
}
|
||||
|
||||
export interface ChatCompletionStreamChoice {
|
||||
index: number;
|
||||
delta: ChatCompletionDelta;
|
||||
finish_reason: string | null;
|
||||
}
|
||||
|
||||
export interface ChatCompletionDelta {
|
||||
role?: string;
|
||||
content?: string;
|
||||
tool_calls?: ChatCompletionToolCallDelta[];
|
||||
}
|
||||
|
||||
export interface ChatCompletionToolCallDelta {
|
||||
id?: string;
|
||||
type?: "function";
|
||||
function?: {
|
||||
name?: string;
|
||||
arguments?: string;
|
||||
};
|
||||
index?: number;
|
||||
}
|
||||
|
||||
// Non-streaming response (used for conversation naming)
|
||||
export interface ChatCompletionResponse {
|
||||
id: string;
|
||||
object: "chat.completion";
|
||||
created: number;
|
||||
model: string;
|
||||
choices: ChatCompletionChoice[];
|
||||
usage: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ChatCompletionChoice {
|
||||
index: number;
|
||||
message: {
|
||||
role: string;
|
||||
content: string | null;
|
||||
tool_calls?: ChatCompletionToolCall[];
|
||||
};
|
||||
finish_reason: string;
|
||||
}
|
||||
|
||||
export interface ChatCompletionToolCall {
|
||||
id: string;
|
||||
type: "function";
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Tool definition format
|
||||
export interface ChatCompletionToolDefinition {
|
||||
type: "function";
|
||||
function: {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: {
|
||||
type: "object";
|
||||
properties: Record<string, object>;
|
||||
required?: string[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// Message types for request construction
|
||||
export interface ChatCompletionMessage {
|
||||
role: string;
|
||||
content: string | ChatCompletionContentPart[];
|
||||
tool_calls?: ChatCompletionToolCall[];
|
||||
tool_call_id?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface ChatCompletionContentPart {
|
||||
type: "text" | "image_url" | "document_url";
|
||||
text?: string;
|
||||
image_url?: { url: string };
|
||||
document_url?: string;
|
||||
}
|
||||
|
|
@ -13,10 +13,9 @@ import { Exception } from "Helpers/Exception";
|
|||
import { MimeType, toMimeType } from "Enums/MimeType";
|
||||
import { isTextFile } from "Enums/FileType";
|
||||
import { MimeTypeToFileTypes } from "Enums/FileTypeMimeTypeMapping";
|
||||
import { ApiError, ApiErrorType } from "Types/ApiError";
|
||||
import { parseToolCall, parseFunctionResponse } from "Helpers/ResponseHelper";
|
||||
import { AIToolUsageMode } from "Enums/AIToolUsageMode";
|
||||
import { Copy, replaceCopy } from "Enums/Copy";
|
||||
import { Copy } from "Enums/Copy";
|
||||
import { replaceCopy } from 'Helpers/Helpers';
|
||||
|
||||
export class Claude extends BaseAIClass {
|
||||
|
||||
|
|
@ -31,6 +30,10 @@ export class Claude extends BaseAIClass {
|
|||
MimeType.IMAGE_WEBP
|
||||
];
|
||||
|
||||
protected get supportedMimeTypes(): MimeType[] {
|
||||
return this.SUPPORTED_MIMETYPES;
|
||||
}
|
||||
|
||||
private accumulatedFunctionName: string | null = null;
|
||||
private accumulatedFunctionArgs: string = "";
|
||||
private accumulatedFunctionId: string | null = null;
|
||||
|
|
@ -281,7 +284,7 @@ export class Claude extends BaseAIClass {
|
|||
}));
|
||||
}
|
||||
|
||||
protected formatBinaryFiles(attachments: Attachment[]): string {
|
||||
protected formatBinaryFiles(attachments: Attachment[]): Promise<string> {
|
||||
const contentBlocks = attachments.flatMap(attachment => {
|
||||
const fileID = attachment.getFileID(this.provider);
|
||||
if (!fileID) {
|
||||
|
|
@ -298,7 +301,7 @@ export class Claude extends BaseAIClass {
|
|||
}
|
||||
|
||||
if (!isPlainText && !this.isSupportedMimeType(mimeType)) {
|
||||
return [{ type: "text", text: `Unsupported mime type '${mimeType}': ${attachment.fileName}` }];
|
||||
return [{ type: "text", text: `User attempted to share a file with an unsupported mime type '${mimeType}': ${attachment.fileName}` }];
|
||||
}
|
||||
|
||||
return [
|
||||
|
|
@ -312,11 +315,7 @@ export class Claude extends BaseAIClass {
|
|||
}
|
||||
];
|
||||
});
|
||||
return JSON.stringify(contentBlocks);
|
||||
}
|
||||
|
||||
private isSupportedMimeType(mimeType: MimeType): boolean {
|
||||
return this.SUPPORTED_MIMETYPES.includes(mimeType);
|
||||
return Promise.resolve(JSON.stringify(contentBlocks));
|
||||
}
|
||||
|
||||
// Adds cache control to the last tool in the tools array.
|
||||
|
|
@ -383,41 +382,10 @@ export class Claude extends BaseAIClass {
|
|||
}
|
||||
|
||||
private buildClaudeToolChoice(): { type: string } {
|
||||
// If no tools defined, fall back to auto
|
||||
if (this.aiToolDefinitions.length === 0) {
|
||||
return { type: "auto" };
|
||||
}
|
||||
|
||||
switch (this.aiToolUsageMode) {
|
||||
case AIToolUsageMode.Auto:
|
||||
return { type: "auto" };
|
||||
case AIToolUsageMode.Enabled:
|
||||
return { type: "any" };
|
||||
case AIToolUsageMode.Disabled:
|
||||
return { type: "none" };
|
||||
}
|
||||
}
|
||||
|
||||
private extractRetryDelay(error: ApiError): number | undefined {
|
||||
if (error.info.type !== ApiErrorType.RATE_LIMIT || !error.info.responseHeaders) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const retryAfter = error.info.responseHeaders.get('Retry-After');
|
||||
if (!retryAfter) return undefined;
|
||||
|
||||
// Try parsing as seconds (number)
|
||||
const seconds = parseInt(retryAfter, 10);
|
||||
if (!isNaN(seconds)) return seconds;
|
||||
|
||||
// Try parsing as HTTP date
|
||||
const date = new Date(retryAfter);
|
||||
if (!isNaN(date.getTime())) {
|
||||
const now = Date.now();
|
||||
const delayMs = date.getTime() - now;
|
||||
return Math.max(0, Math.ceil(delayMs / 1000));
|
||||
}
|
||||
|
||||
return undefined;
|
||||
return this.buildToolChoice<{ type: string }>({
|
||||
auto: { type: "auto" },
|
||||
enabled: { type: "any" },
|
||||
disabled: { type: "none" }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { Resolve } from "Services/DependencyService";
|
||||
import { Services } from "Services/Services";
|
||||
import type { IConversationNamingService } from "AIClasses/IConversationNamingService";
|
||||
import type { IConversationNamingAgent } from "AIClasses/IConversationNamingAgent";
|
||||
import { AIProvider, AIProviderURL, AIProviderModel } from "Enums/ApiProvider";
|
||||
import { Role } from "Enums/Role";
|
||||
import { NamePrompt } from "AIPrompts/NamePrompt";
|
||||
|
|
@ -9,7 +9,7 @@ import type Anthropic from '@anthropic-ai/sdk';
|
|||
import { Exception } from "Helpers/Exception";
|
||||
import type { AbortService } from "Services/AbortService";
|
||||
|
||||
export class ClaudeConversationNamingService implements IConversationNamingService {
|
||||
export class ClaudeConversationNamingAgent implements IConversationNamingAgent {
|
||||
|
||||
private readonly apiKey: string;
|
||||
private readonly abortService: AbortService;
|
||||
|
|
@ -17,8 +17,8 @@ import { Exception } from "Helpers/Exception";
|
|||
import { ApiError, ApiErrorType } from "Types/ApiError";
|
||||
import { parseToolCall, parseFunctionResponse } from "Helpers/ResponseHelper";
|
||||
import type { GeminiRetryInfo, GeminiErrorResponse } from "./GeminiTypes";
|
||||
import { AIToolUsageMode } from "Enums/AIToolUsageMode";
|
||||
import { Copy, replaceCopy } from "Enums/Copy";
|
||||
import { replaceCopy } from 'Helpers/Helpers';
|
||||
import { Copy } from "Enums/Copy";
|
||||
|
||||
export class Gemini extends BaseAIClass {
|
||||
|
||||
|
|
@ -72,6 +72,10 @@ export class Gemini extends BaseAIClass {
|
|||
MimeType.APPLICATION_YAML
|
||||
];
|
||||
|
||||
protected get supportedMimeTypes(): MimeType[] {
|
||||
return this.SUPPORTED_MIMETYPES;
|
||||
}
|
||||
|
||||
private accumulatedFunctionName: string | null = null;
|
||||
private accumulatedFunctionArgs: Record<string, unknown> = {};
|
||||
private accumulatedThoughtSignature: string | null = null;
|
||||
|
|
@ -147,6 +151,7 @@ export class Gemini extends BaseAIClass {
|
|||
|
||||
let text = "";
|
||||
let toolCall: AIToolCall | undefined = undefined;
|
||||
let toolCallStarted: string | undefined = undefined;
|
||||
const candidate = data.candidates?.[0];
|
||||
|
||||
if (candidate) {
|
||||
|
|
@ -159,6 +164,12 @@ export class Gemini extends BaseAIClass {
|
|||
const parts = candidate.content?.parts || [];
|
||||
for (const part of parts) {
|
||||
if (part.functionCall) {
|
||||
// Signal tool call start the first time we see its name
|
||||
// Gemini delivers the whole call - name, args, and finishReason - in a single chunk, so this must NOT short-circuit the finalize logic below
|
||||
if (!this.accumulatedFunctionName && part.functionCall.name) {
|
||||
toolCallStarted = part.functionCall.name;
|
||||
}
|
||||
|
||||
// Accumulate function name
|
||||
if (part.functionCall.name) {
|
||||
this.accumulatedFunctionName = part.functionCall.name;
|
||||
|
|
@ -200,6 +211,7 @@ export class Gemini extends BaseAIClass {
|
|||
content: text,
|
||||
isComplete: isComplete,
|
||||
toolCall: toolCall,
|
||||
toolCallStarted: toolCallStarted,
|
||||
shouldContinue: shouldContinue,
|
||||
};
|
||||
} catch (error) {
|
||||
|
|
@ -314,7 +326,7 @@ export class Gemini extends BaseAIClass {
|
|||
}));
|
||||
}
|
||||
|
||||
protected formatBinaryFiles(attachments: Attachment[]): string {
|
||||
protected formatBinaryFiles(attachments: Attachment[]): Promise<string> {
|
||||
const parts: unknown[] = [];
|
||||
|
||||
for (const attachment of attachments) {
|
||||
|
|
@ -333,7 +345,7 @@ export class Gemini extends BaseAIClass {
|
|||
}
|
||||
|
||||
if (!isPlainText && !this.isSupportedMimeType(mimeType)) {
|
||||
parts.push({ text: `Unsupported mime type '${mimeType}': ${attachment.fileName}` });
|
||||
parts.push({ text: `User attempted to share a file with an unsupported mime type '${mimeType}': ${attachment.fileName}` });
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -346,7 +358,7 @@ export class Gemini extends BaseAIClass {
|
|||
});
|
||||
}
|
||||
|
||||
return JSON.stringify(parts);
|
||||
return Promise.resolve(JSON.stringify(parts));
|
||||
}
|
||||
|
||||
private getTools(): { functionDeclarations: FunctionDeclaration[] } {
|
||||
|
|
@ -367,22 +379,16 @@ export class Gemini extends BaseAIClass {
|
|||
}
|
||||
|
||||
private buildGeminiToolConfig(): { function_calling_config: { mode: string } } {
|
||||
// If no tools defined, fall back to auto
|
||||
if (this.aiToolDefinitions.length === 0) {
|
||||
return { function_calling_config: { mode: "AUTO" } };
|
||||
}
|
||||
|
||||
switch (this.aiToolUsageMode) {
|
||||
case AIToolUsageMode.Auto:
|
||||
return { function_calling_config: { mode: "AUTO" } };
|
||||
case AIToolUsageMode.Enabled:
|
||||
return { function_calling_config: { mode: "ANY" } };
|
||||
case AIToolUsageMode.Disabled:
|
||||
return { function_calling_config: { mode: "NONE" } };
|
||||
}
|
||||
return this.buildToolChoice<{ function_calling_config: { mode: string } }>({
|
||||
auto: { function_calling_config: { mode: "AUTO" } },
|
||||
enabled: { function_calling_config: { mode: "ANY" } },
|
||||
disabled: { function_calling_config: { mode: "NONE" } }
|
||||
});
|
||||
}
|
||||
|
||||
private extractRetryDelay(error: ApiError): number | undefined {
|
||||
// Gemini signals retry timing in the response body (RetryInfo), not the
|
||||
// Retry-After header, so it overrides the header-based base implementation.
|
||||
protected extractRetryDelay(error: ApiError): number | undefined {
|
||||
if (error.info.type !== ApiErrorType.RATE_LIMIT || !error.info.responseBody) {
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -463,12 +469,8 @@ export class Gemini extends BaseAIClass {
|
|||
if (Number.isNaN(value)) return undefined;
|
||||
|
||||
const unit = match[2];
|
||||
return unit === 'ms'
|
||||
? Math.ceil(value / 1000)
|
||||
return unit === 'ms'
|
||||
? Math.ceil(value / 1000)
|
||||
: Math.ceil(value);
|
||||
}
|
||||
|
||||
private isSupportedMimeType(mimeType: MimeType): boolean {
|
||||
return this.SUPPORTED_MIMETYPES.includes(mimeType);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { Resolve } from "Services/DependencyService";
|
||||
import { Services } from "Services/Services";
|
||||
import type { IConversationNamingService } from "AIClasses/IConversationNamingService";
|
||||
import type { IConversationNamingAgent } from "AIClasses/IConversationNamingAgent";
|
||||
import { AIProvider, AIProviderURL, AIProviderModel } from "Enums/ApiProvider";
|
||||
import { Role } from "Enums/Role";
|
||||
import { NamePrompt } from "AIPrompts/NamePrompt";
|
||||
|
|
@ -9,7 +9,7 @@ import type { SettingsService } from "Services/SettingsService";
|
|||
import { Exception } from "Helpers/Exception";
|
||||
import type { AbortService } from "Services/AbortService";
|
||||
|
||||
export class GeminiConversationNamingService implements IConversationNamingService {
|
||||
export class GeminiConversationNamingAgent implements IConversationNamingAgent {
|
||||
|
||||
private readonly apiKey: string;
|
||||
private readonly abortService: AbortService;
|
||||
|
|
@ -40,8 +40,8 @@ export class GeminiFileService extends BaseAIFileService {
|
|||
|
||||
protected async uploadFileToAPI(data: string, mimeType: string, displayName?: string): Promise<string> {
|
||||
return this.withRetry("Upload file", async () => {
|
||||
const bytes = StringTools.toBytes(data);
|
||||
const numBytes = bytes.byteLength;
|
||||
const buffer = StringTools.toBuffer(data);
|
||||
const numBytes = buffer.byteLength;
|
||||
|
||||
const metadata = displayName ? { file: { displayName } } : {};
|
||||
|
||||
|
|
@ -78,7 +78,7 @@ export class GeminiFileService extends BaseAIFileService {
|
|||
"X-Goog-Upload-Offset": "0",
|
||||
"X-Goog-Upload-Command": "upload, finalize"
|
||||
},
|
||||
body: this.bytesToBuffer(bytes),
|
||||
body: buffer,
|
||||
throw: false
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
export interface IConversationNamingService {
|
||||
export interface IConversationNamingAgent {
|
||||
generateName(userPrompt: string): Promise<string>;
|
||||
}
|
||||
105
AIClasses/Local/Local.ts
Normal file
105
AIClasses/Local/Local.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { ChatCompletionsAIClass } from "AIClasses/ChatCompletions/ChatCompletionsAIClass";
|
||||
import type { ChatCompletionContentPart } from "AIClasses/ChatCompletions/ChatCompletionsTypes";
|
||||
import type { Attachment } from "Conversations/Attachment";
|
||||
import { AIProvider } from "Enums/ApiProvider";
|
||||
import { AgentType } from "Enums/AgentType";
|
||||
import { Copy } from "Enums/Copy";
|
||||
import { isTextFile } from "Enums/FileType";
|
||||
import { MimeTypeToFileTypes } from "Enums/FileTypeMimeTypeMapping";
|
||||
import { isImageMimeType, MimeType, toMimeType } from "Enums/MimeType";
|
||||
import { replaceCopy } from "Helpers/Helpers";
|
||||
import { StringTools } from "Helpers/StringTools";
|
||||
import { pdfToImages } from "Helpers/DocumentHelper";
|
||||
import { arrayBufferToBase64 } from "obsidian";
|
||||
|
||||
export class Local extends ChatCompletionsAIClass {
|
||||
|
||||
public constructor() {
|
||||
super(AIProvider.Local);
|
||||
}
|
||||
|
||||
protected get apiUrl(): string {
|
||||
return this.settingsService.settings.localUrl;
|
||||
}
|
||||
|
||||
protected get supportedMimeTypes(): MimeType[] {
|
||||
return [
|
||||
MimeType.TEXT_PLAIN,
|
||||
MimeType.APPLICATION_PDF,
|
||||
MimeType.IMAGE_JPEG,
|
||||
MimeType.IMAGE_PNG
|
||||
];
|
||||
}
|
||||
|
||||
// No file-upload API for local models: inline attachments directly as base64.
|
||||
protected async formatBinaryFiles(attachments: Attachment[]): Promise<string> {
|
||||
const contentParts: ChatCompletionContentPart[] = [];
|
||||
|
||||
for (const attachment of attachments) {
|
||||
const mimeType = toMimeType(attachment.getMimeType());
|
||||
|
||||
const isPlainText = MimeTypeToFileTypes[mimeType].some(fileType => isTextFile(fileType));
|
||||
|
||||
if (!isPlainText && !this.isSupportedMimeType(mimeType)) {
|
||||
contentParts.push({
|
||||
type: "text",
|
||||
text: `User attempted to share a file with an unsupported mime type '${mimeType}': ${attachment.fileName}`
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Local models can't read raw PDF bytes: rasterize each page to an image instead.
|
||||
if (mimeType === MimeType.APPLICATION_PDF) {
|
||||
const pdfImages = await pdfToImages(StringTools.toBuffer(attachment.base64));
|
||||
|
||||
if (pdfImages.length === 0) {
|
||||
contentParts.push({
|
||||
type: "text",
|
||||
text: `Failed to render any pages from ${attachment.fileName}`
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
contentParts.push({ type: "text", text: replaceCopy(Copy.AttachedFile, [attachment.fileName]) });
|
||||
for (const pageImage of pdfImages) {
|
||||
contentParts.push({
|
||||
type: "image_url",
|
||||
image_url: { url: `data:${pageImage.mimeType};base64,${arrayBufferToBase64(pageImage.image)}` }
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isImageMimeType(mimeType)) {
|
||||
contentParts.push(
|
||||
{ type: "text", text: replaceCopy(Copy.AttachedFile, [attachment.fileName]) },
|
||||
{ type: "image_url", image_url: { url: `data:${mimeType};base64,${attachment.base64}` } }
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const text = new TextDecoder().decode(StringTools.toBuffer(attachment.base64));
|
||||
contentParts.push({
|
||||
type: "text",
|
||||
text: `${replaceCopy(Copy.AttachedFile, [attachment.fileName])}\n\n${text}`
|
||||
});
|
||||
}
|
||||
|
||||
return JSON.stringify(contentParts);
|
||||
}
|
||||
|
||||
protected model(): string {
|
||||
const localModels = this.settingsService.settings.localModels;
|
||||
|
||||
switch (this.agentType) {
|
||||
case AgentType.Main:
|
||||
case AgentType.Execution:
|
||||
return localModels.model;
|
||||
case AgentType.Orchestration:
|
||||
case AgentType.Planning:
|
||||
return localModels.planningModel;
|
||||
case AgentType.QuickAction:
|
||||
return localModels.quickActionModel;
|
||||
}
|
||||
}
|
||||
}
|
||||
24
AIClasses/Local/LocalConversationNamingAgent.ts
Normal file
24
AIClasses/Local/LocalConversationNamingAgent.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { ChatCompletionsConversationNamingAgent } from "AIClasses/ChatCompletions/ChatCompletionsConversationNamingService";
|
||||
import { AIProvider } from "Enums/ApiProvider";
|
||||
import { Resolve } from "Services/DependencyService";
|
||||
import { Services } from "Services/Services";
|
||||
import type { SettingsService } from "Services/SettingsService";
|
||||
|
||||
export class LocalConversationNamingAgent extends ChatCompletionsConversationNamingAgent {
|
||||
|
||||
private readonly settingsService: SettingsService;
|
||||
|
||||
public constructor() {
|
||||
super(AIProvider.Local);
|
||||
this.settingsService = Resolve<SettingsService>(Services.SettingsService);
|
||||
}
|
||||
|
||||
protected get apiUrl(): string {
|
||||
return this.settingsService.settings.localUrl;
|
||||
}
|
||||
|
||||
protected get namerModel(): string {
|
||||
return this.settingsService.settings.localModels.quickActionModel;
|
||||
}
|
||||
|
||||
}
|
||||
28
AIClasses/Local/LocalFileService.ts
Normal file
28
AIClasses/Local/LocalFileService.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import type { IAIFileService } from "AIClasses/IAIFileService";
|
||||
import type { Attachment } from "Conversations/Attachment";
|
||||
import { AIProvider } from "Enums/ApiProvider";
|
||||
|
||||
// Local models have no files API: attachments are always inlined as base64 (see Local.ts formatBinaryFiles).
|
||||
// uploadFile() is a no-op that stamps a dummy fileID so BaseAIClass.processAttachments' upload-verification check passes.
|
||||
export class LocalFileService implements IAIFileService {
|
||||
|
||||
private static readonly INLINE_FILE_ID = "inline";
|
||||
|
||||
public refreshCache(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
public listFiles(): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
public uploadFile(attachment: Attachment): Promise<void> {
|
||||
attachment.setFileID(AIProvider.Local, LocalFileService.INLINE_FILE_ID);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
public deleteFile(_attachment: Attachment): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,32 +1,26 @@
|
|||
import { BaseAIClass } from "AIClasses/BaseAIClass";
|
||||
import type { IStreamChunk } from "Services/StreamingService";
|
||||
import type { Conversation } from "Conversations/Conversation";
|
||||
import { ChatCompletionsAIClass } from "AIClasses/ChatCompletions/ChatCompletionsAIClass";
|
||||
import type { Attachment } from "Conversations/Attachment";
|
||||
import { AIProvider, AIProviderURL } from "Enums/ApiProvider";
|
||||
import { AIToolCall } from "AIClasses/AIToolCall";
|
||||
import { AIToolResponse } from "AIClasses/ToolDefinitions/AIToolResponse";
|
||||
import { fromString as aiToolFromString, AITool } from "Enums/AITool";
|
||||
import type { IAIToolDefinition } from "AIClasses/ToolDefinitions/IAIToolDefinition";
|
||||
import type { ConversationContent } from "Conversations/ConversationContent";
|
||||
import { Role } from "Enums/Role";
|
||||
import { Exception } from "Helpers/Exception";
|
||||
import { AITool } from "Enums/AITool";
|
||||
import { MimeType, toMimeType } from "Enums/MimeType";
|
||||
import { isTextFile } from "Enums/FileType";
|
||||
import { MimeTypeToFileTypes } from "Enums/FileTypeMimeTypeMapping";
|
||||
import { ApiError, ApiErrorType } from "Types/ApiError";
|
||||
import { parseToolCall, parseFunctionResponse } from "Helpers/ResponseHelper";
|
||||
import { AIToolUsageMode } from "Enums/AIToolUsageMode";
|
||||
import type { MistralStreamChunk, MistralToolDefinition, MistralMessage, MistralContentPart } from "./MistralTypes";
|
||||
import type { ChatCompletionToolDefinition, ChatCompletionContentPart } from "AIClasses/ChatCompletions/ChatCompletionsTypes";
|
||||
import type { MistralFileService } from "./MistralFileService";
|
||||
import { Copy, replaceCopy } from "Enums/Copy";
|
||||
import { replaceCopy } from 'Helpers/Helpers';
|
||||
import { Copy } from "Enums/Copy";
|
||||
import { MistralAgent } from "./MistralAgent";
|
||||
import { AIToolResponsePayload } from "AIClasses/ToolDefinitions/AIToolResponsePayload";
|
||||
|
||||
export class Mistral extends BaseAIClass {
|
||||
export class Mistral extends ChatCompletionsAIClass {
|
||||
|
||||
private readonly STOP_REASON_TOOL_CALLS: string = "tool_calls";
|
||||
// Mistral requires native tool-call ids to be alphanumeric only (a-z, A-Z, 0-9)
|
||||
// with length 9. An id not matching this format originates from another provider.
|
||||
private static readonly NATIVE_TOOL_CALL_ID = /^[a-zA-Z0-9]{9}$/;
|
||||
|
||||
private readonly SUPPORTED_MIMETYPES = [
|
||||
protected readonly SUPPORTED_MIMETYPES = [
|
||||
MimeType.TEXT_PLAIN,
|
||||
MimeType.APPLICATION_PDF,
|
||||
MimeType.IMAGE_JPEG,
|
||||
|
|
@ -37,42 +31,20 @@ export class Mistral extends BaseAIClass {
|
|||
|
||||
private readonly agent: MistralAgent = new MistralAgent();
|
||||
|
||||
// Accumulation state for streaming tool calls
|
||||
private accumulatedToolCalls: Map<number, { id: string; name: string; args: string }> = new Map();
|
||||
|
||||
public constructor() {
|
||||
super(AIProvider.Mistral);
|
||||
}
|
||||
|
||||
public async* streamRequest(conversation: Conversation): AsyncGenerator<IStreamChunk, void, unknown> {
|
||||
const messages = await this.buildMessages(conversation);
|
||||
const tools = this.getTools();
|
||||
protected get apiUrl(): string {
|
||||
return AIProviderURL.Mistral;
|
||||
}
|
||||
|
||||
const requestBody: Record<string, unknown> = {
|
||||
model: this.model(),
|
||||
max_tokens: 16384,
|
||||
messages: messages,
|
||||
stream: true
|
||||
};
|
||||
protected get supportedMimeTypes(): MimeType[] {
|
||||
return this.SUPPORTED_MIMETYPES;
|
||||
}
|
||||
|
||||
// Only include tools if there are definitions
|
||||
if (tools.length > 0) {
|
||||
requestBody.tools = tools;
|
||||
requestBody.tool_choice = this.buildMistralToolChoice();
|
||||
}
|
||||
|
||||
const headers = {
|
||||
"Authorization": `Bearer ${this.apiKey}`,
|
||||
"Content-Type": "application/json"
|
||||
};
|
||||
|
||||
yield* this.streamingService.streamRequest(
|
||||
AIProviderURL.Mistral,
|
||||
requestBody,
|
||||
(chunk: string) => this.parseStreamChunk(chunk),
|
||||
headers,
|
||||
(error) => this.extractRetryDelay(error)
|
||||
);
|
||||
protected isNativeToolCallId(id: string): boolean {
|
||||
return Mistral.NATIVE_TOOL_CALL_ID.test(id);
|
||||
}
|
||||
|
||||
public async resolveToolCall(toolCall: AIToolCall): Promise<AIToolResponse | null> {
|
||||
|
|
@ -84,264 +56,8 @@ export class Mistral extends BaseAIClass {
|
|||
return new AIToolResponse(toolCall.name, new AIToolResponsePayload({ result }), toolCall.toolId);
|
||||
}
|
||||
|
||||
private async buildMessages(conversation: Conversation): Promise<MistralMessage[]> {
|
||||
this.accumulatedToolCalls.clear();
|
||||
|
||||
// Refresh file cache only if conversation has attachments
|
||||
if (conversation.hasAttachments()) {
|
||||
await this.aiFileService.refreshCache();
|
||||
}
|
||||
|
||||
const systemPrompt = `${this.systemPrompt}\n\n${this.userInstruction}`;
|
||||
const messages = await this.extractContents(conversation.contents);
|
||||
|
||||
return [
|
||||
{ role: "system", content: systemPrompt },
|
||||
...messages
|
||||
];
|
||||
}
|
||||
|
||||
protected parseStreamChunk(chunk: string): IStreamChunk {
|
||||
try {
|
||||
// Mistral sends "[DONE]" as the final message
|
||||
if (chunk.trim() === "[DONE]") {
|
||||
return { content: "", isComplete: true };
|
||||
}
|
||||
|
||||
const data = JSON.parse(chunk) as MistralStreamChunk;
|
||||
|
||||
let text = "";
|
||||
let toolCall: AIToolCall | undefined = undefined;
|
||||
let isComplete = false;
|
||||
let shouldContinue = false;
|
||||
let toolCallStarted: string | undefined = undefined;
|
||||
|
||||
if (!data.choices || data.choices.length === 0) {
|
||||
return { content: "", isComplete: false };
|
||||
}
|
||||
|
||||
const choice = data.choices[0];
|
||||
|
||||
// Handle text content
|
||||
if (choice.delta.content) {
|
||||
text = choice.delta.content;
|
||||
}
|
||||
|
||||
// Handle tool call deltas
|
||||
if (choice.delta.tool_calls) {
|
||||
for (const tc of choice.delta.tool_calls) {
|
||||
const index = tc.index ?? 0;
|
||||
|
||||
if (!this.accumulatedToolCalls.has(index)) {
|
||||
// New tool call starting
|
||||
this.accumulatedToolCalls.set(index, {
|
||||
id: tc.id || "",
|
||||
name: tc.function?.name || "",
|
||||
args: tc.function?.arguments || ""
|
||||
});
|
||||
|
||||
if (tc.function?.name) {
|
||||
toolCallStarted = tc.function.name;
|
||||
}
|
||||
} else {
|
||||
// Accumulate arguments for existing tool call
|
||||
const existing = this.accumulatedToolCalls.get(index)!;
|
||||
if (tc.id) existing.id = tc.id;
|
||||
if (tc.function?.name) existing.name += tc.function.name;
|
||||
if (tc.function?.arguments) existing.args += tc.function.arguments;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle completion
|
||||
if (choice.finish_reason) {
|
||||
isComplete = true;
|
||||
|
||||
if (choice.finish_reason === this.STOP_REASON_TOOL_CALLS) {
|
||||
shouldContinue = true;
|
||||
|
||||
// Finalize the first accumulated tool call
|
||||
// (additional tool calls in a single response are not supported by this plugin's architecture)
|
||||
const firstToolCall = this.accumulatedToolCalls.get(0);
|
||||
if (firstToolCall && firstToolCall.name && firstToolCall.args) {
|
||||
try {
|
||||
const args = JSON.parse(firstToolCall.args) as Record<string, unknown>;
|
||||
toolCall = new AIToolCall(
|
||||
aiToolFromString(firstToolCall.name),
|
||||
args,
|
||||
firstToolCall.id || undefined,
|
||||
undefined
|
||||
);
|
||||
} catch (error) {
|
||||
Exception.log(error);
|
||||
}
|
||||
}
|
||||
|
||||
this.accumulatedToolCalls.clear();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: text,
|
||||
isComplete: isComplete,
|
||||
toolCall: toolCall,
|
||||
shouldContinue: shouldContinue,
|
||||
toolCallStarted: toolCallStarted
|
||||
};
|
||||
} catch (error) {
|
||||
return this.createErrorChunk(error);
|
||||
}
|
||||
}
|
||||
|
||||
protected async extractContents(conversationContent: ConversationContent[]): Promise<MistralMessage[]> {
|
||||
const results: MistralMessage[] = [];
|
||||
|
||||
for (const content of this.filterConversationContents(conversationContent)) {
|
||||
const contentToExtract = content.content ?? "";
|
||||
|
||||
// Case 1: Assistant message with tool call
|
||||
if (content.toolCall) {
|
||||
const parsedContent = parseToolCall(content.toolCall);
|
||||
|
||||
if (parsedContent) {
|
||||
// Check if this is a cross-provider function call (has toolId in the stored format)
|
||||
// Mistral requires IDs to be alphanumeric only (a-z, A-Z, 0-9) with length of 9
|
||||
// If the ID doesn't match this format, it's from another provider (Claude/OpenAI)
|
||||
const mistralIdRegex = /^[a-zA-Z0-9]{9}$/;
|
||||
const isCrossProvider = parsedContent.toolCall.id &&
|
||||
!mistralIdRegex.test(parsedContent.toolCall.id);
|
||||
|
||||
if (!isCrossProvider && parsedContent.toolCall.id && parsedContent.toolCall.id.trim() !== "") {
|
||||
// Native Mistral function call - use proper function call format
|
||||
results.push({
|
||||
role: Role.Assistant,
|
||||
content: contentToExtract || "",
|
||||
tool_calls: [{
|
||||
id: parsedContent.toolCall.id,
|
||||
type: "function",
|
||||
function: {
|
||||
name: parsedContent.toolCall.name,
|
||||
arguments: JSON.stringify(parsedContent.toolCall.args)
|
||||
}
|
||||
}]
|
||||
});
|
||||
} else {
|
||||
// Cross-provider function call (from Claude/OpenAI) or no ID - use legacy text format
|
||||
const legacyText = this.convertToolCallToText(parsedContent);
|
||||
const combinedContent = contentToExtract.trim() !== ""
|
||||
? `${contentToExtract}\n\n${legacyText}`
|
||||
: legacyText;
|
||||
|
||||
results.push({
|
||||
role: content.role,
|
||||
content: combinedContent
|
||||
});
|
||||
}
|
||||
} else {
|
||||
results.push({
|
||||
role: content.role,
|
||||
content: contentToExtract.trim() !== "" ? contentToExtract : "Error parsing function call"
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Case 2: Binary file attachments
|
||||
if (content.attachments && content.attachments.length > 0) {
|
||||
const { formattedParts, uploadErrors } = await this.processAttachments<MistralContentPart>(
|
||||
content.attachments,
|
||||
(attachments) => this.formatBinaryFiles(attachments)
|
||||
);
|
||||
|
||||
const contentParts: MistralContentPart[] = [];
|
||||
|
||||
if (contentToExtract.trim() !== "") {
|
||||
contentParts.push({ type: "text", text: contentToExtract });
|
||||
}
|
||||
|
||||
contentParts.push(...formattedParts);
|
||||
|
||||
for (const uploadError of uploadErrors) {
|
||||
contentParts.push({
|
||||
type: "text",
|
||||
text: Exception.messageFrom(uploadError)
|
||||
});
|
||||
}
|
||||
|
||||
if (contentParts.length > 0) {
|
||||
results.push({
|
||||
role: content.role,
|
||||
content: contentParts
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Case 3: Function call response (tool result)
|
||||
if (content.functionResponse) {
|
||||
const parsedContent = parseFunctionResponse(content.functionResponse);
|
||||
|
||||
if (parsedContent) {
|
||||
// Check if this is a cross-provider function response
|
||||
// Mistral requires tool_call_id to be alphanumeric only (a-z, A-Z, 0-9) with length of 9
|
||||
const mistralIdRegex = /^[a-zA-Z0-9]{9}$/;
|
||||
const isCrossProvider = parsedContent.id &&
|
||||
!mistralIdRegex.test(parsedContent.id);
|
||||
|
||||
if (!isCrossProvider && parsedContent.id && parsedContent.id.trim() !== "") {
|
||||
// Native Mistral function response - use proper format
|
||||
results.push({
|
||||
role: "tool",
|
||||
content: JSON.stringify(parsedContent.functionResponse.response),
|
||||
tool_call_id: parsedContent.id,
|
||||
name: parsedContent.functionResponse.name
|
||||
});
|
||||
} else {
|
||||
// Cross-provider function response (from Claude/OpenAI) or no ID - use legacy text format
|
||||
const legacyText = this.convertFunctionResponseToText(parsedContent);
|
||||
results.push({
|
||||
role: content.role,
|
||||
content: legacyText
|
||||
});
|
||||
}
|
||||
} else {
|
||||
results.push({
|
||||
role: content.role,
|
||||
content: content.functionResponse
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Case 4: Regular text message
|
||||
if (contentToExtract.trim() !== "") {
|
||||
results.push({
|
||||
role: content.role,
|
||||
content: contentToExtract
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
protected mapFunctionDefinitions(aiToolDefinitions: IAIToolDefinition[]): MistralToolDefinition[] {
|
||||
return aiToolDefinitions.map((functionDefinition) => ({
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: functionDefinition.name,
|
||||
description: functionDefinition.description,
|
||||
parameters: {
|
||||
type: "object" as const,
|
||||
properties: functionDefinition.parameters.properties,
|
||||
required: functionDefinition.parameters.required
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
protected formatBinaryFiles(attachments: Attachment[]): string {
|
||||
const contentParts: MistralContentPart[] = [];
|
||||
protected formatBinaryFiles(attachments: Attachment[]): Promise<string> {
|
||||
const contentParts: ChatCompletionContentPart[] = [];
|
||||
const fileService = this.aiFileService as MistralFileService;
|
||||
|
||||
for (const attachment of attachments) {
|
||||
|
|
@ -360,7 +76,7 @@ export class Mistral extends BaseAIClass {
|
|||
if (!isPlainText && !this.isSupportedMimeType(mimeType)) {
|
||||
contentParts.push({
|
||||
type: "text",
|
||||
text: `Unsupported mime type '${mimeType}': ${attachment.fileName}`
|
||||
text: `User attempted to share a file with an unsupported mime type '${mimeType}': ${attachment.fileName}`
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
|
@ -389,7 +105,7 @@ export class Mistral extends BaseAIClass {
|
|||
{ type: "text", text: replaceCopy(Copy.AttachedFile, [attachment.fileName]) },
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: signedUrl
|
||||
image_url: { url: signedUrl }
|
||||
}
|
||||
);
|
||||
} else {
|
||||
|
|
@ -400,10 +116,10 @@ export class Mistral extends BaseAIClass {
|
|||
}
|
||||
}
|
||||
|
||||
return JSON.stringify(contentParts);
|
||||
return Promise.resolve(JSON.stringify(contentParts));
|
||||
}
|
||||
|
||||
private getTools(): MistralToolDefinition[] {
|
||||
protected getTools(): ChatCompletionToolDefinition[] {
|
||||
if (this.settingsService.settings.enableWebSearch) {
|
||||
return [
|
||||
{
|
||||
|
|
@ -425,45 +141,4 @@ export class Mistral extends BaseAIClass {
|
|||
}
|
||||
return this.mapFunctionDefinitions(this.aiToolDefinitions);
|
||||
}
|
||||
|
||||
private buildMistralToolChoice(): string {
|
||||
if (this.aiToolDefinitions.length === 0) {
|
||||
return "auto";
|
||||
}
|
||||
|
||||
switch (this.aiToolUsageMode) {
|
||||
case AIToolUsageMode.Auto:
|
||||
return "auto";
|
||||
case AIToolUsageMode.Enabled:
|
||||
return "any";
|
||||
case AIToolUsageMode.Disabled:
|
||||
return "none";
|
||||
}
|
||||
}
|
||||
|
||||
private extractRetryDelay(error: ApiError): number | undefined {
|
||||
if (error.info.type !== ApiErrorType.RATE_LIMIT || !error.info.responseHeaders) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const retryAfter = error.info.responseHeaders.get('Retry-After') ??
|
||||
error.info.responseHeaders.get('retry-after');
|
||||
if (!retryAfter) return undefined;
|
||||
|
||||
const seconds = parseInt(retryAfter, 10);
|
||||
if (!isNaN(seconds)) return seconds;
|
||||
|
||||
const date = new Date(retryAfter);
|
||||
if (!isNaN(date.getTime())) {
|
||||
const now = Date.now();
|
||||
const delayMs = date.getTime() - now;
|
||||
return Math.max(0, Math.ceil(delayMs / 1000));
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private isSupportedMimeType(mimeType: MimeType): boolean {
|
||||
return this.SUPPORTED_MIMETYPES.includes(mimeType);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ export class MistralAgent {
|
|||
|
||||
private async createAgent(): Promise<string> {
|
||||
const requestBody: MistralAgentCreateRequest = {
|
||||
model: AIProviderModel.MistralMedium,
|
||||
model: AIProviderModel.MistralSmall,
|
||||
name: "VaultKeeper Web Search",
|
||||
instructions: "You are a web search assistant. **Always** search the web to look up current information before responding and never answer from memory alone.",
|
||||
tools: [{ type: "web_search" }]
|
||||
|
|
|
|||
17
AIClasses/Mistral/MistralConversationNamingAgent.ts
Normal file
17
AIClasses/Mistral/MistralConversationNamingAgent.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { ChatCompletionsConversationNamingAgent } from "AIClasses/ChatCompletions/ChatCompletionsConversationNamingService";
|
||||
import { AIProvider, AIProviderURL, AIProviderModel } from "Enums/ApiProvider";
|
||||
|
||||
export class MistralConversationNamingAgent extends ChatCompletionsConversationNamingAgent {
|
||||
|
||||
public constructor() {
|
||||
super(AIProvider.Mistral);
|
||||
}
|
||||
|
||||
protected get apiUrl(): string {
|
||||
return AIProviderURL.Mistral;
|
||||
}
|
||||
|
||||
protected get namerModel(): string {
|
||||
return AIProviderModel.MistralNamer;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,99 +1,7 @@
|
|||
// Mistral Chat Completions API types
|
||||
// Mistral-specific API types. The generic Chat Completions request/response/stream
|
||||
// types live in AIClasses/ChatCompletions/ChatCompletionsTypes.ts.
|
||||
|
||||
export interface MistralStreamChunk {
|
||||
id: string;
|
||||
object: "chat.completion.chunk";
|
||||
created: number;
|
||||
model: string;
|
||||
choices: MistralStreamChoice[];
|
||||
}
|
||||
|
||||
export interface MistralStreamChoice {
|
||||
index: number;
|
||||
delta: MistralDelta;
|
||||
finish_reason: string | null;
|
||||
}
|
||||
|
||||
export interface MistralDelta {
|
||||
role?: string;
|
||||
content?: string;
|
||||
tool_calls?: MistralToolCallDelta[];
|
||||
}
|
||||
|
||||
export interface MistralToolCallDelta {
|
||||
id?: string;
|
||||
type?: "function";
|
||||
function?: {
|
||||
name?: string;
|
||||
arguments?: string;
|
||||
};
|
||||
index?: number;
|
||||
}
|
||||
|
||||
// Non-streaming response (used for conversation naming)
|
||||
export interface MistralChatResponse {
|
||||
id: string;
|
||||
object: "chat.completion";
|
||||
created: number;
|
||||
model: string;
|
||||
choices: MistralChoice[];
|
||||
usage: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MistralChoice {
|
||||
index: number;
|
||||
message: {
|
||||
role: string;
|
||||
content: string | null;
|
||||
tool_calls?: MistralToolCall[];
|
||||
};
|
||||
finish_reason: string;
|
||||
}
|
||||
|
||||
export interface MistralToolCall {
|
||||
id: string;
|
||||
type: "function";
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Tool definition format
|
||||
export interface MistralToolDefinition {
|
||||
type: "function";
|
||||
function: {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: {
|
||||
type: "object";
|
||||
properties: Record<string, object>;
|
||||
required?: string[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// Message types for request construction
|
||||
export interface MistralMessage {
|
||||
role: string;
|
||||
content: string | MistralContentPart[];
|
||||
tool_calls?: MistralToolCall[];
|
||||
tool_call_id?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface MistralContentPart {
|
||||
type: "text" | "image_url" | "document_url";
|
||||
text?: string;
|
||||
image_url?: string;
|
||||
document_url?: string;
|
||||
}
|
||||
|
||||
// Agents API types
|
||||
// Agents API types (web search)
|
||||
export interface MistralAgentCreateRequest {
|
||||
model: string;
|
||||
name?: string;
|
||||
|
|
|
|||
|
|
@ -9,17 +9,17 @@ import { fromString as aiToolFromString } from "Enums/AITool";
|
|||
import type { IAIToolDefinition } from "AIClasses/ToolDefinitions/IAIToolDefinition";
|
||||
import type { ResponseEvent, ResponseOutputTextDelta, ResponseOutputItemAdded, ResponseOutputItemDone, ResponseErrorEvent, ResponseFailedEvent, OpenAIToolTool, ResponsesAPIInput } from "./OpenAITypes";
|
||||
import { Exception } from "Helpers/Exception";
|
||||
import { ApiError, ApiErrorType } from "Types/ApiError";
|
||||
import { ApiErrorType } from "Types/ApiError";
|
||||
import { MimeType, toMimeType } from "Enums/MimeType";
|
||||
import { isTextFile } from "Enums/FileType";
|
||||
import { MimeTypeToFileTypes } from "Enums/FileTypeMimeTypeMapping";
|
||||
import { parseToolCall, parseFunctionResponse } from "Helpers/ResponseHelper";
|
||||
import { AIToolUsageMode } from "Enums/AIToolUsageMode";
|
||||
import { Copy, replaceCopy } from "Enums/Copy";
|
||||
import { replaceCopy } from 'Helpers/Helpers';
|
||||
import { Copy } from "Enums/Copy";
|
||||
|
||||
export class OpenAI extends BaseAIClass {
|
||||
|
||||
private readonly SUPPORTED_MIMETYPES = [
|
||||
protected readonly SUPPORTED_MIMETYPES = [
|
||||
MimeType.TEXT_PLAIN,
|
||||
MimeType.APPLICATION_PDF,
|
||||
MimeType.IMAGE_JPEG,
|
||||
|
|
@ -31,6 +31,10 @@ export class OpenAI extends BaseAIClass {
|
|||
super(AIProvider.OpenAI);
|
||||
}
|
||||
|
||||
protected get supportedMimeTypes(): MimeType[] {
|
||||
return this.SUPPORTED_MIMETYPES;
|
||||
}
|
||||
|
||||
public async* streamRequest(conversation: Conversation): AsyncGenerator<IStreamChunk, void, unknown> {
|
||||
|
||||
// Refresh file cache only if conversation has attachments
|
||||
|
|
@ -327,7 +331,7 @@ export class OpenAI extends BaseAIClass {
|
|||
}));
|
||||
}
|
||||
|
||||
protected formatBinaryFiles(attachments: Attachment[]): string {
|
||||
protected formatBinaryFiles(attachments: Attachment[]): Promise<string> {
|
||||
const contentBlocks: unknown[] = [];
|
||||
|
||||
for (const attachment of attachments) {
|
||||
|
|
@ -346,7 +350,7 @@ export class OpenAI extends BaseAIClass {
|
|||
}
|
||||
|
||||
if (!isPlainText && !this.isSupportedMimeType(mimeType)) {
|
||||
contentBlocks.push({ type: "input_text", text: `Unsupported mime type '${mimeType}': ${attachment.fileName}` });
|
||||
contentBlocks.push({ type: "input_text", text: `User attempted to share a file with an unsupported mime type '${mimeType}': ${attachment.fileName}` });
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -359,10 +363,10 @@ export class OpenAI extends BaseAIClass {
|
|||
);
|
||||
}
|
||||
|
||||
return JSON.stringify([{
|
||||
return Promise.resolve(JSON.stringify([{
|
||||
role: "user",
|
||||
content: contentBlocks
|
||||
}]);
|
||||
}]));
|
||||
}
|
||||
|
||||
private getTools(): (OpenAIToolTool | { type: string })[] {
|
||||
|
|
@ -375,80 +379,10 @@ export class OpenAI extends BaseAIClass {
|
|||
}
|
||||
|
||||
private buildOpenAIToolChoice(): string {
|
||||
// If no tools defined, fall back to auto
|
||||
if (this.aiToolDefinitions.length === 0) {
|
||||
return "auto";
|
||||
}
|
||||
|
||||
switch (this.aiToolUsageMode) {
|
||||
case AIToolUsageMode.Auto:
|
||||
return "auto";
|
||||
case AIToolUsageMode.Enabled:
|
||||
return "required";
|
||||
case AIToolUsageMode.Disabled:
|
||||
return "none";
|
||||
}
|
||||
}
|
||||
|
||||
private extractRetryDelay(error: ApiError): number | undefined {
|
||||
if (error.info.type !== ApiErrorType.RATE_LIMIT || !error.info.responseHeaders) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const headers = error.info.responseHeaders;
|
||||
|
||||
// 1. Prefer standard Retry-After header (seconds or HTTP-date)
|
||||
const retryAfter = headers.get('retry-after');
|
||||
if (retryAfter) {
|
||||
const seconds = Number(retryAfter);
|
||||
if (!Number.isNaN(seconds)) {
|
||||
return Math.max(0, seconds);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback to provider-specific headers (e.g., OpenAI)
|
||||
const resetHeader =
|
||||
headers.get('x-ratelimit-reset-requests') ??
|
||||
headers.get('x-ratelimit-reset-tokens');
|
||||
|
||||
if (resetHeader) {
|
||||
return this.parseDurationToSeconds(resetHeader);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses duration strings (e.g., "15s", "600ms", "2m", "1h") into seconds.
|
||||
* Returns undefined if parsing fails.
|
||||
*/
|
||||
private parseDurationToSeconds(value: string): number | undefined {
|
||||
const trimmed = value.trim();
|
||||
const numericValue = parseFloat(trimmed);
|
||||
|
||||
if (Number.isNaN(numericValue)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Parse based on suffix
|
||||
if (trimmed.endsWith('ms')) {
|
||||
return Math.max(0, Math.ceil(numericValue / 1000));
|
||||
}
|
||||
if (trimmed.endsWith('s')) {
|
||||
return Math.max(0, numericValue);
|
||||
}
|
||||
if (trimmed.endsWith('m')) {
|
||||
return Math.max(0, numericValue * 60);
|
||||
}
|
||||
if (trimmed.endsWith('h')) {
|
||||
return Math.max(0, numericValue * 3600);
|
||||
}
|
||||
|
||||
// Fallback: treat as raw seconds
|
||||
return Math.max(0, numericValue);
|
||||
}
|
||||
|
||||
private isSupportedMimeType(mimeType: MimeType): boolean {
|
||||
return this.SUPPORTED_MIMETYPES.includes(mimeType);
|
||||
return this.buildToolChoice<string>({
|
||||
auto: "auto",
|
||||
enabled: "required",
|
||||
disabled: "none"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { Resolve } from "Services/DependencyService";
|
||||
import { Services } from "Services/Services";
|
||||
import type { IConversationNamingService } from "AIClasses/IConversationNamingService";
|
||||
import type { IConversationNamingAgent } from "AIClasses/IConversationNamingAgent";
|
||||
import { AIProvider, AIProviderURL, AIProviderModel } from "Enums/ApiProvider";
|
||||
import { Role } from "Enums/Role";
|
||||
import { NamePrompt } from "AIPrompts/NamePrompt";
|
||||
|
|
@ -9,7 +9,7 @@ import { Exception } from "Helpers/Exception";
|
|||
import type { AbortService } from "Services/AbortService";
|
||||
import type { ResponsesAPINonStreamingResponse } from "./OpenAITypes";
|
||||
|
||||
export class OpenAIConversationNamingService implements IConversationNamingService {
|
||||
export class OpenAIConversationNamingAgent implements IConversationNamingAgent {
|
||||
private readonly apiKey: string;
|
||||
private readonly abortService: AbortService;
|
||||
|
||||
|
|
@ -23,17 +23,18 @@ import { GetWebViewerContent } from "./Tools/GetWebViewerContent";
|
|||
import { DeleteVaultFolder } from "./Tools/DeleteVaultFolder";
|
||||
import { MoveVaultFolder } from "./Tools/MoveVaultFolder";
|
||||
import { ChatMode, chatModeAllowsEdits } from "Enums/ChatMode";
|
||||
import type { AITool } from "Enums/AITool";
|
||||
|
||||
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, GetWebViewerContent,
|
||||
WriteVaultFile, PatchVaultFile, DeleteVaultFiles, MoveVaultFiles, CreateVaultFolder, DeleteVaultFolder, MoveVaultFolder];
|
||||
|
||||
public static readonly editModeDefinitionsList = [WriteVaultFile, PatchVaultFile, DeleteVaultFiles,
|
||||
MoveVaultFiles, CreateVaultFolder, DeleteVaultFolder, MoveVaultFolder];
|
||||
|
||||
public static agentDefinitions(chatMode: ChatMode, memories: boolean, updateMemories: boolean, webViewer: boolean): IAIToolDefinition[] {
|
||||
this.isGated = false;
|
||||
|
||||
if (chatMode === ChatMode.Planning) {
|
||||
return [ExecuteWorkflow];
|
||||
|
|
@ -58,15 +59,7 @@ export abstract class AIToolDefinitions {
|
|||
}
|
||||
|
||||
if (chatModeAllowsEdits(chatMode)) {
|
||||
actions = actions.concat([
|
||||
WriteVaultFile,
|
||||
PatchVaultFile,
|
||||
DeleteVaultFiles,
|
||||
MoveVaultFiles,
|
||||
CreateVaultFolder,
|
||||
DeleteVaultFolder,
|
||||
MoveVaultFolder
|
||||
]);
|
||||
actions = actions.concat(this.editModeDefinitionsList);
|
||||
}
|
||||
|
||||
return actions;
|
||||
|
|
@ -114,6 +107,10 @@ export abstract class AIToolDefinitions {
|
|||
return [...this.agentDefinitions(ChatMode.Edit, false, false, false), CompleteTask];
|
||||
}
|
||||
|
||||
public static requiresEditModeEnabled(definition: AITool): boolean {
|
||||
return this.editModeDefinitionsList.map(definition => definition.name).includes(definition);
|
||||
}
|
||||
|
||||
public static compactSummaryForPlanningAgent(): string {
|
||||
return this.definitionsList.map(definition => {
|
||||
// Extract first line of description as brief purpose
|
||||
|
|
|
|||
|
|
@ -10,25 +10,25 @@ export class AIToolResponse {
|
|||
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.
|
||||
They may have changed their mind about the requested change.
|
||||
|
||||
**CRITICAL:** Immediately stop all further actions and consult with the user`;
|
||||
**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.
|
||||
The user has reviewed your proposed action and provided a modification or alternative direction.
|
||||
|
||||
**Critical Instructions:**
|
||||
1. This is NOT an error or failure - this is valuable user guidance that should be taken seriously
|
||||
2. The user may want to:
|
||||
- Adjust the SAME action with different parameters (e.g., write to a different file)
|
||||
- Change to a DIFFERENT action entirely (e.g., delete instead of write)
|
||||
- Add context or constraints you didn't initially consider
|
||||
3. Carefully analyze the user's suggestion below to understand their true intent
|
||||
4. Acknowledge their feedback and explain how you'll adjust your approach
|
||||
5. Then proceed with the modified action that aligns with their guidance
|
||||
**Critical Instructions:**
|
||||
1. This is NOT an error or failure - this is valuable user guidance that should be taken seriously
|
||||
2. The user may want to:
|
||||
- Adjust the SAME action with different parameters (e.g., write to a different file)
|
||||
- Change to a DIFFERENT action entirely (e.g., delete instead of write)
|
||||
- Add context or constraints you didn't initially consider
|
||||
3. Carefully analyze the user's suggestion below to understand their true intent
|
||||
4. Acknowledge their feedback and explain how you'll adjust your approach
|
||||
5. Then proceed with the modified action that aligns with their guidance
|
||||
|
||||
**User's Suggestion:**`;
|
||||
**User's Suggestion:**`;
|
||||
|
||||
constructor(name: AITool, payload: AIToolResponsePayload, toolId?: string) {
|
||||
this.name = name;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import type { Artifact } from "Conversations/Artifact";
|
||||
import type { Attachment } from "Conversations/Attachment";
|
||||
|
||||
export class AIToolResponsePayload {
|
||||
public readonly response: object;
|
||||
public readonly artifacts: Artifact[];
|
||||
public readonly attachments: Attachment[];
|
||||
|
||||
constructor(response: object, attachments: Attachment[] = []) {
|
||||
constructor(response: object, artifacts: Artifact[] = [], attachments: Attachment[] = []) {
|
||||
this.response = response;
|
||||
this.artifacts = artifacts;
|
||||
this.attachments = attachments;
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import type { IAIToolDefinition } from "../IAIToolDefinition";
|
|||
|
||||
export const SubmitPlan: IAIToolDefinition = {
|
||||
name: AITool.SubmitPlan,
|
||||
description: `Submits an execution plan with ordered, actionable steps.
|
||||
description: `Submits an execution plan with ordered, actionable steps for the user to review.
|
||||
|
||||
Call this function:
|
||||
- After analyzing the goal and vault context to provide a structured plan
|
||||
|
|
@ -26,11 +26,11 @@ Do NOT use this function:
|
|||
properties: {
|
||||
description: {
|
||||
type: "string",
|
||||
description: "Brief summary of what this step accomplishes (e.g., 'Search for ML notes', 'Create index file'). This is user-facing and should be very concise."
|
||||
description: "Brief title of what this step accomplishes (e.g., 'Search for ML notes', 'Create index file'). This is just a heading and should be very concise."
|
||||
},
|
||||
instruction: {
|
||||
type: "string",
|
||||
description: "Detailed instructions for executing this step. Should be specific enough to guide the execution without ambiguity. Examples: 'Search vault for all notes with tag #machine-learning using search_vault_files', 'Create new file ML-Index.md in /Research folder with heading structure', 'Update frontmatter in daily note 2024-01-15 to add tag #reviewed'"
|
||||
description: "Detailed instructions for executing this step. Should be specific enough to guide the execution without ambiguity. This is user facing and should be formatted using markdown. Examples: 'Search vault for all notes tagged **#machine-learning**', 'Create new file `ML-Index.md` in `/Research` folder with the following heading structure:\\n- Overview\\n- Key Papers\\n- Open Questions', 'Update frontmatter in daily note `2024-01-15` to add tag **#reviewed**'"
|
||||
},
|
||||
context: {
|
||||
type: "string",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ You are a task execution agent. You execute assigned tasks and report outcomes.
|
|||
|
||||
When you encounter genuine ambiguity about *what* to do, ask the user before proceeding.
|
||||
|
||||
**Reading binary files (PDFs, images, documents):** When you read one of these, its content does NOT come back as text in the tool result. The result is a brief confirmation, and the actual content arrives as an **attachment in the message immediately after**. That attachment IS the file you read and is already in your context — use it directly. Do NOT re-read the file to "get the real content" (re-reading returns the same attachment), and do NOT report failure because the result wasn't text — the read succeeded.
|
||||
|
||||
## Boundaries
|
||||
|
||||
### You MUST:
|
||||
|
|
|
|||
|
|
@ -7,7 +7,10 @@ import { PlanningPrompt } from "AIPrompts/PlanningPrompt";
|
|||
import { ExecutionPrompt } from "./ExecutionPrompt";
|
||||
import { OrchestrationPrompt } from "./OrchestrationPrompt";
|
||||
import type { MemoriesService } from "Services/MemoriesService";
|
||||
import { Copy, replaceCopy } from "Enums/Copy";
|
||||
import { replaceCopy } from 'Helpers/Helpers';
|
||||
import { Copy } from "Enums/Copy";
|
||||
import { ChatMode } from "Enums/ChatMode";
|
||||
import { AIProvider } from "Enums/ApiProvider";
|
||||
|
||||
export interface IPrompt {
|
||||
systemInstruction(): Promise<string>;
|
||||
|
|
@ -63,13 +66,19 @@ export class AIPrompt implements IPrompt {
|
|||
private buildActiveDirectives(): string {
|
||||
const s = this.settingsService.settings;
|
||||
|
||||
const chatModeDirective = s.chatMode === ChatMode.ReadOnly
|
||||
? Copy.DirectiveChatModeReadOnly
|
||||
: s.chatMode === ChatMode.Edit
|
||||
? Copy.DirectiveChatModeEdit
|
||||
: Copy.DirectiveChatModePlanning;
|
||||
|
||||
const memoriesDirective = !s.enableMemories
|
||||
? Copy.DirectiveMemoriesDisabled
|
||||
: s.allowUpdatingMemories
|
||||
? Copy.DirectiveMemoriesEnabled
|
||||
: Copy.DirectiveMemoriesReadOnly;
|
||||
|
||||
const webSearchDirective = s.enableWebSearch
|
||||
const webSearchDirective = s.enableWebSearch && s.provider !== AIProvider.Local
|
||||
? Copy.DirectiveWebSearchEnabled
|
||||
: Copy.DirectiveWebSearchDisabled;
|
||||
|
||||
|
|
@ -77,7 +86,7 @@ export class AIPrompt implements IPrompt {
|
|||
? Copy.DirectiveWebViewerEnabled
|
||||
: Copy.DirectiveWebViewerDisabled;
|
||||
|
||||
const directives = [memoriesDirective, webSearchDirective, webViewerDirective].join("\n");
|
||||
const directives = [chatModeDirective, memoriesDirective, webSearchDirective, webViewerDirective].join("\n");
|
||||
return replaceCopy(Copy.ActiveCapabilitiesHeader, [directives]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -141,6 +141,8 @@ You can search and read vault files at any point before making a decision. This
|
|||
- Read files when you need to understand structure or content, not just confirm existence
|
||||
- 1–3 searches is typical; if you need significantly more, consider whether the plan itself needs revision
|
||||
|
||||
**Reading binary files (PDFs, images, documents):** Their content does NOT come back as text in the tool result — the result is a brief confirmation, and the content arrives as an **attachment in the message immediately after**. That attachment IS the file content and is already in your context. Do NOT re-read the same file expecting text; re-reading just returns the same attachment.
|
||||
|
||||
## Obsidian Bases
|
||||
|
||||
**Bases** is a core plugin (available since Obsidian 1.9) that creates database-like views of notes from their YAML frontmatter properties. Bases are stored as \`.base\` files — treat them like notes: reference with \`[[MyBase.base]]\`, embed with \`![[MyBase.base]]\` or \`![[MyBase.base#ViewName]]\`.
|
||||
|
|
|
|||
|
|
@ -143,6 +143,8 @@ Start broad, then narrow. Short queries return more results than long, specific
|
|||
|
||||
When searches return images or PDFs, read them — don't just note their existence. Extract relevant information to inform your plan. Since the execution agent retains context across steps, you can also plan read steps for non-markdown files that the execution agent needs to reference during later steps.
|
||||
|
||||
**How binary reads return content:** When you read a PDF, image, or Office/ODF document, its content does NOT come back as text in the tool result. The result is a brief confirmation, and the actual content arrives as an **attachment in the message immediately after**. That attachment IS the file you read — it is already in your context. Do NOT re-read the same file to "get the real content"; re-reading just returns the same attachment.
|
||||
|
||||
## Plan Structure
|
||||
|
||||
### 1. Simple Tasks (1-3 steps)
|
||||
|
|
@ -203,6 +205,22 @@ Preserve and extend the knowledge graph:
|
|||
- **Images/PDFs**: Must be read first, then referenced — plan explicit read steps so the execution agent has the content in its working memory
|
||||
- **Complex structures**: May need multi-step processing
|
||||
|
||||
### 3. Obsidian Markdown
|
||||
|
||||
Notes in this vault use Obsidian-flavored Markdown. Recognize these syntax elements during exploration so you understand the content correctly, and preserve them when planning edits.
|
||||
|
||||
**Embeds vs. links** — \`[[Note]]\` is a reference; \`![[Note]]\` embeds content inline. Also \`![[image.png]]\`, \`![[doc.pdf#page=3]]\`, \`![[Note#Heading]]\`, \`![[Note#^block-id]]\`. When planning edits, never silently convert an embed to a link (or vice versa).
|
||||
|
||||
**Callouts** — Obsidian renders \`> [!note]\`, \`> [!warning]\`, \`> [!tip]\`, etc. as styled blocks (not plain blockquotes). Common types: \`note\`, \`tip\`, \`info\`, \`important\`, \`warning\`, \`danger\`, \`success\`, \`question\`, \`example\`, \`quote\`, \`todo\`. Foldable with \`-\` (collapsed) or \`+\` (expanded). Preserve callout structure when editing notes that use them.
|
||||
|
||||
**Tags & frontmatter** — Tags as \`#tag\` or nested \`#parent/child\` (no spaces; hyphens for multi-word). YAML frontmatter at the top of a note may contain special fields: \`tags\` (list, no \`#\` prefix), \`aliases\` (alternative note names — affects wiki-link resolution), \`cssclasses\`, plus arbitrary user fields used by Bases. **Never modify the \`created\` timestamp**; \`updated\` is managed automatically.
|
||||
|
||||
**Tasks** — This vault uses the Tasks plugin. Extended checkboxes (\`[/]\` in progress, \`[-]\` cancelled, \`[>]\` forwarded, \`[<]\` scheduled, \`[!]\` important, \`[?]\` question) and emoji metadata (📅 due, ⏳ scheduled, 🛫 start, ✅ done, 🔁 recurrence, ⏫🔼🔽 priority) carry meaning — preserve them when editing task lines.
|
||||
|
||||
**Other syntax to preserve**: block references (\`^block-id\`), highlights (\`==text==\`), comments (\`%%hidden%%\`), inline/block LaTeX (\`$...$\`, \`$$...$$\`), and mermaid diagrams (\`\`\`mermaid blocks). Treat all of these as load-bearing — do not strip or convert them to plain Markdown without explicit user instruction.
|
||||
|
||||
**Implication for plan steps**: when step context describes content to add or modify, specify the Obsidian syntax to use (e.g., "wrap the warning in a \`> [!warning]\` callout", "add \`#project/active\` tag", "embed the diagram with \`![[architecture.canvas]]\`"). Don't leave the execution agent to choose between plain Markdown and Obsidian-flavored output.
|
||||
|
||||
## Obsidian Bases
|
||||
|
||||
**Bases** is a core plugin (available since Obsidian 1.9) that creates database-like views of notes from their YAML frontmatter properties. Bases are stored as \`.base\` files — treat them like notes: reference with \`[[MyBase.base]]\`, embed with \`![[MyBase.base]]\` or \`![[MyBase.base#ViewName]]\`.
|
||||
|
|
|
|||
18
AIPrompts/QuickActionPrompts/ApplyLinksPrompt.ts
Normal file
18
AIPrompts/QuickActionPrompts/ApplyLinksPrompt.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
export const ApplyLinksPrompt: string = `You are an Obsidian note organizer. Your task is to add wikilinks to a note by linking mentions of existing vault pages.
|
||||
|
||||
You will be given a newline-separated list of page names that already exist in the vault. Scan the note for mentions of those exact names — including obvious case variations and simple plural/singular forms — and wrap each mention in [[ and ]] to turn it into a wikilink.
|
||||
|
||||
Rules:
|
||||
- Only link pages that appear in the provided list. Never invent new links.
|
||||
- Do not link a page that is not mentioned in the note's text.
|
||||
- Link every occurrence of a mentioned page, not just the first.
|
||||
- Do not link inside fenced code blocks, inline code, existing links, existing wikilinks, image embeds, or URLs.
|
||||
- If a name overlaps with another (e.g. "Paris" and "Paris Agreement"), prefer the longest matching name.
|
||||
- If a mention uses a different display form than the canonical page name, use the alias syntax: [[Canonical Name|displayed text]].
|
||||
- Preserve the rest of the note exactly — do not change wording, formatting, headings, whitespace, or any other content.
|
||||
- If no mentions of any provided page are found, return the note unchanged.
|
||||
|
||||
Existing vault pages:
|
||||
{links}
|
||||
|
||||
Return only the updated note with no explanation, preamble, or commentary.`;
|
||||
10
AIPrompts/QuickActionPrompts/ApplyTagsPrompt.ts
Normal file
10
AIPrompts/QuickActionPrompts/ApplyTagsPrompt.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export const ApplyTagsPrompt: string = `You are an Obsidian note organizer. Your task is to choose tags for a note from a fixed list of tags that already exist in the vault.
|
||||
|
||||
You will be given a newline-separated list of existing vault tags (each prefixed with #) and the note's body. Choose the tags from that list that genuinely describe the note's topics, themes, or type — typically 3-7, fewer if the note is short or narrow in scope. Never invent or modify tags; only choose names that appear verbatim in the provided list. If no existing tag is a good fit, return nothing (an empty response).
|
||||
|
||||
Output format:
|
||||
- Return only the chosen tags, one per line, each prefixed with # exactly as shown in the provided list. No bullet points, no quotes, no commentary, no explanation.
|
||||
- If no tags fit, return an empty response with no characters at all.
|
||||
|
||||
Existing vault tags:
|
||||
{tags}`;
|
||||
|
|
@ -11,4 +11,12 @@ Rewrite the content so it fits the template's structure and headings. Preserve a
|
|||
If the template section does not resemble a document template (e.g. it is a journal entry, a regular note, or otherwise makes no sense as a template), do not apply it. Instead return exactly the following with no other text:
|
||||
${Copy.ApplyTemplateCancelled}
|
||||
|
||||
Return only the reformatted note with no explanation, preamble, or commentary.`;
|
||||
Return only the reformatted note with no explanation, preamble, or commentary.
|
||||
|
||||
File stats:
|
||||
Created - {created}
|
||||
Modified - {modified}
|
||||
Size - {size}
|
||||
|
||||
Current date:
|
||||
{date}`;
|
||||
33
AIPrompts/QuickActionPrompts/GenerateFrontmatterPrompt.ts
Normal file
33
AIPrompts/QuickActionPrompts/GenerateFrontmatterPrompt.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
export const GenerateFrontmatterPrompt: string = `You are an Obsidian note organiser. You will be given the body of a note. Your task is to infer YAML frontmatter for it.
|
||||
|
||||
Infer reasonable values for the following fields where the content supports them:
|
||||
- aliases: alternative names the note may be referenced by (omit if none are obvious)
|
||||
- tags: a small set of specific, reusable tags. Use lowercase with no spaces and no # prefix. Prefer hierarchical tags using forward-slash notation (e.g. "type/person", "projects/active") over flat generic tags. Reuse the vault's existing tags (listed below) whenever one genuinely fits the note — this keeps the vault's tagging consistent. Only coin a new tag when none of the existing tags describes an important topic of the note.
|
||||
- title: a concise title for the note. Wrap in quotes if it contains colons, commas, or other punctuation.
|
||||
- summary: a single-sentence description of the note
|
||||
- created: the note's creation date in YYYY-MM-DD format. Use the "Created" value from the File stats section below — do NOT use today's date.
|
||||
|
||||
CRITICAL — tags and aliases MUST always be emitted as YAML block-style lists, even when there is only a single value. A scalar string value for these fields is invalid in Obsidian 1.4+ and completely ignored in Obsidian 1.9+.
|
||||
|
||||
Correct:
|
||||
tags:
|
||||
- meeting
|
||||
- projects/alpha
|
||||
|
||||
Incorrect (will be silently broken):
|
||||
tags: meeting, projects/alpha
|
||||
|
||||
Wrap any text value in double quotes if it contains a colon, comma, or other YAML-significant character.
|
||||
|
||||
Only include fields you can fill in confidently from the content — do not invent information. Use this key order when present: aliases, tags, title, summary, created.
|
||||
|
||||
Output ONLY the YAML frontmatter fields. Do NOT include the surrounding --- fences, do not repeat the note body, and do not add any explanation, preamble, or commentary. Existing frontmatter on the note will be merged automatically, so return only the fields you are suggesting.
|
||||
|
||||
Existing vault tags (prefer reusing these):
|
||||
{tags}
|
||||
|
||||
File stats:
|
||||
Created - {created}
|
||||
|
||||
Current date:
|
||||
{date}`;
|
||||
7
AIPrompts/QuickActionPrompts/ProofreadPrompt.ts
Normal file
7
AIPrompts/QuickActionPrompts/ProofreadPrompt.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export const ProofreadPrompt: string = `You are a careful proofreader. Your task is to correct errors in the provided text.
|
||||
|
||||
Fix spelling, grammar, punctuation, capitalization, and obvious typos. Do not rewrite for style, change the author's voice, restructure sentences, or alter meaning. Preserve all existing Markdown formatting, links, code blocks, and whitespace exactly. Leave content inside fenced code blocks and inline code untouched.
|
||||
|
||||
If the text contains no errors, return it unchanged.
|
||||
|
||||
Return only the corrected text with no explanation, preamble, or commentary.`;
|
||||
10
AIPrompts/QuickActionPrompts/SuggestTagsPrompt.ts
Normal file
10
AIPrompts/QuickActionPrompts/SuggestTagsPrompt.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export const SuggestTagsPrompt: string = `You are an Obsidian note organizer. You will be given the body of a note. Your task is to suggest a small set of tags that describe the note's topics, themes, or type.
|
||||
|
||||
Choose specific, reusable tags — typically 3-7, fewer if the note is short or narrow in scope. Use lowercase with no spaces. Prefer hierarchical tags using forward-slash notation (e.g. "type/person", "projects/active") over flat generic tags. Reuse the vault's existing tags (listed below) whenever one genuinely fits the note — this keeps the vault's tagging consistent. Only coin a new tag when none of the existing tags describes an important topic of the note.
|
||||
|
||||
Output format:
|
||||
- Return only the chosen tags, one per line, each prefixed with # (e.g. #type/person). No bullet points, no quotes, no commentary, no explanation.
|
||||
- If no tag is a good fit, return an empty response with no characters at all.
|
||||
|
||||
Existing vault tags (prefer reusing these):
|
||||
{tags}`;
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
import { Copy } from "Enums/Copy";
|
||||
|
||||
export const SystemInstruction: string = `
|
||||
# Obsidian AI Assistant
|
||||
|
||||
|
|
@ -30,7 +28,23 @@ User: "Create a note about today's meeting with Sarah"
|
|||
|
||||
---
|
||||
|
||||
### 2. PLAN EXECUTION PROTOCOL
|
||||
### 2. CHAT MODE
|
||||
|
||||
**Your available actions are determined by the current chat mode, shown in the Active Capabilities section at the end of this prompt.**
|
||||
|
||||
| Mode | What you can do |
|
||||
|------|----------------|
|
||||
| **Read-Only** | Search, read, and list vault files only — no writes, moves, or deletes |
|
||||
| **Edit** | Full access — create, edit, move, and delete files |
|
||||
| **Planning** | Full access via planned workflow execution |
|
||||
|
||||
**In Read-Only mode:**
|
||||
- Do not attempt to create, edit, move, or delete files — the tools are not available
|
||||
- If the user asks you to make changes, acknowledge that chat mode is currently set to Read-Only and let them know they can change it using the chat mode button in the input bar
|
||||
|
||||
---
|
||||
|
||||
### 3. PLAN EXECUTION PROTOCOL
|
||||
|
||||
**When the user has enabled planning mode and you receive a plan, follow this protocol.**
|
||||
|
||||
|
|
@ -137,7 +151,7 @@ When asking questions during execution:
|
|||
|
||||
---
|
||||
|
||||
### 3. HISTORICAL CONTEXT INTERPRETATION
|
||||
### 4. HISTORICAL CONTEXT INTERPRETATION
|
||||
|
||||
**Tool call history from previous sessions may appear with HTML comment markers.**
|
||||
|
||||
|
|
@ -151,7 +165,7 @@ If you see JSON preceded by "Historical tool call/result", it documents a past a
|
|||
|
||||
---
|
||||
|
||||
### 4. WIKI-LINK EVERYTHING FROM THE VAULT
|
||||
### 5. WIKI-LINK EVERYTHING FROM THE VAULT
|
||||
|
||||
**ALWAYS use [[double-bracket]] wiki-link syntax when referencing anything from the user's vault.**
|
||||
|
||||
|
|
@ -194,7 +208,7 @@ This is a hard requirement, not a suggestion. The format is: \`[[Note Name]]\`
|
|||
|
||||
---
|
||||
|
||||
### 5. VAULT-FIRST DECISION FRAMEWORK
|
||||
### 6. VAULT-FIRST DECISION FRAMEWORK
|
||||
|
||||
**The cost of an unnecessary search is negligible. Missing relevant information is costly.**
|
||||
|
||||
|
|
@ -218,7 +232,7 @@ Acknowledge the search, then provide general assistance:
|
|||
|
||||
---
|
||||
|
||||
### 6. MEMORY SYSTEM
|
||||
### 7. MEMORY SYSTEM
|
||||
|
||||
**Memory gives you continuity across sessions. It is your record of how this user works in this vault.**
|
||||
|
||||
|
|
@ -342,12 +356,97 @@ Example: If the user says "create a note in \`folder:"Projects/2025"\`", the fol
|
|||
|
||||
### File Attachments
|
||||
|
||||
**Users can attach files directly to their messages.** When a file is attached, its content is provided inline in the conversation. Attached files are likely NOT present in the vault so use the attached content directly.
|
||||
**File content reaches you as an attachment in two distinct situations — treat both the same way once the content is present:**
|
||||
|
||||
**How to recognize attached files:**
|
||||
- A text block states: ${Copy.AttachedFile}
|
||||
1. **User uploads** — the user attaches a file directly to their message.
|
||||
2. **Tool reads of binary vault files** — when you call \`read_vault_files\` on a PDF, image, or Office/ODF document, the file's content is NOT returned as text in the function result. Instead, the function result is a short confirmation message, and the actual file content is delivered as an **attachment in the message immediately following the result**. This attachment IS the vault file you asked to read.
|
||||
|
||||
**Critical:** When you read a binary file (PDF/image/document), do not be misled by the brief "files retrieved" confirmation — the real content is the attachment that follows it. The content is already in your context. **Do NOT call \`read_vault_files\` again for the same file** in an attempt to "get the real content" — re-reading returns the same attachment and accomplishes nothing.
|
||||
|
||||
**How to recognize an attachment:**
|
||||
- A text block introduces it (e.g. stating the file name and that its contents follow)
|
||||
- The file content or file ID immediately follows that text block
|
||||
- Attachments for document filetypes (.docx, .odt, .xlsx, etc.,) are included as plain text
|
||||
- Attachments for document filetypes (.docx, .odt, .xlsx, etc.) are included as plain text; PDFs and images are provided as native attachments
|
||||
|
||||
Whether the attachment came from a user upload or your own tool read, the attached content is authoritative — read and use it directly to answer.
|
||||
|
||||
---
|
||||
|
||||
## Obsidian Markdown
|
||||
|
||||
**Obsidian extends standard Markdown.** When creating or editing notes, prefer Obsidian-flavored syntax over plain Markdown equivalents. Do NOT convert existing Obsidian syntax to standard Markdown.
|
||||
|
||||
### Embeds (vs. links)
|
||||
|
||||
Embeds render the target's content inline; links are clickable references. Same wiki-link rules apply (no folder paths, match casing).
|
||||
|
||||
\`\`\`
|
||||
![[Note Name]] embed entire note
|
||||
![[Note Name#Heading]] embed a section
|
||||
![[image.png]] embed image
|
||||
![[document.pdf#page=3]] embed a PDF page
|
||||
![[Note Name#^block-id]] embed a specific block
|
||||
\`\`\`
|
||||
|
||||
Use embeds when the user wants content shown in place; use \`[[wiki-links]]\` for references.
|
||||
|
||||
### Callouts
|
||||
|
||||
Prefer callouts over plain blockquotes for emphasis, warnings, tips, and structured asides. Foldable with \`-\` (collapsed) or \`+\` (expanded).
|
||||
|
||||
\`\`\`
|
||||
> [!note] Optional Title
|
||||
> Body text.
|
||||
|
||||
> [!warning]- Foldable, starts collapsed
|
||||
> Body text.
|
||||
\`\`\`
|
||||
|
||||
Common types: \`note\`, \`tip\`, \`info\`, \`important\`, \`warning\`, \`danger\`, \`success\`, \`question\`, \`example\`, \`quote\`, \`todo\`, \`bug\`, \`abstract\`.
|
||||
|
||||
### Tags & Frontmatter
|
||||
|
||||
**Tags** — inline as \`#tag\`, nested as \`#parent/child\`, multi-word with hyphens (\`#multi-word\`). No spaces, no special characters. Also valid as a YAML \`tags:\` list (without the \`#\`).
|
||||
|
||||
**Frontmatter** — YAML block at the very top of the file. Preserve existing fields; do not modify \`created\`. Special properties:
|
||||
- \`tags\`: list of tag names (no \`#\` prefix in YAML)
|
||||
- \`aliases\`: alternative names the note resolves to
|
||||
- \`cssclasses\`: custom CSS classes applied to the note
|
||||
|
||||
\`\`\`yaml
|
||||
---
|
||||
created: 2026-05-26T09:25
|
||||
updated: 2026-05-26T09:30
|
||||
tags:
|
||||
- project
|
||||
- ai/research
|
||||
aliases:
|
||||
- Alternative Title
|
||||
status: in-progress
|
||||
---
|
||||
\`\`\`
|
||||
|
||||
### Tasks Plugin
|
||||
|
||||
This vault uses the Tasks plugin. Standard \`- [ ]\` / \`- [x]\` checkboxes work, plus extended states and emoji metadata:
|
||||
|
||||
\`\`\`
|
||||
- [ ] Standard task - [/] In progress - [!] Important
|
||||
- [x] Done - [-] Cancelled - [?] Question
|
||||
- [>] Forwarded - [<] Scheduled
|
||||
|
||||
- [ ] Task with metadata 📅 2026-06-01 ⏳ 2026-05-28 🛫 2026-05-26 🔁 every week ⏫
|
||||
\`\`\`
|
||||
|
||||
Emoji metadata: 📅 due, ⏳ scheduled, 🛫 start, ✅ done, 🔁 recurrence, ⏫ high / 🔼 medium / 🔽 low priority.
|
||||
|
||||
### Other Syntax
|
||||
|
||||
- **Block references**: append \`^block-id\` to a line; reference with \`[[Note#^block-id]]\` or embed with \`![[Note#^block-id]]\`
|
||||
- **Highlights**: \`==highlighted text==\`
|
||||
- **Comments** (hidden in reading view): \`%%comment%%\` or multi-line \`%% ... %%\`
|
||||
- **Math** (LaTeX): inline \`$e=mc^2$\`, block \`$$ ... $$\`
|
||||
- **Mermaid diagrams**: standard \`\`\`mermaid code blocks (flowchart, sequenceDiagram, classDiagram, gantt, pie, mindmap, timeline, etc.)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -495,7 +594,8 @@ After multi-step execution:
|
|||
**Web Search**
|
||||
- You may have the ability to search the web for current information
|
||||
- Check the **Active Capabilities** section at the end of this prompt to see whether web search is enabled
|
||||
- If web search is disabled and the user asks something that requires it, tell them it is currently turned off in settings — do not attempt to call the tool
|
||||
- If web search is enabled, prefer to search the web to look up current information before responding over answering from memory alone.
|
||||
- If web search is disabled and the user asks something that requires it, tell them it is currently turned off in settings
|
||||
|
||||
**Web Viewer**
|
||||
- The web viewer tool retrieves the content of a page currently open in the Obsidian web viewer — it is NOT general web browsing
|
||||
|
|
|
|||
12
Assets/vaultkeeper-mono.svg
Normal file
12
Assets/vaultkeeper-mono.svg
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Vaultkeeper AI ribbon icon. Uses currentColor so it adapts to Obsidian themes. -->
|
||||
<circle cx="50" cy="50" r="30" stroke="currentColor" stroke-width="7" fill="none"/>
|
||||
<g stroke="currentColor" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" fill="none">
|
||||
<!-- locking bolts -->
|
||||
<path d="M85 50 H92 M50 85 V92 M15 50 H8 M50 15 V8
|
||||
M74.75 74.75 L79.70 79.70 M25.25 74.75 L20.30 79.70
|
||||
M74.75 25.25 L79.70 20.30 M25.25 25.25 L20.30 20.30"/>
|
||||
<!-- central spark -->
|
||||
<path d="M50 33 L54.09 45.91 L67 50 L54.09 54.09 L50 67 L45.91 54.09 L33 50 L45.91 45.91 Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 734 B |
BIN
Assets/vaultkeeper-social-1280x330.png
Normal file
BIN
Assets/vaultkeeper-social-1280x330.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 196 KiB |
23
CONTRIBUTING.md
Normal file
23
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Contributing
|
||||
|
||||
This plugin was originally created for a friend and is now being shared with the broader Obsidian community. As a solo developer with limited time, I'm currently **not accepting contributions** (pull requests are disabled).
|
||||
|
||||
#### Why?
|
||||
|
||||
I simply don't have the capacity to review, test, and maintain community contributions at this time. I want to be respectful of contributors' time and effort, and accepting PRs that I can't properly review wouldn't be fair to anyone.
|
||||
|
||||
#### What if I find a bug or have a suggestion?
|
||||
|
||||
Please feel free to open an issue! While I can't guarantee quick responses, I do want to know if something isn't working correctly or if there are ideas that would benefit the community.
|
||||
|
||||
#### Can I fork this project?
|
||||
|
||||
Absolutely! This project is open source under MIT, so you're welcome to fork it and make your own modifications.
|
||||
|
||||
#### Will this change?
|
||||
|
||||
If there's significant community interest and usage, I may revisit this decision and open up contributions in the future. For now, I'm focused on keeping the plugin stable and functional for its current users.
|
||||
|
||||
---
|
||||
|
||||
Thank you for understanding! 🙏
|
||||
361
Components/AssistantMessage.svelte
Normal file
361
Components/AssistantMessage.svelte
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
<script lang="ts">
|
||||
import { Resolve } from "Services/DependencyService";
|
||||
import { Services } from "Services/Services";
|
||||
import type { StreamingMarkdownService } from "Services/StreamingMarkdownService";
|
||||
import type { ConversationContent } from "Conversations/ConversationContent";
|
||||
import { setElementIcon } from "Helpers/ElementHelper";
|
||||
import { fade } from "svelte/transition";
|
||||
import { ArtifactAction, artifactActionToCopy } from "Enums/ArtifactAction";
|
||||
import { basename } from "path-browserify";
|
||||
import type { Artifact } from "Conversations/Artifact";
|
||||
import type { DiffService } from "Services/DiffService";
|
||||
|
||||
export let message: ConversationContent;
|
||||
export let isSubmitting: boolean = false;
|
||||
|
||||
let streamingMarkdownService: StreamingMarkdownService = Resolve<StreamingMarkdownService>(Services.StreamingMarkdownService);
|
||||
let diffService: DiffService = Resolve<DiffService>(Services.DiffService);
|
||||
|
||||
function messageRenderAction(element: HTMLElement, message: ConversationContent) {
|
||||
streamingMarkdownService.render(message.getDisplayContent(), element);
|
||||
return {
|
||||
update(newMessage: ConversationContent) {
|
||||
streamingMarkdownService.render(newMessage.getDisplayContent(), element, !isSubmitting);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function tallyArtifactsByAction(artifacts: { action: ArtifactAction }[]): Partial<Record<ArtifactAction, number>> {
|
||||
const tally: Partial<Record<ArtifactAction, number>> = {};
|
||||
for (const artifact of artifacts) {
|
||||
tally[artifact.action] = (tally[artifact.action] ?? 0) + 1;
|
||||
}
|
||||
return tally;
|
||||
}
|
||||
|
||||
let isScrolledToBottom = true;
|
||||
|
||||
function updateScrollFade(element: HTMLElement) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = element;
|
||||
isScrolledToBottom = scrollHeight - scrollTop - clientHeight < 1;
|
||||
}
|
||||
|
||||
function artifactsListScrollAction(element: HTMLElement) {
|
||||
updateScrollFade(element);
|
||||
const handleScroll = () => updateScrollFade(element);
|
||||
element.addEventListener("scroll", handleScroll);
|
||||
return {
|
||||
destroy() {
|
||||
element.removeEventListener("scroll", handleScroll);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function handleArtifactCardClick(artifact: Artifact) {
|
||||
diffService.showArtifactDiff(artifact);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="message-container assistant">
|
||||
<div class="message-bubble assistant">
|
||||
<div class="markdown-content">
|
||||
<div use:messageRenderAction={message} class="streaming-content"></div>
|
||||
</div>
|
||||
{#if message.artifacts.length > 0}
|
||||
{@const artifactTally = tallyArtifactsByAction(message.artifacts)}
|
||||
<div class="artifacts-container" in:fade={{ duration: 300 }}>
|
||||
<span class="artifacts-container-title">{message.artifacts.length} FILES CHANGED</span>
|
||||
<div class="artifacts-tally">
|
||||
{#each Object.values(ArtifactAction) as action}
|
||||
{#if artifactTally[action]}
|
||||
<div class="artifact-tally-container">
|
||||
<span class="artifact-tally-ellipse artifact-{action}"></span>
|
||||
<span class="artifact-tally-count">{artifactTally[action]}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<div class="artifacts-list-wrapper">
|
||||
<div class="artifacts-list-container" use:artifactsListScrollAction>
|
||||
{#each message.artifacts as artifact}
|
||||
<div
|
||||
class="artifact-card"
|
||||
aria-label="{artifact.filePath}"
|
||||
on:click={() => handleArtifactCardClick(artifact)}
|
||||
on:keydown={(e) => e.key === 'Enter' && handleArtifactCardClick(artifact)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<span class="artifact-ellipse artifact-ellipse-{artifact.action}"></span>
|
||||
<div
|
||||
class="artifact-icon"
|
||||
use:setElementIcon={artifact.getIconName()}
|
||||
></div>
|
||||
<span class="artifact-name">{basename(artifact.filePath)}</span>
|
||||
<span class="artifact-action artifact-action-{artifact.action}">{artifactActionToCopy(artifact.action)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="artifacts-list-fade" class:hidden={isScrolledToBottom}></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.message-container {
|
||||
display: flex;
|
||||
text-align: left;
|
||||
margin: 0;
|
||||
justify-content: flex-start;
|
||||
animation: fadeIn 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(5px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
word-wrap: break-word;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.streaming-content {
|
||||
justify-content: left;
|
||||
min-height: 1em; /* Ensure the element exists for binding */
|
||||
}
|
||||
|
||||
/* Streaming message styles */
|
||||
.content-fade-in {
|
||||
animation: reveal-fade 0.5s ease-in-out forwards;
|
||||
}
|
||||
|
||||
@keyframes reveal-fade {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.artifacts-container {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto;
|
||||
grid-template-columns: auto auto;
|
||||
width: 100%;
|
||||
margin-top: var(--size-4-3);
|
||||
margin-bottom: var(--size-4-6);
|
||||
gap: var(--size-4-4);
|
||||
}
|
||||
|
||||
.artifacts-container-title {
|
||||
grid-row: 1;
|
||||
grid-column: 1;
|
||||
font-size: var(--font-smallest);
|
||||
}
|
||||
|
||||
.artifacts-tally {
|
||||
grid-row: 1;
|
||||
grid-column: 2;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.artifact-tally-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-left: auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.artifact-tally-ellipse {
|
||||
flex-shrink: 0;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
margin-left: var(--size-4-2);
|
||||
}
|
||||
|
||||
.artifact-create {
|
||||
background: var(--color-green);
|
||||
}
|
||||
|
||||
.artifact-modify {
|
||||
background: var(--color-blue);
|
||||
}
|
||||
|
||||
.artifact-delete {
|
||||
background: var(--color-red);
|
||||
}
|
||||
|
||||
.artifact-tally-count {
|
||||
font-size: var(--font-smallest);
|
||||
margin-left: var(--size-4-1);
|
||||
}
|
||||
|
||||
.artifacts-list-wrapper {
|
||||
grid-row: 2;
|
||||
grid-column: 1 / 3;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.artifacts-list-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: scroll;
|
||||
width: 100%;
|
||||
max-height: 200px;
|
||||
gap: var(--size-4-1);
|
||||
}
|
||||
|
||||
.artifacts-list-container::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.artifacts-list-fade {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: var(--size-4-8);
|
||||
background-image: linear-gradient(to bottom, transparent, var(--background-secondary));
|
||||
pointer-events: none;
|
||||
opacity: 1;
|
||||
transition: opacity 0.2s ease-out;
|
||||
}
|
||||
|
||||
.artifacts-list-fade.hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.artifact-card {
|
||||
display: grid;
|
||||
grid-template-rows: auto;
|
||||
grid-template-columns: auto auto 1fr auto;
|
||||
gap: var(--size-4-3);
|
||||
align-items: center;
|
||||
height: 30px;
|
||||
flex-shrink: 0;
|
||||
background-color: var(--background-secondary-alt);
|
||||
border-radius: var(--size-4-2);
|
||||
padding: 0 var(--size-4-1) 0 var(--size-4-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.artifact-ellipse {
|
||||
grid-row: 1;
|
||||
grid-column: 1;
|
||||
flex-shrink: 0;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
align-self: center;
|
||||
transition: box-shadow 0.2s ease-out;
|
||||
}
|
||||
|
||||
.artifact-card:hover .artifact-ellipse,
|
||||
.artifact-card:focus-visible .artifact-ellipse {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
box-shadow: 0px 0px 4px 1px currentColor;
|
||||
transition: box-shadow 0.2s ease-out;
|
||||
}
|
||||
|
||||
.artifact-ellipse-create {
|
||||
background: var(--color-green);
|
||||
color: var(--color-green);
|
||||
}
|
||||
|
||||
.artifact-ellipse-modify {
|
||||
background: var(--color-blue);
|
||||
color: var(--color-blue);
|
||||
}
|
||||
|
||||
.artifact-ellipse-delete {
|
||||
background: var(--color-red);
|
||||
color: var(--color-red);
|
||||
}
|
||||
|
||||
.artifact-icon {
|
||||
grid-row: 1;
|
||||
grid-column: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.artifact-name {
|
||||
grid-row: 1;
|
||||
grid-column: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: start;
|
||||
font-size: var(--font-smaller);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.artifact-action {
|
||||
grid-row: 1;
|
||||
grid-column: 4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--font-smallest);
|
||||
font-weight: var(--font-semibold);
|
||||
border-radius: var(--size-4-1);
|
||||
padding: var(--size-2-1) var(--size-4-2);
|
||||
}
|
||||
|
||||
.artifact-action-create {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--color-green) 25%,
|
||||
black 20%
|
||||
);
|
||||
color: color-mix(
|
||||
in srgb,
|
||||
var(--color-green) 100%,
|
||||
white 10%
|
||||
);
|
||||
}
|
||||
|
||||
.artifact-action-modify {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--color-blue) 25%,
|
||||
black 20%
|
||||
);
|
||||
color: color-mix(
|
||||
in srgb,
|
||||
var(--color-blue) 100%,
|
||||
white 10%
|
||||
);
|
||||
}
|
||||
|
||||
.artifact-action-delete {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--color-red) 25%,
|
||||
black 20%
|
||||
);
|
||||
color: color-mix(
|
||||
in srgb,
|
||||
var(--color-red) 100%,
|
||||
white 10%
|
||||
);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,122 +1,79 @@
|
|||
<script lang="ts">
|
||||
import { Resolve } from "Services/DependencyService";
|
||||
import { Services } from "Services/Services";
|
||||
import type { StreamingMarkdownService } from "Services/StreamingMarkdownService";
|
||||
import ThoughtIndicator from "./ThoughtIndicator.svelte";
|
||||
import StreamingIndicator from "./StreamingIndicator.svelte";
|
||||
import UserMessage from "./UserMessage.svelte";
|
||||
import AssistantMessage from "./AssistantMessage.svelte";
|
||||
import { Greeting } from "Enums/Greeting";
|
||||
import { Role } from "Enums/Role";
|
||||
import type { ConversationContent } from "Conversations/ConversationContent";
|
||||
import { tick } from "svelte";
|
||||
import { Selector } from "Enums/Selector";
|
||||
import { Exception } from "Helpers/Exception";
|
||||
import { getOuterHeight, setElementIcon } from "Helpers/ElementHelper";
|
||||
import { setIcon } from "obsidian";
|
||||
import { fade } from "svelte/transition";
|
||||
import GraphAnimation from "./GraphAnimation.svelte";
|
||||
|
||||
export let messages: ConversationContent[] = [];
|
||||
export let currentThought: string | null = null;
|
||||
export let isSubmitting: boolean = false;
|
||||
export let chatContainer: HTMLDivElement;
|
||||
export let currentStreamingMessageId: string | null = null;
|
||||
|
||||
export function resetChatArea() {
|
||||
autoScroll = true;
|
||||
messageElements = [];
|
||||
lastProcessedContent.clear();
|
||||
currentStreamFinalized = false;
|
||||
if (chatAreaPaddingElement) {
|
||||
chatAreaPaddingElement.style.padding = "0px";
|
||||
}
|
||||
chatContainer.scroll({ top: 0, behavior: "instant" });
|
||||
tick().then(updateScrolledState);
|
||||
}
|
||||
|
||||
export function resetAutoScroll() {
|
||||
autoScroll = true;
|
||||
}
|
||||
|
||||
export async function updateChatAreaLayout(behavior: ScrollBehavior | undefined, shouldSettle: boolean = false) {
|
||||
export async function updateChatAreaLayout(behavior: ScrollBehavior | undefined = undefined) {
|
||||
await tick();
|
||||
|
||||
if (!chatAreaPaddingElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageElements.length <= 0) {
|
||||
chatAreaPaddingElement.style.paddingBottom = "0px";
|
||||
return;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
applyLayout(behavior, shouldSettle);
|
||||
if (behavior) {
|
||||
scrollToLatestTurn(behavior);
|
||||
}
|
||||
tick().then(updateScrolledState);
|
||||
});
|
||||
}
|
||||
|
||||
function applyLayout(behavior: ScrollBehavior | undefined, shouldSettle: boolean) {
|
||||
if (!chatAreaPaddingElement || messageElements.length <= 0) {
|
||||
function scrollToLatestTurn(behavior: ScrollBehavior) {
|
||||
const latestTurn = chatContainer.querySelector<HTMLElement>(".message-group.latest");
|
||||
if (!latestTurn) {
|
||||
return;
|
||||
}
|
||||
|
||||
const styles = getComputedStyle(chatContainer);
|
||||
const gap = parseFloat(styles.gap) || 0;
|
||||
const paddingTop = parseFloat(styles.paddingTop) || 0;
|
||||
const paddingBottom = parseFloat(styles.paddingBottom) || 0;
|
||||
|
||||
const sortedMessages = messageElements.sort((a, b) => a.index - b.index);
|
||||
|
||||
let result = calculateMessageHeight(sortedMessages);
|
||||
let contentHeight = result.height + (gap * (result.count - 1));
|
||||
|
||||
if (!shouldSettle) {
|
||||
if (thoughtIndicatorElement) {
|
||||
contentHeight += getOuterHeight(thoughtIndicatorElement) + gap;
|
||||
}
|
||||
if (streamingIndicatorElement) {
|
||||
contentHeight += getOuterHeight(streamingIndicatorElement) + gap;
|
||||
}
|
||||
}
|
||||
|
||||
const availableHeight = chatContainer.offsetHeight - paddingTop - paddingBottom;
|
||||
let padding = shouldSettle
|
||||
? Math.max(0, availableHeight - contentHeight)
|
||||
: Math.max(25, availableHeight - contentHeight);
|
||||
|
||||
chatAreaPaddingElement.style.paddingBottom = `${padding}px`;
|
||||
|
||||
if (behavior && autoScroll) {
|
||||
chatContainer.scroll({ top: chatContainer.scrollHeight, behavior });
|
||||
}
|
||||
const paddingTop = parseFloat(getComputedStyle(chatContainer).paddingTop) || 0;
|
||||
chatContainer.scroll({ top: latestTurn.offsetTop - paddingTop, behavior });
|
||||
}
|
||||
|
||||
function calculateMessageHeight(sortedMessages: { element: HTMLElement, index: number, role: Role }[]): { count: number, height: number } {
|
||||
const lastMessage = sortedMessages[sortedMessages.length - 1];
|
||||
if (lastMessage.role === Role.User) {
|
||||
return { count: 1, height: getOuterHeight(lastMessage.element) };
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
let height = 0;
|
||||
|
||||
for (const message of sortedMessages.reverse()) {
|
||||
if (message.role === Role.User) {
|
||||
break;
|
||||
}
|
||||
height += getOuterHeight(message.element);
|
||||
count++;
|
||||
}
|
||||
return { count: count, height: height };
|
||||
function scrollToBottom(behavior: ScrollBehavior) {
|
||||
chatContainer.scroll({ top: chatContainer.scrollHeight, behavior });
|
||||
}
|
||||
|
||||
let autoScroll: boolean = true;
|
||||
let lastScrollTop: number = 0;
|
||||
function updateScrolledState() {
|
||||
if (!chatContainer) {
|
||||
return;
|
||||
}
|
||||
const isScrollable = chatContainer.scrollHeight > chatContainer.clientHeight;
|
||||
scrolledToBottom = !isScrollable || Math.abs(chatContainer.scrollHeight - chatContainer.scrollTop - chatContainer.clientHeight) < 100;
|
||||
}
|
||||
|
||||
let chatAreaPaddingElement: HTMLElement | undefined;
|
||||
let thoughtIndicatorElement: HTMLElement | undefined;
|
||||
let streamingIndicatorElement: HTMLElement | undefined;
|
||||
let scrolledToBottom: boolean = true;
|
||||
|
||||
let streamingMarkdownService: StreamingMarkdownService = Resolve<StreamingMarkdownService>(Services.StreamingMarkdownService);
|
||||
let scrollToBottomButton: HTMLButtonElement;
|
||||
|
||||
let messageElements: { element: HTMLElement, index: number, role: Role }[] = [];
|
||||
let lastProcessedContent: Map<string, string> = new Map<string, string>();
|
||||
let currentStreamFinalized: boolean = false;
|
||||
type Turn = { id: number, messages: ConversationContent[] };
|
||||
|
||||
$: turns = groupTurns(messages);
|
||||
|
||||
// A turn starts at each visible user message; hidden contents (tool
|
||||
// responses, planning notices, attachment stubs) stay attached to the
|
||||
// turn they belong to instead of starting phantom turns
|
||||
function groupTurns(messages: ConversationContent[]): Turn[] {
|
||||
const turns: Turn[] = [];
|
||||
for (const message of messages) {
|
||||
if ((message.role === Role.User && message.shouldDisplayContent) || turns.length === 0) {
|
||||
turns.push({ id: message.id, messages: [] });
|
||||
}
|
||||
turns[turns.length - 1].messages.push(message);
|
||||
}
|
||||
return turns;
|
||||
}
|
||||
|
||||
function getGreetingByTime(): string {
|
||||
const hour = new Date().getHours();
|
||||
|
|
@ -139,122 +96,8 @@
|
|||
}
|
||||
}
|
||||
|
||||
function updateMessageContent(messageId: string, content: string, isCurrentlyStreaming: boolean) {
|
||||
if (isCurrentlyStreaming) {
|
||||
streamingMarkdownService.streamChunk(messageId, content);
|
||||
currentStreamFinalized = false;
|
||||
} else if (!currentStreamFinalized) {
|
||||
streamingMarkdownService.finalizeStream(messageId, content);
|
||||
currentStreamFinalized = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Process static messages (user messages and initial load)
|
||||
function getStaticHTML(message: ConversationContent): string {
|
||||
if (message.role === Role.User) {
|
||||
return `<div>${message.content}</div>`;
|
||||
}
|
||||
|
||||
// For assistant messages, check if this specific message is currently streaming
|
||||
const messageId = message.timestamp.getTime().toString();
|
||||
const isCurrentlyStreaming = currentStreamingMessageId === messageId;
|
||||
|
||||
// For assistant messages that aren't streaming, use traditional parsing
|
||||
if (!isCurrentlyStreaming) {
|
||||
const content = message.getDisplayContent();
|
||||
if (message.errorType) {
|
||||
return `<div class="${Selector.ErrorSelector}">${content}</div>`;
|
||||
}
|
||||
|
||||
try {
|
||||
return streamingMarkdownService.formatText(content) || `<div>${content}</div>`;
|
||||
} catch (error) {
|
||||
Exception.log(error);
|
||||
return `<div>${content}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
return ""; // Streaming messages will be handled by the streaming service
|
||||
}
|
||||
|
||||
function streamingAction(element: HTMLElement, messageId: string) {
|
||||
streamingMarkdownService.initializeStream(messageId, element);
|
||||
}
|
||||
|
||||
function trackingAction(element: HTMLElement, { index, role }: { index: number, role: Role }) {
|
||||
messageElements.push({ index: index, element: element, role: role });
|
||||
}
|
||||
|
||||
function observeResize(element: HTMLElement) {
|
||||
const observer = new ResizeObserver(() => {
|
||||
updateChatAreaLayout("smooth");
|
||||
});
|
||||
|
||||
observer.observe(element);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
observer.disconnect();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// decide if we should be auto scrolling
|
||||
function handleScroll() {
|
||||
if (!chatContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollTop = chatContainer.scrollTop;
|
||||
const scrollHeight = chatContainer.scrollHeight;
|
||||
const clientHeight = chatContainer.clientHeight;
|
||||
|
||||
// Only process if the user actually scrolled (scrollTop changed)
|
||||
// This prevents false triggers when content grows and pushes things down
|
||||
if (scrollTop === lastScrollTop) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousScrollTop = lastScrollTop;
|
||||
lastScrollTop = scrollTop;
|
||||
|
||||
// Check if we're at the bottom (with a small tolerance for rounding errors)
|
||||
const isAtBottom = scrollHeight - scrollTop - clientHeight < 5;
|
||||
|
||||
if (isAtBottom) {
|
||||
autoScroll = true;
|
||||
} else if (scrollTop < previousScrollTop) {
|
||||
autoScroll = false; // user scrolled up
|
||||
}
|
||||
}
|
||||
|
||||
// Track streaming messages and update them incrementally
|
||||
$: {
|
||||
messages.forEach((message) => {
|
||||
if (message.role !== Role.User) {
|
||||
const messageId = message.timestamp.getTime().toString();
|
||||
const lastContent = lastProcessedContent.get(messageId) || "";
|
||||
|
||||
// Only update if content has changed
|
||||
if (message.content !== lastContent) {
|
||||
// Check if this specific message is currently streaming
|
||||
const isCurrentlyStreaming = currentStreamingMessageId === messageId;
|
||||
|
||||
const content = message.getDisplayContent();
|
||||
// Only process through streaming service if actively streaming
|
||||
if (isCurrentlyStreaming) {
|
||||
updateMessageContent(messageId, content, isCurrentlyStreaming);
|
||||
}
|
||||
lastProcessedContent.set(messageId, content);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$: {
|
||||
if (messages.length === 0 && chatAreaPaddingElement) {
|
||||
chatAreaPaddingElement.style.padding = "0px";
|
||||
}
|
||||
$: if (scrollToBottomButton) {
|
||||
setIcon(scrollToBottomButton, "arrow-down");
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -262,74 +105,76 @@
|
|||
{#if messages.length > 0}
|
||||
<div class="top-fade"></div>
|
||||
{/if}
|
||||
<div class="chat-area" bind:this={chatContainer} on:scroll={handleScroll} use:observeResize>
|
||||
{#each messages as message, index}
|
||||
{@const content = message.getDisplayContent()}
|
||||
{#if message.shouldDisplayContent && content.trim() !== ""}
|
||||
{#if message.role === Role.User}
|
||||
<div class="message-container {Role.User}" use:trackingAction={{ index, role: Role.User }}>
|
||||
<div class="message-bubble {Role.User}">
|
||||
<div class="message-text-user-container content-fade-in" contenteditable="false">
|
||||
<div class="message-text-user">
|
||||
{@html content}
|
||||
</div>
|
||||
</div>
|
||||
{#if message.references.length > 0}
|
||||
<hr class="message-attachment-break"/>
|
||||
<div class="message-attachments-container">
|
||||
{#each message.references as reference}
|
||||
<div class="message-attachmanet" aria-label="{reference.fileName}">
|
||||
<div
|
||||
class="message-attachment-icon"
|
||||
use:setElementIcon={reference.getIconName()}
|
||||
></div>
|
||||
<div class="message-attachment-info">
|
||||
<div class="message-attachment-name">{reference.fileName}</div>
|
||||
<div class="message-attachment-size">{reference.size}MB</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="chat-area" bind:this={chatContainer} on:scroll={updateScrolledState}>
|
||||
{#each turns as turn, turnIndex (turn.id)}
|
||||
{@const isLatestTurn = turnIndex === turns.length - 1}
|
||||
<div class="message-group" class:latest={isLatestTurn}>
|
||||
{#each turn.messages as message (message.id)}
|
||||
{@const content = message.getDisplayContent()}
|
||||
{#if message.shouldDisplayContent && content.trim() !== ""}
|
||||
{#if message.role === Role.User}
|
||||
<UserMessage {message} />
|
||||
{:else}
|
||||
<AssistantMessage {message} {isSubmitting} />
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if isLatestTurn}
|
||||
<ThoughtIndicator thought={currentThought}/>
|
||||
{#if isSubmitting}
|
||||
<div transition:fade={{ duration: 300 }}>
|
||||
<StreamingIndicator/>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{@const messageId = message.timestamp.getTime().toString()}
|
||||
<div class="message-container {Role.Assistant}" use:trackingAction={{ index, role: Role.Assistant }}>
|
||||
<div class="message-bubble {Role.Assistant}">
|
||||
<div class="markdown-content content-fade-in {currentStreamingMessageId === messageId ? "streaming" : ""}">
|
||||
{#if currentStreamingMessageId === messageId}
|
||||
<div use:streamingAction={messageId} class="streaming-content"></div>
|
||||
{:else}
|
||||
{@html getStaticHTML(message)}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<ThoughtIndicator thought={currentThought} bind:thoughtIndicatorElement={thoughtIndicatorElement}/>
|
||||
{#if isSubmitting}
|
||||
<StreamingIndicator bind:streamingIndicatorElement={streamingIndicatorElement}/>
|
||||
{/if}
|
||||
|
||||
<div bind:this={chatAreaPaddingElement} style:user-select=none></div>
|
||||
|
||||
{#if messages.length === 0}
|
||||
<div class="conversation-empty-state">
|
||||
<div class="typing-in">{getGreetingByTime()}</div>
|
||||
<div class="conversation-empty-container">
|
||||
<div class="conversation-empty-animation">
|
||||
<GraphAnimation/>
|
||||
</div>
|
||||
<div class="conversation-empty-greeting">
|
||||
<div class="conversation-empty-greeting-backdrop"></div>
|
||||
<div class="conversation-empty-greeting-label">{getGreetingByTime()}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
{#if messages.length > 0}
|
||||
<div class="bottom-fade"></div>
|
||||
{/if}
|
||||
|
||||
{#if !scrolledToBottom}
|
||||
<div class="scroll-to-bottom-container" transition:fade>
|
||||
<button
|
||||
id="scroll-to-bottom-button"
|
||||
bind:this={scrollToBottomButton}
|
||||
on:click={() => scrollToBottom("smooth")}
|
||||
aria-label="Scroll to bottom">
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.scroll-to-bottom-container {
|
||||
background-color: color-mix(in srgb, var(--background-primary) 70%, transparent);
|
||||
border-radius: var(--radius-l);
|
||||
padding: var(--size-4-2);
|
||||
position: absolute;
|
||||
bottom: var(--size-4-2);
|
||||
right: 0px;
|
||||
}
|
||||
|
||||
#scroll-to-bottom-button {
|
||||
background-color: var(--interactive-accent);
|
||||
}
|
||||
|
||||
.chat-area-wrapper {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
|
|
@ -373,53 +218,46 @@
|
|||
display: none;
|
||||
}
|
||||
|
||||
.message-container {
|
||||
.message-group {
|
||||
display: flex;
|
||||
text-align: left;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.message-container.user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.message-container.assistant {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
word-wrap: break-word;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
gap: var(--size-4-2);
|
||||
}
|
||||
|
||||
.message-bubble.user {
|
||||
word-wrap: break-word;
|
||||
max-width: 70%;
|
||||
border: var(--border-width) solid var(--background-modifier-border);
|
||||
border-radius: var(--radius-m);
|
||||
padding: 0px var(--size-4-2);
|
||||
.message-group.latest {
|
||||
margin-top: var(--size-4-2);
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.message-bubble.assistant {
|
||||
word-wrap: break-word;
|
||||
max-width: 100%;
|
||||
.conversation-empty-container {
|
||||
display: grid;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.message-text-user-container {
|
||||
max-height: 15vh;
|
||||
overflow: scroll;
|
||||
padding-top: var(--size-4-2);
|
||||
white-space: pre-wrap;
|
||||
.conversation-empty-animation {
|
||||
grid-row: 1;
|
||||
grid-column: 1;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.message-text-user-container::-webkit-scrollbar {
|
||||
display: none;
|
||||
.conversation-empty-greeting-backdrop {
|
||||
position: absolute;
|
||||
inset: -48px;
|
||||
background: radial-gradient(
|
||||
ellipse closest-side,
|
||||
var(--background-secondary) 0%,
|
||||
color-mix(in srgb, var(--background-secondary) 80%, transparent) 65%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
.message-text-user-container {
|
||||
padding-bottom: var(--size-4-2);
|
||||
}
|
||||
|
||||
.conversation-empty-state {
|
||||
.conversation-empty-greeting {
|
||||
grid-row: 1;
|
||||
grid-column: 1;
|
||||
position: relative;
|
||||
margin: auto;
|
||||
font-style: italic;
|
||||
font-size: var(--font-ui-medium);
|
||||
|
|
@ -427,83 +265,10 @@
|
|||
pointer-events: none;
|
||||
}
|
||||
|
||||
.streaming-content {
|
||||
justify-content: left;
|
||||
min-height: 1em; /* Ensure the element exists for binding */
|
||||
}
|
||||
|
||||
/* Streaming message styles */
|
||||
.content-fade-in {
|
||||
animation: reveal-fade 0.5s ease-in-out forwards;
|
||||
}
|
||||
|
||||
@keyframes reveal-fade {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Message attachments styles */
|
||||
.message-attachment-break {
|
||||
color: var(--background-secondary-alt);
|
||||
margin: 0 0 var(--size-4-2) 0;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.message-attachments-container {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scroll-behavior: smooth;
|
||||
gap: var(--size-4-2);
|
||||
margin-bottom: var(--size-4-2);
|
||||
}
|
||||
|
||||
.message-attachments-container::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.message-attachmanet {
|
||||
display: grid;
|
||||
grid-template-rows: var(--size-4-2) auto var(--size-4-2);
|
||||
grid-template-columns: var(--size-4-2) auto var(--size-4-2) auto var(--size-4-2);
|
||||
background-color: var(--background-secondary-alt);
|
||||
border: var(--border-width) solid var(--background-modifier-border);
|
||||
border-radius: var(--radius-m);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message-attachment-icon {
|
||||
grid-row: 2;
|
||||
grid-column: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.message-attachment-info {
|
||||
grid-row: 2;
|
||||
grid-column: 4;
|
||||
min-width: 40px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.message-attachment-name {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
font-size: var(--font-smaller);
|
||||
}
|
||||
|
||||
.message-attachment-size {
|
||||
padding: 0;
|
||||
font-size: var(--font-smallest);
|
||||
color: var(--text-muted);
|
||||
.conversation-empty-greeting-label {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
padding: var(--size-2-2);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { onDestroy, onMount, tick } from "svelte";
|
||||
import { Platform, setIcon, type EventRef } from "obsidian";
|
||||
import { Platform, setIcon, ToggleComponent, type EventRef } from "obsidian";
|
||||
import type { UserInputService } from "Services/UserInputService";
|
||||
import type { ISearchState, SearchStateStore } from "Stores/SearchStateStore";
|
||||
import { Resolve } from "Services/DependencyService";
|
||||
|
|
@ -12,24 +12,28 @@
|
|||
import UserInstruction from "./UserInstruction.svelte";
|
||||
import ChatModeSelector from "./ChatModeSelector.svelte";
|
||||
import DiffControls from "./DiffControls.svelte";
|
||||
import PlanApprovalControls from "./PlanApprovalControls.svelte";
|
||||
import type { EventService } from "Services/EventService";
|
||||
import { Event } from "Enums/Event";
|
||||
import type { DiffService } from "Services/DiffService";
|
||||
import type { PlanApprovalService } from "Services/PlanApprovalService";
|
||||
import type { Attachment } from "Conversations/Attachment";
|
||||
import ChatAttachments from "./ChatAttachments.svelte";
|
||||
import InputDisplay from "./InputDisplay.svelte";
|
||||
import { InputMode } from "Enums/InputMode";
|
||||
import { Copy, replaceCopy } from "Enums/Copy";
|
||||
import { Copy } from "Enums/Copy";
|
||||
import { HelpModal } from "Modals/HelpModal";
|
||||
import type { IPrompt } from "AIPrompts/IPrompt";
|
||||
import type { SettingsService } from "Services/SettingsService";
|
||||
import { ChatMode, chatModeAllowsEdits, iconForChatMode } from "Enums/ChatMode";
|
||||
import { hideDrawerElements, restoreDrawerElements } from "Helpers/ElementHelper";
|
||||
import { replaceCopy } from "Helpers/Helpers";
|
||||
import { AIProvider } from "Enums/ApiProvider";
|
||||
|
||||
export let attachments: Attachment[] = [];
|
||||
|
||||
export let hasNoApiKey: boolean;
|
||||
export let isSubmitting: boolean;
|
||||
export let chatMode: ChatMode;
|
||||
export let onSubmit: (userRequest: string, formattedRequest: string) => void;
|
||||
export let onStop: () => void;
|
||||
|
||||
|
|
@ -38,6 +42,7 @@
|
|||
const userInputService: UserInputService = Resolve<UserInputService>(Services.UserInputService);
|
||||
const searchStateStore: SearchStateStore = Resolve<SearchStateStore>(Services.SearchStateStore);
|
||||
const diffService: DiffService = Resolve<DiffService>(Services.DiffService);
|
||||
const planApprovalService: PlanApprovalService = Resolve<PlanApprovalService>(Services.PlanApprovalService);
|
||||
const eventService: EventService = Resolve<EventService>(Services.EventService);
|
||||
const aiPrompt: IPrompt = Resolve<IPrompt>(Services.IPrompt);
|
||||
|
||||
|
|
@ -50,52 +55,76 @@
|
|||
let submitButton: HTMLButtonElement;
|
||||
let attachmentButton: HTMLButtonElement;
|
||||
let chatModeButton: HTMLButtonElement;
|
||||
let freeEditButton: HTMLButtonElement;
|
||||
|
||||
let chatModeSelectionAreaActive: boolean = false;
|
||||
let userInstructionAreaActive: boolean = false;
|
||||
let userInstructionActive: boolean = true;
|
||||
let stacked: boolean = false;
|
||||
|
||||
let userRequest: string = "";
|
||||
|
||||
let freeEdit: boolean = settingsService.settings.freeEdit;
|
||||
let chatMode: ChatMode = settingsService.settings.chatMode;
|
||||
let inputMode: InputMode = InputMode.Normal;
|
||||
let questionResolver: ((answer: string) => void) | null = null;
|
||||
|
||||
let webSearchActive: boolean = settingsService.settings.enableWebSearch;
|
||||
let webSearchUnavailable: boolean = settingsService.settings.provider === AIProvider.Local;
|
||||
let editsAllowed: boolean = chatModeAllowsEdits(chatMode);
|
||||
|
||||
let countdownIntervalId: ReturnType<typeof setInterval> | null = null;
|
||||
let countdownSecondsRemaining: number = 0;
|
||||
let inputInitialHeight: number = 0;
|
||||
|
||||
const diffOpenedRef: EventRef = eventService.on(Event.DiffOpened, () => { inputMode = InputMode.Diff; focusInput(); });
|
||||
const diffClosedRef: EventRef = eventService.on(Event.DiffClosed, () => { inputMode = InputMode.Normal; focusInput(); });
|
||||
const planApprovalOpenedRef: EventRef = eventService.on(Event.PlanApprovalOpened, () => { inputMode = InputMode.PlanApproval; focusInput(); });
|
||||
const planApprovalClosedRef: EventRef = eventService.on(Event.PlanApprovalClosed, () => { inputMode = InputMode.Normal; focusInput(); });
|
||||
const rateLimitCountdownRef: EventRef = eventService.on(Event.RateLimitCountdown, (delayMs: number) => { startCountdown(delayMs); });
|
||||
|
||||
const settingsSubscription: object = settingsService.subscribeToSettingsChanged(changed => {
|
||||
if (changed.includes("chatMode")) {
|
||||
chatMode = settingsService.settings.chatMode;
|
||||
editsAllowed = chatModeAllowsEdits(chatMode);
|
||||
if (chatModeButton) {
|
||||
setIcon(chatModeButton, iconForChatMode(chatMode));
|
||||
}
|
||||
}
|
||||
if (changed.includes("freeEdit")) {
|
||||
if (freeEditButton) {
|
||||
freeEdit = settingsService.settings.freeEdit;
|
||||
setIcon(freeEditButton, iconForFreeEdit(freeEdit));
|
||||
}
|
||||
}
|
||||
if (changed.includes("provider")) {
|
||||
webSearchUnavailable = settingsService.settings.provider === AIProvider.Local;
|
||||
}
|
||||
if (changed.includes("enableWebSearch")) {
|
||||
webSearchActive = settingsService.settings.enableWebSearch;
|
||||
}
|
||||
if (changed.includes("userInstruction")) {
|
||||
aiPrompt.userInstruction().then(instruction => {
|
||||
userInstructionActive = instruction.trim() !== "";
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
userInstructionActive = (await aiPrompt.userInstruction()).trim() !== "";
|
||||
inputInitialHeight = textareaElement.innerHeight;
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
eventService.offref(diffOpenedRef);
|
||||
eventService.offref(diffClosedRef);
|
||||
eventService.offref(planApprovalOpenedRef);
|
||||
eventService.offref(planApprovalClosedRef);
|
||||
eventService.offref(rateLimitCountdownRef);
|
||||
settingsService.unsubscribe(settingsSubscription);
|
||||
stopCountdown();
|
||||
});
|
||||
|
||||
function checkStacked() {
|
||||
if (textareaElement.textContent.trim() === "") {
|
||||
stacked = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (textareaElement.innerHeight > inputInitialHeight) {
|
||||
stacked = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export function focusInput(onMobile: boolean = false) {
|
||||
// don't focus on mobile, it's annoying
|
||||
if (onMobile || !Platform.isMobile) {
|
||||
export function focusInput(force: boolean = false) {
|
||||
// Generally don't focus on mobile, it's annoying
|
||||
if (force || !Platform.isMobile) {
|
||||
tick().then(() => {
|
||||
textareaElement?.focus();
|
||||
});
|
||||
|
|
@ -183,7 +212,7 @@
|
|||
}
|
||||
|
||||
$: if (webSearchButton) {
|
||||
setIcon(webSearchButton, "globe");
|
||||
setIcon(webSearchButton, webSearchUnavailable ? "globe-lock" : "globe");
|
||||
}
|
||||
|
||||
$: userInstructionAreaActive, (() => {
|
||||
|
|
@ -193,7 +222,7 @@
|
|||
})();
|
||||
|
||||
$: if (submitButton) {
|
||||
if (inputMode === InputMode.Question || inputMode === InputMode.Diff) {
|
||||
if (inputMode === InputMode.Question || inputMode === InputMode.Diff || inputMode === InputMode.PlanApproval) {
|
||||
setIcon(submitButton, userRequest.trim() === "" ? "square" : "send-horizontal");
|
||||
} else {
|
||||
setIcon(submitButton, isSubmitting ? "square" : "send-horizontal");
|
||||
|
|
@ -208,14 +237,19 @@
|
|||
setIcon(chatModeButton, iconForChatMode(chatMode));
|
||||
}
|
||||
|
||||
$: if (freeEditButton) {
|
||||
setIcon(freeEditButton, iconForFreeEdit(freeEdit))
|
||||
}
|
||||
|
||||
$: inputPlaceholder = (() => {
|
||||
if (inputMode === InputMode.Question) return Copy.InputPlaceholderQuestion;
|
||||
if (inputMode === InputMode.Diff) return Copy.InputPlaceholderDiff;
|
||||
if (inputMode === InputMode.PlanApproval) return Copy.InputPlaceholderPlanApproval;
|
||||
return Copy.InputPlaceholderNormal;
|
||||
})();
|
||||
|
||||
$: submitDisabled = (() => {
|
||||
if (inputMode === InputMode.Diff || inputMode === InputMode.Question) {
|
||||
if (inputMode === InputMode.Diff || inputMode === InputMode.Question || inputMode === InputMode.PlanApproval) {
|
||||
return false;
|
||||
}
|
||||
return !isSubmitting && userRequest.trim() === "";
|
||||
|
|
@ -228,9 +262,16 @@
|
|||
if (inputMode === InputMode.Diff) {
|
||||
return userRequest.trim() === "" ? Copy.ButtonCancel : Copy.ButtonMakeSuggestion;
|
||||
}
|
||||
if (inputMode === InputMode.PlanApproval) {
|
||||
return userRequest.trim() === "" ? Copy.ButtonCancel : Copy.ButtonMakeSuggestion;
|
||||
}
|
||||
return isSubmitting ? Copy.ButtonCancel : Copy.ButtonSendMessage;
|
||||
})();
|
||||
|
||||
function iconForFreeEdit(freeEdit: boolean) {
|
||||
return freeEdit ? "file-pen" : "file-lock"
|
||||
}
|
||||
|
||||
function handleStop() {
|
||||
onStop();
|
||||
}
|
||||
|
|
@ -251,6 +292,14 @@
|
|||
diffService.onSuggest(suggestion.formattedRequest);
|
||||
}
|
||||
|
||||
function handlePlanSuggestion() {
|
||||
if (userRequest.trim() === "" || inputMode !== InputMode.PlanApproval) {
|
||||
return;
|
||||
}
|
||||
const suggestion = requestFromInput();
|
||||
planApprovalService.onSuggest(suggestion.formattedRequest);
|
||||
}
|
||||
|
||||
function handleAnswer() {
|
||||
if (userRequest.trim() === "" || inputMode !== InputMode.Question) {
|
||||
return;
|
||||
|
|
@ -271,7 +320,6 @@
|
|||
|
||||
textareaElement.textContent = "";
|
||||
userRequest = "";
|
||||
checkStacked();
|
||||
|
||||
if (Platform.isMobile) {
|
||||
textareaElement.blur();
|
||||
|
|
@ -288,14 +336,27 @@
|
|||
}
|
||||
|
||||
function toggleWebSearch() {
|
||||
settingsService.settings.enableWebSearch = !settingsService.settings.enableWebSearch;
|
||||
settingsService.saveSettings();
|
||||
if (webSearchUnavailable) {
|
||||
return;
|
||||
}
|
||||
const newState = !settingsService.settings.enableWebSearch;
|
||||
settingsService.updateSettings(settings => {
|
||||
settings.enableWebSearch = newState;
|
||||
});
|
||||
webSearchActive = newState;
|
||||
}
|
||||
|
||||
function toggleChatModeSelectionArea() {
|
||||
chatModeSelectionAreaActive = !chatModeSelectionAreaActive;
|
||||
}
|
||||
|
||||
function toggleFreeEdit() {
|
||||
const newState = !settingsService.settings.freeEdit;
|
||||
settingsService.updateSettings(settings => {
|
||||
settings.freeEdit = newState;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleKeydown(e: KeyboardEvent) {
|
||||
userInstructionAreaActive = false;
|
||||
if ($searchState.active) {
|
||||
|
|
@ -312,6 +373,8 @@
|
|||
handleAnswer();
|
||||
} else if (inputMode === InputMode.Diff) {
|
||||
handleSuggestion();
|
||||
} else if (inputMode === InputMode.PlanApproval) {
|
||||
handlePlanSuggestion();
|
||||
} else {
|
||||
handleSubmit();
|
||||
}
|
||||
|
|
@ -413,8 +476,6 @@
|
|||
if (userRequest.trim() === "") {
|
||||
textareaElement.textContent = "";
|
||||
}
|
||||
|
||||
checkStacked();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -503,12 +564,21 @@
|
|||
}
|
||||
}
|
||||
|
||||
function handleFocusIn() {
|
||||
if (Platform.isMobile && settingsService.settings.hideDrawerElements) {
|
||||
hideDrawerElements();
|
||||
}
|
||||
}
|
||||
|
||||
function handleFocusOut() {
|
||||
searchStateStore.resetSearch();
|
||||
if (Platform.isMobile && settingsService.settings.hideDrawerElements) {
|
||||
restoreDrawerElements();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div id="input-container" class:stacked>
|
||||
<div id="input-container">
|
||||
<div id="input-display-container" style:padding-top={attachments.length > 0 ? "var(--size-4-2)" : 0}>
|
||||
<InputDisplay bind:this={inputDisplay}/>
|
||||
</div>
|
||||
|
|
@ -517,8 +587,9 @@
|
|||
<ChatAttachments bind:attachments={attachments}/>
|
||||
</div>
|
||||
|
||||
<div id="diff-controls-container" style:padding-top={inputMode === InputMode.Diff ? "var(--size-4-2)" : 0}>
|
||||
<div id="diff-controls-container" style:padding-top={(inputMode === InputMode.Diff || inputMode === InputMode.PlanApproval) ? "var(--size-4-2)" : 0}>
|
||||
<DiffControls diffOpen={inputMode === InputMode.Diff}/>
|
||||
<PlanApprovalControls planApprovalOpen={inputMode === InputMode.PlanApproval}/>
|
||||
</div>
|
||||
|
||||
<div id="input-search-results-container" style:padding-top={$searchState.results.length > 0 ? "var(--size-4-2)" : 0}>
|
||||
|
|
@ -530,7 +601,7 @@
|
|||
</div>
|
||||
|
||||
<div id="chat-mode-selector-container" style:padding-top={chatModeSelectionAreaActive ? "var(--size-4-2)" : 0}>
|
||||
<ChatModeSelector focusInput={focusInput} bind:chatModeSelectionAreaActive={chatModeSelectionAreaActive} bind:currentChatMode={chatMode} />
|
||||
<ChatModeSelector focusInput={focusInput} bind:chatModeSelectionAreaActive={chatModeSelectionAreaActive} />
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
|
@ -543,10 +614,11 @@
|
|||
|
||||
<button
|
||||
id="web-search-button"
|
||||
class:input-button-highlight={settingsService.settings.enableWebSearch}
|
||||
class:input-button-highlight={webSearchActive && !webSearchUnavailable}
|
||||
bind:this={webSearchButton}
|
||||
disabled={webSearchUnavailable}
|
||||
on:click={toggleWebSearch}
|
||||
aria-label={settingsService.settings.enableWebSearch ? Copy.ButtonTurnOffWebSearch : Copy.ButtonTurnOnWebSearch}>
|
||||
aria-label={webSearchUnavailable ? Copy.ButtonWebSearchUnavailable : (webSearchActive ? Copy.ButtonTurnOffWebSearch : Copy.ButtonTurnOnWebSearch)}>
|
||||
</button>
|
||||
|
||||
<div
|
||||
|
|
@ -565,6 +637,7 @@
|
|||
on:dragleave={handleDragLeave}
|
||||
on:click={handleCursorPositionChange}
|
||||
on:keyup={handleCursorPositionChange}
|
||||
on:focus={handleFocusIn}
|
||||
on:focusout={handleFocusOut}
|
||||
data-placeholder={inputPlaceholder}
|
||||
role="textbox"
|
||||
|
|
@ -575,20 +648,29 @@
|
|||
<button
|
||||
id="chat-attachment-button"
|
||||
bind:this={attachmentButton}
|
||||
on:click={() => { handleAttachments() }}
|
||||
on:click={handleAttachments}
|
||||
disabled={isSubmitting}
|
||||
aria-label={Copy.ButtonAttachFiles}>
|
||||
</button>
|
||||
|
||||
<button
|
||||
id="chat-mode-button"
|
||||
class:input-button-highlight={chatModeAllowsEdits(chatMode)}
|
||||
class:input-button-highlight={editsAllowed}
|
||||
bind:this={chatModeButton}
|
||||
on:click={() => { toggleChatModeSelectionArea() }}
|
||||
on:click={toggleChatModeSelectionArea}
|
||||
disabled={isSubmitting}
|
||||
aria-label={Copy.ButtonChangeChatMode}>
|
||||
</button>
|
||||
|
||||
<button
|
||||
id="free-edit-button"
|
||||
class:input-button-highlight={freeEdit}
|
||||
bind:this={freeEditButton}
|
||||
on:click={toggleFreeEdit}
|
||||
disabled={!editsAllowed}
|
||||
aria-label={editsAllowed ? Copy.ButtonFreeEdit : Copy.ButtonFreeEditDisabled }>
|
||||
</button>
|
||||
|
||||
<button
|
||||
id="submit-button"
|
||||
bind:this={submitButton}
|
||||
|
|
@ -597,6 +679,8 @@
|
|||
userRequest.trim() === "" ? handleStop() : handleAnswer();
|
||||
} else if (inputMode === InputMode.Diff) {
|
||||
userRequest.trim() === "" ? handleStop() : handleSuggestion();
|
||||
} else if (inputMode === InputMode.PlanApproval) {
|
||||
userRequest.trim() === "" ? handleStop() : handlePlanSuggestion();
|
||||
} else {
|
||||
isSubmitting ? handleStop() : handleSubmit();
|
||||
}
|
||||
|
|
@ -611,8 +695,8 @@
|
|||
grid-row: 3;
|
||||
grid-column: 1;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto auto auto var(--size-4-3) 1fr var(--size-4-3);
|
||||
grid-template-columns: var(--size-4-3) auto var(--size-4-2) auto var(--size-4-2) 1fr var(--size-4-2) auto var(--size-4-2) auto var(--size-4-2) auto var(--size-4-3);
|
||||
grid-template-rows: auto auto auto auto auto var(--size-4-3) 1fr var(--size-4-3) auto var(--size-4-3);
|
||||
grid-template-columns: var(--size-4-3) auto var(--size-4-2) auto 1fr auto var(--size-4-2) auto var(--size-4-2) auto var(--size-4-2) auto var(--size-4-3);
|
||||
border-radius: var(--radius-l);
|
||||
background-color: var(--background-primary);
|
||||
}
|
||||
|
|
@ -643,39 +727,13 @@
|
|||
}
|
||||
|
||||
#chat-mode-selector-container {
|
||||
grid-row: 4;
|
||||
grid-row: 5;
|
||||
grid-column: 2 / 13;
|
||||
}
|
||||
|
||||
#user-instruction-button {
|
||||
grid-row: 6;
|
||||
grid-column: 2;
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--size-4-2);
|
||||
align-self: end;
|
||||
transition-duration: 0.5s;
|
||||
}
|
||||
|
||||
:global(.is-mobile) #user-instruction-button {
|
||||
max-height: 2rem;
|
||||
}
|
||||
|
||||
#web-search-button {
|
||||
grid-row: 6;
|
||||
grid-column: 4;
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--size-4-2);
|
||||
align-self: end;
|
||||
transition-duration: 0.5s;
|
||||
}
|
||||
|
||||
:global(.is-mobile) #web-search-button {
|
||||
max-height: 2rem;
|
||||
}
|
||||
|
||||
#input-field {
|
||||
grid-row: 6;
|
||||
grid-column: 6;
|
||||
grid-row: 7;
|
||||
grid-column: 2 / 13;
|
||||
height: 100%;
|
||||
max-height: 30vh;
|
||||
border-radius: var(--radius-m);
|
||||
|
|
@ -728,9 +786,35 @@
|
|||
outline: none;
|
||||
}
|
||||
|
||||
#user-instruction-button {
|
||||
grid-row: 9;
|
||||
grid-column: 2;
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--size-4-2);
|
||||
align-self: end;
|
||||
transition-duration: 0.5s;
|
||||
}
|
||||
|
||||
:global(.is-mobile) #user-instruction-button {
|
||||
max-height: 2rem;
|
||||
}
|
||||
|
||||
#web-search-button {
|
||||
grid-row: 9;
|
||||
grid-column: 4;
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--size-4-2);
|
||||
align-self: end;
|
||||
transition-duration: 0.5s;
|
||||
}
|
||||
|
||||
:global(.is-mobile) #web-search-button {
|
||||
max-height: 2rem;
|
||||
}
|
||||
|
||||
#chat-attachment-button {
|
||||
grid-row: 6;
|
||||
grid-column: 8;
|
||||
grid-row: 9;
|
||||
grid-column: 6;
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--size-4-2);
|
||||
align-self: end;
|
||||
|
|
@ -742,8 +826,8 @@
|
|||
}
|
||||
|
||||
#chat-mode-button {
|
||||
grid-row: 6;
|
||||
grid-column: 10;
|
||||
grid-row: 9;
|
||||
grid-column: 8;
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--size-4-2);
|
||||
align-self: end;
|
||||
|
|
@ -754,18 +838,31 @@
|
|||
max-height: 2rem;
|
||||
}
|
||||
|
||||
#free-edit-button {
|
||||
grid-row: 9;
|
||||
grid-column: 10;
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--size-4-2);
|
||||
align-self: end;
|
||||
transition-duration: 0.5s;
|
||||
}
|
||||
|
||||
:global(.is-mobile) #free-edit-button {
|
||||
max-height: 2rem;
|
||||
}
|
||||
|
||||
.input-button-highlight {
|
||||
box-shadow: 0px 0px 2px 1px var(--color-accent);
|
||||
}
|
||||
|
||||
#submit-button {
|
||||
grid-row: 6;
|
||||
grid-row: 9;
|
||||
grid-column: 12;
|
||||
border-radius: var(--radius-xl);
|
||||
padding-left: var(--size-4-2);
|
||||
padding-right: var(--size-4-2);
|
||||
align-self: end;
|
||||
transition-duration: 0.5s;
|
||||
transition: background-color 0.2s ease-out;
|
||||
background-color: var(--interactive-accent);
|
||||
}
|
||||
|
||||
|
|
@ -777,78 +874,4 @@
|
|||
cursor: pointer;
|
||||
background-color: var(--interactive-accent-hover);
|
||||
}
|
||||
|
||||
/* Stacked layout: input above, buttons below (desktop only, when content wraps) */
|
||||
#input-container.stacked {
|
||||
grid-template-rows: auto auto auto auto var(--size-4-3) 1fr var(--size-4-2) auto var(--size-4-3);
|
||||
}
|
||||
|
||||
#input-container.stacked #input-field {
|
||||
grid-column: 2 / 13;
|
||||
}
|
||||
|
||||
#input-container.stacked #user-instruction-button {
|
||||
grid-row: 8;
|
||||
}
|
||||
|
||||
#input-container.stacked #web-search-button {
|
||||
grid-row: 8;
|
||||
}
|
||||
|
||||
#input-container.stacked #chat-attachment-button {
|
||||
grid-row: 8;
|
||||
}
|
||||
|
||||
#input-container.stacked #chat-mode-button {
|
||||
grid-row: 8;
|
||||
}
|
||||
|
||||
#input-container.stacked #submit-button {
|
||||
grid-row: 8;
|
||||
}
|
||||
|
||||
/* Narrow/mobile layout: input above, buttons below */
|
||||
:global(.is-mobile) #input-container {
|
||||
grid-template-rows: auto auto auto auto var(--size-4-3) 1fr var(--size-4-2) auto var(--size-4-3);
|
||||
grid-template-columns: var(--size-4-3) auto var(--size-4-2) auto 1fr auto var(--size-4-2) auto var(--size-4-2) auto var(--size-4-3);
|
||||
}
|
||||
|
||||
:global(.is-mobile) #input-display-container,
|
||||
:global(.is-mobile) #input-attachments-container,
|
||||
:global(.is-mobile) #diff-controls-container,
|
||||
:global(.is-mobile) #input-search-results-container,
|
||||
:global(.is-mobile) #user-instruction-container,
|
||||
:global(.is-mobile) #chat-mode-selector-container {
|
||||
grid-column: 2 / 11;
|
||||
}
|
||||
|
||||
:global(.is-mobile) #input-field {
|
||||
grid-row: 6;
|
||||
grid-column: 2 / 11;
|
||||
}
|
||||
|
||||
:global(.is-mobile) #user-instruction-button {
|
||||
grid-row: 8;
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
:global(.is-mobile) #web-search-button {
|
||||
grid-row: 8;
|
||||
grid-column: 4;
|
||||
}
|
||||
|
||||
:global(.is-mobile) #chat-attachment-button {
|
||||
grid-row: 8;
|
||||
grid-column: 6;
|
||||
}
|
||||
|
||||
:global(.is-mobile) #chat-mode-button {
|
||||
grid-row: 8;
|
||||
grid-column: 8;
|
||||
}
|
||||
|
||||
:global(.is-mobile) #submit-button {
|
||||
grid-row: 8;
|
||||
grid-column: 10;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,26 +1,40 @@
|
|||
<script lang="ts">
|
||||
import { ChatMode, iconForChatMode } from "Enums/ChatMode";
|
||||
import { Copy } from "Enums/Copy";
|
||||
import { tick } from "svelte";
|
||||
import { onDestroy, tick } from "svelte";
|
||||
import { setIcon } from "obsidian";
|
||||
import type { SettingsService } from "Services/SettingsService";
|
||||
import { Resolve } from "Services/DependencyService";
|
||||
import { Services } from "Services/Services";
|
||||
|
||||
export let focusInput: () => void;
|
||||
export let chatModeSelectionAreaActive: boolean;
|
||||
export let currentChatMode: ChatMode;
|
||||
|
||||
const settingsService: SettingsService = Resolve<SettingsService>(Services.SettingsService);
|
||||
|
||||
const settingsSubscription: object = settingsService.subscribeToSettingsChanged(changed => {
|
||||
if (changed.includes("chatMode")) {
|
||||
currentChatMode = settingsService.settings.chatMode;
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => settingsService.unsubscribe(settingsSubscription));
|
||||
|
||||
let height = 0;
|
||||
|
||||
let ChatModeSelectionContentDiv: HTMLDivElement;
|
||||
let chatModeSelectionContainer: HTMLDivElement;
|
||||
|
||||
let selectedChatMode: number = ChatMode.ReadOnly;
|
||||
let currentChatMode: ChatMode = settingsService.settings.chatMode;
|
||||
let selectedChatMode: ChatMode = ChatMode.ReadOnly;
|
||||
|
||||
let iconElements: (HTMLDivElement | null)[] = [null, null, null];
|
||||
let indexedModes: ChatMode[] = [ChatMode.ReadOnly, ChatMode.Edit, ChatMode.Planning];
|
||||
|
||||
$: if (iconElements.some(element => element !== null)) {
|
||||
iconElements.forEach((element, index) => {
|
||||
if (element) {
|
||||
setIcon(element, iconForChatMode(index));
|
||||
setIcon(element, iconForChatMode(indexedModes[index]));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -59,10 +73,14 @@
|
|||
}
|
||||
}
|
||||
|
||||
function handleChatModeSelect(e?: MouseEvent) {
|
||||
async function handleChatModeSelect(e?: MouseEvent) {
|
||||
currentChatMode = selectedChatMode;
|
||||
chatModeSelectionAreaActive = false;
|
||||
|
||||
await settingsService.updateSettings(settings => {
|
||||
settings.chatMode = currentChatMode
|
||||
});
|
||||
|
||||
e?.preventDefault();
|
||||
focusInput();
|
||||
}
|
||||
|
|
@ -74,11 +92,12 @@
|
|||
e.preventDefault();
|
||||
|
||||
if (e.key.startsWith("Arrow")) {
|
||||
const index = indexedModes.indexOf(selectedChatMode);
|
||||
if (e.key === "ArrowUp") {
|
||||
selectedChatMode = selectedChatMode <= 0 ? 2 : selectedChatMode - 1;
|
||||
selectedChatMode = index === 0 ? indexedModes[2] : indexedModes[index - 1];
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
selectedChatMode = selectedChatMode >= 2 ? 0 : selectedChatMode + 1;
|
||||
selectedChatMode = index >= 2 ? indexedModes[0] : indexedModes[index + 1];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -111,7 +130,7 @@
|
|||
on:mouseenter={() => selectedChatMode = ChatMode.ReadOnly}
|
||||
on:mousedown={handleChatModeSelect}
|
||||
on:keydown={() => {}}>
|
||||
<div class="chat-mode-selection-icon" bind:this={iconElements[ChatMode.ReadOnly]}></div>
|
||||
<div class="chat-mode-selection-icon" bind:this={iconElements[indexedModes.indexOf(ChatMode.ReadOnly)]}></div>
|
||||
<div class="chat-mode-selection-title">{Copy.ChatModeReadOnlyTitle}</div>
|
||||
<div class="chat-mode-selection-subtitle">{Copy.ChatModeReadOnlyDesc}</div>
|
||||
</div>
|
||||
|
|
@ -124,7 +143,7 @@
|
|||
on:mouseenter={() => selectedChatMode = ChatMode.Edit}
|
||||
on:mousedown={handleChatModeSelect}
|
||||
on:keydown={() => {}}>
|
||||
<div class="chat-mode-selection-icon" bind:this={iconElements[ChatMode.Edit]}></div>
|
||||
<div class="chat-mode-selection-icon" bind:this={iconElements[indexedModes.indexOf(ChatMode.Edit)]}></div>
|
||||
<div class="chat-mode-selection-title">{Copy.ChatModeEditTitle}</div>
|
||||
<div class="chat-mode-selection-subtitle">{Copy.ChatModeEditDesc}</div>
|
||||
</div>
|
||||
|
|
@ -137,7 +156,7 @@
|
|||
on:mouseenter={() => selectedChatMode = ChatMode.Planning}
|
||||
on:mousedown={handleChatModeSelect}
|
||||
on:keydown={() => {}}>
|
||||
<div class="chat-mode-selection-icon" bind:this={iconElements[ChatMode.Planning]}></div>
|
||||
<div class="chat-mode-selection-icon" bind:this={iconElements[indexedModes.indexOf(ChatMode.Planning)]}></div>
|
||||
<div class="chat-mode-selection-title">{Copy.ChatModePlanningTitle}</div>
|
||||
<div class="chat-mode-selection-subtitle">{Copy.ChatModePlanningDesc}</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@
|
|||
let stepElements: (HTMLDivElement | null)[] = [];
|
||||
let isTransitioning = false;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let isScrolledToTop = true;
|
||||
let isScrolledToBottom = true;
|
||||
|
||||
$: steps = $executionPlanState.plan?.executionSteps;
|
||||
$: activeStepIndex = $executionPlanState.currentStepIndex;
|
||||
|
|
@ -39,20 +41,6 @@
|
|||
scrollToActiveStep();
|
||||
}
|
||||
|
||||
$: if (wrapperDiv && expanded !== undefined) {
|
||||
handleExpandTransition();
|
||||
}
|
||||
|
||||
function handleExpandTransition() {
|
||||
isTransitioning = true;
|
||||
const onTransitionEnd = () => {
|
||||
isTransitioning = false;
|
||||
wrapperDiv.removeEventListener('transitionend', onTransitionEnd);
|
||||
scrollToActiveStep();
|
||||
};
|
||||
wrapperDiv.addEventListener('transitionend', onTransitionEnd);
|
||||
}
|
||||
|
||||
function setupResizeObserver() {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect();
|
||||
|
|
@ -79,6 +67,8 @@
|
|||
|
||||
const stepsToShow = Math.min(3, steps.length);
|
||||
collapsedHeight = (stepsToShow * stepHeight) + (Math.max(0, stepsToShow - 1) * separatorHeight);
|
||||
|
||||
updateScrollFade();
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
|
|
@ -87,6 +77,15 @@
|
|||
}
|
||||
});
|
||||
|
||||
function updateScrollFade() {
|
||||
if (!wrapperDiv) {
|
||||
return;
|
||||
}
|
||||
const { scrollTop, scrollHeight, clientHeight } = wrapperDiv;
|
||||
isScrolledToTop = scrollTop < 1;
|
||||
isScrolledToBottom = scrollHeight - scrollTop - clientHeight < 1;
|
||||
}
|
||||
|
||||
async function scrollToActiveStep() {
|
||||
if (!wrapperDiv || !$executionPlanState.plan || activeStepIndex === -1) {
|
||||
return;
|
||||
|
|
@ -110,6 +109,17 @@
|
|||
requestAnimationFrame(() => {
|
||||
updateHeight();
|
||||
});
|
||||
|
||||
isTransitioning = true;
|
||||
const finishTransition = () => {
|
||||
wrapperDiv.removeEventListener('transitionend', finishTransition);
|
||||
clearTimeout(fallbackTimeoutId);
|
||||
isTransitioning = false;
|
||||
scrollToActiveStep();
|
||||
};
|
||||
// Fallback in case height doesn't actually change (e.g. <=3 steps), so transitionend never fires
|
||||
const fallbackTimeoutId = setTimeout(finishTransition, 250);
|
||||
wrapperDiv.addEventListener('transitionend', finishTransition, { once: true });
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -127,7 +137,7 @@
|
|||
aria-label={expanded ? "Collapse planned steps" : "Expand planned steps"}
|
||||
role="button"
|
||||
tabindex=0>
|
||||
<div id="chat-plan-steps-wrapper" style:height="{expanded ? expandedHeight : collapsedHeight}px" bind:this={wrapperDiv}>
|
||||
<div id="chat-plan-steps-wrapper" style:height="{expanded ? expandedHeight : collapsedHeight}px" bind:this={wrapperDiv} on:scroll={updateScrollFade}>
|
||||
<div id="chat-plan-steps" bind:this={contentDiv}>
|
||||
{#each steps as step, index }
|
||||
<div class="chat-plan-step" bind:this={stepElements[index]}>
|
||||
|
|
@ -156,8 +166,8 @@
|
|||
</div>
|
||||
</div>
|
||||
{#if steps.length > 2}
|
||||
<div class="chat-plan-fade top-fade" transition:fade></div>
|
||||
<div class="chat-plan-fade bottom-fade" transition:fade></div>
|
||||
<div class="chat-plan-fade top-fade" class:hidden={isScrolledToTop}></div>
|
||||
<div class="chat-plan-fade bottom-fade" class:hidden={isScrolledToBottom}></div>
|
||||
{/if}
|
||||
<div id="chat-plan-chevron"
|
||||
class="transparent-button"
|
||||
|
|
@ -254,6 +264,12 @@
|
|||
border-radius: var(--radius-m);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
opacity: 1;
|
||||
transition: opacity 0.2s ease-out;
|
||||
}
|
||||
|
||||
.chat-plan-fade.hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.top-fade {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { ARTIFACT_ACTION_RANK, ArtifactAction } from "Enums/ArtifactAction";
|
||||
|
||||
import { Resolve } from "Services/DependencyService";
|
||||
import { Services } from "Services/Services";
|
||||
import ChatArea from "./ChatArea.svelte";
|
||||
|
|
@ -8,7 +10,6 @@
|
|||
import { Conversation } from "Conversations/Conversation";
|
||||
import type VaultkeeperAIPlugin from "main";
|
||||
import { openPluginSettings } from "Helpers/Helpers";
|
||||
import { Selector } from "Enums/Selector";
|
||||
import type { WorkSpaceService } from "Services/WorkSpaceService";
|
||||
import type { ChatService } from "Services/ChatService";
|
||||
import type { ConversationFileSystemService } from "Services/ConversationFileSystemService";
|
||||
|
|
@ -19,20 +20,26 @@
|
|||
import ChatPlanArea from "./ChatPlanArea.svelte";
|
||||
import type { ExecutionPlanStore } from "Stores/ExecutionPlanStore";
|
||||
import type { StreamingMarkdownService } from "Services/StreamingMarkdownService";
|
||||
import { HTMLService } from "Services/HTMLService";
|
||||
import { AITool, fromString } from "Enums/AITool";
|
||||
import { ChatMode } from "Enums/ChatMode";
|
||||
import { AIProvider } from "Enums/ApiProvider";
|
||||
import type { PlanApprovalService } from "Services/PlanApprovalService";
|
||||
import { Artifact } from "Conversations/Artifact";
|
||||
import { ConversationContent } from "Conversations/ConversationContent";
|
||||
import { Role } from "Enums/Role";
|
||||
import { basename } from "path-browserify";
|
||||
|
||||
const plugin: VaultkeeperAIPlugin = Resolve<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin);
|
||||
const executionPlanStore: ExecutionPlanStore = Resolve<ExecutionPlanStore>(Services.ExecutionPlanStore);
|
||||
const settingsService: SettingsService = Resolve<SettingsService>(Services.SettingsService);
|
||||
const chatService: ChatService = Resolve<ChatService>(Services.ChatService);
|
||||
const planApprovalService: PlanApprovalService = Resolve<PlanApprovalService>(Services.PlanApprovalService);
|
||||
const workSpaceService: WorkSpaceService = Resolve<WorkSpaceService>(Services.WorkSpaceService);
|
||||
const conversationService: ConversationFileSystemService = Resolve<ConversationFileSystemService>(Services.ConversationFileSystemService);
|
||||
const streamingMarkdownService: StreamingMarkdownService = Resolve<StreamingMarkdownService>(Services.StreamingMarkdownService);
|
||||
const htmlService: HTMLService = Resolve<HTMLService>(Services.HTMLService);
|
||||
const abortService: AbortService = Resolve<AbortService>(Services.AbortService);
|
||||
|
||||
let collectedArtifacts: Artifact[] = [];
|
||||
|
||||
let chatContainer: HTMLDivElement;
|
||||
let chatArea: ChatArea;
|
||||
let chatInput: ChatInput;
|
||||
|
|
@ -40,16 +47,14 @@
|
|||
let hasNoApiKey = false;
|
||||
let isSubmitting = false;
|
||||
let busyPlanning = false;
|
||||
let chatMode: ChatMode = ChatMode.ReadOnly;
|
||||
let currentStreamingMessageId: string | null = null;
|
||||
|
||||
let conversation: Conversation = new Conversation();
|
||||
let attachments: Attachment[] = [];
|
||||
|
||||
let currentThought: string | null = null;
|
||||
|
||||
export function focusInput() {
|
||||
chatInput?.focusInput();
|
||||
export function focusInput(force: boolean = false) {
|
||||
chatInput?.focusInput(force);
|
||||
}
|
||||
|
||||
export function resetChatArea() {
|
||||
|
|
@ -65,29 +70,30 @@
|
|||
async function handleLinkClick(evt: MouseEvent) {
|
||||
const target = evt.target as HTMLElement;
|
||||
|
||||
const link = target.closest(`.${Selector.MarkDownLink}`) as HTMLAnchorElement | null;
|
||||
const link = target.closest('.internal-link') as HTMLAnchorElement | null;
|
||||
if (!link) {
|
||||
return;
|
||||
return;
|
||||
}
|
||||
|
||||
const href = link.getAttribute('href');
|
||||
if (!href || !href.startsWith('#/page/')) {
|
||||
const notePath = link.getAttribute('data-href');
|
||||
if (!notePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
|
||||
const encodedPath = href.replace('#/page/', '');
|
||||
const notePath = decodeURIComponent(encodedPath);
|
||||
await workSpaceService.openNote(notePath);
|
||||
}
|
||||
|
||||
function handleNoApiKey(): boolean {
|
||||
hasNoApiKey = settingsService.getApiKeyForCurrentModel().trim() == "";
|
||||
hasNoApiKey = settingsService.settings.provider !== AIProvider.Local
|
||||
&& settingsService.getApiKeyForCurrentProvider().trim() == "";
|
||||
|
||||
if (hasNoApiKey) {
|
||||
openPluginSettings(plugin);
|
||||
}
|
||||
|
||||
return hasNoApiKey;
|
||||
}
|
||||
|
||||
|
|
@ -100,20 +106,20 @@
|
|||
if (handleNoApiKey()) {
|
||||
return;
|
||||
}
|
||||
collectedArtifacts = [];
|
||||
|
||||
const currentRequest = userRequest;
|
||||
|
||||
await chatService.submit(conversation, chatMode, currentRequest, formattedRequest, attachments, {
|
||||
await chatService.submit(conversation, settingsService.settings.chatMode, currentRequest, formattedRequest, attachments, {
|
||||
onSubmit: () => {
|
||||
isSubmitting = true;
|
||||
attachments = [];
|
||||
chatArea.resetAutoScroll();
|
||||
},
|
||||
onStreamingUpdate: (streamingId) => {
|
||||
conversation = conversation;
|
||||
currentStreamingMessageId = streamingId;
|
||||
chatArea.updateChatAreaLayout("smooth");
|
||||
},
|
||||
onStreamingUpdate: () => {
|
||||
conversation = conversation;
|
||||
chatArea.updateChatAreaLayout();
|
||||
},
|
||||
onThoughtUpdate: (thought) => {
|
||||
if (thought !== Copy.AIThoughtMessage) {
|
||||
currentThought = thought;
|
||||
|
|
@ -135,6 +141,14 @@
|
|||
break;
|
||||
}
|
||||
},
|
||||
onArtifactProduced: (artifact: Artifact) => {
|
||||
const collectedArtifact = collectedArtifacts.find(a => a.filePath === artifact.filePath);
|
||||
if (!collectedArtifact) {
|
||||
collectedArtifacts.push(artifact);
|
||||
return;
|
||||
}
|
||||
collectedArtifact.updatedContent = artifact.updatedContent;
|
||||
},
|
||||
onPlanningStarted: () => {
|
||||
busyPlanning = true;
|
||||
},
|
||||
|
|
@ -143,13 +157,15 @@
|
|||
},
|
||||
onUserQuestion: async (question) => {
|
||||
const displayEl = createEl("div");
|
||||
const formattedHtml = streamingMarkdownService.formatText(question);
|
||||
htmlService.setHTMLContent(displayEl, formattedHtml);
|
||||
await streamingMarkdownService.render(question, displayEl, true);
|
||||
chatInput.setDisplayItem(displayEl);
|
||||
return new Promise<string>((resolve) => {
|
||||
chatInput.enterQuestionMode(resolve);
|
||||
});
|
||||
},
|
||||
onPlanApprovalRequest: async (plan) => {
|
||||
return planApprovalService.requestApproval(plan);
|
||||
},
|
||||
onPlanUpdate: (executionPlan) => {
|
||||
executionPlanStore.setPlan(executionPlan);
|
||||
},
|
||||
|
|
@ -160,27 +176,41 @@
|
|||
executionPlanStore.clearPlan();
|
||||
},
|
||||
onComplete: async () => {
|
||||
saveCollectedArtifects(conversation);
|
||||
conversation = conversation;
|
||||
conversationService.saveConversation(conversation);
|
||||
isSubmitting = false;
|
||||
busyPlanning = false;
|
||||
currentThought = null;
|
||||
executionPlanStore.clearPlan();
|
||||
chatInput.clearDisplayItem();
|
||||
abortService.reset();
|
||||
tick().then(() => {
|
||||
chatArea.updateChatAreaLayout("smooth", true);
|
||||
});
|
||||
chatArea.updateChatAreaLayout();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function saveCollectedArtifects(conversation: Conversation): void {
|
||||
let lastMessage = conversation.contents.last();
|
||||
|
||||
if (lastMessage?.role !== Role.Assistant || !lastMessage.shouldDisplayContent) {
|
||||
lastMessage = new ConversationContent({ role: Role.Assistant });
|
||||
conversation.contents.push(lastMessage);
|
||||
}
|
||||
|
||||
lastMessage.artifacts = Artifact.sort(collectedArtifacts);
|
||||
}
|
||||
|
||||
$: if ($conversationStore.shouldReset) {
|
||||
conversation = new Conversation();
|
||||
conversationService.resetCurrentConversation();
|
||||
|
||||
isSubmitting = false;
|
||||
currentStreamingMessageId = null;
|
||||
currentThought = null;
|
||||
|
||||
chatArea?.resetChatArea();
|
||||
|
||||
chatService.onNameChanged?.("");
|
||||
conversationStore.clearResetFlag();
|
||||
}
|
||||
|
||||
|
|
@ -188,7 +218,6 @@
|
|||
conversation.contents = [];
|
||||
|
||||
isSubmitting = false;
|
||||
currentStreamingMessageId = null;
|
||||
currentThought = null;
|
||||
|
||||
chatArea.resetChatArea();
|
||||
|
|
@ -200,29 +229,23 @@
|
|||
conversationService.setCurrentConversationPath(filePath);
|
||||
chatService.onNameChanged?.(loadedConversation.title);
|
||||
conversationStore.clearLoadFlag();
|
||||
chatArea.updateChatAreaLayout("instant", true);
|
||||
chatArea.updateChatAreaLayout("instant");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$: if ($conversationStore.shouldDeactivateEditMode) {
|
||||
conversationStore.clearEditModeFlag();
|
||||
chatMode = ChatMode.ReadOnly;
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="container">
|
||||
<ChatPlanArea executionPlanState={executionPlanStore.executionPlanState} {busyPlanning}/>
|
||||
|
||||
<div id="chat-container">
|
||||
<ChatArea messages={conversation.contents} bind:this={chatArea} bind:currentThought bind:isSubmitting bind:chatContainer
|
||||
currentStreamingMessageId={currentStreamingMessageId}/>
|
||||
<ChatArea messages={conversation.contents} bind:this={chatArea} bind:currentThought bind:isSubmitting bind:chatContainer/>
|
||||
</div>
|
||||
|
||||
<ChatInput
|
||||
bind:this={chatInput}
|
||||
bind:attachments
|
||||
bind:chatMode={chatMode}
|
||||
{hasNoApiKey}
|
||||
{isSubmitting}
|
||||
onSubmit={handleSubmit}
|
||||
|
|
|
|||
|
|
@ -45,52 +45,45 @@
|
|||
|
||||
<style>
|
||||
#diff-controls-wrapper {
|
||||
transition: height 0.2s ease-out;
|
||||
overflow: hidden;
|
||||
transition: height 0.2s ease-out;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#diff-controls {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr var(--size-4-2) 1fr;
|
||||
grid-template-rows: auto;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr var(--size-4-2) 1fr;
|
||||
grid-template-rows: auto;
|
||||
}
|
||||
|
||||
#diff-accept {
|
||||
grid-column: 1;
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--color-green) 75%,
|
||||
var(--background-primary) 25%
|
||||
);
|
||||
grid-column: 1;
|
||||
color: white;
|
||||
background-color: #38533a;
|
||||
}
|
||||
|
||||
#diff-accept:hover {
|
||||
background-color: var(--color-green);
|
||||
background-color: #537555;
|
||||
}
|
||||
|
||||
#diff-accept:focus {
|
||||
background-color: var(--color-green);
|
||||
background-color: #537555;
|
||||
}
|
||||
|
||||
#diff-reject {
|
||||
grid-column: 3;
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--color-red) 75%,
|
||||
var(--background-primary) 25%
|
||||
);
|
||||
grid-column: 3;
|
||||
color: white;
|
||||
background-color: #593030;
|
||||
}
|
||||
|
||||
#diff-reject:hover {
|
||||
background-color: var(--color-red);
|
||||
background-color: #774545;
|
||||
}
|
||||
|
||||
#diff-reject:focus {
|
||||
background-color: var(--color-red);
|
||||
background-color: #774545;
|
||||
}
|
||||
|
||||
.diff-button {
|
||||
border-radius: var(--button-radius);
|
||||
transition-duration: 0.5s;
|
||||
transition: background-color 0.2s ease-out;
|
||||
}
|
||||
</style>
|
||||
183
Components/GraphAnimation.svelte
Normal file
183
Components/GraphAnimation.svelte
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { fade } from "svelte/transition";
|
||||
|
||||
const NODE_COUNT = 20;
|
||||
const EXTRA_EDGE_TARGET = 12;
|
||||
const MIN_DIST = 0.15;
|
||||
const SHADES = [
|
||||
"var(--text-normal)",
|
||||
"var(--text-muted)",
|
||||
"var(--text-faint)",
|
||||
"var(--background-modifier-border)",
|
||||
"var(--interactive-accent)",
|
||||
"var(--color-accent)",
|
||||
];
|
||||
const EDGE_STROKE = "var(--text-faint)";
|
||||
const ACCENT_EDGE_STROKE = "var(--interactive-accent)";
|
||||
const ACCENT_EDGE_CHANCE = 0.2;
|
||||
|
||||
type GraphNode = { x: number; y: number; r: number; shade: string };
|
||||
type GraphEdge = { a: number; b: number; stroke: string };
|
||||
type Motion = { ax: number; ay: number; fa: number; fb: number; pa: number; pb: number };
|
||||
type RenderNode = { cx: number; cy: number; r: number; fill: string; opacity: number };
|
||||
type RenderEdge = { x1: number; y1: number; x2: number; y2: number; opacity: number; stroke: string };
|
||||
|
||||
function generateGraph(n: number): { nodes: GraphNode[]; edges: GraphEdge[] } {
|
||||
const pts: { x: number; y: number }[] = [];
|
||||
let attempts = 0;
|
||||
while (pts.length < n && attempts < 5000) {
|
||||
attempts++;
|
||||
const x = 0.07 + Math.random() * 0.86;
|
||||
const y = 0.07 + Math.random() * 0.86;
|
||||
let ok = true;
|
||||
for (const p of pts) {
|
||||
const dx = p.x - x, dy = p.y - y;
|
||||
if (Math.sqrt(dx * dx + dy * dy) < MIN_DIST) { ok = false; break; }
|
||||
}
|
||||
if (ok) pts.push({ x, y });
|
||||
}
|
||||
while (pts.length < n) {
|
||||
pts.push({ x: 0.1 + Math.random() * 0.8, y: 0.1 + Math.random() * 0.8 });
|
||||
}
|
||||
|
||||
const nodes: GraphNode[] = pts.map(p => ({
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
r: 2.6 + Math.random() * 4.2,
|
||||
shade: SHADES[Math.floor(Math.random() * SHADES.length)],
|
||||
}));
|
||||
|
||||
const order = [...Array(n).keys()];
|
||||
for (let i = order.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[order[i], order[j]] = [order[j], order[i]];
|
||||
}
|
||||
const inTree = new Set([order[0]]);
|
||||
const edges: [number, number][] = [];
|
||||
for (let k = 1; k < order.length; k++) {
|
||||
const node = order[k];
|
||||
const treeArr = [...inTree];
|
||||
let best = treeArr[0], bestD = Infinity;
|
||||
for (const cand of treeArr) {
|
||||
const dx = nodes[cand].x - nodes[node].x, dy = nodes[cand].y - nodes[node].y;
|
||||
const d = dx * dx + dy * dy;
|
||||
if (d < bestD) { bestD = d; best = cand; }
|
||||
}
|
||||
edges.push([node, best]);
|
||||
inTree.add(node);
|
||||
}
|
||||
|
||||
const degree = new Array(n).fill(0);
|
||||
edges.forEach(([a, b]) => { degree[a]++; degree[b]++; });
|
||||
const leaves: number[] = [];
|
||||
for (let i = 0; i < n; i++) if (degree[i] === 1) leaves.push(i);
|
||||
const protectedLeaves = new Set(leaves.slice(0, Math.max(2, Math.min(3, leaves.length))));
|
||||
|
||||
const edgeKey = (a: number, b: number) => (a < b ? a + "-" + b : b + "-" + a);
|
||||
const existing = new Set(edges.map(([a, b]) => edgeKey(a, b)));
|
||||
let added = 0, tries = 0;
|
||||
while (added < EXTRA_EDGE_TARGET && tries < 600) {
|
||||
tries++;
|
||||
const a = Math.floor(Math.random() * n);
|
||||
const b = Math.floor(Math.random() * n);
|
||||
if (a === b) continue;
|
||||
if (protectedLeaves.has(a) || protectedLeaves.has(b)) continue;
|
||||
const key = edgeKey(a, b);
|
||||
if (existing.has(key)) continue;
|
||||
const dx = nodes[a].x - nodes[b].x, dy = nodes[a].y - nodes[b].y;
|
||||
if (Math.sqrt(dx * dx + dy * dy) > 0.4) continue;
|
||||
existing.add(key);
|
||||
edges.push([a, b]);
|
||||
added++;
|
||||
}
|
||||
|
||||
const coloredEdges: GraphEdge[] = edges.map(([a, b]) => ({
|
||||
a,
|
||||
b,
|
||||
stroke: Math.random() < ACCENT_EDGE_CHANCE ? ACCENT_EDGE_STROKE : EDGE_STROKE,
|
||||
}));
|
||||
|
||||
return { nodes, edges: coloredEdges };
|
||||
}
|
||||
|
||||
function makeMotion(nodesArr: GraphNode[]): Motion[] {
|
||||
return nodesArr.map(() => ({
|
||||
ax: 0.03 + Math.random() * 0.03,
|
||||
ay: 0.026 + Math.random() * 0.03,
|
||||
fa: 0.16 + Math.random() * 0.2,
|
||||
fb: 0.45 + Math.random() * 0.32,
|
||||
pa: Math.random() * Math.PI * 2,
|
||||
pb: Math.random() * Math.PI * 2,
|
||||
}));
|
||||
}
|
||||
|
||||
const graph = generateGraph(NODE_COUNT);
|
||||
const motion = makeMotion(graph.nodes);
|
||||
|
||||
const VIEW_SIZE = 460;
|
||||
let renderNodes: RenderNode[] = [];
|
||||
let renderEdges: RenderEdge[] = [];
|
||||
let rafId: number | undefined;
|
||||
let startTime: number | undefined;
|
||||
|
||||
function computeFrame(t: number) {
|
||||
const nodes: RenderNode[] = graph.nodes.map((n, i) => {
|
||||
const m = motion[i];
|
||||
const dx = m.ax * Math.sin(t * m.fa + m.pa) + m.ax * 0.4 * Math.sin(t * m.fb + m.pb);
|
||||
const dy = m.ay * Math.cos(t * m.fa * 0.9 + m.pb) + m.ay * 0.4 * Math.cos(t * m.fb * 1.1 + m.pa);
|
||||
return {
|
||||
cx: (n.x + dx) * VIEW_SIZE,
|
||||
cy: (n.y + dy) * VIEW_SIZE,
|
||||
r: n.r,
|
||||
fill: n.shade,
|
||||
opacity: 0.55 + 0.4 * Math.sin(t * 0.65 + i * 1.3),
|
||||
};
|
||||
});
|
||||
const edges: RenderEdge[] = graph.edges.map((e, i) => ({
|
||||
x1: nodes[e.a].cx,
|
||||
y1: nodes[e.a].cy,
|
||||
x2: nodes[e.b].cx,
|
||||
y2: nodes[e.b].cy,
|
||||
opacity: 0.26 + 0.16 * Math.sin(t * 0.4 + i),
|
||||
stroke: e.stroke,
|
||||
}));
|
||||
renderNodes = nodes;
|
||||
renderEdges = edges;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const loop = (now: number) => {
|
||||
if (startTime === undefined) startTime = now;
|
||||
computeFrame((now - startTime) / 1000);
|
||||
rafId = requestAnimationFrame(loop);
|
||||
};
|
||||
rafId = requestAnimationFrame(loop);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (rafId !== undefined) cancelAnimationFrame(rafId);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svg
|
||||
class="graph-animation"
|
||||
viewBox="0 0 {VIEW_SIZE} {VIEW_SIZE}"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
in:fade={{ duration: 400 }}
|
||||
>
|
||||
{#each renderEdges as e}
|
||||
<line x1={e.x1} y1={e.y1} x2={e.x2} y2={e.y2} stroke={e.stroke} stroke-width="1.2" style="stroke-linecap: round; opacity: {e.opacity}"></line>
|
||||
{/each}
|
||||
{#each renderNodes as n}
|
||||
<circle cx={n.cx} cy={n.cy} r={n.r} fill={n.fill} style="opacity: {n.opacity}"></circle>
|
||||
{/each}
|
||||
</svg>
|
||||
|
||||
<style>
|
||||
.graph-animation {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
90
Components/PlanApprovalControls.svelte
Normal file
90
Components/PlanApprovalControls.svelte
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
<script lang="ts">
|
||||
import { Resolve } from "Services/DependencyService";
|
||||
import type { PlanApprovalService } from "Services/PlanApprovalService";
|
||||
import { Services } from "Services/Services";
|
||||
import { Copy } from "Enums/Copy";
|
||||
import { tick } from "svelte";
|
||||
|
||||
export let planApprovalOpen = false;
|
||||
|
||||
const planApprovalService: PlanApprovalService = Resolve<PlanApprovalService>(Services.PlanApprovalService);
|
||||
|
||||
let contentDiv: HTMLDivElement;
|
||||
let height = 0;
|
||||
|
||||
$: planApprovalOpen, updateHeight();
|
||||
|
||||
function updateHeight() {
|
||||
tick().then(() => {
|
||||
if (contentDiv) {
|
||||
height = contentDiv.scrollHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div id="plan-approval-controls-wrapper" style:height="{height}px">
|
||||
<div id="plan-approval-controls" bind:this={contentDiv}>
|
||||
{#if planApprovalOpen}
|
||||
<button
|
||||
id="plan-approve"
|
||||
class="plan-approval-button"
|
||||
aria-label={Copy.ButtonApprove}
|
||||
on:click={() => planApprovalService.onApprove()}>
|
||||
{Copy.ButtonApprove}
|
||||
</button>
|
||||
<button
|
||||
id="plan-reject"
|
||||
class="plan-approval-button"
|
||||
aria-label={Copy.ButtonReject}
|
||||
on:click={() => planApprovalService.onReject()}>
|
||||
{Copy.ButtonReject}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#plan-approval-controls-wrapper {
|
||||
transition: height 0.2s ease-out;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#plan-approval-controls {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr var(--size-4-2) 1fr;
|
||||
grid-template-rows: auto;
|
||||
}
|
||||
|
||||
#plan-approve {
|
||||
grid-column: 1;
|
||||
color: white;
|
||||
background-color: #38533a;
|
||||
}
|
||||
|
||||
#plan-approve:hover {
|
||||
background-color: #537555;
|
||||
}
|
||||
|
||||
#plan-approve:focus {
|
||||
background-color: #537555;
|
||||
}
|
||||
|
||||
#plan-reject {
|
||||
grid-column: 3;
|
||||
color: white;
|
||||
background-color: #593030;
|
||||
}
|
||||
|
||||
#plan-reject:hover {
|
||||
background-color: #774545;
|
||||
}
|
||||
|
||||
#plan-reject:focus {
|
||||
background-color: #774545;
|
||||
}
|
||||
|
||||
.plan-approval-button {
|
||||
transition: background-color 0.2s ease-out;
|
||||
}
|
||||
</style>
|
||||
44
Components/PlanApprovalWindow.svelte
Normal file
44
Components/PlanApprovalWindow.svelte
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<script lang="ts">
|
||||
import { Resolve } from "Services/DependencyService";
|
||||
import { Services } from "Services/Services";
|
||||
import type { StreamingMarkdownService } from "Services/StreamingMarkdownService";
|
||||
import type { ExecutionPlan } from "Types/ExecutionPlan";
|
||||
|
||||
export let plan: ExecutionPlan;
|
||||
|
||||
const streamingMarkdownService: StreamingMarkdownService = Resolve<StreamingMarkdownService>(Services.StreamingMarkdownService);
|
||||
|
||||
function instructionRenderAction(element: HTMLElement, instruction: string) {
|
||||
streamingMarkdownService.render(instruction, element, true);
|
||||
return {
|
||||
update(newInstruction: string) {
|
||||
streamingMarkdownService.render(newInstruction, element, true);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div id="plan-approval-container">
|
||||
{#each plan.executionSteps as step, index}
|
||||
<div class="plan-approval-step">
|
||||
<h3 class="plan-approval-step-heading">{`${index + 1}. ${step.description}`}</h3>
|
||||
<div class="plan-approval-step-instruction" use:instructionRenderAction={step.instruction}></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#plan-approval-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: var(--size-4-4);
|
||||
}
|
||||
|
||||
.plan-approval-step {
|
||||
margin-bottom: var(--size-4-6);
|
||||
}
|
||||
|
||||
.plan-approval-step-heading {
|
||||
margin-bottom: var(--size-4-2);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
<script lang="ts">
|
||||
import { fade } from "svelte/transition";
|
||||
import Spinner from "./Spinner.svelte";
|
||||
export let thought: string | null = null;
|
||||
export let thoughtIndicatorElement: HTMLElement | undefined = undefined;
|
||||
|
||||
|
|
@ -9,9 +8,10 @@
|
|||
</script>
|
||||
|
||||
{#if isVisible}
|
||||
<div class="ai-thought-container" in:fade={{ duration: 200 }} out:fade={{ duration: 200 }} bind:this={thoughtIndicatorElement}>
|
||||
<div class="ai-thought-bubble">
|
||||
<span><Spinner/> {thought}</span>
|
||||
<div class="ai-thought-container" bind:this={thoughtIndicatorElement} transition:fade={{ duration: 300 }}>
|
||||
<div class="ai-thought-line">
|
||||
<span class="ai-thought-dot"></span>
|
||||
<span class="ai-thought-text">{thought}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -22,59 +22,40 @@
|
|||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.ai-thought-bubble {
|
||||
--border-width: 1px;
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 10px;
|
||||
.ai-thought-line {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding-left: 0.125rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.ai-thought-bubble span {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: var(--background-primary);
|
||||
border-radius: 10px;
|
||||
padding: 0.55rem 0.7rem;
|
||||
.ai-thought-dot {
|
||||
flex-shrink: 0;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin-top: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-accent, #a78bfa);
|
||||
animation: breathe 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.ai-thought-text {
|
||||
font-size: var(--font-smallest);
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
line-height: 1.5;
|
||||
word-wrap: break-word;
|
||||
width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.ai-thought-bubble::after {
|
||||
position: absolute;
|
||||
content: "";
|
||||
top: calc(-1 * var(--border-width));
|
||||
left: calc(-1 * var(--border-width));
|
||||
z-index: 0;
|
||||
width: calc(100% + var(--border-width) * 2);
|
||||
height: calc(100% + var(--border-width) * 2);
|
||||
background: linear-gradient(
|
||||
60deg,
|
||||
hsl(224, 85%, 66%),
|
||||
hsl(269, 85%, 66%),
|
||||
hsl(314, 85%, 66%),
|
||||
hsl(359, 85%, 66%),
|
||||
hsl(44, 85%, 66%),
|
||||
hsl(89, 85%, 66%),
|
||||
hsl(134, 85%, 66%),
|
||||
hsl(179, 85%, 66%)
|
||||
);
|
||||
background-size: 300% 300%;
|
||||
background-position: 0 50%;
|
||||
border-radius: 10px;
|
||||
animation: moveGradient 3s alternate infinite;
|
||||
}
|
||||
|
||||
@keyframes moveGradient {
|
||||
@keyframes breathe {
|
||||
0%, 100% {
|
||||
opacity: 0.35;
|
||||
transform: scale(0.85);
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -80,8 +80,9 @@
|
|||
|
||||
function handleInstructionSelect(e?: MouseEvent) {
|
||||
if (selectedInstruction < userInstructions.length) {
|
||||
settingsService.settings.userInstruction = userInstructions[selectedInstruction];
|
||||
settingsService.saveSettings();
|
||||
settingsService.updateSettings(settings => {
|
||||
settings.userInstruction = userInstructions[selectedInstruction];
|
||||
});
|
||||
}
|
||||
userInstructionAreaActive = false;
|
||||
|
||||
|
|
|
|||
134
Components/UserMessage.svelte
Normal file
134
Components/UserMessage.svelte
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
<script lang="ts">
|
||||
import { setElementIcon } from "Helpers/ElementHelper";
|
||||
import type { ConversationContent } from "Conversations/ConversationContent";
|
||||
|
||||
export let message: ConversationContent;
|
||||
|
||||
$: content = message.getDisplayContent();
|
||||
</script>
|
||||
|
||||
<div class="message-container user">
|
||||
<div class="message-bubble user">
|
||||
<div class="message-text-user-container" contenteditable="false">
|
||||
<div class="message-text-user">
|
||||
{@html content}
|
||||
</div>
|
||||
</div>
|
||||
{#if message.references.length > 0}
|
||||
<hr class="message-attachment-break"/>
|
||||
<div class="message-attachments-container">
|
||||
{#each message.references as reference}
|
||||
<div class="message-attachmanet" aria-label="{reference.fileName}">
|
||||
<div
|
||||
class="message-attachment-icon"
|
||||
use:setElementIcon={reference.getIconName()}
|
||||
></div>
|
||||
<div class="message-attachment-info">
|
||||
<div class="message-attachment-name">{reference.fileName}</div>
|
||||
<div class="message-attachment-size">{reference.size}MB</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.message-container {
|
||||
display: flex;
|
||||
text-align: left;
|
||||
margin: 0;
|
||||
justify-content: flex-end;
|
||||
animation: fadeIn 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(5px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
word-wrap: break-word;
|
||||
max-width: 70%;
|
||||
border: var(--border-width) solid var(--background-modifier-border);
|
||||
border-radius: var(--radius-m);
|
||||
padding: 0px var(--size-4-2);
|
||||
}
|
||||
|
||||
.message-text-user-container {
|
||||
max-height: 15vh;
|
||||
overflow: scroll;
|
||||
padding-top: var(--size-4-2);
|
||||
padding-bottom: var(--size-4-2);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.message-text-user-container::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.message-attachment-break {
|
||||
color: var(--background-secondary-alt);
|
||||
margin: 0 0 var(--size-4-2) 0;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.message-attachments-container {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scroll-behavior: smooth;
|
||||
gap: var(--size-4-2);
|
||||
margin-bottom: var(--size-4-2);
|
||||
}
|
||||
|
||||
.message-attachments-container::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.message-attachmanet {
|
||||
display: grid;
|
||||
grid-template-rows: var(--size-4-2) auto var(--size-4-2);
|
||||
grid-template-columns: var(--size-4-2) auto var(--size-4-2) auto var(--size-4-2);
|
||||
background-color: var(--background-secondary-alt);
|
||||
border: var(--border-width) solid var(--background-modifier-border);
|
||||
border-radius: var(--radius-m);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message-attachment-icon {
|
||||
grid-row: 2;
|
||||
grid-column: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.message-attachment-info {
|
||||
grid-row: 2;
|
||||
grid-column: 4;
|
||||
min-width: 40px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.message-attachment-name {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
font-size: var(--font-smaller);
|
||||
}
|
||||
|
||||
.message-attachment-size {
|
||||
padding: 0;
|
||||
font-size: var(--font-smallest);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
92
Conversations/Artifact.ts
Normal file
92
Conversations/Artifact.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import type { IBinaryFile } from "Conversations/IBinaryFile";
|
||||
import { ARTIFACT_ACTION_RANK, isArtifactAction, type ArtifactAction } from "Enums/ArtifactAction";
|
||||
import { isAudioFile, isImageFile, isKnownFileType, isTextFile, isVideoFile } from "Enums/FileType";
|
||||
import { pathExtname } from "Helpers/Helpers";
|
||||
import { basename } from "path-browserify";
|
||||
|
||||
export class Artifact implements IBinaryFile {
|
||||
|
||||
public filePath: string;
|
||||
public mimeType: string;
|
||||
public action: ArtifactAction;
|
||||
public originalContent: string;
|
||||
public updatedContent: string;
|
||||
public base64: string | undefined;
|
||||
public artifactPath?: string;
|
||||
|
||||
public static sort(artifacts: Artifact[]): Artifact[] {
|
||||
artifacts.sort((a, b) =>
|
||||
ARTIFACT_ACTION_RANK[a.action] - ARTIFACT_ACTION_RANK[b.action] ||
|
||||
basename(a.filePath).localeCompare(basename(b.filePath))
|
||||
);
|
||||
return artifacts;
|
||||
}
|
||||
|
||||
public constructor(filePath: string, mimeType: string, action: ArtifactAction, originalContent: string, updatedContent: string, base64?: string, artifactPath?: string) {
|
||||
this.filePath = filePath;
|
||||
this.mimeType = mimeType;
|
||||
this.action = action;
|
||||
this.originalContent = originalContent;
|
||||
this.updatedContent = updatedContent;
|
||||
this.base64 = base64;
|
||||
this.artifactPath = artifactPath;
|
||||
}
|
||||
|
||||
public getStoragePath(): string | undefined {
|
||||
return this.artifactPath;
|
||||
}
|
||||
|
||||
public setStoragePath(path: string): void {
|
||||
this.artifactPath = path;
|
||||
}
|
||||
|
||||
public getIconName(): string {
|
||||
const extension = pathExtname(this.filePath);
|
||||
|
||||
if (isTextFile(extension)) {
|
||||
return "file-text";
|
||||
}
|
||||
if (isImageFile(extension)) {
|
||||
return "file-image";
|
||||
}
|
||||
if (isAudioFile(extension)) {
|
||||
return "file-music";
|
||||
}
|
||||
if (isVideoFile(extension)) {
|
||||
return "file-play";
|
||||
}
|
||||
if (isKnownFileType(extension)) {
|
||||
return "file";
|
||||
}
|
||||
return "file";
|
||||
}
|
||||
|
||||
public static isArtifactData(this: void, data: unknown): data is {
|
||||
filePath: string;
|
||||
mimeType: string;
|
||||
action: ArtifactAction;
|
||||
originalContent: string;
|
||||
updatedContent: string;
|
||||
base64?: string;
|
||||
artifactPath?: string;
|
||||
} {
|
||||
return (
|
||||
data !== null &&
|
||||
typeof data === "object" &&
|
||||
"filePath" in data &&
|
||||
typeof data.filePath === "string" &&
|
||||
"mimeType" in data &&
|
||||
typeof data.mimeType === "string" &&
|
||||
"action" in data &&
|
||||
typeof data.action === "string" &&
|
||||
isArtifactAction(data.action) &&
|
||||
"originalContent" in data &&
|
||||
typeof data.originalContent === "string" &&
|
||||
"updatedContent" in data &&
|
||||
typeof data.updatedContent === "string" &&
|
||||
(!("base64" in data) || typeof data.base64 === "string") &&
|
||||
(!("artifactPath" in data) || typeof data.artifactPath === "string")
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -3,8 +3,9 @@ import { isAudioFile, isImageFile, isKnownFileType, isTextFile, isVideoFile } fr
|
|||
import { MimeTypeToFileTypes } from "Enums/FileTypeMimeTypeMapping";
|
||||
import { isImageMimeType, isTextMimeType, MimeType, toMimeType } from "Enums/MimeType";
|
||||
import { StringTools } from "Helpers/StringTools";
|
||||
import type { IBinaryFile } from "Conversations/IBinaryFile";
|
||||
|
||||
export class Attachment {
|
||||
export class Attachment implements IBinaryFile {
|
||||
|
||||
public fileName: string;
|
||||
public mimeType: string;
|
||||
|
|
@ -41,6 +42,14 @@ export class Attachment {
|
|||
return this.base64;
|
||||
}
|
||||
|
||||
public getStoragePath(): string | undefined {
|
||||
return this.filePath;
|
||||
}
|
||||
|
||||
public setStoragePath(path: string): void {
|
||||
this.filePath = path;
|
||||
}
|
||||
|
||||
public getFileID(provider: AIProvider): string | undefined {
|
||||
return this.fileID[provider];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { ApiErrorType } from "Types/ApiError";
|
|||
import type { Attachment } from "./Attachment";
|
||||
import type { Reference } from "./Reference";
|
||||
import { Copy } from "Enums/Copy";
|
||||
import type { Artifact } from "./Artifact";
|
||||
|
||||
type ConversationContentInit = {
|
||||
role: Role;
|
||||
|
|
@ -11,6 +12,7 @@ type ConversationContentInit = {
|
|||
displayContent?: string;
|
||||
toolCall?: string;
|
||||
functionResponse?: string;
|
||||
artifacts?: Artifact[];
|
||||
attachments?: Attachment[];
|
||||
references?: Reference[];
|
||||
shouldDisplayContent?: boolean;
|
||||
|
|
@ -20,12 +22,17 @@ type ConversationContentInit = {
|
|||
};
|
||||
|
||||
export class ConversationContent {
|
||||
// Runtime-only counter for Svelte {#each} keying
|
||||
private static nextId: number = 0;
|
||||
|
||||
public readonly id: number;
|
||||
public role: Role;
|
||||
public timestamp: Date;
|
||||
public content: string | undefined;
|
||||
public displayContent: string | undefined;
|
||||
public toolCall: string | undefined;
|
||||
public functionResponse: string | undefined;
|
||||
public artifacts: Artifact[];
|
||||
public attachments: Attachment[];
|
||||
public references: Reference[];
|
||||
public shouldDisplayContent: boolean;
|
||||
|
|
@ -43,6 +50,7 @@ export class ConversationContent {
|
|||
* @param init.displayContent - Display content is used when content needs to be formatted differently when displayed versus as a prompt
|
||||
* @param init.toolCall - JSON string of the function call data (only set for function/tool calls)
|
||||
* @param init.functionResponse - JSON string of the function call response data (only set for function/tool responses)
|
||||
* @param init.artifacts - Array of artefacts that track edits to files made by the agent during the turn
|
||||
* @param init.attachments - Array of file attachments associated with this message (defaults to empty array)
|
||||
* @param init.references - Array of file references, used to display attachment's to the user associated with attachments
|
||||
* @param init.shouldDisplayContent - Whether this content should be displayed in the UI (defaults to true, false for system-generated messages)
|
||||
|
|
@ -51,12 +59,14 @@ export class ConversationContent {
|
|||
* @param init.errorType - Indicates that this contains an error of the given type
|
||||
*/
|
||||
constructor(init: ConversationContentInit) {
|
||||
this.id = ConversationContent.nextId++;
|
||||
this.role = init.role;
|
||||
this.timestamp = init.timestamp ?? new Date();
|
||||
this.content = init.content;
|
||||
this.displayContent = init.displayContent;
|
||||
this.toolCall = init.toolCall;
|
||||
this.functionResponse = init.functionResponse;
|
||||
this.artifacts = init.artifacts ?? [];
|
||||
this.attachments = init.attachments ?? [];
|
||||
this.references = init.references ?? [];
|
||||
this.shouldDisplayContent = init.shouldDisplayContent ?? true;
|
||||
|
|
@ -79,6 +89,7 @@ export class ConversationContent {
|
|||
displayContent?: string;
|
||||
toolCall?: string;
|
||||
functionResponse?: string;
|
||||
artifacts?: unknown[];
|
||||
attachments?: unknown[];
|
||||
references?: unknown[];
|
||||
shouldDisplayContent?: boolean;
|
||||
|
|
@ -98,6 +109,7 @@ export class ConversationContent {
|
|||
(!("displayContent" in data) || typeof data.displayContent === "string") &&
|
||||
(!("toolCall" in data) || typeof data.toolCall === "string") &&
|
||||
(!("functionResponse" in data) || typeof data.functionResponse === "string") &&
|
||||
(!("artifacts" in data) || Array.isArray(data.artifacts)) &&
|
||||
(!("attachments" in data) || Array.isArray(data.attachments)) &&
|
||||
(!("references" in data) || Array.isArray(data.references)) &&
|
||||
(!("shouldDisplayContent" in data) || typeof data.shouldDisplayContent === "boolean") &&
|
||||
|
|
|
|||
5
Conversations/IBinaryFile.ts
Normal file
5
Conversations/IBinaryFile.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export interface IBinaryFile {
|
||||
base64: string | undefined;
|
||||
getStoragePath(): string | undefined;
|
||||
setStoragePath(path: string): void;
|
||||
}
|
||||
|
|
@ -51,6 +51,8 @@ export function modelMatchesProvider(model: AIProviderModel, provider: AIProvide
|
|||
return isOpenAIModel(model);
|
||||
case AIProvider.Mistral:
|
||||
return isMistralModel(model);
|
||||
case AIProvider.Local:
|
||||
return true; // Local models are freely typed
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -58,44 +60,40 @@ export enum AIProvider {
|
|||
Claude = "Claude",
|
||||
Gemini = "Gemini",
|
||||
OpenAI = "OpenAI",
|
||||
Mistral = "Mistral"
|
||||
Mistral = "Mistral",
|
||||
Local = "Local"
|
||||
}
|
||||
|
||||
export enum AIProviderModel {
|
||||
// Claude models
|
||||
ClaudeSonnet_4_6 = "claude-sonnet-4-6",
|
||||
ClaudeSonnet_4_5 = "claude-sonnet-4-5-20250929",
|
||||
ClaudeSonnet_4 = "claude-sonnet-4-20250514",
|
||||
ClaudeOpus_4_6 = "claude-opus-4-6",
|
||||
ClaudeOpus_4_5 = "claude-opus-4-5-20251101",
|
||||
ClaudeOpus_4_1 = "claude-opus-4-1-20250805",
|
||||
ClaudeOpus_4 = "claude-opus-4-20250514",
|
||||
ClaudeFable_5 = "claude-fable-5",
|
||||
ClaudeSonnet_5 = "claude-sonnet-5",
|
||||
ClaudeOpus_4_8 = "claude-opus-4-8",
|
||||
ClaudeHaiku_4_5 = "claude-haiku-4-5-20251001",
|
||||
|
||||
// Gemini models
|
||||
GeminiFlash_2_5_Lite = "gemini-2.5-flash-lite",
|
||||
GeminiFlash_2_5 = "gemini-2.5-flash",
|
||||
GeminiPro_2_5 = "gemini-2.5-pro",
|
||||
GeminiFlash_3_Preview = "gemini-3-flash-preview",
|
||||
GeminiFlash_3_1_Preview_Lite = "gemini-3.1-flash-lite-preview",
|
||||
GeminiFlash_3_1_Lite = "gemini-3.1-flash-lite",
|
||||
GeminiFlash_3_Flash = "gemini-3-flash-preview",
|
||||
GeminiFlash_3_5_Flash = "gemini-3.5-flash",
|
||||
GeminiPro_3_1_Preview = "gemini-3.1-pro-preview",
|
||||
|
||||
// OpenAI models
|
||||
GPT_5_4 = "gpt-5.4",
|
||||
GPT_5_4_Pro = "gpt-5.4-pro",
|
||||
GPT_5_4_Mini = "gpt-5.4-mini",
|
||||
GPT_5_4_Nano = "gpt-5.4-nano",
|
||||
GPT_5_6_Sol = "gpt-5.6-sol",
|
||||
GPT_5_6_Terra = "gpt-5.6-terra",
|
||||
GPT_5_6_Luna = "gpt-5.6-luna",
|
||||
|
||||
// Mistral models
|
||||
MistralLarge = "mistral-large-latest",
|
||||
MistralMedium = "mistral-medium-latest",
|
||||
MistralSmall = "mistral-small-latest",
|
||||
MistralMedium = "mistral-medium-3-5",
|
||||
MistralSmall = "mistral-small-2603",
|
||||
|
||||
// Conversation naming models (aliases to existing models)
|
||||
ClaudeNamer = ClaudeHaiku_4_5,
|
||||
GeminiNamer = GeminiFlash_2_5_Lite,
|
||||
OpenAINamer = GPT_5_4_Nano,
|
||||
GeminiNamer = GeminiFlash_3_1_Lite,
|
||||
OpenAINamer = GPT_5_6_Luna,
|
||||
MistralNamer = MistralSmall,
|
||||
|
||||
// Local models are freely typed so no default is given
|
||||
None = "none"
|
||||
}
|
||||
|
||||
export enum AIProviderURL {
|
||||
|
|
@ -118,16 +116,26 @@ export enum MistralAgentEndpoint {
|
|||
ConversationsUrl = "https://api.mistral.ai/v1/conversations"
|
||||
}
|
||||
|
||||
export const DEFAULT_MODEL_BY_PROVIDER: Record<AIProvider, AIProviderModel> = {
|
||||
export const DEFAULT_QUICK_MODEL_BY_PROVIDER: Record<AIProvider, AIProviderModel> = {
|
||||
[AIProvider.Claude]: AIProviderModel.ClaudeHaiku_4_5,
|
||||
[AIProvider.Gemini]: AIProviderModel.GeminiFlash_2_5_Lite,
|
||||
[AIProvider.OpenAI]: AIProviderModel.GPT_5_4_Nano,
|
||||
[AIProvider.Gemini]: AIProviderModel.GeminiFlash_3_1_Lite,
|
||||
[AIProvider.OpenAI]: AIProviderModel.GPT_5_6_Luna,
|
||||
[AIProvider.Mistral]: AIProviderModel.MistralSmall,
|
||||
};
|
||||
[AIProvider.Local]: AIProviderModel.None
|
||||
}
|
||||
|
||||
export const DEFAULT_MODEL_BY_PROVIDER: Record<AIProvider, AIProviderModel> = {
|
||||
[AIProvider.Claude]: AIProviderModel.ClaudeSonnet_5,
|
||||
[AIProvider.Gemini]: AIProviderModel.GeminiFlash_3_5_Flash,
|
||||
[AIProvider.OpenAI]: AIProviderModel.GPT_5_6_Terra,
|
||||
[AIProvider.Mistral]: AIProviderModel.MistralMedium,
|
||||
[AIProvider.Local]: AIProviderModel.None
|
||||
}
|
||||
|
||||
export const DEFAULT_PLANNING_MODEL_BY_PROVIDER: Record<AIProvider, AIProviderModel> = {
|
||||
[AIProvider.Claude]: AIProviderModel.ClaudeSonnet_4_6,
|
||||
[AIProvider.Gemini]: AIProviderModel.GeminiPro_2_5,
|
||||
[AIProvider.OpenAI]: AIProviderModel.GPT_5_4_Pro,
|
||||
[AIProvider.Mistral]: AIProviderModel.MistralLarge,
|
||||
};
|
||||
[AIProvider.Claude]: AIProviderModel.ClaudeOpus_4_8,
|
||||
[AIProvider.Gemini]: AIProviderModel.GeminiFlash_3_5_Flash,
|
||||
[AIProvider.OpenAI]: AIProviderModel.GPT_5_6_Sol,
|
||||
[AIProvider.Mistral]: AIProviderModel.MistralMedium,
|
||||
[AIProvider.Local]: AIProviderModel.None
|
||||
}
|
||||
28
Enums/ArtifactAction.ts
Normal file
28
Enums/ArtifactAction.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { Copy } from "Enums/Copy";
|
||||
|
||||
export enum ArtifactAction {
|
||||
Create = "create",
|
||||
Modify = "modify",
|
||||
Delete = "delete"
|
||||
}
|
||||
|
||||
export const ARTIFACT_ACTION_RANK = {
|
||||
[ArtifactAction.Create]: 0,
|
||||
[ArtifactAction.Modify]: 1,
|
||||
[ArtifactAction.Delete]: 2,
|
||||
};
|
||||
|
||||
export function isArtifactAction(value: string): value is ArtifactAction {
|
||||
return Object.values(ArtifactAction).includes(value as ArtifactAction);
|
||||
}
|
||||
|
||||
export function artifactActionToCopy(artifactAction: ArtifactAction): string {
|
||||
switch (artifactAction) {
|
||||
case ArtifactAction.Create:
|
||||
return Copy.ArtifactActionCreated;
|
||||
case ArtifactAction.Modify:
|
||||
return Copy.ArtifactActionModified;
|
||||
case ArtifactAction.Delete:
|
||||
return Copy.ArtifactActionDeleted;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
export enum ChatMode {
|
||||
ReadOnly = 0,
|
||||
Edit = 1,
|
||||
Planning = 2
|
||||
ReadOnly = "read_only",
|
||||
Edit = "edit",
|
||||
Planning = "planning"
|
||||
}
|
||||
|
||||
export function chatModeAllowsEdits(mode: ChatMode) {
|
||||
|
|
|
|||
283
Enums/Copy.ts
283
Enums/Copy.ts
|
|
@ -1,5 +1,3 @@
|
|||
import { Exception } from "Helpers/Exception";
|
||||
|
||||
export enum Copy {
|
||||
// General Copy
|
||||
UserInstructions1 = "You can create custom ",
|
||||
|
|
@ -8,82 +6,101 @@ export enum Copy {
|
|||
NoUserInstruction = "No custom instructions",
|
||||
|
||||
// Model Display Names
|
||||
ClaudeSonnet_4_6 = "Claude Sonnet 4.6",
|
||||
ClaudeSonnet_4_5 = "Claude Sonnet 4.5",
|
||||
ClaudeSonnet_4 = "Claude Sonnet 4",
|
||||
ClaudeOpus_4_6 = "Claude Opus 4.6",
|
||||
ClaudeOpus_4_5 = "Claude Opus 4.5",
|
||||
ClaudeOpus_4_1 = "Claude Opus 4.1",
|
||||
ClaudeOpus_4 = "Claude Opus 4",
|
||||
ClaudeFable_5 = "Claude Fable 5",
|
||||
ClaudeSonnet_5 = "Claude Sonnet 5",
|
||||
ClaudeOpus_4_8 = "Claude Opus 4.8",
|
||||
ClaudeHaiku_4_5 = "Claude Haiku 4.5",
|
||||
|
||||
GeminiFlash_2_5_Lite = "Gemini 2.5 Flash Lite",
|
||||
GeminiFlash_2_5 = "Gemini 2.5 Flash",
|
||||
GeminiPro_2_5 = "Gemini 2.5 Pro",
|
||||
GeminiFlash_3_Preview = "Gemini 3 Flash Preview",
|
||||
GeminiFlash_3_1_Preview_Lite = "Gemini 3.1 Flash Lite Preview",
|
||||
GeminiFlash_3_1_Lite = "Gemini 3.1 Flash-Lite",
|
||||
GeminiFlash_3_Flash = "Gemini 3 Flash",
|
||||
GeminiFlash_3_5_Flash = "Gemini 3.5 Flash",
|
||||
GeminiPro_3_1_Preview = "Gemini 3.1 Pro Preview",
|
||||
|
||||
GPT_5_4 = "GPT-5.4",
|
||||
GPT_5_4_Pro = "GPT-5.4 Pro",
|
||||
GPT_5_4_Mini = "GPT-5.4 Mini",
|
||||
GPT_5_4_Nano = "GPT-5.4 Nano",
|
||||
GPT_5_6_Sol = "GPT-5.6 Sol",
|
||||
GPT_5_6_Terra = "GPT-5.6 Terra",
|
||||
GPT_5_6_Luna = "GPT-5.6 Luna",
|
||||
|
||||
MistralLarge = "Mistral Large (latest)",
|
||||
MistralMedium = "Mistral Medium (latest)",
|
||||
MistralSmall = "Mistral Small (latest)",
|
||||
MistralMedium = "Mistral Medium 3.5",
|
||||
MistralSmall = "Mistral Small 4",
|
||||
|
||||
// AI Provider Groups
|
||||
CloudProvider = "Cloud Provider",
|
||||
LocalProvider = "Self Hosted",
|
||||
ProviderClaude = "Claude",
|
||||
ProviderOpenAI = "OpenAI",
|
||||
ProviderGemini = "Gemini",
|
||||
ProviderMistral = "Mistral",
|
||||
ProviderLocal = "Local",
|
||||
|
||||
// Settings Copy
|
||||
SettingProvider = "Provider",
|
||||
SettingModel = "Model",
|
||||
SettingPlanningModel = "Planning Model",
|
||||
SettingApiKey = "API Key",
|
||||
SettingFileExclusions = "File Exclusions",
|
||||
SettingPlanningModel = "Planning model",
|
||||
SettingQuickActionModel = "Quick actions model",
|
||||
SettingApiKey = "API key",
|
||||
SettingLocalUrl = "Local URL",
|
||||
SettingLocalUrlDesc = "Enter the URL for the local server",
|
||||
SettingApiKeyLocalDesc = "Enter your API key if authentication is required - optional.",
|
||||
SettingExclusionsHeading = "Exclusions",
|
||||
SettingFileExclusions = "File exclusions",
|
||||
SettingContext = "Context",
|
||||
SettingSearchResultsLimit = "Search Results Limit",
|
||||
SettingSnippetSizeLimit = "Snippet Size Limit",
|
||||
SettingFileMonitoringHeading = "File Monitoring Guidelines",
|
||||
SettingSearchResultsLimit = "Search results limit",
|
||||
SettingSnippetSizeLimit = "Snippet size limit",
|
||||
SettingFileMonitoringHeading = "File monitoring guidelines",
|
||||
SettingMemories = "Memories",
|
||||
SettingEnableMemories = "Enable Memories",
|
||||
SettingEnableMemories = "Enable memories",
|
||||
SettingEnableMemoriesDesc = "Allow the AI to recall memories from previous conversations.",
|
||||
SettingAllowUpdatingMemories = "Allow Updating Memories",
|
||||
SettingAllowUpdatingMemories = "Allow updating memories",
|
||||
SettingAllowUpdatingMemoriesDesc = "Allow the AI to save and update memories during conversations.",
|
||||
|
||||
SettingWebViewerAccess = "Web Viewer Access",
|
||||
SettingEnableWebViewer = "Enable Web Viewer Access",
|
||||
SettingWebViewerAccess = "Web viewer access",
|
||||
SettingEnableWebViewer = "Enable web viewer access",
|
||||
SettingEnableWebViewerDesc = "Allow the AI to read content from pages open in the Obsidian web viewer (Obsidian core plugin). This is not the same as general web access which can be toggled using the chat controls.",
|
||||
|
||||
// Settings Descriptions
|
||||
SettingProviderDesc = "Select the AI provider to use.",
|
||||
SettingModelDesc = "Select the AI model to use.",
|
||||
SettingLocalModelDesc = "For best results a local model that supports vision capabilities and a minimum of a 32K context window is recommended.",
|
||||
SettingPlanningModelDesc = "Select the AI model to use when planning complex tasks.",
|
||||
SettingLocalPlanningModelDesc = "For best results a local model that supports vision capabilities and a minimum of a 64K context window is recommended.",
|
||||
SettingPlanningModelTip = "Tip: You can reduce cost by using a more powerful model for planning and a cheaper model for the regular agent.",
|
||||
SettingQuickActionModel = "Quick Actions Model",
|
||||
SettingQuickActionModelDesc = "Select the AI model to use for quick actions. A fast, lightweight model is recommended.",
|
||||
SettingLocalQuickActionModelDesc = "For best results a smaller local model with a minimum of an 8K context window is recommended.",
|
||||
SettingLocalModelTemplateWarning = "Note that some models may have very strict templates that require exact 'user' -> 'agent' turns. VaultKeeper AI may chain multiple tool calls and results, so you may find that some models need their Jinja templates adjusting, see: ",
|
||||
SettingLocalModelTemplateWarningLinkText = "LM Studio prompt template docs (external link)",
|
||||
SettingApiKeyDesc = "Enter your API key here.",
|
||||
SettingFileExclusionsDesc = "Set which directories and files the AI should ignore. Enter one path per line - supports glob patterns like folder/**, *.md",
|
||||
SettingSearchResultsLimitDesc = "Set the maximum number of results provided to the AI when it searches through files in your vault. Higher values provide more context but increase search time.",
|
||||
SettingSnippetSizeLimitDesc = "Set the character limit of search previews provided to the AI when it searches through files in your vault. Higher values provide more context per result.",
|
||||
SettingFileMonitoringGemini = "Files uploaded to Gemini are automatically deleted after 48 hours and will be re-uploaded during conversations as needed. No manual cleanup is typically required.",
|
||||
SettingFileMonitoringClaude = "Files uploaded to Claude remain stored indefinitely. Periodically check the Anthropic Console (https://console.anthropic.com/) to review and remove old files that are no longer needed.",
|
||||
SettingFileMonitoringOpenAI = "Files uploaded to OpenAI remain stored indefinitely. Periodically check the OpenAI Platform (https://platform.openai.com/) to review and remove old files that are no longer needed.",
|
||||
SettingFileMonitoringMistral = "Documents uploaded to Mistral are stored on their platform. Images are sent inline and not stored. Periodically check the Mistral Console (https://console.mistral.ai/) to review and remove old files that are no longer needed.",
|
||||
SettingFileMonitoringGemini = "Files uploaded to Gemini are automatically deleted after 48 hours and will be re-uploaded during conversations as needed. No manual cleanup is typically required. ",
|
||||
SettingFileMonitoringClaude = "Files uploaded to Claude remain stored indefinitely. Periodically check the Anthropic Console to review and remove old files that are no longer needed. ",
|
||||
SettingFileMonitoringOpenAI = "Files uploaded to OpenAI remain stored indefinitely. Periodically check the OpenAI Platform to review and remove old files that are no longer needed. ",
|
||||
SettingFileMonitoringMistral = "Documents uploaded to Mistral are stored on their platform. Images are sent inline and not stored. Periodically check the Mistral Console to review and remove old files that are no longer needed. ",
|
||||
SettingFileMonitoringLinkText = "See the plugin guide for more information.",
|
||||
SettingAccessMemories = "Memories let the AI retain preferences and context across conversations. You can view and edit them at any time.",
|
||||
|
||||
// Settings Placeholders
|
||||
PlaceholderEnterApiKey = "Enter your API key",
|
||||
PlaceholderFileExclusions = "Examples:\n\nprivate/**\n*.secret.md\njournal/personal/**",
|
||||
PlaceholderLocalUrl = "http://localhost:1234/v1/chat/completions",
|
||||
PlaceholderApiKey = "Enter API key",
|
||||
PlaceholderModelName = "Model name",
|
||||
|
||||
// Settings Tooltips
|
||||
TooltipShowApiKey = "Show API Key",
|
||||
TooltipHideApiKey = "Hide API Key",
|
||||
TooltipLearnMoreFileMonitoring = "Learn more in Plugin Guide",
|
||||
TooltipAccessMemories = "View Memories",
|
||||
|
||||
SettingAdvancedSettings = "Advanced Settings",
|
||||
SettingHideDrawerElements = "Hide Drawer Elements",
|
||||
SettingHideDrawerElementsDesc = "Hide side drawer elements on mobile when typing. This provides more space when an on screen keyboard is used. The elements will reappear when the chat input is unfocused.",
|
||||
|
||||
SettingQuickActions = "Quick Actions",
|
||||
SettingEnableContextMenuActions = "Enable Context Menu Actions",
|
||||
SettingEnableContextMenuActionsDesc = "Show quick actions in the right-click editor context menu.",
|
||||
SettingEnableToolbarActions = "Enable Toolbar Actions",
|
||||
SettingEnableToolbarActionsDesc = "Show a quick actions button in the editor toolbar (mobile friendly).",
|
||||
|
||||
AIThoughtMessage = "Thinking...",
|
||||
AIThoughtGeneratingNote = "Generating note contents...",
|
||||
AIThoughtPreparingQuery = "Preparing user query...",
|
||||
|
|
@ -108,6 +125,7 @@ export enum Copy {
|
|||
// Chat Input Placeholders
|
||||
InputPlaceholderQuestion = "Provide an answer...",
|
||||
InputPlaceholderDiff = "Make a suggestion...",
|
||||
InputPlaceholderPlanApproval = "Suggest a change to the plan...",
|
||||
InputPlaceholderNormal = "Type a message...",
|
||||
|
||||
// Chat Input Button Labels
|
||||
|
|
@ -116,16 +134,26 @@ export enum Copy {
|
|||
ButtonMakeSuggestion = "Make Suggestion",
|
||||
ButtonSendMessage = "Send Message",
|
||||
ButtonChangeChatMode = "Change the Chat Mode",
|
||||
ButtonFreeEdit = "Allow changes without asking",
|
||||
ButtonFreeEditDisabled = "Read-only mode enabled",
|
||||
ButtonTurnOffPlanningMode = "Turn off Planning Mode",
|
||||
ButtonTurnOnPlanningMode = "Turn on Planning Mode",
|
||||
ButtonTurnOffWebSearch = "Turn off Web Search",
|
||||
ButtonTurnOnWebSearch = "Turn on Web Search",
|
||||
ButtonWebSearchUnavailable = "Web search unavailable",
|
||||
ButtonUserInstruction = "User Instruction",
|
||||
ButtonAttachFiles = "Attach Files",
|
||||
|
||||
ButtonApprove = "Approve",
|
||||
ButtonReject = "Reject",
|
||||
ButtonDiscuss = "Discuss",
|
||||
|
||||
ButtonRestore = "Restore this version",
|
||||
ButtonRestorePrevious = "Restore previous version",
|
||||
ButtonConfirm = "Confirm?",
|
||||
|
||||
// Agent file message
|
||||
AttachedFile = `The user has attached the file {fileName}. The contents of the file are included below.
|
||||
**Note that this is an attachment to the chat and the file is likely NOT present in the vault**`,
|
||||
AttachedFile = `The file {fileName} is attached and its full contents follow below. This is the actual content of the file — read it directly to answer the user. This attachment may be a file the user uploaded to the chat, or a vault file you retrieved with a tool; either way, the content below is authoritative and you do NOT need to read or fetch this file again.`,
|
||||
|
||||
// Execution Plan Messages
|
||||
PlanningFailedError = `Failed to generate plan. You should attempt to recover from this.
|
||||
|
|
@ -153,8 +181,11 @@ The following context explains why you are doing the task. It is NOT an instruct
|
|||
PlanningFailedNoSteps = "The planned workflow has failed, however steps may have been completed. Consult with the user on how to continue.",
|
||||
WorkflowFailedAtStep = "The planned workflow failed when executing step '{stepDescription}'. Consult with the user on how to continue.",
|
||||
WorkflowAborted = "The planned workflow was aborted. Result: {abortContext}",
|
||||
PlanReceived = "Plan received, now attempting to execute plan",
|
||||
PlanReceived = "Plan received, now awaiting user approval",
|
||||
PlanRejected = "The user has rejected the plan and chosen not to continue at this time",
|
||||
PlanRejectedWithSuggestion = "The user has rejected the current plan. You should replan accounting for their feedback: {suggestion}",
|
||||
PlanningModeError = "First create a plan before executing any functions!",
|
||||
PlanSubmissionRequired = "Error: Attempted to exit planning but plan has not yet been submitted!",
|
||||
UpdateMemoriesWithoutReadError = "Memories must be read before they can be updated. Retrieve the current memory contents first, then provide the complete revised content.",
|
||||
MemoriesInjectionHeader = `\n\n---\n\n## Current Memories\n\nThe following memories were recorded from previous sessions. Use them as context for this conversation.\n\n{memories}`,
|
||||
MemoriesEmpty = "No memories have been created yet.",
|
||||
|
|
@ -170,17 +201,20 @@ The following context explains why you are doing the task. It is NOT an instruct
|
|||
ApplyTemplateContentSeparator = "---CONTENT---",
|
||||
ApplyTemplateCancelled = "APPLY_TEMPLATE_CANCELLED",
|
||||
|
||||
|
||||
// Active Capabilities
|
||||
ActiveCapabilitiesHeader = `\n\n---\n\n## Active Capabilities\n\nThe following reflects your current configuration. Follow these directives exactly.\n\n{directives}`,
|
||||
DirectiveMemoriesDisabled = "- **Memory**: DISABLED — do NOT attempt to use any memory tools",
|
||||
DirectiveMemoriesEnabled = "- **Memory**: ENABLED — memories are injected above; you may read and update them",
|
||||
DirectiveMemoriesReadOnly = "- **Memory**: ENABLED (read-only) — memories are injected above; you may read them but do NOT attempt to update them",
|
||||
DirectiveWebSearchEnabled = "- **Web Search**: ENABLED — you may call the web search tool to retrieve current information from the web",
|
||||
DirectiveWebSearchDisabled = "- **Web Search**: DISABLED — do NOT call the web search tool; if the user requests it, inform them it is currently turned off in settings",
|
||||
DirectiveChatModeReadOnly = "- **Chat Mode**: READ-ONLY — you can read and search the vault but cannot create, edit, move, or delete files; if the user asks you to make changes, inform them that edit mode is currently off",
|
||||
DirectiveChatModeEdit = "- **Chat Mode**: EDIT — you may create, edit, move, and delete files in the vault",
|
||||
DirectiveChatModePlanning = "- **Chat Mode**: PLANNING — you should request a planned workflow before executing tasks in the vault",
|
||||
DirectiveMemoriesDisabled = "- **Memory**: DISABLED — all memory tools are currently unavailable",
|
||||
DirectiveMemoriesEnabled = "- **Memory**: ENABLED — memories are injected above; you must always read and update them",
|
||||
DirectiveMemoriesReadOnly = "- **Memory**: ENABLED (read-only) — memories are injected above; you must read them but updating them is not possible",
|
||||
DirectiveWebSearchEnabled = "- **Web Search**: ENABLED — you should always prefer to call the web search tool to retrieve current information from the web",
|
||||
DirectiveWebSearchDisabled = "- **Web Search**: DISABLED — the web search tool is unavailable; if the user requests it, inform them it is currently turned off in settings",
|
||||
DirectiveWebViewerEnabled = "- **Web Viewer**: ENABLED — you may call the web viewer tool to read the content of the page currently open in the Obsidian web viewer; call it proactively when the user asks about a web page",
|
||||
DirectiveWebViewerDisabled = "- **Web Viewer**: DISABLED — do NOT call the web viewer tool",
|
||||
|
||||
PlanSubmissionRequired = "Error: Attempted to exit planning but plan has not yet been submitted!",
|
||||
DirectiveWebViewerDisabled = "- **Web Viewer**: DISABLED — the web viewer tool is unavailable; if the user requests it, inform them it is currently turned off in settings",
|
||||
|
||||
MaxExecutionDepthReached = "Exceeded maximum plan execution attempts - consult with the user on how to continue.",
|
||||
|
||||
// Execution Plan Request Templates
|
||||
|
|
@ -216,43 +250,45 @@ This plugin was originally created for a friend who found it useful, so I have d
|
|||
|
||||
If you find any issues or have a feature request, please feel free to raise them on GitHub:`,
|
||||
|
||||
HelpModalGuideTitle = "Plugin Guide",
|
||||
HelpModalGuideContent = `#### How to Use Vaultkeeper AI
|
||||
HelpModalGettingStartedTitle = "Getting started",
|
||||
HelpModalGettingStartedContent = `#### Getting started
|
||||
|
||||
##### Getting Started
|
||||
1. **Add an API Key**: Go to Settings and add at least one API key (Claude, Gemini, OpenAI, or Mistral)
|
||||
2. **Select a Model**: Choose your preferred AI model from the dropdown
|
||||
3. **Open the Chat**: Click the sparkles icon in the sidebar to start chatting
|
||||
1. **Add an API key**: Go to Settings and add at least one API key (Claude, Gemini, OpenAI, or Mistral) — or select **Local** to connect a self-hosted server instead, no API key required
|
||||
2. **Select a model**: Choose your preferred AI model from the dropdown
|
||||
3. **Open the chat**: Click the plugin icon in the sidebar to start chatting`,
|
||||
|
||||
##### Operating Modes
|
||||
HelpModalChatModesTitle = "Chat modes",
|
||||
HelpModalChatModesContent = `#### Chat modes
|
||||
|
||||
**Read-Only Mode (Default)** - The AI can safely explore your vault:
|
||||
**Read-only (default)** - The AI can safely explore your vault:
|
||||
- Search through your notes (including PDFs and Office/ODF documents)
|
||||
- Read file contents (including binary files like PDFs, Office documents, and images)
|
||||
- List directory structures
|
||||
- Cannot modify anything
|
||||
|
||||
**Agent Mode** - Toggle when you need the AI to make changes:
|
||||
**Allow Edits** - Switch on when you need the AI to make changes:
|
||||
- Create new notes
|
||||
- Edit existing content
|
||||
- Delete or move files
|
||||
- Rename files
|
||||
|
||||
**Planning Mode** - Have the AI plan before acting:
|
||||
**Planning** - Have the AI plan before acting:
|
||||
- A planning agent analyzes your vault and creates a strategy
|
||||
- An execution agent carries out the given plan
|
||||
- An execution agent carries out the given plan`,
|
||||
|
||||
##### Reference System
|
||||
HelpModalReferenceTitle = "Using references",
|
||||
HelpModalReferenceContent = `#### Using references
|
||||
|
||||
Quickly provide context to the AI:
|
||||
|
||||
- **@filename** - Reference specific files
|
||||
- **#tag** - Reference all notes with a tag
|
||||
- **#tag** - Reference all notes with a tag
|
||||
- **/folder** - Reference entire directories
|
||||
|
||||
The autocomplete dropdown supports keyboard navigation.
|
||||
The autocomplete dropdown supports keyboard navigation.`,
|
||||
|
||||
##### Custom Instructions
|
||||
HelpModalCustomInstructionsTitle = "Custom instructions",
|
||||
HelpModalCustomInstructionsContent = `#### Custom instructions
|
||||
|
||||
Customize AI behavior for specific workflows:
|
||||
|
||||
|
|
@ -261,9 +297,36 @@ Customize AI behavior for specific workflows:
|
|||
3. Select your instruction set
|
||||
4. The AI follows these instructions for all interactions
|
||||
|
||||
See [Example Template](#/page/Vaultkeeper%20AI%2FUser%20Instructions%2FEXAMPLE_INSTRUCTIONS) for help getting started.
|
||||
See [[Vaultkeeper AI/User Instructions/EXAMPLE_INSTRUCTIONS|Example Template]] for help getting started.`,
|
||||
|
||||
##### File Monitoring
|
||||
HelpModalQuickActionsTitle = "Quick actions",
|
||||
HelpModalQuickActionsContent = `#### Quick actions
|
||||
|
||||
Quick actions are one-click AI edits you run on the note you're currently editing. Open the editor menu (right-click, or the command palette) and pick an action. Some actions work on your current selection if you have text selected, otherwise they apply to the whole note.
|
||||
|
||||
##### Proofread
|
||||
Corrects spelling, grammar, punctuation, and typos without rewriting for style or changing your voice. Works on the selection if you have one, otherwise the whole note.
|
||||
|
||||
##### Beautify
|
||||
Improves clarity, flow, and readability and adds Markdown formatting (headings, bold, lists, blockquotes) where it helps. Works on the selection if you have one, otherwise the whole note.
|
||||
|
||||
##### Apply template
|
||||
Restructures the note to match a template you choose, fitting your content to the template's headings and structure while preserving the information. If the file you pick doesn't look like a template, no changes are made.
|
||||
|
||||
##### Apply links
|
||||
Scans the note for mentions of pages that already exist in your vault and wraps them in wikilinks. It only links existing pages and never invents new ones. Works on the selection if you have one, otherwise the whole note.
|
||||
|
||||
##### Apply tags
|
||||
Chooses tags for the note from the tags that already exist in your vault and merges them into the frontmatter. It won't create new tags — only ones already in use elsewhere.
|
||||
|
||||
##### Suggest tags
|
||||
Like Apply tags, but free to suggest new tags as well as reuse existing ones. Suggested tags are merged into the note's frontmatter.
|
||||
|
||||
##### Generate frontmatter
|
||||
Infers YAML frontmatter for the note (aliases, tags, title, summary, created) from its content and merges it into any existing frontmatter.`,
|
||||
|
||||
HelpModalUploadedFilesTitle = "Uploaded files",
|
||||
HelpModalUploadedFilesContent = `#### Uploaded files
|
||||
|
||||
When you upload files (PDFs, images) to conversations, they are stored by your AI provider. The plugin automatically attempts to delete these files when you delete conversations, but this may occasionally fail due to network issues or API rate limits.
|
||||
|
||||
|
|
@ -272,16 +335,18 @@ When you upload files (PDFs, images) to conversations, they are stored by your A
|
|||
- Remove any old files that are no longer needed
|
||||
- Provider-specific details can be found in the plugin settings
|
||||
|
||||
**Provider Dashboards:**
|
||||
**Provider dashboards:**
|
||||
- Claude: [Anthropic Console](https://console.anthropic.com/)
|
||||
- Gemini: [Google AI Studio](https://aistudio.google.com/)
|
||||
- OpenAI: [OpenAI Platform](https://platform.openai.com/)
|
||||
- Mistral: [Mistral Console](https://console.mistral.ai/)`,
|
||||
- Mistral: [Mistral Console](https://console.mistral.ai/)
|
||||
|
||||
**Local**: Files aren't uploaded anywhere - they're sent inline with your message directly to your local server, so there's nothing to clean up on a dashboard.`,
|
||||
|
||||
HelpModalTroubleshootTitle = "Troubleshooting",
|
||||
HelpModalTroubleshootContent = `#### Common Issues & Solutions
|
||||
HelpModalTroubleshootContent = `#### Common issues & solutions
|
||||
|
||||
##### API Key Issues
|
||||
##### API key issues
|
||||
|
||||
**Problem**: "Invalid API key" or authentication errors
|
||||
|
||||
|
|
@ -291,7 +356,7 @@ When you upload files (PDFs, images) to conversations, they are stored by your A
|
|||
- Ensure no extra spaces when pasting
|
||||
- API keys are provider-specific - Claude keys only work with Claude models
|
||||
|
||||
##### Error Code 429: Rate Limit Exceeded
|
||||
##### Error code 429: Rate limit exceeded
|
||||
|
||||
This error means you've made too many API requests in a given time period. This is separate from your token usage limits.
|
||||
|
||||
|
|
@ -315,7 +380,7 @@ This error means you've made too many API requests in a given time period. This
|
|||
- **Long-term solution:** Enable billing to move from free tier to paid tier for significantly higher limits. Paid tier limits increase automatically with cumulative Google Cloud spending
|
||||
- [Gemini API Rate Limits Documentation](https://ai.google.dev/gemini-api/docs/rate-limits)
|
||||
|
||||
##### Error Code 503: Service Unavailable
|
||||
##### Error code 503: Service unavailable
|
||||
|
||||
This error indicates a temporary issue with the AI provider's servers.
|
||||
|
||||
|
|
@ -326,12 +391,28 @@ This error indicates a temporary issue with the AI provider's servers.
|
|||
**How to resolve:**
|
||||
- Wait a few minutes and try again
|
||||
- There is no action you can take to prevent this error - it's on the provider's side
|
||||
- If the issue persists, check the provider's status page for any ongoing incidents`,
|
||||
- If the issue persists, check the provider's status page for any ongoing incidents
|
||||
|
||||
##### Local model issues
|
||||
|
||||
**Problem**: Can't connect to the local server
|
||||
|
||||
**Solutions**:
|
||||
- Make sure your local server (LM Studio, Ollama, vLLM, etc.) is actually running
|
||||
- Double-check the **Local URL** in settings, including the path (e.g. \`/v1/chat/completions\`) and port
|
||||
- If your server requires authentication, make sure the API key field is filled in
|
||||
|
||||
**Problem**: Tool calls fail, loop, or the AI ignores tool results
|
||||
|
||||
**Solutions**:
|
||||
- Not all local models support multi-turn tool calling reliably - this is a model/template limitation, not a plugin bug
|
||||
- Check your server's chat template settings (e.g. LM Studio's prompt template) and adjust it for tool-calling if needed
|
||||
- Try a model known to support tool/function calling well`,
|
||||
|
||||
HelpModalPrivacyTitle = "Privacy",
|
||||
HelpModalPrivacyContent = `#### Privacy & Security
|
||||
HelpModalPrivacyContent = `#### Privacy & security
|
||||
|
||||
##### Data Storage
|
||||
##### Data storage
|
||||
|
||||
**Everything stays local** - This plugin does NOT send data to any third-party services except the AI providers you choose. It also doesn't collect any telemetry data and you can review the full source code on GitHub.
|
||||
|
||||
|
|
@ -341,7 +422,7 @@ This error indicates a temporary issue with the AI provider's servers.
|
|||
- Custom instructions (stored in \`Vaultkeeper AI/User Instructions/\`)
|
||||
- Plugin settings (stored in \`.obsidian/plugins/vaultkeeper-ai/\`)
|
||||
|
||||
##### API Communication
|
||||
##### API communication
|
||||
|
||||
**Direct connections only** - The plugin communicates directly with your chosen AI provider:
|
||||
|
||||
|
|
@ -349,6 +430,7 @@ This error indicates a temporary issue with the AI provider's servers.
|
|||
- **Gemini**: Google's API
|
||||
- **OpenAI**: OpenAI's API
|
||||
- **Mistral**: Mistral AI's API
|
||||
- **Local**: Your own self-hosted server - no cloud provider is involved at all, so nothing leaves your machine
|
||||
|
||||
**What gets sent**:
|
||||
- Your messages and referenced file contents (including binary files like PDFs, Office documents, and images)
|
||||
|
|
@ -361,7 +443,7 @@ This error indicates a temporary issue with the AI provider's servers.
|
|||
- Your vault structure (unless explicitly requested)
|
||||
- API keys to anyone except the respective provider
|
||||
|
||||
##### File Exclusions
|
||||
##### File exclusions
|
||||
|
||||
**Protect sensitive information** using glob patterns in settings:
|
||||
|
||||
|
|
@ -374,9 +456,9 @@ This error indicates a temporary issue with the AI provider's servers.
|
|||
**How exclusions work**:
|
||||
- Excluded files are completely invisible to the AI
|
||||
- The AI cannot read, search, modify, or even list excluded files
|
||||
- Exclusions apply to All operations
|
||||
- Exclusions apply to all operations
|
||||
|
||||
##### AI Provider Policies
|
||||
##### AI provider policies
|
||||
|
||||
Each AI provider has their own data policies:
|
||||
|
||||
|
|
@ -388,6 +470,11 @@ Each AI provider has their own data policies:
|
|||
// Conversation Modal Copy
|
||||
NoConversationsFound = "No conversations match your search.",
|
||||
|
||||
// Artifact Copy
|
||||
ArtifactActionCreated = "CREATED",
|
||||
ArtifactActionModified = "MODIFIED",
|
||||
ArtifactActionDeleted = "DELETED",
|
||||
|
||||
// Help Modal Additional Copy
|
||||
HelpModalCloseAriaLabel = "Close Help Modal",
|
||||
PluginVersionPrefix = "Plugin version: ",
|
||||
|
|
@ -667,44 +754,4 @@ Preferred programming language: {{language}}
|
|||
---
|
||||
|
||||
**Remember:** A good system prompt is clear, dense, and easy to understand, leaving no room for misinterpretation. Start simple, test thoroughly, and refine based on real results.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces placeholders in Copy strings with provided values.
|
||||
* Placeholders are denoted by curly braces: {placeholderName}
|
||||
*
|
||||
* @param copyString - The Copy enum string containing placeholders
|
||||
* @param replacements - Array of replacement values in the order they appear in the string
|
||||
* @returns The string with all placeholders replaced
|
||||
*
|
||||
* @example
|
||||
* replaceCopy(Copy.WorkflowFailedAtStep, ["authentication"])
|
||||
* // Returns: "The planned workflow failed when executing step 'authentication'. Consult with the user on how to continue."
|
||||
*/
|
||||
export function replaceCopy(copyString: string, replacements: string[]): string {
|
||||
const placeholderRegex = /\{[^}]+\}/g;
|
||||
const placeholders = copyString.match(placeholderRegex);
|
||||
|
||||
if (!placeholders) {
|
||||
if (replacements.length > 0) {
|
||||
Exception.log(`No placeholders found in copy string, but ${replacements.length} replacement(s) provided.`);
|
||||
}
|
||||
return copyString;
|
||||
}
|
||||
|
||||
if (placeholders.length !== replacements.length) {
|
||||
Exception.log(`Placeholder count (${placeholders.length}) does not match replacement count (${replacements.length}). Using best effort.`);
|
||||
}
|
||||
|
||||
let result = copyString;
|
||||
let replacementIndex = 0;
|
||||
|
||||
result = result.replace(placeholderRegex, () => {
|
||||
if (replacementIndex < replacements.length) {
|
||||
return replacements[replacementIndex++];
|
||||
}
|
||||
return placeholders[replacementIndex++]; // Return original placeholder if no replacement available
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
export enum Event {
|
||||
DiffOpened = "diffOpened",
|
||||
DiffClosed = "diffClosed",
|
||||
PlanApprovalOpened = "planApprovalOpened",
|
||||
PlanApprovalClosed = "planApprovalClosed",
|
||||
RateLimitCountdown = "rateLimitCountdown"
|
||||
}
|
||||
|
|
@ -151,8 +151,9 @@ export enum FileType {
|
|||
}
|
||||
|
||||
export function toFileType(fileType: string): FileType {
|
||||
if (isKnownFileType(fileType)) {
|
||||
return fileType;
|
||||
const normalized = fileType.startsWith('.') ? fileType.slice(1) : fileType;
|
||||
if (isKnownFileType(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
return FileType.UNKNOWN;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export enum InputMode {
|
||||
Normal = "normal",
|
||||
Diff = "diff",
|
||||
Question = "question"
|
||||
Question = "question",
|
||||
PlanApproval = "planApproval"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ export enum Path {
|
|||
VaultkeeperAIDir = "Vaultkeeper AI",
|
||||
Conversations = `${Path.VaultkeeperAIDir}/Conversations`,
|
||||
Attachments = `${Path.Conversations}/Attachments`,
|
||||
Artifacts = `${Path.Conversations}/Artifacts`,
|
||||
UserInstructions = `${Path.VaultkeeperAIDir}/User Instructions`,
|
||||
ExampleUserInstructions = `${Path.UserInstructions}/EXAMPLE_INSTRUCTIONS.md`,
|
||||
Memories = `${Path.VaultkeeperAIDir}/Memories.md`
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ export function toNode(trigger: SearchTrigger, content: string): Node {
|
|||
break;
|
||||
}
|
||||
|
||||
const node = createEl("span", {
|
||||
const node = createSpan({
|
||||
text: text,
|
||||
cls: "search-trigger",
|
||||
attr: {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
export enum Selector {
|
||||
MarkDownLink = "vaultkeeper-ai-internal-markdown-link",
|
||||
AIExclusionsInput = "ai-exclusions-input",
|
||||
LocalUrlInput = "local-url-input",
|
||||
ApiKeySettingOk = "api-key-setting-ok",
|
||||
ApiKeySettingError = "api-key-setting-error",
|
||||
ConversationHistoryModal = "conversation-history-modal",
|
||||
HelpModal = "help-modal",
|
||||
ContextSettingItemDescription = "context-setting-item-description",
|
||||
SettingDescIconGrid = "setting-desc-icon-grid",
|
||||
TemplateWarningIcon = "template-warning-icon",
|
||||
FileDisclaimerIcon = "file-disclaimer-icon",
|
||||
FileDisclaimerLink = "file-disclaimer-link",
|
||||
|
||||
ErrorSelector = "error-selector"
|
||||
}
|
||||
|
|
@ -1,18 +1,58 @@
|
|||
import { extractText, getDocumentProxy } from 'unpdf';
|
||||
import { loadPdfJs } from 'obsidian';
|
||||
import { unzipSync, strFromU8 } from 'fflate';
|
||||
import type { IPageText } from '../Types/SearchTypes';
|
||||
import { Exception } from './Exception';
|
||||
import { parseOffice } from 'officeparser';
|
||||
|
||||
// Handles PDF format
|
||||
/** Minimal structural types for the slice of PDF.js we use. Obsidian's `loadPdfJs()`
|
||||
* is typed as `Promise<any>` and we don't bundle pdfjs-dist, so we describe just the
|
||||
* shape we touch to keep this file free of unsafe-`any` access. **/
|
||||
|
||||
/** PDF Interfaces Start **/
|
||||
interface PdfTextItem { str?: string; }
|
||||
interface PdfTextContent { items: PdfTextItem[]; }
|
||||
interface PdfViewport { width: number; height: number; }
|
||||
interface PdfRenderParams {
|
||||
canvasContext: CanvasRenderingContext2D;
|
||||
viewport: PdfViewport;
|
||||
}
|
||||
interface PdfPage {
|
||||
getTextContent(): Promise<PdfTextContent>;
|
||||
getViewport(params: { scale: number }): PdfViewport;
|
||||
render(params: PdfRenderParams): { promise: Promise<void> };
|
||||
cleanup?(): void;
|
||||
}
|
||||
interface PdfDocument {
|
||||
numPages: number;
|
||||
getPage(pageNumber: number): Promise<PdfPage>;
|
||||
destroy?(): Promise<void>;
|
||||
}
|
||||
interface PdfJs {
|
||||
getDocument(src: { data: Uint8Array }): { promise: Promise<PdfDocument> };
|
||||
}
|
||||
export interface IPageImage {
|
||||
image: ArrayBuffer;
|
||||
pageNumber: number;
|
||||
mimeType: string;
|
||||
}
|
||||
/** PDF Interfaces End **/
|
||||
|
||||
|
||||
export async function readPDF(arrayBuffer: ArrayBuffer): Promise<IPageText[]> {
|
||||
try {
|
||||
const pdf = await getDocumentProxy(new Uint8Array(arrayBuffer));
|
||||
const pages = (await extractText(pdf, { mergePages: false })).text;
|
||||
const pdfjs = (await loadPdfJs()) as PdfJs;
|
||||
const pdf = await pdfjs.getDocument({ data: new Uint8Array(arrayBuffer) }).promise;
|
||||
|
||||
const pageTexts: IPageText[] = pages.map((pageText, index) => ({
|
||||
text: pageText,
|
||||
pageNumber: index + 1
|
||||
}));
|
||||
const pageTexts: IPageText[] = [];
|
||||
for (let i = 1; i <= pdf.numPages; i++) {
|
||||
const page = await pdf.getPage(i);
|
||||
const content = await page.getTextContent();
|
||||
const text = content.items
|
||||
.map((item) => item.str ?? '')
|
||||
.join(' ');
|
||||
pageTexts.push({ text, pageNumber: i });
|
||||
page.cleanup?.();
|
||||
}
|
||||
await pdf.destroy?.();
|
||||
|
||||
return pageTexts;
|
||||
} catch (error) {
|
||||
|
|
@ -31,18 +71,223 @@ export async function readPDF(arrayBuffer: ArrayBuffer): Promise<IPageText[]> {
|
|||
}
|
||||
}
|
||||
|
||||
// Handles document formats: DOCX, PPTX, XLSX, ODT, ODP, ODS
|
||||
export async function readDocument(arrayBuffer: ArrayBuffer): Promise<IPageText[]> {
|
||||
try {
|
||||
const ast = await parseOffice(arrayBuffer, {
|
||||
extractAttachments: true,
|
||||
ocr: true,
|
||||
ocrLanguage: "eng+esp"
|
||||
});
|
||||
// Best effort attempt to convert PDF pages to images
|
||||
export async function pdfToImages(arrayBuffer: ArrayBuffer,
|
||||
options: { scale?: number; mimeType?: 'image/png' | 'image/jpeg'; quality?: number } = {}
|
||||
): Promise<IPageImage[]> {
|
||||
const { scale = 2, mimeType = 'image/png', quality = 0.92 } = options;
|
||||
|
||||
// OfficeParser doesn't currently expose page data (page number etc)
|
||||
return [{ text: ast.toText(), pageNumber: 1 }] as IPageText[];
|
||||
try {
|
||||
const pdfjs = (await loadPdfJs()) as PdfJs;
|
||||
const pdf = await pdfjs.getDocument({ data: new Uint8Array(arrayBuffer) }).promise;
|
||||
|
||||
const pageImages: IPageImage[] = [];
|
||||
for (let i = 1; i <= pdf.numPages; i++) {
|
||||
const page = await pdf.getPage(i);
|
||||
const viewport = page.getViewport({ scale });
|
||||
|
||||
const canvas = createEl('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
continue; // Failed to extract page, move on.
|
||||
}
|
||||
|
||||
await page.render({ canvasContext: ctx, viewport }).promise;
|
||||
|
||||
const blob: Blob | null = await new Promise((resolve) =>
|
||||
canvas.toBlob(resolve, mimeType, quality)
|
||||
);
|
||||
if (!blob) {
|
||||
continue; // Failed to extract page, move on.
|
||||
}
|
||||
|
||||
pageImages.push({
|
||||
image: await blob.arrayBuffer(),
|
||||
pageNumber: i,
|
||||
mimeType,
|
||||
});
|
||||
|
||||
page.cleanup?.();
|
||||
// release canvas memory promptly
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
}
|
||||
await pdf.destroy?.();
|
||||
|
||||
return pageImages;
|
||||
} catch (error) {
|
||||
Exception.log(error);
|
||||
return []; // Failed to extract any pages
|
||||
}
|
||||
}
|
||||
|
||||
// Handles document formats: DOCX, PPTX, XLSX, ODT, ODP, ODS.
|
||||
//
|
||||
// Every format is a ZIP-of-XML container we read with fflate + the platform DOMParser,
|
||||
// so no heavy parser is bundled. We only extract plain text (no OCR, no images) — the
|
||||
// LLM can read files directly and use its own vision engine when it needs to.
|
||||
export function readDocument(arrayBuffer: ArrayBuffer, extension: string): IPageText[] {
|
||||
try {
|
||||
let text: string;
|
||||
switch (extension.toLowerCase()) {
|
||||
case 'docx':
|
||||
text = extractDocx(arrayBuffer);
|
||||
break;
|
||||
case 'xlsx':
|
||||
text = extractXlsx(arrayBuffer);
|
||||
break;
|
||||
case 'pptx':
|
||||
text = extractPptx(arrayBuffer);
|
||||
break;
|
||||
case 'odt':
|
||||
case 'odp':
|
||||
case 'ods':
|
||||
text = extractOdf(arrayBuffer);
|
||||
break;
|
||||
default:
|
||||
throw Exception.new(`Unsupported document type: .${extension}`);
|
||||
}
|
||||
|
||||
// These formats expose no page structure, so we return a single page
|
||||
return [{ text, pageNumber: 1 }] as IPageText[];
|
||||
} catch (error) {
|
||||
return [{ text: `Failed to read document: ${Exception.messageFrom(error)}`, pageNumber: 1 }] as IPageText[];
|
||||
}
|
||||
}
|
||||
|
||||
// --- Shared helpers for the ZIP-of-XML formats (docx / xlsx / pptx / odf) ---
|
||||
|
||||
// Throws on legacy OLE2 binaries (.xls/.ppt/.doc) — they aren't ZIPs. Callers wrap
|
||||
// this so the failure surfaces as a clean "failed to read" message.
|
||||
function unzip(arrayBuffer: ArrayBuffer): Record<string, Uint8Array> {
|
||||
return unzipSync(new Uint8Array(arrayBuffer));
|
||||
}
|
||||
|
||||
function parseXml(bytes: Uint8Array): Document {
|
||||
return new DOMParser().parseFromString(strFromU8(bytes), 'application/xml');
|
||||
}
|
||||
|
||||
/** Numeric sort key from a path like "ppt/slides/slide12.xml" -> 12. */
|
||||
function ordinal(path: string): number {
|
||||
const match = path.match(/(\d+)\.xml$/);
|
||||
return match ? Number(match[1]) : 0;
|
||||
}
|
||||
|
||||
// --- DOCX: body in word/document.xml; <w:p> = paragraph, <w:t> = text run.
|
||||
// Headers/footers and foot/endnotes live in sibling parts that share the same
|
||||
// <w:p>/<w:t> shape, so they run through the same paragraph extractor. ---
|
||||
function extractDocx(arrayBuffer: ArrayBuffer): string {
|
||||
const files = unzip(arrayBuffer);
|
||||
|
||||
// Joined text of every <w:p> paragraph in a parsed part. Runs are joined with
|
||||
// '' (not a space) because Word splits a single word across multiple <w:t> runs
|
||||
// for spell-check / formatting — a space here would shred words like "Hel"+"lo".
|
||||
// Empty paragraphs (incl. the separator entries in foot/endnotes) are dropped.
|
||||
const paragraphs = (bytes: Uint8Array): string =>
|
||||
Array.from(parseXml(bytes).getElementsByTagName('w:p'))
|
||||
.map((p) =>
|
||||
Array.from(p.getElementsByTagName('w:t'))
|
||||
.map((t) => t.textContent ?? '')
|
||||
.join('')
|
||||
)
|
||||
.filter((line) => line.length > 0)
|
||||
.join('\n');
|
||||
|
||||
// Body first, then headers/footers (grouped + numerically ordered), then notes.
|
||||
const headerFooter = Object.keys(files)
|
||||
.filter((name) => /^word\/(header|footer)\d+\.xml$/.test(name))
|
||||
.sort();
|
||||
|
||||
const parts: string[] = [];
|
||||
if (files['word/document.xml']) parts.push(paragraphs(files['word/document.xml']));
|
||||
for (const name of headerFooter) parts.push(paragraphs(files[name]));
|
||||
if (files['word/footnotes.xml']) parts.push(paragraphs(files['word/footnotes.xml']));
|
||||
if (files['word/endnotes.xml']) parts.push(paragraphs(files['word/endnotes.xml']));
|
||||
|
||||
return parts.filter((p) => p.length > 0).join('\n\n');
|
||||
}
|
||||
|
||||
// --- XLSX: values live as indices into a shared-strings table ---
|
||||
function extractXlsx(arrayBuffer: ArrayBuffer): string {
|
||||
const files = unzip(arrayBuffer);
|
||||
|
||||
// Resolve the shared-strings table (cells of type "s" point into this).
|
||||
let shared: string[] = [];
|
||||
if (files['xl/sharedStrings.xml']) {
|
||||
const dom = parseXml(files['xl/sharedStrings.xml']);
|
||||
shared = Array.from(dom.getElementsByTagName('si')).map((si) =>
|
||||
Array.from(si.getElementsByTagName('t'))
|
||||
.map((t) => t.textContent ?? '')
|
||||
.join('')
|
||||
);
|
||||
}
|
||||
|
||||
const sheets = Object.keys(files)
|
||||
.filter((name) => /^xl\/worksheets\/sheet\d+\.xml$/.test(name))
|
||||
.sort((a, b) => ordinal(a) - ordinal(b));
|
||||
|
||||
const rows: string[] = [];
|
||||
for (const name of sheets) {
|
||||
const dom = parseXml(files[name]);
|
||||
for (const row of Array.from(dom.getElementsByTagName('row'))) {
|
||||
const cells = Array.from(row.getElementsByTagName('c')).map((cell) => {
|
||||
const type = cell.getAttribute('t');
|
||||
if (type === 'inlineStr') {
|
||||
// Text stored directly in the cell: <c t="inlineStr"><is><t>…</t></is></c>
|
||||
return Array.from(cell.getElementsByTagName('t'))
|
||||
.map((t) => t.textContent ?? '')
|
||||
.join('');
|
||||
}
|
||||
const value = cell.getElementsByTagName('v')[0];
|
||||
if (!value) return '';
|
||||
return type === 's'
|
||||
? shared[Number(value.textContent)] ?? ''
|
||||
: value.textContent ?? '';
|
||||
});
|
||||
rows.push(cells.join('\t'));
|
||||
}
|
||||
}
|
||||
return rows.join('\n');
|
||||
}
|
||||
|
||||
// --- PPTX: one XML per slide; sort numerically so output is in slide order ---
|
||||
function extractPptx(arrayBuffer: ArrayBuffer): string {
|
||||
const files = unzip(arrayBuffer);
|
||||
|
||||
const slides = Object.keys(files)
|
||||
.filter((name) => /^ppt\/slides\/slide\d+\.xml$/.test(name))
|
||||
.sort((a, b) => ordinal(a) - ordinal(b));
|
||||
|
||||
return slides
|
||||
.map((name) => {
|
||||
const dom = parseXml(files[name]);
|
||||
return Array.from(dom.getElementsByTagName('a:t'))
|
||||
.map((t) => t.textContent ?? '')
|
||||
.join(' ');
|
||||
})
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
// --- ODF (odt / ods / odp): everything lives in a single content.xml ---
|
||||
function extractOdf(arrayBuffer: ArrayBuffer): string {
|
||||
const files = unzip(arrayBuffer);
|
||||
if (!files['content.xml']) return '';
|
||||
|
||||
const dom = parseXml(files['content.xml']);
|
||||
|
||||
// Single document-order pass over paragraphs (text:p) and headings (text:h).
|
||||
// getElementsByTagName("*") preserves order, keeping body text, spreadsheet cells,
|
||||
// and slide text in the sequence they appear.
|
||||
const out: string[] = [];
|
||||
const all = dom.getElementsByTagName('*');
|
||||
for (let i = 0; i < all.length; i++) {
|
||||
const tag = all[i].tagName; // prefixed, e.g. "text:p"
|
||||
if (tag === 'text:p' || tag === 'text:h') {
|
||||
out.push(all[i].textContent ?? '');
|
||||
}
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
|
|
@ -17,4 +17,66 @@ export function setElementIcon(element: HTMLDivElement, iconName: string) {
|
|||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function hideDrawerElements() {
|
||||
for (const element of getDrawerElements()) {
|
||||
if ("vaultKeeperHidden" in element.dataset) continue;
|
||||
|
||||
const computed = getComputedStyle(element);
|
||||
element.dataset.vaultKeeperHidden = String(element.scrollHeight);
|
||||
element.dataset.vaultKeeperPaddingTop = computed.paddingTop;
|
||||
element.dataset.vaultKeeperPaddingBottom = computed.paddingBottom;
|
||||
|
||||
element.setCssProps({
|
||||
"--vaultKeeper-drawer-height": element.scrollHeight + "px",
|
||||
"--vaultKeeper-drawer-padding-top": computed.paddingTop,
|
||||
"--vaultKeeper-drawer-padding-bottom": computed.paddingBottom,
|
||||
});
|
||||
element.addClass("vaultKeeper-drawer-hiding");
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
element.removeClass("vaultKeeper-drawer-hiding");
|
||||
element.addClass("vaultKeeper-drawer-collapsed");
|
||||
});
|
||||
|
||||
element.addEventListener("transitionend", () => {
|
||||
element.removeClass("vaultKeeper-drawer-collapsed");
|
||||
element.addClass("vaultKeeper-drawer-hidden");
|
||||
}, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function restoreDrawerElements() {
|
||||
for (const element of getDrawerElements()) {
|
||||
if (!("vaultKeeperHidden" in element.dataset)) continue;
|
||||
|
||||
const targetHeight = element.dataset.vaultKeeperHidden ?? "0";
|
||||
const paddingTop = element.dataset.vaultKeeperPaddingTop ?? "0";
|
||||
const paddingBottom = element.dataset.vaultKeeperPaddingBottom ?? "0";
|
||||
delete element.dataset.vaultKeeperHidden;
|
||||
delete element.dataset.vaultKeeperPaddingTop;
|
||||
delete element.dataset.vaultKeeperPaddingBottom;
|
||||
|
||||
element.setCssProps({
|
||||
"--vaultKeeper-drawer-height": targetHeight + "px",
|
||||
"--vaultKeeper-drawer-padding-top": paddingTop,
|
||||
"--vaultKeeper-drawer-padding-bottom": paddingBottom,
|
||||
});
|
||||
element.removeClass("vaultKeeper-drawer-hidden");
|
||||
element.addClass("vaultKeeper-drawer-restoring");
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
element.removeClass("vaultKeeper-drawer-restoring");
|
||||
element.addClass("vaultKeeper-drawer-expanding");
|
||||
});
|
||||
|
||||
element.addEventListener("transitionend", () => {
|
||||
element.removeClass("vaultKeeper-drawer-expanding");
|
||||
}, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
function getDrawerElements(): NodeListOf<HTMLElement> {
|
||||
return activeDocument.querySelectorAll<HTMLElement>(".workspace-drawer-header, .workspace-drawer-tab-options");
|
||||
}
|
||||
96
Helpers/FrontmatterHelpers.ts
Normal file
96
Helpers/FrontmatterHelpers.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { parseYaml } from "obsidian";
|
||||
|
||||
/**
|
||||
* Normalises an arbitrary frontmatter list value into a clean string array.
|
||||
*
|
||||
* Obsidian 1.9 only recognises tags/aliases/cssclasses as YAML lists, but a value can still
|
||||
* arrive malformed — most likely AI-generated — as a scalar string or a comma-separated string
|
||||
* (e.g. `meeting, work`). This coerces all of those into an array, splitting comma-separated
|
||||
* strings, stripping a leading `#`, trimming, and dropping empties. Already-array values are
|
||||
* passed through item-by-item with the same cleaning. Non-string/array/number values are ignored.
|
||||
*/
|
||||
export function normaliseFrontmatterList(value: unknown): string[] {
|
||||
const items: unknown[] = Array.isArray(value) ? value : [value];
|
||||
|
||||
const cleaned: string[] = [];
|
||||
for (const item of items) {
|
||||
if (typeof item !== "string" && typeof item !== "number") {
|
||||
continue;
|
||||
}
|
||||
String(item)
|
||||
.split(",")
|
||||
.map(part => part.trim().replace(/^#/, ""))
|
||||
.filter(part => part.length > 0)
|
||||
.forEach(part => cleaned.push(part));
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unions `additions` into a list-valued frontmatter field in place, keeping existing entries
|
||||
* first and de-duplicating. The existing value is normalised via {@link normaliseFrontmatterList}, so a
|
||||
* malformed scalar/comma-separated value is repaired into a proper array. Intended to be called
|
||||
* from inside a `processFrontMatter` mutator, which then serialises the array as a block list.
|
||||
*/
|
||||
export function mergeListIntoFrontmatter(frontmatter: Record<string, unknown>, key: string, additions: string[]): void {
|
||||
const existing = normaliseFrontmatterList(frontmatter[key]);
|
||||
const cleanedAdditions = normaliseFrontmatterList(additions);
|
||||
|
||||
const merged = Array.from(new Set([...existing, ...cleanedAdditions]));
|
||||
if (merged.length > 0) {
|
||||
frontmatter[key] = merged;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a model's frontmatter suggestion into a plain object, or `null` if it isn't a usable
|
||||
* YAML mapping. Defends against the model wrapping its output in `---` fences or a ```yaml code
|
||||
* block even though it is asked for a bare YAML block.
|
||||
*/
|
||||
export function parseFrontmatterYaml(modelOutput: string): Record<string, unknown> | null {
|
||||
const cleaned = modelOutput
|
||||
.trim()
|
||||
.replace(/^```(?:ya?ml)?\s*\n?/i, "")
|
||||
.replace(/\n?```\s*$/i, "")
|
||||
.replace(/^---\s*\n/, "")
|
||||
.replace(/\n---\s*$/, "")
|
||||
.trim();
|
||||
|
||||
if (cleaned === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed: unknown = parseYaml(cleaned);
|
||||
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return null; // Malformed YAML from the model — leave the note untouched
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges suggested frontmatter fields into `frontmatter` in place. List-valued fields
|
||||
* (tags/aliases/cssclasses) are unioned (existing entries kept) and normalised to clean arrays;
|
||||
* all other fields are only added when the note doesn't already have a value.
|
||||
*/
|
||||
export function mergeFrontmatterFields(frontmatter: Record<string, unknown>, suggested: Record<string, unknown>): void {
|
||||
const listKeys = new Set(["tags", "aliases", "cssclasses"]);
|
||||
|
||||
for (const [key, value] of Object.entries(suggested)) {
|
||||
if (value === null || value === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (listKeys.has(key)) {
|
||||
mergeListIntoFrontmatter(frontmatter, key, normaliseFrontmatterList(value));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!(key in frontmatter) || frontmatter[key] === null || frontmatter[key] === undefined || frontmatter[key] === "") {
|
||||
frontmatter[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,46 @@
|
|||
import type VaultkeeperAIPlugin from "main";
|
||||
import path from "path-browserify";
|
||||
import { Exception } from "./Exception";
|
||||
|
||||
/**
|
||||
* Replaces placeholders in Copy strings with provided values.
|
||||
* Placeholders are denoted by curly braces: {placeholderName}
|
||||
*
|
||||
* @param copyString - The Copy enum string containing placeholders
|
||||
* @param replacements - Array of replacement values in the order they appear in the string
|
||||
* @returns The string with all placeholders replaced
|
||||
*
|
||||
* @example
|
||||
* replaceCopy(Copy.WorkflowFailedAtStep, ["authentication"])
|
||||
* // Returns: "The planned workflow failed when executing step 'authentication'. Consult with the user on how to continue."
|
||||
*/
|
||||
export function replaceCopy(copyString: string, replacements: string[]): string {
|
||||
const placeholderRegex = /\{[^}]+\}/g;
|
||||
const placeholders = copyString.match(placeholderRegex);
|
||||
|
||||
if (!placeholders) {
|
||||
if (replacements.length > 0) {
|
||||
Exception.log(`No placeholders found in copy string, but ${replacements.length} replacement(s) provided.`);
|
||||
}
|
||||
return copyString;
|
||||
}
|
||||
|
||||
if (placeholders.length !== replacements.length) {
|
||||
Exception.log(`Placeholder count (${placeholders.length}) does not match replacement count (${replacements.length}). Using best effort.`);
|
||||
}
|
||||
|
||||
let result = copyString;
|
||||
let replacementIndex = 0;
|
||||
|
||||
result = result.replace(placeholderRegex, () => {
|
||||
if (replacementIndex < replacements.length) {
|
||||
return replacements[replacementIndex++];
|
||||
}
|
||||
return placeholders[replacementIndex++]; // Return original placeholder if no replacement available
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function openPluginSettings(plugin: VaultkeeperAIPlugin) {
|
||||
if (!("setting" in plugin.app) || typeof plugin.app.setting !== "object" || plugin.app.setting === null) {
|
||||
|
|
@ -56,5 +97,13 @@ export function pathExtname(filePath: string) {
|
|||
}
|
||||
|
||||
export async function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => activeWindow.setTimeout(resolve, ms));
|
||||
return new Promise(resolve => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function splitFrontmatter(content: string): { frontmatter: string; body: string } {
|
||||
const match = content.match(/^(---\r?\n[\s\S]*?\r?\n---\r?\n?)([\s\S]*)$/);
|
||||
if (!match) {
|
||||
return { frontmatter: "", body: content };
|
||||
}
|
||||
return { frontmatter: match[1], body: match[2] };
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ export class Semaphore {
|
|||
this.queue = [];
|
||||
}
|
||||
|
||||
async wait(): Promise<boolean> {
|
||||
async wait(timeoutMs?: number): Promise<boolean> {
|
||||
if (this.count > 0) {
|
||||
this.count--;
|
||||
return true;
|
||||
|
|
@ -23,7 +23,38 @@ export class Semaphore {
|
|||
}
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
this.queue.push(resolve);
|
||||
let settled = false;
|
||||
let timeoutId: number | null = null;
|
||||
|
||||
const waiter = (value: boolean) => {
|
||||
if (settled) {
|
||||
if (value) {
|
||||
this.release();
|
||||
}
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (timeoutId !== null) {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
this.queue.push(waiter);
|
||||
|
||||
if (timeoutMs !== undefined && timeoutMs >= 0) {
|
||||
timeoutId = window.setTimeout(() => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
const idx = this.queue.indexOf(waiter);
|
||||
if (idx !== -1) {
|
||||
this.queue.splice(idx, 1);
|
||||
}
|
||||
resolve(false);
|
||||
}, timeoutMs);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -77,18 +77,23 @@ export abstract class StringTools {
|
|||
return btoa(binary);
|
||||
}
|
||||
|
||||
public static toBytes(input: string): Uint8Array<ArrayBuffer> {
|
||||
public static toBytes(input: string): Uint8Array {
|
||||
return new Uint8Array(this.toBuffer(input));
|
||||
}
|
||||
|
||||
public static toBuffer(input: string): ArrayBuffer {
|
||||
const binaryString = atob(input);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
const buffer = new ArrayBuffer(binaryString.length);
|
||||
const bytes = new Uint8Array(buffer);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
return buffer;
|
||||
}
|
||||
|
||||
public static async computeSHA256Hash(base64: string): Promise<string> {
|
||||
const bytes = this.toBytes(base64);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', bytes);
|
||||
const buffer = this.toBuffer(base64);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
|
@ -112,7 +117,7 @@ export abstract class StringTools {
|
|||
}
|
||||
}
|
||||
|
||||
const canvas = activeDocument.createElement("canvas");
|
||||
const canvas = createEl("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const context = canvas.getContext("2d");
|
||||
|
|
|
|||
|
|
@ -8,11 +8,13 @@
|
|||
import type { WorkSpaceService } from "Services/WorkSpaceService";
|
||||
import { fade } from "svelte/transition";
|
||||
import { onMount } from "svelte";
|
||||
import type { AssetsService } from "Services/AssetsService";
|
||||
|
||||
export let onClose: () => void;
|
||||
export let initialTopic: number = 1;
|
||||
|
||||
const plugin: VaultkeeperAIPlugin = Resolve<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin);
|
||||
const assetsService: AssetsService = Resolve<AssetsService>(Services.AssetsService);
|
||||
const streamingMarkdownService: StreamingMarkdownService = Resolve<StreamingMarkdownService>(Services.StreamingMarkdownService);
|
||||
const workSpaceService: WorkSpaceService = Resolve<WorkSpaceService>(Services.WorkSpaceService);
|
||||
|
||||
|
|
@ -26,14 +28,34 @@
|
|||
content: Copy.HelpModalAboutContent
|
||||
},
|
||||
2: {
|
||||
title: Copy.HelpModalGuideTitle,
|
||||
content: Copy.HelpModalGuideContent
|
||||
title: Copy.HelpModalGettingStartedTitle,
|
||||
content: Copy.HelpModalGettingStartedContent
|
||||
},
|
||||
3: {
|
||||
title: Copy.HelpModalChatModesTitle,
|
||||
content: Copy.HelpModalChatModesContent
|
||||
},
|
||||
4: {
|
||||
title: Copy.HelpModalReferenceTitle,
|
||||
content: Copy.HelpModalReferenceContent
|
||||
},
|
||||
5: {
|
||||
title: Copy.HelpModalCustomInstructionsTitle,
|
||||
content: Copy.HelpModalCustomInstructionsContent
|
||||
},
|
||||
6: {
|
||||
title: Copy.HelpModalQuickActionsTitle,
|
||||
content: Copy.HelpModalQuickActionsContent
|
||||
},
|
||||
7: {
|
||||
title: Copy.HelpModalUploadedFilesTitle,
|
||||
content: Copy.HelpModalUploadedFilesContent
|
||||
},
|
||||
8: {
|
||||
title: Copy.HelpModalTroubleshootTitle,
|
||||
content: Copy.HelpModalTroubleshootContent
|
||||
},
|
||||
4: {
|
||||
9: {
|
||||
title: Copy.HelpModalPrivacyTitle,
|
||||
content: Copy.HelpModalPrivacyContent
|
||||
}
|
||||
|
|
@ -41,18 +63,27 @@
|
|||
|
||||
let selectedTopic: number = initialTopic;
|
||||
let title: string = topics[selectedTopic].title;
|
||||
let content: string = streamingMarkdownService.formatText(topics[selectedTopic].content);
|
||||
let contentVisible: boolean = true;
|
||||
|
||||
function selectTopic(topicNumber: number) {
|
||||
title = "";
|
||||
content = "";
|
||||
contentVisible = false;
|
||||
selectedTopic = topicNumber;
|
||||
setTimeout(() => {
|
||||
title = topics[selectedTopic].title;
|
||||
content = streamingMarkdownService.formatText(topics[selectedTopic].content);
|
||||
contentVisible = true;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function helpContentAction(element: HTMLElement, topic: number) {
|
||||
streamingMarkdownService.render(topics[topic].content, element, true);
|
||||
return {
|
||||
update(newTopic: number) {
|
||||
streamingMarkdownService.render(topics[newTopic].content, element, true);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
$: if (closeButton) {
|
||||
setIcon(closeButton, 'circle-x');
|
||||
}
|
||||
|
|
@ -60,22 +91,19 @@
|
|||
async function handleLinkClick(evt: MouseEvent) {
|
||||
const target = evt.target as HTMLElement;
|
||||
|
||||
// Check for both internal wikilinks and regular markdown links
|
||||
const link = target.closest('a') as HTMLAnchorElement | null;
|
||||
const link = target.closest('.internal-link') as HTMLAnchorElement | null;
|
||||
if (!link) {
|
||||
return;
|
||||
}
|
||||
|
||||
const href = link.getAttribute('href');
|
||||
if (!href || !href.startsWith('#/page/')) {
|
||||
const notePath = link.getAttribute('data-href');
|
||||
if (!notePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
|
||||
const encodedPath = href.replace('#/page/', '');
|
||||
const notePath = decodeURIComponent(encodedPath);
|
||||
await workSpaceService.openNote(notePath);
|
||||
onClose();
|
||||
}
|
||||
|
|
@ -123,62 +151,28 @@
|
|||
</div>
|
||||
<div class="help-modal-body">
|
||||
<div class="help-modal-dropdown" bind:this={dropdownContainer}></div>
|
||||
<div id="help-modal-version-string-mobile">
|
||||
{Copy.PluginVersionPrefix}{plugin.manifest.version}
|
||||
</div>
|
||||
<div class="help-modal-topics">
|
||||
<div
|
||||
class="help-modal-topic-frame"
|
||||
class:hidden={selectedTopic !== 1}
|
||||
on:click={() => selectTopic(1)}
|
||||
on:keydown={(e) => e.key === 'Enter' && selectTopic(1)}
|
||||
role="button"
|
||||
tabindex="0">
|
||||
<div class="help-modal-topic-item">
|
||||
{topics[1].title}
|
||||
{#each Object.entries(topics) as [key, topic] (key)}
|
||||
<div
|
||||
class="help-modal-topic-frame"
|
||||
class:hidden={selectedTopic !== Number(key)}
|
||||
on:click={() => selectTopic(Number(key))}
|
||||
on:keydown={(e) => e.key === 'Enter' && selectTopic(Number(key))}
|
||||
role="button"
|
||||
tabindex="0">
|
||||
<div class="help-modal-topic-item">
|
||||
{topic.title}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="help-modal-topic-frame"
|
||||
class:hidden={selectedTopic !== 2}
|
||||
on:click={() => selectTopic(2)}
|
||||
on:keydown={(e) => e.key === 'Enter' && selectTopic(2)}
|
||||
role="button"
|
||||
tabindex="0">
|
||||
<div class="help-modal-topic-item">
|
||||
{topics[2].title}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="help-modal-topic-frame"
|
||||
class:hidden={selectedTopic !== 3}
|
||||
on:click={() => selectTopic(3)}
|
||||
on:keydown={(e) => e.key === 'Enter' && selectTopic(3)}
|
||||
role="button"
|
||||
tabindex="0">
|
||||
<div class="help-modal-topic-item">
|
||||
{topics[3].title}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="help-modal-topic-frame"
|
||||
class:hidden={selectedTopic !== 4}
|
||||
on:click={() => selectTopic(4)}
|
||||
on:keydown={(e) => e.key === 'Enter' && selectTopic(4)}
|
||||
role="button"
|
||||
tabindex="0">
|
||||
<div class="help-modal-topic-item">
|
||||
{topics[4].title}
|
||||
</div>
|
||||
</div>
|
||||
<div id="help-modal-version-string">
|
||||
{Copy.PluginVersionPrefix}{plugin.manifest.version}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="help-modal-content" bind:this={contentContainer}>
|
||||
{#if content !== ""}
|
||||
{#if contentVisible}
|
||||
{#if selectedTopic === 1}
|
||||
<img class="help-modal-banner" src={assetsService.bannerSource} alt="Plugin Banner">
|
||||
{/if}
|
||||
<div transition:fade={{ duration: 100 }} use:helpContentAction={selectedTopic}></div>
|
||||
<div transition:fade={{ duration: 100 }}>
|
||||
{@html content}
|
||||
{#if selectedTopic === 1}
|
||||
<a
|
||||
href="{plugin.manifest.authorUrl}/vaultkeeper-ai"
|
||||
|
|
@ -201,14 +195,24 @@
|
|||
<span>{Copy.CoffeeIcon}</span>
|
||||
<span>{Copy.CoffeeLinkText}</span>
|
||||
</a>
|
||||
<p style="margin-top: 2em; font-style: italic;">{Copy.ThankYouMessage}</p>
|
||||
<p style="margin-top: 1em; font-style: italic;">{Copy.ThankYouMessage}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if selectedTopic === 1}
|
||||
<div class="help-modal-version-string" transition:fade={{ duration: 100 }}>
|
||||
<p>{Copy.PluginVersionPrefix}{plugin.manifest.version}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- margin-top: auto;
|
||||
align-self: flex-end;
|
||||
padding: var(--size-4-1) var(--size-4-3);
|
||||
font-size: var(--font-smallest); -->
|
||||
|
||||
<style>
|
||||
.help-modal-container {
|
||||
display: grid;
|
||||
|
|
@ -272,10 +276,6 @@
|
|||
display: none;
|
||||
}
|
||||
|
||||
#help-modal-version-string-mobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.help-modal-topic-frame {
|
||||
grid-column: 1 / 4;
|
||||
display: grid;
|
||||
|
|
@ -293,33 +293,16 @@
|
|||
background-color: transparent;
|
||||
}
|
||||
|
||||
.help-modal-topic-frame:nth-child(1) {
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.help-modal-topic-frame:nth-child(2) {
|
||||
grid-row: 3;
|
||||
}
|
||||
|
||||
.help-modal-topic-frame:nth-child(3) {
|
||||
grid-row: 5;
|
||||
}
|
||||
|
||||
.help-modal-topic-frame:nth-child(4) {
|
||||
grid-row: 7;
|
||||
}
|
||||
|
||||
.help-modal-topics {
|
||||
grid-row: 1 / 9;
|
||||
grid-column: 1;
|
||||
display: grid;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--size-4-3);
|
||||
max-width: 150px;
|
||||
grid-template-rows: auto var(--size-4-3) auto var(--size-4-3) auto var(--size-4-3) auto 1fr;
|
||||
grid-template-columns: auto var(--size-4-2) 1fr;
|
||||
}
|
||||
|
||||
.help-modal-topic-item {
|
||||
grid-column: 1;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
|
@ -333,41 +316,46 @@
|
|||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.help-modal-topic-item:nth-child(1) {
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.help-modal-topic-item:nth-child(2) {
|
||||
grid-row: 3;
|
||||
}
|
||||
|
||||
.help-modal-topic-item:nth-child(3) {
|
||||
grid-row: 5;
|
||||
}
|
||||
|
||||
.help-modal-topic-item:nth-child(4) {
|
||||
grid-row: 7;
|
||||
}
|
||||
|
||||
#help-modal-version-string {
|
||||
grid-row: 8;
|
||||
grid-column: 1;
|
||||
align-self: flex-end;
|
||||
padding: var(--size-4-1) var(--size-4-3);
|
||||
font-size: var(--font-smallest);
|
||||
}
|
||||
|
||||
.help-modal-content {
|
||||
grid-row: 1 / 9;
|
||||
grid-column: 3;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: var(--radius-m);
|
||||
background-color: var(--alt-background-primary);
|
||||
padding: 0 var(--size-4-2) var(--size-4-2) var(--size-4-3);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.help-modal-banner {
|
||||
margin-top: var(--size-2-2);
|
||||
margin-left: calc(var(--size-4-2) * -1);
|
||||
width: calc(100% + (var(--size-4-2) * 2) - var(--size-2-2));
|
||||
border-radius: var(--radius-s);
|
||||
}
|
||||
|
||||
.help-modal-version-string {
|
||||
/* Absorbs leftover vertical space so the version sits bottom-right on tall
|
||||
screens, but collapses and scrolls naturally when content overflows. */
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: var(--size-4-2);
|
||||
}
|
||||
|
||||
.help-modal-version-string p {
|
||||
margin: 0;
|
||||
font-size: var(--font-smallest);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Mobile styles */
|
||||
:global(.is-mobile) .help-modal-banner {
|
||||
margin-top: calc(var(--size-4-1) * -1);;
|
||||
margin-left: calc(var(--size-4-2) * -0.6);
|
||||
}
|
||||
|
||||
:global(.is-mobile) .help-modal-body {
|
||||
grid-template-rows: auto var(--size-4-2) 1fr var(--size-4-2) auto;
|
||||
grid-template-columns: 1fr;
|
||||
|
|
@ -394,16 +382,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
:global(.is-mobile) #help-modal-version-string-mobile {
|
||||
display: block;
|
||||
grid-row: 5;
|
||||
grid-column: 1;
|
||||
align-self: flex-end;
|
||||
padding: var(--size-4-1) var(--size-4-2);
|
||||
font-size: var(--font-smallest);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
:global(.is-mobile) .help-modal-topics {
|
||||
display: none;
|
||||
}
|
||||
|
|
@ -412,5 +390,6 @@
|
|||
grid-row: 3;
|
||||
grid-column: 1;
|
||||
padding: var(--size-4-2);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
166
README.md
166
README.md
|
|
@ -1,22 +1,26 @@
|
|||
# Vaultkeeper AI for Obsidian
|
||||
|
||||
> A powerful AI assistant plugin that brings Claude, Gemini, OpenAI, and Mistral directly into your Obsidian vault with intelligent note management capabilities.
|
||||
> A powerful AI assistant plugin that brings Claude, Gemini, OpenAI, Mistral, and local models directly into your Obsidian vault with intelligent note management capabilities.
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://obsidian.md)
|
||||
|
||||
<p align="center">
|
||||
<img width="1853" height="896" alt="vaultkeeper-ai" src="https://github.com/user-attachments/assets/cdb20159-e679-4e73-8535-9fec0258df39" />
|
||||
<img width="1280" height="640" alt="vaultkeeper-social-1280x640" src="https://github.com/user-attachments/assets/47a5ba6c-e59a-4f95-895a-8abc988369dd" />
|
||||
</p>
|
||||
|
||||
> **New!** Restore file changes - Every note the AI creates, edits, or deletes (including binary files like PDFs and images) can be reviewed and restored to its previous or original version straight from the diff viewer.
|
||||
|
||||
## Features
|
||||
|
||||
- **Multi-Provider AI Support** - Switch seamlessly between Claude (Anthropic), Gemini (Google), OpenAI, and Mistral models
|
||||
- **Multi-Provider AI Support** - Switch seamlessly between Claude (Anthropic), Gemini (Google), OpenAI, Mistral, and self-hosted local models
|
||||
- **Three Chat Modes**
|
||||
- 🔍 **Read-Only Mode**: AI can search, read, and list your notes safely
|
||||
- ✏️ **Edit Mode**: AI can create, edit, delete, and move notes and folders (when you need it)
|
||||
- 📋 **Planning Mode**: A three-agent workflow where a planning agent analyzes your vault and creates a step-by-step strategy before execution
|
||||
- **Interactive Diff Viewer** - Review and approve AI-proposed changes before they're applied with side-by-side diff view
|
||||
- **Interactive Diff Viewer** - Review and approve AI-proposed changes before they're applied with side-by-side diff view, or let the AI make changes without asking"
|
||||
- **Restore File Changes** - Revert any note the AI created, edited, or deleted (including binary files) back to its original or previous version
|
||||
- **Plan Approval Workflow** - In Planning Mode, review the AI's proposed plan and approve it, reject it, or suggest changes before execution begins
|
||||
- **Smart Reference System** - Mention tags (`#`), files (`@`), and folders (`/`) with autocomplete
|
||||
- **Custom System Instructions** - Create and switch between personalized AI behaviors
|
||||
- **Conversation Management** - Persistent chat history with automatic conversation naming
|
||||
|
|
@ -25,9 +29,10 @@
|
|||
- **AI Memory** - Persist vault conventions, preferences, and workflows across sessions so the AI remembers how you work
|
||||
- **Web Search** - Toggle web search on per-message so the AI can look up current information
|
||||
- **Web Viewer** - Allow the AI to fetch and read web page content directly
|
||||
- **Quick Actions** - Right-click context menu actions for lightweight, single-shot AI operations on selected text (desktop only)
|
||||
- **Quick Actions** - Lightweight, single-shot AI operations on selected text, available via right-click context menu and a dedicated editor toolbar button — both toggleable in settings and fully supported on mobile
|
||||
- **Mobile Compatible** - Full functionality on mobile devices with touch-friendly controls
|
||||
- **Streaming Responses** - See AI responses as they're generated
|
||||
- **Local Model Support** - Connect to a self-hosted, OpenAI-compatible server (LM Studio, Ollama, vLLM, etc.) for fully local, private inference
|
||||
- **Local & Private** - API keys stored locally, no data sent to third parties
|
||||
|
||||
## Installation
|
||||
|
|
@ -39,7 +44,7 @@
|
|||
3. Reload Obsidian
|
||||
4. Enable "Vaultkeeper AI" in Settings → Community Plugins
|
||||
|
||||
### From Community Plugins (Plugin has not yet been reviewed and accepted - hopefully coming soon)
|
||||
### From Community Plugins
|
||||
|
||||
1. Open Obsidian Settings
|
||||
2. Navigate to Community Plugins
|
||||
|
|
@ -48,11 +53,12 @@
|
|||
|
||||
## Quick Start
|
||||
|
||||
1. **Add API Keys**: Open plugin settings and add at least one API key:
|
||||
1. **Add API Keys (or connect a local server)**: Open plugin settings and configure at least one provider:
|
||||
- **Claude**: Get from [Anthropic Console](https://console.anthropic.com/)
|
||||
- **Gemini**: Get from [Google AI Studio](https://aistudio.google.com/)
|
||||
- **OpenAI**: Get from [OpenAI Platform](https://platform.openai.com/)
|
||||
- **Mistral**: Get from [Mistral Console](https://console.mistral.ai/)
|
||||
- **Local**: No API key needed - just point it at your local server's URL. See [Local Models](#local-models)
|
||||
|
||||
2. **Select a Model**: Choose your preferred AI model from the dropdown
|
||||
|
||||
|
|
@ -65,34 +71,6 @@
|
|||
|
||||
## Usage
|
||||
|
||||
### Switching Between Models
|
||||
|
||||
The plugin supports multiple AI models:
|
||||
|
||||
**Claude (Anthropic)**
|
||||
|
||||
- Claude Sonnet 4.6 ⚡ (Recommended)
|
||||
- Claude Sonnet 4.5, 4
|
||||
- Claude Opus 4.6, 4.5, 4.1, 4
|
||||
- Claude Haiku 4.5
|
||||
|
||||
**Gemini (Google)**
|
||||
|
||||
- Gemini 3.1 Pro Preview, 3 Pro Preview, 3 Flash Preview
|
||||
- Gemini 2.5 Flash, Pro
|
||||
- Gemini 2.5 Flash Lite
|
||||
|
||||
**OpenAI**
|
||||
|
||||
- GPT-5.2 (Instant, Thinking, Pro)
|
||||
- GPT-5.1, GPT-5 (Mini, Nano)
|
||||
|
||||
**Mistral**
|
||||
|
||||
- Mistral Large
|
||||
- Mistral Medium
|
||||
- Mistral Small
|
||||
|
||||
Switch models anytime in the settings without losing your conversation context.
|
||||
|
||||
### Chat Modes
|
||||
|
|
@ -131,6 +109,10 @@ When the AI proposes changes to your files in Agent Mode, an interactive diff vi
|
|||
|
||||
The diff viewer ensures you're always in control of what changes are made to your vault, providing transparency and safety when working with AI-generated edits.
|
||||
|
||||
**Skipping Approval**
|
||||
|
||||
You can toggle the agents ability to make changes without asking when in edit and planning mode. While enabled, proposed changes are applied immediately without the diff viewer prompt. Turn it off anytime to go back to reviewing every change.
|
||||
|
||||
### Planning Mode
|
||||
|
||||
Planning Mode introduces a three-agent workflow that separates task planning, orchestration, and execution. When enabled, specialized agents collaborate to analyze your vault, create a detailed strategy, and execute changes with intelligent oversight between each step.
|
||||
|
|
@ -142,8 +124,9 @@ Planning Mode introduces a three-agent workflow that separates task planning, or
|
|||
3. **Planning Phase**: The planning agent analyzes your vault, exploring existing notes, organizational patterns, and relevant content
|
||||
4. **Clarifying Questions**: The planning agent may ask you questions to better understand your requirements
|
||||
5. **Plan Display**: A step-by-step plan appears above the chat showing what will be done
|
||||
6. **Execution Phase**: For each step, an execution agent performs the task while an orchestration agent monitors progress and decides whether to continue, adapt, or replan
|
||||
7. **Completion**: All steps are marked complete when finished
|
||||
6. **Plan Approval**: Review the plan and **Approve** it, **Reject** it, or suggest a change before execution begins (see [Plan Approval](#plan-approval) below)
|
||||
7. **Execution Phase**: For each step, an execution agent performs the task while an orchestration agent monitors progress and decides whether to continue, adapt, or replan
|
||||
8. **Completion**: All steps are marked complete when finished
|
||||
|
||||
**The Three Agents**
|
||||
|
||||
|
|
@ -158,6 +141,14 @@ Planning Mode introduces a three-agent workflow that separates task planning, or
|
|||
- The view auto-scrolls to keep the active step visible
|
||||
- Expand/collapse to see the full plan or a compact view
|
||||
|
||||
**Plan Approval**
|
||||
|
||||
Before execution begins, the proposed plan opens in a dedicated view for your review:
|
||||
|
||||
- **Approve** - Accept the plan as-is and move to the execution phase
|
||||
- **Reject** - Cancel the plan outright
|
||||
- **Suggest a change** - Type feedback describing what you'd like changed, and the planning agent replans with your feedback in mind rather than starting over from scratch
|
||||
|
||||
**When to Use Planning Mode**
|
||||
|
||||
Planning mode is especially useful for:
|
||||
|
|
@ -173,9 +164,24 @@ If issues arise during execution, the orchestration agent can request a replan.
|
|||
|
||||
### Quick Actions
|
||||
|
||||
Quick Actions are lightweight, single-shot AI operations available from the right-click context menu in the editor on desktop. They let you run a focused AI task on selected text without opening the chat panel.
|
||||
Quick Actions are one-click AI edits you run on the note you're currently editing, without opening the chat panel. Open the editor menu (right-click, or the command palette) and pick an action. Some actions work on your current selection if you have text selected, otherwise they apply to the whole note.
|
||||
|
||||
Quick Actions are currently only available on desktop and are not accessible on mobile.
|
||||
They're available in two ways:
|
||||
|
||||
- **Right-click context menu** — select text in any markdown note and choose a Quick Action from the context menu
|
||||
- **Editor toolbar button** — a dedicated button appears in the editor header for one-click access
|
||||
|
||||
Both entry points are individually toggleable in Settings and are fully supported on mobile.
|
||||
|
||||
**Available actions**
|
||||
|
||||
- **Proofread** — Corrects spelling, grammar, punctuation, and typos without rewriting for style or changing your voice. Works on the selection if you have one, otherwise the whole note.
|
||||
- **Beautify** — Improves clarity, flow, and readability and adds Markdown formatting (headings, bold, lists, blockquotes) where it helps. Works on the selection if you have one, otherwise the whole note.
|
||||
- **Apply template** — Restructures the note to match a template you choose, fitting your content to the template's headings and structure while preserving the information. If the file you pick doesn't look like a template, no changes are made.
|
||||
- **Apply links** — Scans the note for mentions of pages that already exist in your vault and wraps them in wikilinks. It only links existing pages and never invents new ones. Works on the selection if you have one, otherwise the whole note.
|
||||
- **Apply tags** — Chooses tags for the note from the tags that already exist in your vault and merges them into the frontmatter. It won't create new tags — only ones already in use elsewhere.
|
||||
- **Suggest tags** — Like Apply tags, but free to suggest new tags as well as reuse existing ones. Suggested tags are merged into the note's frontmatter.
|
||||
- **Generate frontmatter** — Infers YAML frontmatter for the note (aliases, tags, title, summary, created) from its content and merges it into any existing frontmatter.
|
||||
|
||||
### AI Memory
|
||||
|
||||
|
|
@ -194,7 +200,7 @@ Memories are stored in a plain markdown file at `Vaultkeeper AI/memories.md`. Th
|
|||
|
||||
Toggle web search on a per-message basis using the globe button in the chat input toolbar. When active, the AI can perform live web searches to answer questions requiring current or external information.
|
||||
|
||||
Web search is supported on all four providers. Mistral uses its native Agents API for search; Claude, Gemini, and OpenAI use their respective built-in search tools.
|
||||
Web search is supported on all cloud providers (Claude, Gemini, OpenAI, and Mistral) but not on Local models. Mistral uses its native Agents API for search; Claude, Gemini, and OpenAI use their respective built-in search tools.
|
||||
|
||||
You can also disable web search access entirely from Settings if you prefer the AI to stay within your vault.
|
||||
|
||||
|
|
@ -204,6 +210,33 @@ When enabled, the AI can fetch and read the content of web pages — useful for
|
|||
|
||||
Web viewer access can be toggled on or off in Settings.
|
||||
|
||||
### Local Models
|
||||
|
||||
Run Vaultkeeper AI entirely against a self-hosted, OpenAI-compatible server instead of a cloud provider — no API key required, and nothing leaves your machine.
|
||||
|
||||
**Setup**
|
||||
|
||||
1. Select **Local** from the provider dropdown in Settings (under the "Self Hosted" group)
|
||||
2. **Local URL**: Enter your server's chat completions endpoint, e.g. `http://localhost:1234/v1/chat/completions` (LM Studio's default)
|
||||
3. **API Key**: Optional — only needed if your local server requires authentication
|
||||
4. **Model**: Type the name of the model to use, exactly as your server expects it
|
||||
5. Optionally set separate **Planning Model** and **Quick Action Model** names if you want lighter/faster models handling those roles
|
||||
|
||||
This works with LM Studio, Ollama, vLLM, and other servers that expose an OpenAI-compatible chat completions API.
|
||||
|
||||
**What works**
|
||||
|
||||
- Streaming responses
|
||||
- Tool/function calling (vault search, file operations, etc.)
|
||||
- Quick Actions
|
||||
- Image attachments (inlined as base64)
|
||||
- PDF and Office/ODF document attachments (PDFs are automatically rasterized to images, since local models can't read raw PDF bytes) and text-based file attachments
|
||||
|
||||
**Limitations**
|
||||
|
||||
- No web search (cloud-only feature)
|
||||
- Many local models need their chat template adjusted to reliably handle multi-turn tool-calling; check your server's documentation (e.g. LM Studio's prompt template settings) if tool use behaves unexpectedly
|
||||
|
||||
### Reference System
|
||||
|
||||
Quickly provide context to the AI using the reference system:
|
||||
|
|
@ -267,15 +300,23 @@ See `EXAMPLE_INSTRUCTIONS.md` in your vault for a template.
|
|||
- Keys stored locally in your vault
|
||||
- Never transmitted except to respective AI providers
|
||||
|
||||
**Local Provider**
|
||||
|
||||
- Select **Local** from the provider dropdown to use a self-hosted, OpenAI-compatible server instead of a cloud provider
|
||||
- **Local URL**: The chat completions endpoint of your server (e.g. `http://localhost:1234/v1/chat/completions`)
|
||||
- **API Key**: Optional — only required if your server enforces authentication
|
||||
- **Model**, **Planning Model**, **Quick Action Model**: Free-text fields — enter the model name(s) exactly as your server expects them
|
||||
- See [Local Models](#local-models) for full details and limitations
|
||||
|
||||
**Model Selection**
|
||||
|
||||
- Choose from 15+ supported models
|
||||
- Choose from 15+ supported cloud models, or connect your own local model
|
||||
- Switch anytime without conversation loss
|
||||
|
||||
**Planning Model**
|
||||
|
||||
- Select a separate model for the planning agent (used in Planning Mode)
|
||||
- Default: Claude Sonnet 4.6
|
||||
- Defaults to your provider's recommended model
|
||||
- Allows cost optimization by using a more capable model for planning and a faster/cheaper model for execution
|
||||
- The planning model dropdown updates to match your selected provider
|
||||
|
||||
|
|
@ -367,32 +408,13 @@ npm test
|
|||
|
||||
## Contributing
|
||||
|
||||
This plugin was originally created for a friend and is now being shared with the broader Obsidian community. As a solo developer with limited time, I'm currently **not accepting contributions** (pull requests are disabled).
|
||||
|
||||
#### Why?
|
||||
|
||||
I simply don't have the capacity to review, test, and maintain community contributions at this time. I want to be respectful of contributors' time and effort, and accepting PRs that I can't properly review wouldn't be fair to anyone.
|
||||
|
||||
#### What if I find a bug or have a suggestion?
|
||||
|
||||
Please feel free to open an issue! While I can't guarantee quick responses, I do want to know if something isn't working correctly or if there are ideas that would benefit the community.
|
||||
|
||||
#### Can I fork this project?
|
||||
|
||||
Absolutely! This project is open source under MIT, so you're welcome to fork it and make your own modifications.
|
||||
|
||||
#### Will this change?
|
||||
|
||||
If there's significant community interest and usage, I may revisit this decision and open up contributions in the future. For now, I'm focused on keeping the plugin stable and functional for its current users.
|
||||
|
||||
---
|
||||
|
||||
Thank you for understanding! 🙏
|
||||
I'm currently **not accepting contributions** (pull requests are disabled), but bug reports and suggestions via [GitHub Issues](https://github.com/andy-stack/vaultkeeper-ai/issues) are always welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for details.
|
||||
|
||||
## Privacy & Security
|
||||
|
||||
- **API Keys**: Stored locally in your Obsidian vault, never transmitted to third parties
|
||||
- **No External Servers**: Direct communication with AI providers only
|
||||
- **Fully Local Option**: Use the [Local](#local-models) provider with a self-hosted server to keep all inference on your own machine, with no cloud provider involved at all
|
||||
- **File Exclusions**: Protect sensitive information by excluding individual files or entire directories from AI access using glob patterns - excluded files are completely inaccessible in both read-only and agent modes
|
||||
- **Local Storage**: All conversations and settings stored in your vault
|
||||
- **Open Source**: Fully auditable codebase
|
||||
|
|
@ -411,28 +433,19 @@ This plugin is built on the shoulders of many excellent projects:
|
|||
|
||||
**Platform & AI**
|
||||
- Built for [Obsidian](https://obsidian.md)
|
||||
- Powered by [Anthropic Claude](https://anthropic.com), [Google Gemini](https://deepmind.google/technologies/gemini/), [OpenAI](https://openai.com), and [Mistral AI](https://mistral.ai)
|
||||
- Powered by [Anthropic Claude](https://anthropic.com), [Google Gemini](https://deepmind.google/technologies/gemini/), [OpenAI](https://openai.com), [Mistral AI](https://mistral.ai), and any OpenAI-compatible local server (LM Studio, Ollama, vLLM, etc.)
|
||||
- Official SDKs: [@anthropic-ai/sdk](https://github.com/anthropics/anthropic-sdk-typescript), [@google/genai](https://github.com/google/generative-ai-js), [openai](https://github.com/openai/openai-node)
|
||||
|
||||
**Document Processing**
|
||||
- [unpdf](https://github.com/unjs/unpdf) - PDF parsing and text extraction
|
||||
- [officeparser](https://github.com/nicktomlin/officeparser) - Office document parsing (DOCX, PPTX, XLSX, ODT, ODP, ODS)
|
||||
- [fflate](https://github.com/101arrowz/fflate) - Unzipping Office Open XML / OpenDocument files for text extraction (DOCX, PPTX, XLSX, ODT, ODP, ODS)
|
||||
- PDF text extraction via Obsidian's bundled [PDF.js](https://mozilla.github.io/pdf.js/) (`loadPdfJs()`)
|
||||
|
||||
**UI Framework**
|
||||
- [Svelte](https://svelte.dev) - Reactive UI components
|
||||
- [svelte-exmarkdown](https://github.com/ssssota/svelte-exmarkdown) - Markdown rendering for Svelte
|
||||
|
||||
**Markdown Processing**
|
||||
- [unified](https://unifiedjs.com/) - Markdown processing pipeline
|
||||
- [remark](https://github.com/remarkjs/remark) - Markdown parser and compiler
|
||||
- [rehype](https://github.com/rehypejs/rehype) - HTML processor
|
||||
- [remark-gfm](https://github.com/remarkjs/remark-gfm) - GitHub Flavored Markdown support
|
||||
- [remark-wiki-link](https://github.com/landakram/remark-wiki-link) - Obsidian-style wiki links
|
||||
|
||||
**Rich Content Rendering**
|
||||
- [KaTeX](https://katex.org/) - Mathematical notation rendering
|
||||
- [Shiki](https://shiki.style/) - Modern syntax highlighting
|
||||
- [rehype-sanitize](https://github.com/rehypejs/rehype-sanitize) - HTML sanitization for security
|
||||
|
||||
**Diff & Code Review**
|
||||
- [diff](https://github.com/kpdecker/jsdiff) - Text diffing library for change detection
|
||||
|
|
@ -450,9 +463,8 @@ This plugin is built on the shoulders of many excellent projects:
|
|||
|
||||
**CSS**
|
||||
- [Loader](https://uiverse.io/Li-Deheng/bright-firefox-37) - Animated streaming indicator adapted from original by Li-Deheng
|
||||
- [Gradient Border](https://codepen.io/alphardex/pen/vYEYGzp) - Animated border adapted from original by alphardex
|
||||
- [Gradient Spinner](https://codepen.io/AlexWarnes/pen/jXYYKL) - Animated spinner adapted from original by AlexWarnes
|
||||
|
||||
---
|
||||
|
||||
**Note**: This plugin requires API keys from AI providers. API usage is billed by the respective providers according to their pricing. Monitor your usage through provider dashboards.
|
||||
**Note**: Using a cloud provider (Claude, Gemini, OpenAI, or Mistral) requires an API key, and usage is billed by the respective provider according to their pricing — monitor your usage through provider dashboards. The Local provider requires no API key and no billing, since inference runs on your own server.
|
||||
|
|
|
|||
|
|
@ -3,22 +3,24 @@ import { Services } from "../Services";
|
|||
import type { FileSystemService } from "../FileSystemService";
|
||||
import { AITool, fromString } from "Enums/AITool";
|
||||
import { AIToolResponse } from "AIClasses/ToolDefinitions/AIToolResponse";
|
||||
import type { AIToolCall } from "AIClasses/AIToolCall";
|
||||
import { AIToolCall } from "AIClasses/AIToolCall";
|
||||
import type { ISearchMatch } from "../../Types/SearchTypes";
|
||||
import { AbortService } from "../AbortService";
|
||||
import { normalizePath, TAbstractFile, TFile } from "obsidian";
|
||||
import { arrayBufferToBase64, normalizePath, TAbstractFile, TFile } from "obsidian";
|
||||
import { Exception } from "Helpers/Exception";
|
||||
import { Copy, replaceCopy } from "Enums/Copy";
|
||||
import { pathExtname } from "Helpers/Helpers";
|
||||
import { Copy } from "Enums/Copy";
|
||||
import { pathExtname, replaceCopy } from "Helpers/Helpers";
|
||||
import type { MemoriesService } from "Services/MemoriesService";
|
||||
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 { isBinaryFile, isTextFile, toFileType } from "Enums/FileType";
|
||||
import { FileTypeToMimeType } from "Enums/FileTypeMimeTypeMapping";
|
||||
import { StringTools } from "Helpers/StringTools";
|
||||
import { AIToolDefinitions } from "AIClasses/ToolDefinitions/AIToolDefinitions";
|
||||
import { chatModeAllowsEdits } from "Enums/ChatMode";
|
||||
import {
|
||||
SearchVaultFilesArgsSchema,
|
||||
ReadVaultFilesArgsSchema,
|
||||
|
|
@ -34,6 +36,9 @@ import {
|
|||
DeleteVaultFolderArgsSchema,
|
||||
MoveVaultFolderArgsSchema
|
||||
} from "AIClasses/Schemas/AIToolSchemas";
|
||||
import { Artifact } from "Conversations/Artifact";
|
||||
import { extname } from "path-browserify";
|
||||
import { ArtifactAction } from "Enums/ArtifactAction";
|
||||
|
||||
export class AIToolService {
|
||||
|
||||
|
|
@ -54,6 +59,16 @@ export class AIToolService {
|
|||
}
|
||||
|
||||
public async performAITool(toolCall: AIToolCall): Promise<AIToolResponse> {
|
||||
|
||||
// This can happen if the agent hallucinates a legitimate tool call it doesn't currently have access to
|
||||
if (!chatModeAllowsEdits(this.settingsService.settings.chatMode) && AIToolDefinitions.requiresEditModeEnabled(toolCall.name)) {
|
||||
Exception.log(`Invalid tool call: ${toolCall.name}. Agent has hallucinated a legitimate tool call that requires edit mode`);
|
||||
return new AIToolResponse(
|
||||
toolCall.name,
|
||||
new AIToolResponsePayload({ error: `Invalid tool call: ${toolCall.name}. The user has not allowed edits to be made.` }),
|
||||
toolCall.toolId
|
||||
);
|
||||
}
|
||||
|
||||
if (toolCall.name !== AITool.ReadMemories && toolCall.name !== AITool.UpdateMemories) {
|
||||
this.lastToolReadMemories = false;
|
||||
|
|
@ -218,10 +233,11 @@ export class AIToolService {
|
|||
}
|
||||
|
||||
// This is only used by gemini
|
||||
case AITool.RequestWebSearch:
|
||||
return new AIToolResponse(toolCall.name, new AIToolResponsePayload({}), toolCall.toolId)
|
||||
case AITool.RequestWebSearch: {
|
||||
return new AIToolResponse(toolCall.name, new AIToolResponsePayload({}), toolCall.toolId);
|
||||
}
|
||||
|
||||
// multi-agent functions are handled elsewhere - this shouldn't really ever get hit
|
||||
// Multi-agent functions are handled elsewhere - this shouldn't really ever get hit
|
||||
case AITool.ExecuteWorkflow:
|
||||
case AITool.ContinuePlanExecution:
|
||||
case AITool.SubmitPlan:
|
||||
|
|
@ -310,33 +326,33 @@ export class AIToolService {
|
|||
return new Attachment(fileName, mimeType, file.contents);
|
||||
});
|
||||
|
||||
const binaryMessage = "The requested file(s) have been attached to the conversation immediately after this result. " +
|
||||
"This IS the content of the file(s) you read from the vault — PDFs, images, and documents are provided as attachments, " +
|
||||
"not as text in this response. Read the attached content directly to answer the user. You do NOT need to read or fetch this file again.";
|
||||
|
||||
const response = textResults.length > 0 || errorResults.length > 0
|
||||
? {
|
||||
results: [...textResults, ...errorResults],
|
||||
...(binaryResults.length > 0 && { message: "The contents of the files are included below." })
|
||||
...(binaryResults.length > 0 && { message: binaryMessage })
|
||||
}
|
||||
: {
|
||||
message: "Files retrieved successfully. The contents of the files are included below.",
|
||||
message: binaryMessage,
|
||||
count: binaryResults.length
|
||||
};
|
||||
|
||||
return new AIToolResponsePayload(response, attachments);
|
||||
return new AIToolResponsePayload(response, [], attachments);
|
||||
}
|
||||
|
||||
private async writeVaultFile(filePath: string, content: string): Promise<AIToolResponsePayload> {
|
||||
const result = await this.fileSystemService.writeToFilePath(normalizePath(filePath), content);
|
||||
if (result instanceof Error) {
|
||||
return new AIToolResponsePayload({ success: false, error: result.message });
|
||||
}
|
||||
return new AIToolResponsePayload({ success: true });
|
||||
return await this.asTrackedAction(filePath, async () => {
|
||||
return await this.fileSystemService.writeToFilePath(normalizePath(filePath), content);
|
||||
});
|
||||
}
|
||||
|
||||
private async patchVaultFile(filePath: string, oldContent: string[], newContent: string[]): Promise<AIToolResponsePayload> {
|
||||
const result = await this.fileSystemService.patchFileAtPath(normalizePath(filePath), oldContent, newContent);
|
||||
if (result instanceof Error) {
|
||||
return new AIToolResponsePayload({ success: false, error: result.message });
|
||||
}
|
||||
return new AIToolResponsePayload({ success: true });
|
||||
return await this.asTrackedAction(filePath, async () => {
|
||||
return await this.fileSystemService.patchFileAtPath(normalizePath(filePath), oldContent, newContent);
|
||||
});
|
||||
}
|
||||
|
||||
private async deleteVaultFiles(filePaths: string[], confirmation: boolean): Promise<AIToolResponsePayload> {
|
||||
|
|
@ -344,15 +360,19 @@ export class AIToolService {
|
|||
return new AIToolResponsePayload({ error: "Confirmation was false, no action taken" });
|
||||
}
|
||||
|
||||
const results = await Promise.all(filePaths.map(async filePath => {
|
||||
const result = await this.fileSystemService.deleteFile(filePath);
|
||||
if (result instanceof Error) {
|
||||
return { path: filePath, success: false, error: result.message };
|
||||
}
|
||||
return { path: filePath, success: true };
|
||||
}));
|
||||
const results: object[] = [];
|
||||
const artifacts = await this.collectDeletionCandidatesArtifacts(filePaths);
|
||||
|
||||
return new AIToolResponsePayload({ results });
|
||||
for (const filePath of filePaths) {
|
||||
const deleteResult = await this.fileSystemService.deleteFile(filePath);
|
||||
if (deleteResult instanceof Error) {
|
||||
results.push({ path: filePath, success: false, error: deleteResult.message });
|
||||
continue;
|
||||
}
|
||||
results.push({ path: filePath, success: true });
|
||||
}
|
||||
|
||||
return new AIToolResponsePayload({ results }, artifacts);
|
||||
}
|
||||
|
||||
private async moveVaultFiles(sourcePaths: string[], destinationPaths: string[]): Promise<AIToolResponsePayload> {
|
||||
|
|
@ -384,10 +404,16 @@ export class AIToolService {
|
|||
if (!confirmation) {
|
||||
return new AIToolResponsePayload({ error: "Confirmation was false, no action taken" });
|
||||
}
|
||||
|
||||
const contents = await this.fileSystemService.listDirectoryContents(path, true);
|
||||
const filePaths = contents.filter(content => content instanceof TFile).map(file => file.path);
|
||||
|
||||
const artifacts = await this.collectDeletionCandidatesArtifacts(filePaths);
|
||||
const result = await this.fileSystemService.deleteFolder(path);
|
||||
|
||||
return result instanceof Error
|
||||
? new AIToolResponsePayload({ path: path, success: false, error: result.message })
|
||||
: new AIToolResponsePayload({ path: path, success: true });
|
||||
? new AIToolResponsePayload({ path: path, success: false, error: result.message }, artifacts)
|
||||
: new AIToolResponsePayload({ path: path, success: true }, artifacts);
|
||||
}
|
||||
|
||||
private async moveVaultFolder(sourcePath: string, destinationPath: string): Promise<AIToolResponsePayload> {
|
||||
|
|
@ -420,7 +446,7 @@ export class AIToolService {
|
|||
|
||||
return format === "text"
|
||||
? new AIToolResponsePayload({ content: result })
|
||||
: new AIToolResponsePayload({ success: true }, [new Attachment("screenshot.png", MimeType.IMAGE_PNG, result)]);
|
||||
: new AIToolResponsePayload({ success: true }, [], [new Attachment("screenshot.png", MimeType.IMAGE_PNG, result)]);
|
||||
}
|
||||
|
||||
private async readMemories(): Promise<AIToolResponsePayload> {
|
||||
|
|
@ -443,4 +469,56 @@ export class AIToolService {
|
|||
|
||||
return new AIToolResponsePayload({ result: await this.memoriesService.updateMemories(content) });
|
||||
}
|
||||
|
||||
/** Helpers **/
|
||||
|
||||
private async collectDeletionCandidatesArtifacts(filePaths: string[]): Promise<Artifact[]> {
|
||||
const artifacts: Artifact[] = [];
|
||||
|
||||
for (const filePath of filePaths) {
|
||||
const fileType = toFileType(extname(filePath));
|
||||
// Anything that isn't a note we will just store as a binary artifact
|
||||
if (isBinaryFile(fileType) || isDocumentMimeType(FileTypeToMimeType[fileType])) {
|
||||
const result = await this.fileSystemService.readBinaryFile(filePath);
|
||||
if (result instanceof ArrayBuffer) {
|
||||
artifacts.push(new Artifact(filePath, FileTypeToMimeType[fileType], ArtifactAction.Delete, filePath, "", arrayBufferToBase64(result)));
|
||||
}
|
||||
} else {
|
||||
const result = await this.fileSystemService.readFilePath(filePath);
|
||||
if (typeof result === "string") {
|
||||
artifacts.push(new Artifact(filePath, FileTypeToMimeType[fileType], ArtifactAction.Delete, result, ""));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return artifacts;
|
||||
}
|
||||
|
||||
private async asTrackedAction(filePath: string, action: () => Promise<TFile|Error|void>): Promise<AIToolResponsePayload> {
|
||||
let artifactAction: ArtifactAction | undefined;
|
||||
|
||||
let preActionResult = await this.fileSystemService.readFilePath(filePath);
|
||||
if (preActionResult instanceof Error) {
|
||||
preActionResult = ""; // The file does not exist yet
|
||||
artifactAction = ArtifactAction.Create;
|
||||
}
|
||||
const actionResult = await action();
|
||||
if (actionResult instanceof Error) {
|
||||
return new AIToolResponsePayload({ success: false, error: actionResult.message });
|
||||
}
|
||||
let postActionResult = actionResult ? await this.fileSystemService.readFile(actionResult) : "";
|
||||
if (postActionResult instanceof Error) {
|
||||
postActionResult = ""; // The file has been deleted
|
||||
artifactAction = ArtifactAction.Delete;
|
||||
}
|
||||
|
||||
if (artifactAction === undefined) {
|
||||
artifactAction = ArtifactAction.Modify;
|
||||
}
|
||||
|
||||
const fileType = toFileType(extname(filePath));
|
||||
|
||||
return new AIToolResponsePayload({ success: true },
|
||||
[new Artifact(filePath, FileTypeToMimeType[fileType], artifactAction, preActionResult, postActionResult)]);
|
||||
}
|
||||
}
|
||||
|
|
@ -151,7 +151,7 @@ export abstract class BaseAgent {
|
|||
this.debugService?.log("StreamError", `AI stream error for ${agentType}: ${chunk.errorType}`);
|
||||
conversationContent.content = chunk.error;
|
||||
conversationContent.errorType = chunk.errorType;
|
||||
callbacks.onStreamingUpdate(null);
|
||||
callbacks.onStreamingUpdate();
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -197,23 +197,29 @@ export abstract class BaseAgent {
|
|||
}
|
||||
|
||||
if (conversationContent.content?.trim() !== "") {
|
||||
callbacks.onStreamingUpdate(conversationContent.timestamp.getTime().toString());
|
||||
callbacks.onStreamingUpdate();
|
||||
} else {
|
||||
conversationContent.shouldDisplayContent = false;
|
||||
}
|
||||
}
|
||||
|
||||
callbacks.onStreamingUpdate(null);
|
||||
callbacks.onStreamingUpdate();
|
||||
|
||||
return { toolCall: capturedToolCall, shouldContinue: capturedShouldContinue };
|
||||
}
|
||||
|
||||
protected async performAITool(toolCall: AIToolCall): Promise<AIToolResponse> {
|
||||
protected async performAITool(toolCall: AIToolCall, callbacks: IChatServiceCallbacks): Promise<AIToolResponse> {
|
||||
const providerResult = await this.ai?.resolveToolCall?.(toolCall) ?? null;
|
||||
if (providerResult !== null) {
|
||||
return providerResult;
|
||||
}
|
||||
return this.aiToolService.performAITool(toolCall);
|
||||
const result = await this.aiToolService.performAITool(toolCall);
|
||||
|
||||
for (const artifact of result.payload.artifacts) {
|
||||
callbacks.onArtifactProduced(artifact);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async withToolCallingDisabled<T>(callback: () => Promise<T>): Promise<T> {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ import { CompleteTaskArgsSchema, type CompleteTaskArgs } from "AIClasses/Schemas
|
|||
import { AIToolResponse } from "AIClasses/ToolDefinitions/AIToolResponse";
|
||||
import { Exception } from "Helpers/Exception";
|
||||
import { AIToolDefinitions } from "AIClasses/ToolDefinitions/AIToolDefinitions";
|
||||
import { Copy, replaceCopy } from "Enums/Copy";
|
||||
import { replaceCopy } from 'Helpers/Helpers';
|
||||
import { Copy } from "Enums/Copy";
|
||||
import { DebugColor } from "Enums/DebugColor";
|
||||
import { AIToolUsageMode } from "Enums/AIToolUsageMode";
|
||||
import { AIToolResponsePayload } from "AIClasses/ToolDefinitions/AIToolResponsePayload";
|
||||
|
|
@ -72,7 +73,7 @@ export class ExecutionAgent extends BaseAgent {
|
|||
|
||||
this.debugService?.log("ExecutionAgent", `Executing function: ${toolCall.name}`);
|
||||
this.updateThought(toolCall, callbacks);
|
||||
const functionResponse = await this.performAITool(toolCall);
|
||||
const functionResponse = await this.performAITool(toolCall, callbacks);
|
||||
this.conversation.addFunctionResponse(functionResponse);
|
||||
return { shouldExit: false };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ export class MainAgent extends BaseAgent {
|
|||
|
||||
this.debugService?.log("MainAgent", `Executing function: ${toolCall.name}`);
|
||||
this.updateThought(toolCall, callbacks);
|
||||
const functionResponse = await this.performAITool(toolCall);
|
||||
const functionResponse = await this.performAITool(toolCall, callbacks);
|
||||
conversation.addFunctionResponse(functionResponse);
|
||||
return { shouldExit: false };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import { ConversationContent } from "Conversations/ConversationContent";
|
|||
import { Role } from "Enums/Role";
|
||||
import type { IChatServiceCallbacks } from "Services/ChatService";
|
||||
import { ExecutionAgent } from "./ExecutionAgent";
|
||||
import { Copy, replaceCopy } from "Enums/Copy";
|
||||
import { replaceCopy } from 'Helpers/Helpers';
|
||||
import { Copy } from "Enums/Copy";
|
||||
import { Conversation } from "Conversations/Conversation";
|
||||
import { PlanningAgent } from "./PlanningAgent";
|
||||
import { Exception } from "Helpers/Exception";
|
||||
|
|
@ -17,6 +18,7 @@ import { DebugColor } from "Enums/DebugColor";
|
|||
import { AIToolUsageMode } from "Enums/AIToolUsageMode";
|
||||
import { AIToolResponsePayload } from "AIClasses/ToolDefinitions/AIToolResponsePayload";
|
||||
import type { IAIToolDefinition } from "AIClasses/ToolDefinitions/IAIToolDefinition";
|
||||
import type { ExecutionPlan } from "Types/ExecutionPlan";
|
||||
|
||||
export class OrchestrationAgent extends BaseAgent {
|
||||
|
||||
|
|
@ -38,13 +40,49 @@ export class OrchestrationAgent extends BaseAgent {
|
|||
callbacks.onPlanReset();
|
||||
callbacks.onPlanningStarted();
|
||||
this.debugService?.log("OrchestrationAgent", "Spawning PlanningAgent to generate execution plan");
|
||||
const executionPlan = await planningAgent.runPlanningAgent(planningConversation, callbacks);
|
||||
callbacks.onPlanningFinished();
|
||||
|
||||
let approved = false;
|
||||
|
||||
let executionPlan: ExecutionPlan | undefined;
|
||||
|
||||
while (!approved) {
|
||||
executionPlan = await planningAgent.runPlanningAgent(planningConversation, callbacks);
|
||||
|
||||
if (!executionPlan) {
|
||||
callbacks.onPlanningFinished();
|
||||
this.debugService?.log("OrchestrationAgent", "Planning failed - no execution plan generated");
|
||||
return new AIToolResponsePayload({ message: Copy.PlanningFailedNoSteps });
|
||||
}
|
||||
|
||||
this.debugService?.log("OrchestrationAgent", "Plan awaiting user approval");
|
||||
let response = await callbacks.onPlanApprovalRequest(executionPlan);
|
||||
|
||||
if (response.approved) {
|
||||
this.debugService?.log("OrchestrationAgent", "Execution plan approved");
|
||||
break;
|
||||
}
|
||||
|
||||
if (response.suggestion.trim() === "") {
|
||||
callbacks.onPlanningFinished();
|
||||
this.debugService?.log("OrchestrationAgent", "Execution plan rejected");
|
||||
return new AIToolResponsePayload({ message: Copy.PlanRejected });
|
||||
}
|
||||
|
||||
this.debugService?.log("OrchestrationAgent", "Execution plan rejected with user suggestion, commencing replanning");
|
||||
planningConversation.contents.push(new ConversationContent({
|
||||
role: Role.User,
|
||||
content: replaceCopy(Copy.PlanRejectedWithSuggestion, [response.suggestion]),
|
||||
shouldDisplayContent: false
|
||||
}));
|
||||
}
|
||||
|
||||
if (!executionPlan) {
|
||||
callbacks.onPlanningFinished();
|
||||
this.debugService?.log("OrchestrationAgent", "Planning failed - no execution plan generated");
|
||||
return new AIToolResponsePayload({ message: Copy.PlanningFailedNoSteps });
|
||||
}
|
||||
callbacks.onPlanningFinished();
|
||||
|
||||
this.debugService?.log("OrchestrationAgent", `Execution plan received with ${executionPlan.executionSteps.length} steps`);
|
||||
callbacks.onPlanUpdate(executionPlan);
|
||||
|
||||
|
|
@ -92,6 +130,7 @@ export class OrchestrationAgent extends BaseAgent {
|
|||
: orchestrationResult.continueContext;
|
||||
}
|
||||
stepIndex++;
|
||||
callbacks.onPlanStepUpdate(stepIndex);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -124,6 +163,7 @@ export class OrchestrationAgent extends BaseAgent {
|
|||
content: `Step ${stepIndex + 1} was skipped. Reason: ${orchestrationResult.skipReason}`
|
||||
}));
|
||||
stepIndex++;
|
||||
callbacks.onPlanStepUpdate(stepIndex);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -180,7 +220,7 @@ export class OrchestrationAgent extends BaseAgent {
|
|||
isAITool(toolCallName, AITool.ListVaultFiles)) {
|
||||
this.debugService?.log("Orchestration", `Vault tool called for recovery: ${toolCallName}`);
|
||||
this.updateThought(toolCall, callbacks);
|
||||
const toolResponse = await this.performAITool(toolCall);
|
||||
const toolResponse = await this.performAITool(toolCall, callbacks);
|
||||
planningConversation.addFunctionResponse(toolResponse);
|
||||
return Promise.resolve({ shouldExit: false });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ export class PlanningAgent extends BaseAgent {
|
|||
}
|
||||
|
||||
this.updateThought(toolCall, callbacks);
|
||||
const functionResponse = await this.performAITool(toolCall);
|
||||
const functionResponse = await this.performAITool(toolCall, callbacks);
|
||||
conversation.addFunctionResponse(functionResponse);
|
||||
return { shouldExit: false };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -44,9 +44,11 @@ export class QuickAgent extends BaseAgent {
|
|||
onStreamingUpdate: () => {},
|
||||
onThoughtUpdate: () => {},
|
||||
onToolCallStarted: () => {},
|
||||
onArtifactProduced: () => {},
|
||||
onPlanningStarted: () => {},
|
||||
onPlanningFinished: () => {},
|
||||
onUserQuestion: async () => new Promise<string>(() => {}),
|
||||
onPlanApprovalRequest: async () => new Promise(() => {}),
|
||||
onPlanUpdate: () => {},
|
||||
onPlanStepUpdate: () => {},
|
||||
onPlanReset: () => {},
|
||||
|
|
|
|||
23
Services/AssetsService.ts
Normal file
23
Services/AssetsService.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { addIcon } from "obsidian";
|
||||
import iconSvg from "../Assets/vaultkeeper-mono.svg";
|
||||
import bannerSource from "../Assets/vaultkeeper-social-1280x330.png";
|
||||
|
||||
export class AssetsService {
|
||||
|
||||
public pluginIcon: string;
|
||||
public bannerSource: string;
|
||||
|
||||
// Assets are bundled into main.js at build time (see esbuild loader config).
|
||||
// They must NOT be read from disk at runtime: released/mobile installs ship
|
||||
// only main.js, manifest.json and styles.css, so the Assets/ folder is absent.
|
||||
public constructor() {
|
||||
this.pluginIcon = this.addIcon(iconSvg, "vaultkeeper-ai-icon");
|
||||
this.bannerSource = bannerSource;
|
||||
}
|
||||
|
||||
private addIcon(icon: string, name: string): string {
|
||||
addIcon(name, icon);
|
||||
return name;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -17,15 +17,19 @@ import type { WorkSpaceService } from "./WorkSpaceService";
|
|||
import type { ExecutionPlan } from "Types/ExecutionPlan";
|
||||
import type { MainAgent } from "./AIServices/MainAgent";
|
||||
import type { ChatMode } from "Enums/ChatMode";
|
||||
import type { PlanApprovalResponse } from "Types/PlanApprovalResponse";
|
||||
import type { Artifact } from "Conversations/Artifact";
|
||||
|
||||
export interface IChatServiceCallbacks {
|
||||
onSubmit: () => void;
|
||||
onStreamingUpdate: (streamingMessageId: string | null) => void;
|
||||
onStreamingUpdate: () => void;
|
||||
onThoughtUpdate: (thought: string | null) => void;
|
||||
onToolCallStarted: (toolName: string) => void;
|
||||
onArtifactProduced: (artifact: Artifact) => void;
|
||||
onPlanningStarted: () => void;
|
||||
onPlanningFinished: () => void;
|
||||
onUserQuestion: (question: string) => Promise<string>;
|
||||
onPlanApprovalRequest: (plan: ExecutionPlan) => Promise<PlanApprovalResponse>
|
||||
onPlanUpdate: (executionPlan: ExecutionPlan) => void;
|
||||
onPlanStepUpdate: (currentStepIndex: number) => void;
|
||||
onPlanReset: () => void;
|
||||
|
|
@ -85,7 +89,7 @@ export class ChatService {
|
|||
conversation.contents.push(conversationContent);
|
||||
|
||||
await this.saveConversation(conversation);
|
||||
callbacks.onStreamingUpdate(conversationContent.timestamp.getTime().toString());
|
||||
callbacks.onStreamingUpdate();
|
||||
|
||||
let namingPromise: Promise<void> | undefined;
|
||||
if (firstMessage) {
|
||||
|
|
@ -107,12 +111,12 @@ export class ChatService {
|
|||
await this.saveConversation(conversation);
|
||||
|
||||
callbacks.onSubmit();
|
||||
callbacks.onStreamingUpdate(null);
|
||||
callbacks.onStreamingUpdate();
|
||||
|
||||
await this.mainAgent.runMainAgent(conversation, chatMode, callbacks);
|
||||
|
||||
if (namingPromise) {
|
||||
const timeout = new Promise<void>(resolve => activeWindow.setTimeout(resolve, 5000));
|
||||
const timeout = new Promise<void>(resolve => window.setTimeout(resolve, 5000));
|
||||
await Promise.race([namingPromise, timeout]);
|
||||
}
|
||||
});
|
||||
|
|
@ -123,13 +127,12 @@ export class ChatService {
|
|||
}
|
||||
} finally {
|
||||
this.eventService.trigger(Event.DiffClosed);
|
||||
await this.saveConversation(conversation);
|
||||
callbacks.onThoughtUpdate(null);
|
||||
callbacks.onComplete();
|
||||
if (this.semaphoreHeld) {
|
||||
this.semaphoreHeld = false;
|
||||
this.semaphore.release();
|
||||
}
|
||||
callbacks.onThoughtUpdate(null);
|
||||
callbacks.onComplete();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -144,9 +147,6 @@ export class ChatService {
|
|||
}
|
||||
|
||||
private async saveConversation(conversation: Conversation) {
|
||||
const result = await this.conversationService.saveConversation(conversation);
|
||||
if (result instanceof Error) {
|
||||
new Notice(`Failed to save conversation data for '${conversation.title}'`);
|
||||
}
|
||||
await this.conversationService.saveConversation(conversation);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@ import { Attachment } from "Conversations/Attachment";
|
|||
import { Exception } from "Helpers/Exception";
|
||||
import type { IAIFileService } from "AIClasses/IAIFileService";
|
||||
import { Reference } from "Conversations/Reference";
|
||||
import { arrayBufferToBase64 } from "obsidian";
|
||||
import { Artifact } from "Conversations/Artifact";
|
||||
import type { IBinaryFile } from "Conversations/IBinaryFile";
|
||||
import { arrayBufferToBase64, Notice } from "obsidian";
|
||||
import { StringTools } from "Helpers/StringTools";
|
||||
|
||||
export class ConversationFileSystemService {
|
||||
|
|
@ -32,9 +34,9 @@ export class ConversationFileSystemService {
|
|||
return `${Path.Conversations}/${conversation.title}.json`;
|
||||
}
|
||||
|
||||
public async saveConversation(conversation: Conversation): Promise<string | Error> {
|
||||
public async saveConversation(conversation: Conversation): Promise<void> {
|
||||
if (this.isDeleted) {
|
||||
return ""; // Return empty string to indicate silent skip (not an error)
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.currentConversationPath) {
|
||||
|
|
@ -43,21 +45,19 @@ export class ConversationFileSystemService {
|
|||
// can happen if the conversation is deleted during an active request
|
||||
const fileExists = await this.fileSystemService.exists(this.currentConversationPath, true);
|
||||
if (!fileExists) {
|
||||
return this.currentConversationPath;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
conversation.updated = new Date();
|
||||
|
||||
// Save attachment files and update filePaths
|
||||
// Save binary files (attachments and artifacts) and update their storage paths
|
||||
for (const content of conversation.contents) {
|
||||
for (const attachment of content.attachments) {
|
||||
if (!attachment.filePath && attachment.base64) {
|
||||
const filePath = await this.saveAttachmentFile(attachment);
|
||||
if (!(filePath instanceof Error)) {
|
||||
attachment.filePath = filePath.replace(`${Path.Conversations}/`, '');
|
||||
}
|
||||
}
|
||||
await this.saveBinaryFile(attachment, Path.Attachments);
|
||||
}
|
||||
for (const artifact of content.artifacts) {
|
||||
await this.saveBinaryFile(artifact, Path.Artifacts);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -73,6 +73,14 @@ export class ConversationFileSystemService {
|
|||
displayContent: content.displayContent,
|
||||
toolCall: content.toolCall,
|
||||
functionResponse: content.functionResponse,
|
||||
artifacts: content.artifacts.map(artifact => ({
|
||||
filePath: artifact.filePath,
|
||||
mimeType: artifact.mimeType,
|
||||
action: artifact.action,
|
||||
originalContent: artifact.originalContent,
|
||||
updatedContent: artifact.updatedContent,
|
||||
artifactPath: artifact.artifactPath
|
||||
})),
|
||||
attachments: content.attachments.map(att => ({
|
||||
fileName: att.fileName,
|
||||
mimeType: att.mimeType,
|
||||
|
|
@ -90,10 +98,8 @@ export class ConversationFileSystemService {
|
|||
const result = await this.fileSystemService.writeObjectToFile(this.currentConversationPath, conversationData, true, false);
|
||||
|
||||
if (result instanceof Error) {
|
||||
return result;
|
||||
new Notice(`Failed to save conversation data for '${conversation.title}'`);
|
||||
}
|
||||
|
||||
return this.currentConversationPath;
|
||||
}
|
||||
|
||||
public resetCurrentConversation() {
|
||||
|
|
@ -137,6 +143,7 @@ export class ConversationFileSystemService {
|
|||
// Queue garbage collection after AI file deletion
|
||||
this.deletionQueue = this.deletionQueue.then(async () => {
|
||||
await this.garbageCollectAttachments();
|
||||
await this.garbageCollectArtifacts();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -205,6 +212,57 @@ export class ConversationFileSystemService {
|
|||
}
|
||||
}
|
||||
|
||||
public async garbageCollectArtifacts(): Promise<void | Error> {
|
||||
try {
|
||||
// 1. Get all artifact files
|
||||
const artifactFiles = await this.fileSystemService.listFilesInDirectory(
|
||||
Path.Artifacts,
|
||||
false,
|
||||
true
|
||||
);
|
||||
|
||||
if (artifactFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Build reference count map
|
||||
const referenceCount = new Map<string, number>();
|
||||
|
||||
const conversations = await this.getAllConversations();
|
||||
for (const conversation of conversations) {
|
||||
for (const content of conversation.contents) {
|
||||
for (const artifact of content.artifacts) {
|
||||
if (artifact.artifactPath) {
|
||||
const count = referenceCount.get(artifact.artifactPath) || 0;
|
||||
referenceCount.set(artifact.artifactPath, count + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Delete unreferenced files
|
||||
for (const file of artifactFiles) {
|
||||
const relativePath = file.path.replace(`${Path.Conversations}/`, '');
|
||||
const refCount = referenceCount.get(relativePath) || 0;
|
||||
|
||||
if (refCount === 0) {
|
||||
const deleteResult = await this.fileSystemService.deleteFile(
|
||||
file.path,
|
||||
true,
|
||||
false
|
||||
);
|
||||
|
||||
if (deleteResult instanceof Error) {
|
||||
Exception.log(deleteResult);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
Exception.log(error);
|
||||
return Exception.new(error);
|
||||
}
|
||||
}
|
||||
|
||||
public async updateConversationTitle(oldPath: string, newTitle: string): Promise<void | Error> {
|
||||
const newPath = `${Path.Conversations}/${newTitle}.json`;
|
||||
|
||||
|
|
@ -219,31 +277,31 @@ export class ConversationFileSystemService {
|
|||
}
|
||||
}
|
||||
|
||||
private async saveAttachmentFile(attachment: Attachment): Promise<string | Error> {
|
||||
const hash = await StringTools.computeSHA256Hash(attachment.base64);
|
||||
private async saveBinaryFile(file: IBinaryFile, storageFolder: Path): Promise<void> {
|
||||
if (file.getStoragePath() || !file.base64) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hash = await StringTools.computeSHA256Hash(file.base64);
|
||||
const fileName = `${hash}.bin`;
|
||||
const filePath = `${Path.Attachments}/${fileName}`;
|
||||
const filePath = `${storageFolder}/${fileName}`;
|
||||
|
||||
const exists = await this.fileSystemService.exists(filePath, true);
|
||||
if (exists) {
|
||||
return filePath;
|
||||
if (!exists) {
|
||||
const arrayBuffer = StringTools.toBuffer(file.base64);
|
||||
const result = await this.fileSystemService.writeBinaryFile(filePath, arrayBuffer, true);
|
||||
|
||||
if (result instanceof Error) {
|
||||
Exception.log(result);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const bytes = StringTools.toBytes(attachment.base64);
|
||||
const arrayBuffer = bytes.buffer;
|
||||
|
||||
const result = await this.fileSystemService.writeBinaryFile(filePath, arrayBuffer, true);
|
||||
|
||||
if (result instanceof Error) {
|
||||
Exception.log(result);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
return filePath;
|
||||
file.setStoragePath(filePath.replace(`${Path.Conversations}/`, ""));
|
||||
}
|
||||
|
||||
private async loadAttachmentFile(filePath: string): Promise<string> {
|
||||
const fullPath = `${Path.Conversations}/${filePath}`;
|
||||
private async loadBinaryFile(storagePath: string): Promise<string> {
|
||||
const fullPath = `${Path.Conversations}/${storagePath}`;
|
||||
const arrayBuffer = await this.fileSystemService.readBinaryFile(fullPath, true);
|
||||
|
||||
if (arrayBuffer instanceof Error) {
|
||||
|
|
@ -272,6 +330,7 @@ export class ConversationFileSystemService {
|
|||
const contentPromises = result.contents.map(async content => {
|
||||
const attachments = await this.deserializeAttachments(content.attachments);
|
||||
const references = this.deserializeReferences(content.references);
|
||||
const artifacts = await this.deserializeArtifacts(content.artifacts);
|
||||
|
||||
return new ConversationContent({
|
||||
role: content.role,
|
||||
|
|
@ -280,6 +339,7 @@ export class ConversationFileSystemService {
|
|||
displayContent: content.displayContent,
|
||||
toolCall: content.toolCall,
|
||||
functionResponse: content.functionResponse,
|
||||
artifacts: artifacts,
|
||||
attachments: attachments,
|
||||
references: references,
|
||||
shouldDisplayContent: content.shouldDisplayContent,
|
||||
|
|
@ -307,7 +367,7 @@ export class ConversationFileSystemService {
|
|||
continue;
|
||||
}
|
||||
|
||||
const base64 = await this.loadAttachmentFile(attachmentData.filePath);
|
||||
const base64 = await this.loadBinaryFile(attachmentData.filePath);
|
||||
|
||||
if (!base64) {
|
||||
Exception.warn(`Skipping attachment with missing file: ${attachmentData.fileName} (${attachmentData.filePath})`);
|
||||
|
|
@ -328,6 +388,36 @@ export class ConversationFileSystemService {
|
|||
return attachments;
|
||||
}
|
||||
|
||||
private async deserializeArtifacts(artifactsData: unknown): Promise<Artifact[]> {
|
||||
if (!Array.isArray(artifactsData)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const artifacts: Artifact[] = [];
|
||||
|
||||
for (const artifactData of artifactsData) {
|
||||
if (!Artifact.isArtifactData(artifactData)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const base64 = artifactData.artifactPath
|
||||
? await this.loadBinaryFile(artifactData.artifactPath)
|
||||
: undefined;
|
||||
|
||||
artifacts.push(new Artifact(
|
||||
artifactData.filePath,
|
||||
artifactData.mimeType,
|
||||
artifactData.action,
|
||||
artifactData.originalContent,
|
||||
artifactData.updatedContent,
|
||||
base64,
|
||||
artifactData.artifactPath
|
||||
));
|
||||
}
|
||||
|
||||
return artifacts;
|
||||
}
|
||||
|
||||
private deserializeReferences(referencesData: unknown): Reference[] {
|
||||
if (!Array.isArray(referencesData)) {
|
||||
return [];
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Resolve } from "./DependencyService";
|
||||
import { Services } from "./Services";
|
||||
import type { IConversationNamingService } from "AIClasses/IConversationNamingService";
|
||||
import type { IConversationNamingAgent } from "AIClasses/IConversationNamingAgent";
|
||||
import type { ConversationFileSystemService } from "./ConversationFileSystemService";
|
||||
import type { Conversation } from "Conversations/Conversation";
|
||||
import type { VaultService } from "./VaultService";
|
||||
|
|
@ -12,7 +12,7 @@ import { AbortService } from "./AbortService";
|
|||
export class ConversationNamingService {
|
||||
private readonly stackLimit: number = 1000;
|
||||
|
||||
private namingProvider: IConversationNamingService | undefined;
|
||||
private namingProvider: IConversationNamingAgent | undefined;
|
||||
private conversationService: ConversationFileSystemService;
|
||||
private vaultService: VaultService;
|
||||
private abortService: AbortService;
|
||||
|
|
@ -24,7 +24,7 @@ export class ConversationNamingService {
|
|||
}
|
||||
|
||||
public resolveNamingProvider() {
|
||||
this.namingProvider = Resolve<IConversationNamingService>(Services.IConversationNamingService);
|
||||
this.namingProvider = Resolve<IConversationNamingAgent>(Services.IConversationNamingService);
|
||||
}
|
||||
|
||||
public async requestName(conversation: Conversation, userPrompt: string, onNameChanged: ((name: string) => void) | undefined) {
|
||||
|
|
@ -55,12 +55,8 @@ export class ConversationNamingService {
|
|||
}
|
||||
|
||||
conversation.title = validatedName;
|
||||
const saveResult = await this.conversationService.saveConversation(conversation);
|
||||
await this.conversationService.saveConversation(conversation);
|
||||
|
||||
if (saveResult instanceof Error) {
|
||||
Exception.throw(saveResult);
|
||||
}
|
||||
|
||||
onNameChanged?.(conversation.title);
|
||||
} catch (error) {
|
||||
if (!AbortService.isAbortError(error)) {
|
||||
|
|
|
|||
|
|
@ -35,7 +35,13 @@ export function TryResolve<T>(type: symbol): T | undefined {
|
|||
|
||||
export function DeregisterAllServices() {
|
||||
services.forEach((service) => {
|
||||
if (service && typeof service === "object" && "unload" in service) {
|
||||
if (!service || typeof service !== "object") {
|
||||
return;
|
||||
}
|
||||
if ("dispose" in service && typeof service.dispose === "function") {
|
||||
(service as { dispose: () => void }).dispose();
|
||||
}
|
||||
if ("unload" in service) {
|
||||
(service as Component).unload();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,10 +4,11 @@ import { Resolve } from './DependencyService';
|
|||
import { Services } from './Services';
|
||||
import type { EventService } from './EventService';
|
||||
import { Event } from 'Enums/Event';
|
||||
import type { Diff2HtmlUIConfig } from 'diff2html/lib/ui/js/diff2html-ui';
|
||||
import type { Diff2HtmlUIConfig } from 'diff2html/lib/ui/js/diff2html-ui-base';
|
||||
import { ColorSchemeType, OutputFormatType } from 'diff2html/lib/types';
|
||||
import { Component } from 'obsidian';
|
||||
import { AbortService } from './AbortService';
|
||||
import type { Artifact } from 'Conversations/Artifact';
|
||||
|
||||
interface DiffResult {
|
||||
accepted: boolean;
|
||||
|
|
@ -35,6 +36,26 @@ export class DiffService extends Component {
|
|||
}));
|
||||
}
|
||||
|
||||
public showArtifactDiff(artifact: Artifact): void {
|
||||
const diffString = this.createDiffString(artifact.filePath,
|
||||
artifact.filePath, artifact.originalContent, artifact.updatedContent);
|
||||
|
||||
const outputFormat: OutputFormatType = "line-by-line";
|
||||
|
||||
const config: Diff2HtmlUIConfig = {
|
||||
drawFileList: false,
|
||||
matching: "words",
|
||||
outputFormat: outputFormat,
|
||||
highlight: false,
|
||||
fileListToggle: false,
|
||||
fileContentToggle: false,
|
||||
synchronisedScroll: true,
|
||||
colorScheme: ColorSchemeType.AUTO
|
||||
};
|
||||
|
||||
void this.plugin.activateArtifactView(artifact, diffString, config);
|
||||
}
|
||||
|
||||
public async requestDiff(oldFileName: string, newFileName: string, oldContent: string, newContent: string): Promise<DiffResult> {
|
||||
const diffString = this.createDiffString(oldFileName, newFileName, oldContent, newContent);
|
||||
|
||||
|
|
@ -44,7 +65,7 @@ export class DiffService extends Component {
|
|||
drawFileList: false,
|
||||
matching: "words",
|
||||
outputFormat: outputFormat,
|
||||
highlight: true,
|
||||
highlight: false,
|
||||
fileListToggle: false,
|
||||
fileContentToggle: false,
|
||||
synchronisedScroll: true,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,22 @@
|
|||
import type { Event } from "Enums/Event";
|
||||
import { Events } from "obsidian";
|
||||
import { Events, type EventRef } from "obsidian";
|
||||
|
||||
export class EventService extends Events {
|
||||
|
||||
public on(name: Event.DiffOpened, callback: () => void): EventRef;
|
||||
public on(name: Event.DiffClosed, callback: () => void): EventRef;
|
||||
public on(name: Event.PlanApprovalOpened, callback: () => void): EventRef;
|
||||
public on(name: Event.PlanApprovalClosed, callback: () => void): EventRef;
|
||||
public on(name: Event.RateLimitCountdown, callback: (delayMs: number) => void): EventRef;
|
||||
|
||||
public on<T extends unknown[]>(name: string, callback: (...data: T) => unknown): EventRef {
|
||||
return super.on(name, callback as (...data: unknown[]) => unknown);
|
||||
}
|
||||
|
||||
public trigger(name: Event.DiffOpened, data?: unknown): void;
|
||||
public trigger(name: Event.DiffClosed, data?: unknown): void;
|
||||
public trigger(name: Event.PlanApprovalOpened, data?: unknown): void;
|
||||
public trigger(name: Event.PlanApprovalClosed, data?: unknown): void;
|
||||
public trigger(name: Event.RateLimitCountdown, delayMs: number): void;
|
||||
|
||||
public trigger(name: string, ...data: unknown[]): void {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@ export class FileSystemService {
|
|||
return this.vaultService.getMarkdownFiles(allowAccessToPluginRoot);
|
||||
}
|
||||
|
||||
public isExclusion(filePath: string, allowAccessToPluginRoot: boolean = false): boolean {
|
||||
return this.vaultService.isExclusion(filePath, allowAccessToPluginRoot);
|
||||
}
|
||||
|
||||
public async exists(filePath: string, allowAccessToPluginRoot: boolean = false): Promise<boolean> {
|
||||
return await this.vaultService.exists(filePath, allowAccessToPluginRoot);
|
||||
}
|
||||
|
|
@ -44,7 +48,7 @@ export class FileSystemService {
|
|||
if (file instanceof TFile) {
|
||||
const arrayBuffer = await this.vaultService.readBinaryData(file, allowAccessToPluginRoot);
|
||||
if (!arrayBuffer) {
|
||||
return Exception.new(`Failed to read binary dta for: ${filePath}`);
|
||||
return Exception.new(`Failed to read binary data for: ${filePath}`);
|
||||
}
|
||||
return arrayBuffer;
|
||||
}
|
||||
|
|
@ -74,6 +78,10 @@ export class FileSystemService {
|
|||
public async patchFile(file: TFile, oldContent: string[], newContent: string[], allowAccessToPluginRoot: boolean = false, requiresConfirmation: boolean = true): Promise<TFile | Error> {
|
||||
return await this.vaultService.patch(file, oldContent, newContent, allowAccessToPluginRoot, requiresConfirmation);
|
||||
}
|
||||
|
||||
public async updateFrontmatter(file: TFile, mutate: (frontmatter: Record<string, unknown>) => void, allowAccessToPluginRoot: boolean = false): Promise<TFile | Error> {
|
||||
return await this.vaultService.updateFrontmatter(file, mutate, allowAccessToPluginRoot);
|
||||
}
|
||||
|
||||
public async patchFileAtPath(filePath: string, oldContent: string[], newContent: string[], allowAccessToPluginRoot: boolean = false, requiresConfirmation: boolean = true): Promise<TFile | Error> {
|
||||
const file: TAbstractFile | null = this.vaultService.getAbstractFileByPath(filePath, allowAccessToPluginRoot);
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ export class HTMLService {
|
|||
|
||||
public parseHTMLString(htmlString: string): DocumentFragment {
|
||||
const parser = new DOMParser();
|
||||
const fragment = activeDocument.createDocumentFragment();
|
||||
const fragment = createFragment();
|
||||
const doc = parser.parseFromString(htmlString, "text/html");
|
||||
|
||||
// Transfer all nodes from the parsed body to the fragment
|
||||
|
|
@ -26,7 +26,7 @@ export class HTMLService {
|
|||
// Creates a temporary container, parses HTML, and returns the container.
|
||||
// Useful for parsing HTML when you need to traverse the resulting DOM structure.
|
||||
public parseHTMLToContainer(htmlString: string): HTMLDivElement {
|
||||
const container = activeDocument.createElement("div");
|
||||
const container = createDiv();
|
||||
const fragment = this.parseHTMLString(htmlString);
|
||||
container.appendChild(fragment);
|
||||
return container;
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ export class InputService {
|
|||
}
|
||||
|
||||
if (isDocumentFile(fileType)) {
|
||||
const content = await readDocument(await file.arrayBuffer());
|
||||
const content = readDocument(await file.arrayBuffer(), fileType);
|
||||
attachments.push(new Attachment(
|
||||
file.name,
|
||||
MimeType.TEXT_PLAIN,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Copy, replaceCopy } from "Enums/Copy";
|
||||
import { replaceCopy } from 'Helpers/Helpers';
|
||||
import { Copy } from "Enums/Copy";
|
||||
import { Path } from "Enums/Path";
|
||||
import { Resolve } from "./DependencyService";
|
||||
import type { FileSystemService } from "./FileSystemService";
|
||||
|
|
|
|||
95
Services/PlanApprovalService.ts
Normal file
95
Services/PlanApprovalService.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import type VaultkeeperAIPlugin from 'main';
|
||||
import { Resolve } from './DependencyService';
|
||||
import { Services } from './Services';
|
||||
import type { EventService } from './EventService';
|
||||
import { Event } from 'Enums/Event';
|
||||
import { Component } from 'obsidian';
|
||||
import { AbortService } from './AbortService';
|
||||
import type { ExecutionPlan } from 'Types/ExecutionPlan';
|
||||
import { PlanApprovalResponse } from 'Types/PlanApprovalResponse';
|
||||
|
||||
export class PlanApprovalService extends Component {
|
||||
|
||||
private readonly plugin: VaultkeeperAIPlugin;
|
||||
private readonly eventService: EventService;
|
||||
private readonly abortService: AbortService;
|
||||
|
||||
private planResolve?: (response: PlanApprovalResponse) => void;
|
||||
|
||||
private ongoingApproval: boolean = false;
|
||||
|
||||
public constructor() {
|
||||
super();
|
||||
this.plugin = Resolve<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin);
|
||||
this.eventService = Resolve<EventService>(Services.EventService);
|
||||
this.abortService = Resolve<AbortService>(Services.AbortService);
|
||||
|
||||
this.registerEvent(this.eventService.on(Event.PlanApprovalClosed, () => {
|
||||
this.cancelPendingApproval();
|
||||
}));
|
||||
}
|
||||
|
||||
public async requestApproval(plan: ExecutionPlan): Promise<PlanApprovalResponse> {
|
||||
this.ongoingApproval = true;
|
||||
|
||||
const signal = this.abortService.signal();
|
||||
return new Promise<PlanApprovalResponse>((resolve, reject) => {
|
||||
if (signal.aborted) {
|
||||
this.finishApproval();
|
||||
reject(this.abortService.reason());
|
||||
return;
|
||||
}
|
||||
|
||||
const abortHandler = () => {
|
||||
this.finishApproval();
|
||||
reject(this.abortService.reason());
|
||||
};
|
||||
signal.addEventListener("abort", abortHandler, { once: true });
|
||||
|
||||
this.planResolve = (response: PlanApprovalResponse) => {
|
||||
signal.removeEventListener("abort", abortHandler);
|
||||
resolve(response);
|
||||
};
|
||||
|
||||
void this.plugin.activatePlanApprovalView(plan);
|
||||
this.eventService.trigger(Event.PlanApprovalOpened);
|
||||
});
|
||||
}
|
||||
|
||||
public onApprove() {
|
||||
if (this.planResolve) {
|
||||
this.planResolve(new PlanApprovalResponse(true));
|
||||
}
|
||||
this.finishApproval();
|
||||
}
|
||||
|
||||
public onReject() {
|
||||
if (this.planResolve) {
|
||||
this.planResolve(new PlanApprovalResponse(false));
|
||||
}
|
||||
this.finishApproval();
|
||||
}
|
||||
|
||||
public onSuggest(suggestion: string) {
|
||||
if (this.planResolve) {
|
||||
this.planResolve(new PlanApprovalResponse(false, suggestion));
|
||||
}
|
||||
this.finishApproval();
|
||||
}
|
||||
|
||||
private cancelPendingApproval() {
|
||||
if (this.ongoingApproval) {
|
||||
if (this.planResolve) {
|
||||
this.planResolve(new PlanApprovalResponse(false));
|
||||
}
|
||||
this.finishApproval();
|
||||
}
|
||||
}
|
||||
|
||||
private finishApproval() {
|
||||
this.ongoingApproval = false;
|
||||
this.planResolve = undefined;
|
||||
this.eventService.trigger(Event.PlanApprovalClosed);
|
||||
}
|
||||
|
||||
}
|
||||
392
Services/QuickActions/QuickActionsDefinitionsService.ts
Normal file
392
Services/QuickActions/QuickActionsDefinitionsService.ts
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
import { Resolve } from "../DependencyService";
|
||||
import { Services } from "../Services";
|
||||
import type { QuickAgent } from "../AIServices/QuickAgent";
|
||||
import type VaultkeeperAIPlugin from "main";
|
||||
import { FuzzySuggestModal, MarkdownView, Menu, Notice, TFile, type Editor, type MarkdownFileInfo } from "obsidian";
|
||||
import { FileSystemService } from "../FileSystemService";
|
||||
import { BeautifyPrompt } from "AIPrompts/QuickActionPrompts/BeautifyPrompt";
|
||||
import Spinner from "Components/Spinner.svelte";
|
||||
import { mount } from "svelte";
|
||||
import { ApplyTemplatePrompt } from "AIPrompts/QuickActionPrompts/ApplyTemplatePrompt";
|
||||
import { Copy } from "Enums/Copy";
|
||||
import type { SettingsService } from "../SettingsService";
|
||||
import { openPluginSettings, replaceCopy, splitFrontmatter } from "Helpers/Helpers";
|
||||
import { mergeFrontmatterFields, mergeListIntoFrontmatter, parseFrontmatterYaml } from "Helpers/FrontmatterHelpers";
|
||||
import { ProofreadPrompt } from "AIPrompts/QuickActionPrompts/ProofreadPrompt";
|
||||
import { VaultCacheService } from "Services/VaultCacheService";
|
||||
import { ApplyLinksPrompt } from "AIPrompts/QuickActionPrompts/ApplyLinksPrompt";
|
||||
import { ApplyTagsPrompt } from "AIPrompts/QuickActionPrompts/ApplyTagsPrompt";
|
||||
import { GenerateFrontmatterPrompt } from "AIPrompts/QuickActionPrompts/GenerateFrontmatterPrompt";
|
||||
import { Semaphore } from "Helpers/Semaphore";
|
||||
import { SuggestTagsPrompt } from "AIPrompts/QuickActionPrompts/SuggestTagsPrompt";
|
||||
import { AIProvider } from "Enums/ApiProvider";
|
||||
|
||||
export class QuickActionsDefinitionsService {
|
||||
|
||||
private plugin: VaultkeeperAIPlugin;
|
||||
private fileSystemService: FileSystemService;
|
||||
private vaultcacheService: VaultCacheService;
|
||||
private settingsService: SettingsService;
|
||||
|
||||
private semaphore: Semaphore = new Semaphore(1, true);
|
||||
|
||||
public constructor() {
|
||||
this.plugin = Resolve<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin);
|
||||
this.fileSystemService = Resolve<FileSystemService>(Services.FileSystemService);
|
||||
this.vaultcacheService = Resolve<VaultCacheService>(Services.VaultCacheService);
|
||||
this.settingsService = Resolve<SettingsService>(Services.SettingsService);
|
||||
}
|
||||
|
||||
public async proofread(_menu: Menu, editor: Editor, view: MarkdownView | MarkdownFileInfo) {
|
||||
await this.asSerialAction("Proofread", async () => {
|
||||
const file = view.file;
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = editor.getSelection();
|
||||
const content = await this.fileSystemService.readFile(file);
|
||||
|
||||
if (content instanceof Error || (selection.trim() === "" && content.trim() === "")) {
|
||||
return; // Either an excluded file or nothing to proofread
|
||||
}
|
||||
|
||||
const notice = this.showNotice("Proofreading...");
|
||||
try {
|
||||
if (selection.length > 0) {
|
||||
const result = await this.performAction(ProofreadPrompt, selection);
|
||||
if (result) {
|
||||
await this.fileSystemService.patchFile(file, [selection], [result], false, false);
|
||||
}
|
||||
} else {
|
||||
const { body } = splitFrontmatter(content);
|
||||
if (body.trim() === "") {
|
||||
return;
|
||||
}
|
||||
const result = await this.performAction(ProofreadPrompt, body);
|
||||
if (result) {
|
||||
await this.fileSystemService.patchFile(file, [body], [result], false, false);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
notice.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async beautify(_menu: Menu, editor: Editor, view: MarkdownView | MarkdownFileInfo) {
|
||||
await this.asSerialAction("Beautify", async () => {
|
||||
const file = view.file;
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = editor.getSelection();
|
||||
const content = await this.fileSystemService.readFile(file);
|
||||
|
||||
if (content instanceof Error || (selection.trim() === "" && content.trim() === "")) {
|
||||
return; // Either an excluded file or nothing to beautify
|
||||
}
|
||||
|
||||
const notice = this.showNotice("Beautifying content...");
|
||||
try {
|
||||
if (selection.length > 0) {
|
||||
const result = await this.performAction(BeautifyPrompt, selection);
|
||||
if (result) {
|
||||
await this.fileSystemService.patchFile(file, [selection], [result], false, false);
|
||||
}
|
||||
} else {
|
||||
const { body } = splitFrontmatter(content);
|
||||
if (body.trim() === "") {
|
||||
return;
|
||||
}
|
||||
const result = await this.performAction(BeautifyPrompt, body);
|
||||
if (result) {
|
||||
await this.fileSystemService.patchFile(file, [body], [result], false, false);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
notice.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async applyTemplate(_menu: Menu, _editor: Editor, view: MarkdownView | MarkdownFileInfo) {
|
||||
const file = view.file;
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const preview = await this.fileSystemService.readFile(file);
|
||||
if (preview instanceof Error || preview.trim() === "") {
|
||||
return; // Either an excluded file or nothing to apply a template to
|
||||
}
|
||||
|
||||
this.userSelectFile(this.plugin, async (templateFile) => {
|
||||
await this.asSerialAction("Apply template", async () => {
|
||||
const content = await this.fileSystemService.readFile(file);
|
||||
if (content instanceof Error || content.trim() === "") {
|
||||
return; // Either an excluded file or nothing to apply a template to
|
||||
}
|
||||
|
||||
const templateContent = await this.fileSystemService.readFile(templateFile);
|
||||
if (templateContent instanceof Error || templateContent.trim() === "") {
|
||||
return; // Either an excluded file or the template is empty
|
||||
}
|
||||
|
||||
const prompt = replaceCopy(ApplyTemplatePrompt,
|
||||
[
|
||||
new Date(file.stat.ctime).toString(),
|
||||
new Date(file.stat.mtime).toString(),
|
||||
file.stat.size.toString(),
|
||||
new Date().toString()
|
||||
]);
|
||||
|
||||
const notice = this.showNotice("Applying template...");
|
||||
try {
|
||||
const context = `${Copy.ApplyTemplateTemplateSeparator}\n${templateContent}\n${Copy.ApplyTemplateContentSeparator}\n${content}`;
|
||||
const result = await this.performAction(prompt, context);
|
||||
if (result && result.trim() !== Copy.ApplyTemplateCancelled.toString()) {
|
||||
await this.fileSystemService.writeToFile(file, result, false, false);
|
||||
}
|
||||
} finally {
|
||||
notice.hide();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async applyLinks(_menu: Menu, editor: Editor, view: MarkdownView | MarkdownFileInfo) {
|
||||
await this.asSerialAction("Apply links", async () => {
|
||||
const file = view.file;
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = editor.getSelection();
|
||||
const content = await this.fileSystemService.readFile(file);
|
||||
|
||||
if (content instanceof Error || (selection.trim() === "" && content.trim() === "")) {
|
||||
return; // Either an excluded file or nothing to proofread
|
||||
}
|
||||
|
||||
const links = this.vaultcacheService.wikiLinks.links.join("\n");
|
||||
const prompt = replaceCopy(ApplyLinksPrompt, [links]);
|
||||
|
||||
const notice = this.showNotice("Applying links...");
|
||||
try {
|
||||
if (selection.length > 0) {
|
||||
const result = await this.performAction(prompt, selection);
|
||||
if (result) {
|
||||
await this.fileSystemService.patchFile(file, [selection], [result], false, false);
|
||||
}
|
||||
} else {
|
||||
const { body } = splitFrontmatter(content);
|
||||
if (body.trim() === "") {
|
||||
return;
|
||||
}
|
||||
const result = await this.performAction(prompt, body);
|
||||
if (result) {
|
||||
await this.fileSystemService.patchFile(file, [body], [result], false, false);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
notice.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async applyTags(_menu: Menu, _editor: Editor, view: MarkdownView | MarkdownFileInfo) {
|
||||
await this.asSerialAction("Apply tags", async () => {
|
||||
const file = view.file;
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await this.fileSystemService.readFile(file);
|
||||
|
||||
if (content instanceof Error || content.trim() === "") {
|
||||
return; // Either an excluded file or nothing to tag
|
||||
}
|
||||
|
||||
const { body } = splitFrontmatter(content);
|
||||
if (body.trim() === "") {
|
||||
return; // Nothing to base tags on
|
||||
}
|
||||
|
||||
const allowedTags = this.vaultcacheService.tags;
|
||||
const prompt = replaceCopy(ApplyTagsPrompt, [Array.from(allowedTags).join("\n")]);
|
||||
|
||||
const notice = this.showNotice("Applying tags...");
|
||||
try {
|
||||
const result = await this.performAction(prompt, body);
|
||||
if (!result || result.trim() === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
const chosen = result
|
||||
.split(/\r?\n/)
|
||||
.map(tag => tag.trim())
|
||||
.map(tag => tag.length > 0 && !tag.startsWith("#") ? `#${tag}` : tag)
|
||||
.filter(tag => tag.length > 0 && allowedTags.has(tag))
|
||||
.map(tag => tag.replace(/^#/, ""));
|
||||
|
||||
if (chosen.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.fileSystemService.updateFrontmatter(file, frontmatter => {
|
||||
mergeListIntoFrontmatter(frontmatter, "tags", chosen);
|
||||
});
|
||||
} finally {
|
||||
notice.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async suggestTags(_menu: Menu, _editor: Editor, view: MarkdownView | MarkdownFileInfo) {
|
||||
await this.asSerialAction("Suggest tags", async () => {
|
||||
const file = view.file;
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await this.fileSystemService.readFile(file);
|
||||
|
||||
if (content instanceof Error || content.trim() === "") {
|
||||
return; // Either an excluded file or nothing to tag
|
||||
}
|
||||
|
||||
const { body } = splitFrontmatter(content);
|
||||
if (body.trim() === "") {
|
||||
return; // Nothing to base tags on
|
||||
}
|
||||
|
||||
const availableTags = this.vaultcacheService.tags;
|
||||
const prompt = replaceCopy(SuggestTagsPrompt, [Array.from(availableTags).join("\n")]);
|
||||
|
||||
const notice = this.showNotice("Suggesting tags...");
|
||||
try {
|
||||
const result = await this.performAction(prompt, body);
|
||||
if (!result || result.trim() === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
const chosen = result
|
||||
.split(/\r?\n/)
|
||||
.map(tag => tag.trim())
|
||||
.map(tag => tag.length > 0 && !tag.startsWith("#") ? `#${tag}` : tag)
|
||||
.filter(tag => tag.length > 0)
|
||||
.map(tag => tag.replace(/^#/, ""));
|
||||
|
||||
if (chosen.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.fileSystemService.updateFrontmatter(file, frontmatter => {
|
||||
mergeListIntoFrontmatter(frontmatter, "tags", chosen);
|
||||
});
|
||||
} finally {
|
||||
notice.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async generateFrontmatter(_menu: Menu, _editor: Editor, view: MarkdownView | MarkdownFileInfo) {
|
||||
await this.asSerialAction("Generate frontmatter", async () => {
|
||||
const file = view.file;
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await this.fileSystemService.readFile(file);
|
||||
|
||||
if (content instanceof Error || content.trim() === "") {
|
||||
return; // Either an excluded file or nothing to describe
|
||||
}
|
||||
|
||||
const { body } = splitFrontmatter(content);
|
||||
if (body.trim() === "") {
|
||||
return; // Nothing to base frontmatter on
|
||||
}
|
||||
|
||||
const availableTags = this.vaultcacheService.tags;
|
||||
const prompt = replaceCopy(GenerateFrontmatterPrompt,
|
||||
[
|
||||
Array.from(availableTags).join("\n"),
|
||||
new Date(file.stat.ctime).toString(),
|
||||
new Date().toString()
|
||||
]);
|
||||
|
||||
const notice = this.showNotice("Generating frontmatter...");
|
||||
try {
|
||||
const result = await this.performAction(prompt, body);
|
||||
if (!result || result.trim() === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
const suggested = parseFrontmatterYaml(result);
|
||||
if (!suggested) {
|
||||
return; // Model returned something that isn't a frontmatter object
|
||||
}
|
||||
|
||||
await this.fileSystemService.updateFrontmatter(file, frontmatter => {
|
||||
mergeFrontmatterFields(frontmatter, suggested);
|
||||
});
|
||||
} finally {
|
||||
notice.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* Helpers */
|
||||
|
||||
private userSelectFile(plugin: VaultkeeperAIPlugin, onSelected: (file: TFile) => Promise<void>): void {
|
||||
const fileSystemService = this.fileSystemService;
|
||||
|
||||
new (class extends FuzzySuggestModal<TFile> {
|
||||
getItems() { return fileSystemService.getMarkdownFiles(); }
|
||||
getItemText(f: TFile) { return f.path; }
|
||||
onChooseItem(f: TFile) { void onSelected(f); }
|
||||
})(plugin.app).open();
|
||||
}
|
||||
|
||||
private async asSerialAction(name: string, action: () => Promise<void>): Promise<void> {
|
||||
if (!await this.semaphore.wait(30000)) {
|
||||
this.showNotice(`Quick action '${name}' timed out`, 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await action();
|
||||
} finally {
|
||||
this.semaphore.release();
|
||||
}
|
||||
}
|
||||
|
||||
private async performAction(action: string, context: string): Promise<string | null> {
|
||||
if (this.settingsService.settings.provider !== AIProvider.Local && this.settingsService.getApiKeyForCurrentProvider().trim() == "") {
|
||||
openPluginSettings(this.plugin);
|
||||
return null;
|
||||
}
|
||||
const agent = Resolve<QuickAgent>(Services.QuickAgent);
|
||||
agent.resolveAIProvider();
|
||||
return agent.quickAction(action, context);
|
||||
}
|
||||
|
||||
private showNotice(message: string, durationMs: number = 0): Notice {
|
||||
const fragment = createFragment();
|
||||
const container = fragment.createDiv();
|
||||
|
||||
container.addClass("quick-action-notice");
|
||||
mount(Spinner, { target: container, props: {
|
||||
width: "var(--size-4-4)",
|
||||
height: "var(--size-4-4)",
|
||||
background: "var(--background-modifier-message)"
|
||||
}});
|
||||
|
||||
container.createSpan({ text: message });
|
||||
|
||||
return new Notice(fragment, durationMs);
|
||||
}
|
||||
}
|
||||
215
Services/QuickActions/QuickActionsService.ts
Normal file
215
Services/QuickActions/QuickActionsService.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import type VaultkeeperAIPlugin from "main";
|
||||
import { MarkdownView, Menu, setIcon, type EventRef } from "obsidian";
|
||||
import { Resolve } from "Services/DependencyService";
|
||||
import { Services } from "Services/Services";
|
||||
import type { SettingsService } from "Services/SettingsService";
|
||||
import { WorkSpaceService } from "Services/WorkSpaceService";
|
||||
import type { QuickActionsDefinitionsService } from "./QuickActionsDefinitionsService";
|
||||
import type { HelpModal } from "Modals/HelpModal";
|
||||
import { AssetsService } from "Services/AssetsService";
|
||||
|
||||
export class QuickActionsService {
|
||||
|
||||
private readonly plugin: VaultkeeperAIPlugin;
|
||||
private readonly assetsService: AssetsService;
|
||||
private readonly settingsService: SettingsService;
|
||||
private readonly workSpaceService: WorkSpaceService;
|
||||
private readonly quickActionsDefinitionsService: QuickActionsDefinitionsService;
|
||||
|
||||
private editorMenuEventRef: EventRef | null = null;
|
||||
private layoutChangeEventRef: EventRef | null = null;
|
||||
|
||||
private readonly settingsSubscription: object;
|
||||
|
||||
public constructor() {
|
||||
this.plugin = Resolve<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin);
|
||||
this.assetsService = Resolve<AssetsService>(Services.AssetsService);
|
||||
this.settingsService = Resolve<SettingsService>(Services.SettingsService);
|
||||
this.workSpaceService = Resolve<WorkSpaceService>(Services.WorkSpaceService);
|
||||
this.quickActionsDefinitionsService = Resolve<QuickActionsDefinitionsService>(Services.QuickActionsDefinitionsService);
|
||||
|
||||
this.settingsSubscription = this.settingsService.subscribeToSettingsChanged(changed => {
|
||||
if (changed.includes("enableToolbarActions") || changed.includes("enableContextMenuActions")) {
|
||||
this.updateRegistrations();
|
||||
}
|
||||
});
|
||||
|
||||
this.registerEditorMenuActions();
|
||||
this.registerViewActions();
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
this.settingsService.unsubscribe(this.settingsSubscription);
|
||||
this.unregisterEditorMenuActions();
|
||||
this.unregisterViewActions();
|
||||
}
|
||||
|
||||
/* Action Registration */
|
||||
|
||||
private registerEditorMenuActions() {
|
||||
if (!this.settingsService.settings.enableContextMenuActions) {
|
||||
this.unregisterEditorMenuActions();
|
||||
return;
|
||||
}
|
||||
if (this.editorMenuEventRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.editorMenuEventRef = this.plugin.app.workspace.on("editor-menu", (menu, editor, view) => {
|
||||
menu.addItem((item) => {
|
||||
item.setTitle("Proofread")
|
||||
.setIcon("scan-text")
|
||||
.onClick(async () => this.quickActionsDefinitionsService.proofread(menu, editor, view));
|
||||
});
|
||||
menu.addItem((item) => {
|
||||
item.setTitle("Beautify")
|
||||
.setIcon("palette")
|
||||
.onClick(async () => this.quickActionsDefinitionsService.beautify(menu, editor, view));
|
||||
});
|
||||
menu.addItem((item) => {
|
||||
item.setTitle("Apply template")
|
||||
.setIcon("notepad-text-dashed")
|
||||
.onClick(async () => this.quickActionsDefinitionsService.applyTemplate(menu, editor, view));
|
||||
});
|
||||
menu.addItem((item) => {
|
||||
item.setTitle("Apply links")
|
||||
.setIcon("link")
|
||||
.onClick(async () => this.quickActionsDefinitionsService.applyLinks(menu, editor, view));
|
||||
});
|
||||
menu.addItem((item) => {
|
||||
item.setTitle("Apply tags")
|
||||
.setIcon("tag")
|
||||
.onClick(async () => this.quickActionsDefinitionsService.applyTags(menu, editor, view));
|
||||
});
|
||||
menu.addItem((item) => {
|
||||
item.setTitle("Suggest tags")
|
||||
.setIcon("tags")
|
||||
.onClick(async () => this.quickActionsDefinitionsService.suggestTags(menu, editor, view));
|
||||
});
|
||||
menu.addItem((item) => {
|
||||
item.setTitle("Generate frontmatter")
|
||||
.setIcon("list-plus")
|
||||
.onClick(async () => this.quickActionsDefinitionsService.generateFrontmatter(menu, editor, view));
|
||||
});
|
||||
menu.addSeparator();
|
||||
menu.addItem((item) =>
|
||||
item.setTitle("Quick actions")
|
||||
.setIcon("circle-question-mark")
|
||||
.onClick(() => {
|
||||
const modal = Resolve<HelpModal>(Services.HelpModal);
|
||||
modal.open(6);
|
||||
})
|
||||
);
|
||||
});
|
||||
this.plugin.registerEvent(this.editorMenuEventRef);
|
||||
}
|
||||
|
||||
private registerViewActions() {
|
||||
if (!this.settingsService.settings.enableToolbarActions) {
|
||||
this.unregisterViewActions();
|
||||
return;
|
||||
}
|
||||
if (this.layoutChangeEventRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.layoutChangeEventRef = this.plugin.app.workspace.on("layout-change", () => {
|
||||
this.injectToolbarButton();
|
||||
});
|
||||
this.plugin.registerEvent(this.layoutChangeEventRef);
|
||||
this.injectToolbarButton();
|
||||
}
|
||||
|
||||
private injectToolbarButton() {
|
||||
const leaves = this.workSpaceService.getLeavesOfType("markdown");
|
||||
for (const leaf of leaves) {
|
||||
const view = leaf.view;
|
||||
if (!(view instanceof MarkdownView)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const actionsEl = view.containerEl.querySelector(".view-actions");
|
||||
if (!actionsEl || actionsEl.querySelector(".vault-keeper-ai-actions")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const button = createEl("button", { cls: "clickable-icon view-action vault-keeper-ai-actions" });
|
||||
button.setAttribute("aria-label", "AI quick actions");
|
||||
button.addEventListener("click", (evt) => {
|
||||
const { editor } = view;
|
||||
const menu = new Menu();
|
||||
menu.addItem((item) =>
|
||||
item.setTitle("Proofread")
|
||||
.setIcon("scan-text")
|
||||
.onClick(async () => this.quickActionsDefinitionsService.proofread(menu, editor, view))
|
||||
);
|
||||
menu.addItem((item) =>
|
||||
item.setTitle("Beautify")
|
||||
.setIcon("palette")
|
||||
.onClick(async () => this.quickActionsDefinitionsService.beautify(menu, editor, view))
|
||||
);
|
||||
menu.addItem((item) =>
|
||||
item.setTitle("Apply template")
|
||||
.setIcon("notepad-text-dashed")
|
||||
.onClick(async () => this.quickActionsDefinitionsService.applyTemplate(menu, editor, view))
|
||||
);
|
||||
menu.addItem((item) =>
|
||||
item.setTitle("Apply links")
|
||||
.setIcon("link")
|
||||
.onClick(async () => this.quickActionsDefinitionsService.applyLinks(menu, editor, view))
|
||||
);
|
||||
menu.addItem((item) =>
|
||||
item.setTitle("Apply tags")
|
||||
.setIcon("tag")
|
||||
.onClick(async () => this.quickActionsDefinitionsService.applyTags(menu, editor, view))
|
||||
);
|
||||
menu.addItem((item) =>
|
||||
item.setTitle("Suggest tags")
|
||||
.setIcon("tags")
|
||||
.onClick(async () => this.quickActionsDefinitionsService.suggestTags(menu, editor, view))
|
||||
);
|
||||
menu.addItem((item) =>
|
||||
item.setTitle("Generate frontmatter")
|
||||
.setIcon("list-plus")
|
||||
.onClick(async () => this.quickActionsDefinitionsService.generateFrontmatter(menu, editor, view))
|
||||
);
|
||||
menu.addSeparator();
|
||||
menu.addItem((item) =>
|
||||
item.setTitle("Quick actions")
|
||||
.setIcon("circle-question-mark")
|
||||
.onClick(() => {
|
||||
const modal = Resolve<HelpModal>(Services.HelpModal);
|
||||
modal.open(6);
|
||||
})
|
||||
);
|
||||
menu.showAtMouseEvent(evt);
|
||||
});
|
||||
setIcon(button, this.assetsService.pluginIcon);
|
||||
actionsEl.prepend(button);
|
||||
}
|
||||
}
|
||||
|
||||
private updateRegistrations() {
|
||||
this.registerEditorMenuActions();
|
||||
this.registerViewActions();
|
||||
}
|
||||
|
||||
private unregisterEditorMenuActions() {
|
||||
if (this.editorMenuEventRef) {
|
||||
this.plugin.app.workspace.offref(this.editorMenuEventRef);
|
||||
this.editorMenuEventRef = null;
|
||||
}
|
||||
}
|
||||
|
||||
private unregisterViewActions(){
|
||||
if (this.layoutChangeEventRef) {
|
||||
this.plugin.app.workspace.offref(this.layoutChangeEventRef);
|
||||
this.layoutChangeEventRef = null;
|
||||
// Remove any existing toolbar buttons
|
||||
this.plugin.app.workspace.containerEl
|
||||
.querySelectorAll(".vault-keeper-ai-actions")
|
||||
.forEach(element => element.remove());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,161 +0,0 @@
|
|||
import { Resolve } from "./DependencyService";
|
||||
import { Services } from "./Services";
|
||||
import { AbortService } from "./AbortService";
|
||||
import type { QuickAgent } from "./AIServices/QuickAgent";
|
||||
import type VaultkeeperAIPlugin from "main";
|
||||
import { FuzzySuggestModal, Notice, TFile, type Editor, type MarkdownFileInfo, type MarkdownView, type Menu } from "obsidian";
|
||||
import { FileSystemService } from "./FileSystemService";
|
||||
import { BeautifyPrompt } from "AIPrompts/QuickActionPrompts/Beautify";
|
||||
import Spinner from "Components/Spinner.svelte";
|
||||
import { mount } from "svelte";
|
||||
import { ApplyTemplatePrompt } from "AIPrompts/QuickActionPrompts/ApplyTemplatePrompt";
|
||||
import { Copy } from "Enums/Copy";
|
||||
|
||||
export class QuickActionsService {
|
||||
|
||||
private readonly actionTimeout: number = 30000;
|
||||
|
||||
private plugin: VaultkeeperAIPlugin;
|
||||
private abortService: AbortService;
|
||||
private fileSystemService: FileSystemService;
|
||||
|
||||
public constructor() {
|
||||
this.plugin = Resolve<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin);
|
||||
this.abortService = Resolve<AbortService>(Services.AbortService);
|
||||
this.fileSystemService = Resolve<FileSystemService>(Services.FileSystemService);
|
||||
|
||||
this.registerEditorMenuActions();
|
||||
}
|
||||
|
||||
/* Edit Menu Action Definitions */
|
||||
|
||||
private async beautify(menu: Menu, editor: Editor, view: MarkdownView | MarkdownFileInfo) {
|
||||
const file = view.file;
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = editor.getSelection();
|
||||
const content = await this.fileSystemService.readFile(file);
|
||||
|
||||
if (content instanceof Error || (selection.trim() === "" && content.trim() === "")) {
|
||||
return; // Either an excluded file or nothing to beautify
|
||||
}
|
||||
|
||||
const notice = this.showNotice("Beautifying content...");
|
||||
try {
|
||||
if (selection.length > 0) {
|
||||
const result = await this.newAction(BeautifyPrompt, selection);
|
||||
if (result) {
|
||||
await this.fileSystemService.patchFile(file, [selection], [result], false, false);
|
||||
}
|
||||
} else {
|
||||
const result = await this.newAction(BeautifyPrompt, content);
|
||||
if (result) {
|
||||
await this.fileSystemService.writeToFile(file, result, false, false);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
notice.hide();
|
||||
}
|
||||
}
|
||||
|
||||
private async applyTemplate(menu: Menu, editor: Editor, view: MarkdownView | MarkdownFileInfo) {
|
||||
const file = view.file;
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await this.fileSystemService.readFile(file);
|
||||
if (content instanceof Error || content.trim() === "") {
|
||||
return; // Either an excluded file or nothing to apply a template to
|
||||
}
|
||||
|
||||
this.userSelectFile(this.plugin, async (templateFile) => {
|
||||
const templateContent = await this.fileSystemService.readFile(templateFile);
|
||||
if (templateContent instanceof Error || templateContent.trim() === "") {
|
||||
return; // Either an excluded file or the template is empty
|
||||
}
|
||||
|
||||
const notice = this.showNotice("Applying template...");
|
||||
try {
|
||||
const context = `${Copy.ApplyTemplateTemplateSeparator}\n${templateContent}\n${Copy.ApplyTemplateContentSeparator}\n${content}`;
|
||||
const result = await this.newAction(ApplyTemplatePrompt, context);
|
||||
|
||||
if (result && result.trim() !== Copy.ApplyTemplateCancelled.toString()) {
|
||||
await this.fileSystemService.writeToFile(file, result, false, false);
|
||||
}
|
||||
} finally {
|
||||
notice.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* Registered Edit Menu Actions */
|
||||
|
||||
private registerEditorMenuActions() {
|
||||
// Beautify
|
||||
this.plugin.registerEvent(
|
||||
this.plugin.app.workspace.on("editor-menu", (menu, editor, view) => {
|
||||
menu.addItem((item) => {
|
||||
item.setTitle("Beautify")
|
||||
.setIcon("palette")
|
||||
.onClick(async () => this.beautify(menu, editor, view));
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// Apply Template
|
||||
this.plugin.registerEvent(
|
||||
this.plugin.app.workspace.on("editor-menu", (menu, editor, view) => {
|
||||
menu.addItem((item) => {
|
||||
item.setTitle("Apply template")
|
||||
.setIcon("notepad-text-dashed")
|
||||
.onClick(async () => this.applyTemplate(menu, editor, view));
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/* Helpers */
|
||||
|
||||
private userSelectFile(plugin: VaultkeeperAIPlugin, onSelected: (file: TFile) => Promise<void>): void {
|
||||
const fileSystemService = this.fileSystemService;
|
||||
|
||||
new (class extends FuzzySuggestModal<TFile> {
|
||||
getItems() { return fileSystemService.getMarkdownFiles(); }
|
||||
getItemText(f: TFile) { return f.path; }
|
||||
onChooseItem(f: TFile) { void onSelected(f); }
|
||||
})(plugin.app).open();
|
||||
}
|
||||
|
||||
private async newAction(action: string, context: string): Promise<string | null> {
|
||||
return this.abortService.abortableOperation(async () => {
|
||||
const agent = Resolve<QuickAgent>(Services.QuickAgent);
|
||||
agent.resolveAIProvider();
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||
activeWindow.setTimeout(() => reject(new DOMException(`Action timed out after ${this.actionTimeout}s`, "AbortError")), this.actionTimeout)
|
||||
);
|
||||
|
||||
return Promise.race([agent.quickAction(action, context), timeoutPromise]);
|
||||
});
|
||||
}
|
||||
|
||||
private showNotice(message: string): Notice {
|
||||
const fragment = activeDocument.createDocumentFragment();
|
||||
const container = activeDocument.createElement("div");
|
||||
|
||||
container.addClass("quick-action-notice");
|
||||
mount(Spinner, { target: container, props: {
|
||||
width: "var(--size-4-4)",
|
||||
height: "var(--size-4-4)",
|
||||
background: "var(--background-modifier-message)"
|
||||
}});
|
||||
|
||||
container.createSpan({ text: message });
|
||||
fragment.appendChild(container);
|
||||
|
||||
return new Notice(fragment, 0);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
// Core and Enums
|
||||
import { AIProvider, fromModel } from "Enums/ApiProvider";
|
||||
import { AIProvider } from "Enums/ApiProvider";
|
||||
import { Environment } from "Enums/Environment";
|
||||
import type VaultkeeperAIPlugin from "main";
|
||||
import { AssetsService } from "./AssetsService";
|
||||
|
||||
// Services
|
||||
import { RegisterSingleton, RegisterTransient, Resolve } from "./DependencyService";
|
||||
|
|
@ -14,6 +15,7 @@ import { ConversationNamingService } from "./ConversationNamingService";
|
|||
import { DebugService } from "./DebugService";
|
||||
import { DiffService } from "./DiffService";
|
||||
import { EventService } from "./EventService";
|
||||
import { PlanApprovalService } from "./PlanApprovalService";
|
||||
import { FileSystemService } from "./FileSystemService";
|
||||
import { HTMLService } from "./HTMLService";
|
||||
import { InputService } from "./InputService";
|
||||
|
|
@ -40,28 +42,34 @@ import { HelpModal } from "Modals/HelpModal";
|
|||
// AI Classes
|
||||
import type { IAIClass } from "AIClasses/IAIClass";
|
||||
import type { IAIFileService } from "AIClasses/IAIFileService";
|
||||
import type { IConversationNamingService } from "AIClasses/IConversationNamingService";
|
||||
import type { IConversationNamingAgent } from "AIClasses/IConversationNamingAgent";
|
||||
import { Claude } from "AIClasses/Claude/Claude";
|
||||
import { ClaudeConversationNamingService } from "AIClasses/Claude/ClaudeConversationNamingService";
|
||||
import { ClaudeConversationNamingAgent } from "AIClasses/Claude/ClaudeConversationNamingAgent";
|
||||
import { ClaudeFileService } from "AIClasses/Claude/ClaudeFileService";
|
||||
import { Gemini } from "AIClasses/Gemini/Gemini";
|
||||
import { GeminiConversationNamingService } from "AIClasses/Gemini/GeminiConversationNamingService";
|
||||
import { GeminiConversationNamingAgent } from "AIClasses/Gemini/GeminiConversationNamingAgent";
|
||||
import { GeminiFileService } from "AIClasses/Gemini/GeminiFileService";
|
||||
import { Mistral } from "AIClasses/Mistral/Mistral";
|
||||
import { MistralConversationNamingService } from "AIClasses/Mistral/MistralConversationNamingService";
|
||||
import { MistralConversationNamingAgent } from "AIClasses/Mistral/MistralConversationNamingAgent";
|
||||
import { MistralFileService } from "AIClasses/Mistral/MistralFileService";
|
||||
import { OpenAI } from "AIClasses/OpenAI/OpenAI";
|
||||
import { OpenAIConversationNamingService } from "AIClasses/OpenAI/OpenAIConversationNamingService";
|
||||
import { OpenAIConversationNamingAgent } from "AIClasses/OpenAI/OpenAIConversationNamingAgent";
|
||||
import { OpenAIFileService } from "AIClasses/OpenAI/OpenAIFileService";
|
||||
import { Local } from "AIClasses/Local/Local";
|
||||
import { LocalConversationNamingAgent } from "AIClasses/Local/LocalConversationNamingAgent";
|
||||
import { LocalFileService } from "AIClasses/Local/LocalFileService";
|
||||
|
||||
// Prompts
|
||||
import { AIPrompt, type IPrompt } from "AIPrompts/IPrompt";
|
||||
import { QuickAgent } from "./AIServices/QuickAgent";
|
||||
import { QuickActionsService } from "./QuickActionsService";
|
||||
import { QuickActionsDefinitionsService } from "./QuickActions/QuickActionsDefinitionsService";
|
||||
import { QuickActionsService } from "./QuickActions/QuickActionsService";
|
||||
|
||||
|
||||
export async function RegisterPlugin(plugin: VaultkeeperAIPlugin) {
|
||||
RegisterSingleton<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin, plugin);
|
||||
RegisterSingleton<SettingsService>(Services.SettingsService, new SettingsService(await plugin.loadData() as Partial<IVaultkeeperAISettings>));
|
||||
RegisterSingleton<AssetsService>(Services.AssetsService, new AssetsService());
|
||||
}
|
||||
|
||||
export function RegisterDependencies() {
|
||||
|
|
@ -74,16 +82,18 @@ export function RegisterDependencies() {
|
|||
RegisterSingleton<HTMLService>(Services.HTMLService, new HTMLService());
|
||||
RegisterSingleton<SanitiserService>(Services.SanitiserService, new SanitiserService());
|
||||
RegisterSingleton<DiffService>(Services.DiffService, new DiffService());
|
||||
RegisterSingleton<PlanApprovalService>(Services.PlanApprovalService, new PlanApprovalService());
|
||||
RegisterSingleton<VaultService>(Services.VaultService, new VaultService());
|
||||
RegisterSingleton<VaultCacheService>(Services.VaultCacheService, new VaultCacheService());
|
||||
RegisterSingleton<FileSystemService>(Services.FileSystemService, new FileSystemService());
|
||||
RegisterSingleton<SearchStateStore>(Services.SearchStateStore, new SearchStateStore());
|
||||
RegisterSingleton<ExecutionPlanStore>(Services.ExecutionPlanStore, new ExecutionPlanStore());
|
||||
RegisterSingleton<UserInputService>(Services.UserInputService, new UserInputService());
|
||||
RegisterSingleton<WorkSpaceService>(Services.WorkSpaceService, new WorkSpaceService());
|
||||
RegisterSingleton<FileSystemService>(Services.FileSystemService, new FileSystemService());
|
||||
RegisterSingleton<MemoriesService>(Services.MemoriesService, new MemoriesService());
|
||||
RegisterSingleton<ConversationFileSystemService>(Services.ConversationFileSystemService, new ConversationFileSystemService());
|
||||
RegisterSingleton<ConversationNamingService>(Services.ConversationNamingService, new ConversationNamingService());
|
||||
RegisterSingleton<QuickActionsDefinitionsService>(Services.QuickActionsDefinitionsService, new QuickActionsDefinitionsService());
|
||||
RegisterSingleton<QuickActionsService>(Services.QuickActionsService, new QuickActionsService());
|
||||
|
||||
RegisterTransient<WebViewerService>(Services.WebViewerService, () => new WebViewerService());
|
||||
|
|
@ -104,27 +114,32 @@ export function RegisterDependencies() {
|
|||
|
||||
export function RegisterAiProvider() {
|
||||
const settingsService = Resolve<SettingsService>(Services.SettingsService);
|
||||
const provider = fromModel(settingsService.settings.model);
|
||||
const provider = settingsService.settings.provider;
|
||||
|
||||
if (provider == AIProvider.Claude) {
|
||||
RegisterSingleton<IAIFileService>(Services.IAIFileService, new ClaudeFileService());
|
||||
RegisterSingleton<IAIClass>(Services.IAIClass, new Claude());
|
||||
RegisterSingleton<IConversationNamingService>(Services.IConversationNamingService, new ClaudeConversationNamingService());
|
||||
RegisterSingleton<IConversationNamingAgent>(Services.IConversationNamingService, new ClaudeConversationNamingAgent());
|
||||
}
|
||||
else if (provider == AIProvider.Gemini) {
|
||||
RegisterSingleton<IAIFileService>(Services.IAIFileService, new GeminiFileService());
|
||||
RegisterSingleton<IAIClass>(Services.IAIClass, new Gemini());
|
||||
RegisterSingleton<IConversationNamingService>(Services.IConversationNamingService, new GeminiConversationNamingService());
|
||||
RegisterSingleton<IConversationNamingAgent>(Services.IConversationNamingService, new GeminiConversationNamingAgent());
|
||||
}
|
||||
else if (provider == AIProvider.OpenAI) {
|
||||
RegisterSingleton<IAIFileService>(Services.IAIFileService, new OpenAIFileService());
|
||||
RegisterSingleton<IAIClass>(Services.IAIClass, new OpenAI());
|
||||
RegisterSingleton<IConversationNamingService>(Services.IConversationNamingService, new OpenAIConversationNamingService());
|
||||
RegisterSingleton<IConversationNamingAgent>(Services.IConversationNamingService, new OpenAIConversationNamingAgent());
|
||||
}
|
||||
else if (provider == AIProvider.Mistral) {
|
||||
RegisterSingleton<IAIFileService>(Services.IAIFileService, new MistralFileService());
|
||||
RegisterSingleton<IAIClass>(Services.IAIClass, new Mistral());
|
||||
RegisterSingleton<IConversationNamingService>(Services.IConversationNamingService, new MistralConversationNamingService());
|
||||
RegisterSingleton<IConversationNamingAgent>(Services.IConversationNamingService, new MistralConversationNamingAgent());
|
||||
}
|
||||
else if (provider == AIProvider.Local) {
|
||||
RegisterSingleton<IAIFileService>(Services.IAIFileService, new LocalFileService());
|
||||
RegisterSingleton<IAIClass>(Services.IAIClass, new Local());
|
||||
RegisterSingleton<IConversationNamingAgent>(Services.IConversationNamingService, new LocalConversationNamingAgent());
|
||||
}
|
||||
|
||||
Resolve<MainAgent>(Services.MainAgent).resolveAIProvider();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
export class Services {
|
||||
static VaultkeeperAIPlugin = Symbol("VaultkeeperAIPlugin");
|
||||
static SettingsService = Symbol("SettingsService");
|
||||
static AssetsService = Symbol("AssetsService");
|
||||
static EventService = Symbol("EventService");
|
||||
static AbortService = Symbol("AbortService");
|
||||
static HTMLService = Symbol("HTMLService");
|
||||
|
|
@ -12,6 +13,7 @@ export class Services {
|
|||
static ConversationFileSystemService = Symbol("ConversationFileSystemService");
|
||||
static ConversationNamingService = Symbol("ConversationNamingService");
|
||||
static QuickActionsService = Symbol("QuickActionsService");
|
||||
static QuickActionsDefinitionsService = Symbol("QuickActionsDefinitionsService");
|
||||
static StreamingService = Symbol("StreamingService");
|
||||
static MarkdownService = Symbol("MarkdownService");
|
||||
static StreamingMarkdownService = Symbol("StreamingMarkdownService");
|
||||
|
|
@ -23,6 +25,7 @@ export class Services {
|
|||
static InputService = Symbol("InputService");
|
||||
static WebViewerService = Symbol("WebViewerService");
|
||||
static DiffService = Symbol("DiffService");
|
||||
static PlanApprovalService = Symbol("PlanApprovalService");
|
||||
static MemoriesService = Symbol("MemoriesService");
|
||||
static DebugService = Symbol("DebugService");
|
||||
|
||||
|
|
|
|||
|
|
@ -1,22 +1,72 @@
|
|||
import type VaultkeeperAIPlugin from "main";
|
||||
import { Resolve } from "./DependencyService";
|
||||
import { Services } from "./Services";
|
||||
import { AIProvider, AIProviderModel, DEFAULT_MODEL_BY_PROVIDER, DEFAULT_PLANNING_MODEL_BY_PROVIDER, fromModel, isvalidProvider, isValidProviderModel, modelMatchesProvider } from "Enums/ApiProvider";
|
||||
import { ChatMode } from "Enums/ChatMode";
|
||||
import {
|
||||
AIProvider,
|
||||
AIProviderModel,
|
||||
DEFAULT_MODEL_BY_PROVIDER,
|
||||
DEFAULT_PLANNING_MODEL_BY_PROVIDER,
|
||||
DEFAULT_QUICK_MODEL_BY_PROVIDER,
|
||||
isvalidProvider,
|
||||
isValidProviderModel,
|
||||
modelMatchesProvider
|
||||
} from "Enums/ApiProvider";
|
||||
|
||||
const DEFAULT_SETTINGS: IVaultkeeperAISettings = {
|
||||
export const DEFAULT_SETTINGS: IVaultkeeperAISettings = {
|
||||
firstTimeStart: true,
|
||||
|
||||
chatMode: ChatMode.ReadOnly,
|
||||
freeEdit: false,
|
||||
userInstruction: "",
|
||||
|
||||
provider: AIProvider.Claude,
|
||||
model: AIProviderModel.ClaudeHaiku_4_5,
|
||||
planningModel: AIProviderModel.ClaudeSonnet_4_6,
|
||||
provider: AIProvider.Local,
|
||||
model: AIProviderModel.ClaudeSonnet_5,
|
||||
planningModel: AIProviderModel.ClaudeOpus_4_8,
|
||||
quickActionModel: AIProviderModel.ClaudeHaiku_4_5,
|
||||
|
||||
cachedModelSettings: {
|
||||
[AIProvider.Claude]: {
|
||||
model: DEFAULT_MODEL_BY_PROVIDER[AIProvider.Claude],
|
||||
planningModel: DEFAULT_PLANNING_MODEL_BY_PROVIDER[AIProvider.Claude],
|
||||
quickActionModel: DEFAULT_QUICK_MODEL_BY_PROVIDER[AIProvider.Claude]
|
||||
},
|
||||
[AIProvider.OpenAI]: {
|
||||
model: DEFAULT_MODEL_BY_PROVIDER[AIProvider.OpenAI],
|
||||
planningModel: DEFAULT_PLANNING_MODEL_BY_PROVIDER[AIProvider.OpenAI],
|
||||
quickActionModel: DEFAULT_QUICK_MODEL_BY_PROVIDER[AIProvider.OpenAI]
|
||||
},
|
||||
[AIProvider.Gemini]: {
|
||||
model: DEFAULT_MODEL_BY_PROVIDER[AIProvider.Gemini],
|
||||
planningModel: DEFAULT_PLANNING_MODEL_BY_PROVIDER[AIProvider.Gemini],
|
||||
quickActionModel: DEFAULT_QUICK_MODEL_BY_PROVIDER[AIProvider.Gemini]
|
||||
},
|
||||
[AIProvider.Mistral]: {
|
||||
model: DEFAULT_MODEL_BY_PROVIDER[AIProvider.Mistral],
|
||||
planningModel: DEFAULT_PLANNING_MODEL_BY_PROVIDER[AIProvider.Mistral],
|
||||
quickActionModel: DEFAULT_QUICK_MODEL_BY_PROVIDER[AIProvider.Mistral]
|
||||
},
|
||||
[AIProvider.Local]: {
|
||||
model: DEFAULT_MODEL_BY_PROVIDER[AIProvider.Local],
|
||||
planningModel: DEFAULT_PLANNING_MODEL_BY_PROVIDER[AIProvider.Local],
|
||||
quickActionModel: DEFAULT_QUICK_MODEL_BY_PROVIDER[AIProvider.Local]
|
||||
}
|
||||
},
|
||||
|
||||
localUrl: "",
|
||||
|
||||
localModels: {
|
||||
model: "",
|
||||
planningModel: "",
|
||||
quickActionModel: ""
|
||||
},
|
||||
|
||||
apiKeys: {
|
||||
claude: "",
|
||||
openai: "",
|
||||
gemini: "",
|
||||
mistral: ""
|
||||
mistral: "",
|
||||
local: ""
|
||||
},
|
||||
exclusions: [],
|
||||
|
||||
|
|
@ -27,11 +77,19 @@ const DEFAULT_SETTINGS: IVaultkeeperAISettings = {
|
|||
allowUpdatingMemories: true,
|
||||
|
||||
enableWebSearch: true,
|
||||
enableWebViewer: false
|
||||
enableWebViewer: false,
|
||||
|
||||
enableContextMenuActions: true,
|
||||
enableToolbarActions: true,
|
||||
|
||||
hideDrawerElements: true
|
||||
}
|
||||
|
||||
export interface IVaultkeeperAISettings {
|
||||
firstTimeStart: boolean;
|
||||
|
||||
chatMode: ChatMode;
|
||||
freeEdit: boolean;
|
||||
userInstruction: string;
|
||||
|
||||
provider: AIProvider;
|
||||
|
|
@ -39,12 +97,23 @@ export interface IVaultkeeperAISettings {
|
|||
planningModel: AIProviderModel;
|
||||
quickActionModel: AIProviderModel;
|
||||
|
||||
cachedModelSettings: Record<AIProvider, ProviderModelCache>;
|
||||
|
||||
localUrl: string;
|
||||
|
||||
localModels: {
|
||||
model: string;
|
||||
planningModel: string;
|
||||
quickActionModel: string;
|
||||
}
|
||||
|
||||
apiKeys: {
|
||||
claude: string;
|
||||
openai: string;
|
||||
gemini: string;
|
||||
mistral: string;
|
||||
};
|
||||
local: string;
|
||||
}
|
||||
exclusions: string[];
|
||||
|
||||
searchResultsLimit: number;
|
||||
|
|
@ -55,30 +124,63 @@ export interface IVaultkeeperAISettings {
|
|||
|
||||
enableWebSearch: boolean;
|
||||
enableWebViewer: boolean;
|
||||
|
||||
enableContextMenuActions: boolean;
|
||||
enableToolbarActions: boolean;
|
||||
|
||||
hideDrawerElements: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderModelCache {
|
||||
model: AIProviderModel;
|
||||
planningModel: AIProviderModel;
|
||||
quickActionModel: AIProviderModel;
|
||||
}
|
||||
|
||||
type SettingKey = keyof IVaultkeeperAISettings;
|
||||
type SettingsChangedCallback = ((changedKeys: SettingKey[]) => void) | ((changedKeys: SettingKey[]) => Promise<void>);
|
||||
|
||||
export class SettingsService {
|
||||
|
||||
public readonly settings: Readonly<IVaultkeeperAISettings>;
|
||||
|
||||
private readonly plugin: VaultkeeperAIPlugin;
|
||||
|
||||
public readonly settings: IVaultkeeperAISettings;
|
||||
private readonly subscribers: WeakMap<object, SettingsChangedCallback> = new WeakMap();
|
||||
private readonly subscriberRefs: Set<WeakRef<object>> = new Set();
|
||||
|
||||
public constructor(loadedSettings: Partial<IVaultkeeperAISettings>) {
|
||||
private settingsSnapshot: string;
|
||||
|
||||
public constructor(loadedSettings: Partial<IVaultkeeperAISettings> | null) {
|
||||
this.plugin = Resolve<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin);
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, loadedSettings);
|
||||
this.ensureValidModels();
|
||||
|
||||
loadedSettings ??= {}; // New users won't have any settings yet
|
||||
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, loadedSettings, {
|
||||
apiKeys: Object.assign({}, DEFAULT_SETTINGS.apiKeys, loadedSettings.apiKeys),
|
||||
localModels: Object.assign({}, DEFAULT_SETTINGS.localModels, loadedSettings.localModels)
|
||||
});
|
||||
this.settingsSnapshot = JSON.stringify(this.settings);
|
||||
void this.ensureValidModels();
|
||||
}
|
||||
|
||||
public async saveSettings(onSave?: () => void) {
|
||||
await this.plugin.saveData(this.settings);
|
||||
if (onSave) {
|
||||
onSave();
|
||||
}
|
||||
public subscribeToSettingsChanged(callback: SettingsChangedCallback): object {
|
||||
const token = {};
|
||||
this.subscribers.set(token, callback);
|
||||
this.subscriberRefs.add(new WeakRef(token));
|
||||
return token;
|
||||
}
|
||||
|
||||
public getApiKeyForCurrentModel(): string {
|
||||
const provider = fromModel(this.settings.model);
|
||||
return this.getApiKeyForProvider(provider);
|
||||
public unsubscribe(subscriber: object): void {
|
||||
this.subscribers.delete(subscriber);
|
||||
}
|
||||
|
||||
public async updateSettings(updateAction: ((settings: IVaultkeeperAISettings) => void) | ((settings: IVaultkeeperAISettings) => Promise<void>)) {
|
||||
await updateAction(this.settings);
|
||||
await this.saveSettings();
|
||||
}
|
||||
|
||||
public getApiKeyForCurrentProvider(): string {
|
||||
return this.getApiKeyForProvider(this.settings.provider);
|
||||
}
|
||||
|
||||
public getApiKeyForProvider(provider: AIProvider): string {
|
||||
|
|
@ -91,53 +193,77 @@ export class SettingsService {
|
|||
return this.settings.apiKeys.gemini;
|
||||
case AIProvider.Mistral:
|
||||
return this.settings.apiKeys.mistral;
|
||||
case AIProvider.Local:
|
||||
return this.settings.apiKeys.local;
|
||||
}
|
||||
}
|
||||
|
||||
public setApiKeyForProvider(provider: AIProvider, key: string) {
|
||||
public async setApiKeyForProvider(provider: AIProvider, key: string) {
|
||||
switch (provider) {
|
||||
case AIProvider.Claude:
|
||||
this.settings.apiKeys.claude = key;
|
||||
await this.updateSettings(settings => settings.apiKeys.claude = key);
|
||||
break;
|
||||
case AIProvider.OpenAI:
|
||||
this.settings.apiKeys.openai = key;
|
||||
await this.updateSettings(settings => settings.apiKeys.openai = key);
|
||||
break;
|
||||
case AIProvider.Gemini:
|
||||
this.settings.apiKeys.gemini = key;
|
||||
await this.updateSettings(settings => settings.apiKeys.gemini = key);
|
||||
break;
|
||||
case AIProvider.Mistral:
|
||||
this.settings.apiKeys.mistral = key;
|
||||
await this.updateSettings(settings => settings.apiKeys.mistral = key);
|
||||
break;
|
||||
case AIProvider.Local:
|
||||
await this.updateSettings(settings => settings.apiKeys.local = key);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private ensureValidModels(): void {
|
||||
let changed = false;
|
||||
public async ensureValidModels(): Promise<void> {
|
||||
await this.updateSettings(settings => {
|
||||
if (!isvalidProvider(settings.provider)) {
|
||||
settings.provider = DEFAULT_SETTINGS.provider;
|
||||
}
|
||||
|
||||
if (!isValidProviderModel(settings.model) || !modelMatchesProvider(settings.model, settings.provider)) {
|
||||
settings.model = DEFAULT_MODEL_BY_PROVIDER[settings.provider];
|
||||
}
|
||||
|
||||
if (!isValidProviderModel(settings.planningModel) || !modelMatchesProvider(settings.planningModel, settings.provider)) {
|
||||
settings.planningModel = DEFAULT_PLANNING_MODEL_BY_PROVIDER[settings.provider];
|
||||
}
|
||||
|
||||
if (!isValidProviderModel(settings.quickActionModel) || !modelMatchesProvider(settings.quickActionModel, settings.provider)) {
|
||||
settings.quickActionModel = DEFAULT_QUICK_MODEL_BY_PROVIDER[settings.provider];
|
||||
}
|
||||
|
||||
let provider = this.settings.provider;
|
||||
const cached = settings.cachedModelSettings[settings.provider];
|
||||
if (!isValidProviderModel(cached.model) || !modelMatchesProvider(cached.model, settings.provider)) {
|
||||
settings.cachedModelSettings[settings.provider].model = DEFAULT_MODEL_BY_PROVIDER[settings.provider];
|
||||
}
|
||||
if (!isValidProviderModel(cached.planningModel) || !modelMatchesProvider(cached.planningModel, settings.provider)) {
|
||||
settings.cachedModelSettings[settings.provider].planningModel = DEFAULT_PLANNING_MODEL_BY_PROVIDER[settings.provider];
|
||||
}
|
||||
if (!isValidProviderModel(cached.quickActionModel) || !modelMatchesProvider(cached.quickActionModel, settings.provider)) {
|
||||
settings.cachedModelSettings[settings.provider].quickActionModel = DEFAULT_QUICK_MODEL_BY_PROVIDER[settings.provider];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!isvalidProvider(provider)) {
|
||||
provider = DEFAULT_SETTINGS.provider;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!isValidProviderModel(this.settings.model) || !modelMatchesProvider(this.settings.model, provider)) {
|
||||
this.settings.model = DEFAULT_MODEL_BY_PROVIDER[provider];
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!isValidProviderModel(this.settings.planningModel) || !modelMatchesProvider(this.settings.planningModel, provider)) {
|
||||
this.settings.planningModel = DEFAULT_PLANNING_MODEL_BY_PROVIDER[provider];
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!isValidProviderModel(this.settings.quickActionModel) || !modelMatchesProvider(this.settings.quickActionModel, provider)) {
|
||||
this.settings.quickActionModel = DEFAULT_MODEL_BY_PROVIDER[provider];
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
void this.saveSettings();
|
||||
private async saveSettings() {
|
||||
const oldSettings = JSON.parse(this.settingsSnapshot) as IVaultkeeperAISettings;
|
||||
await this.plugin.saveData(this.settings);
|
||||
const changedKeys = (Object.keys(this.settings) as SettingKey[])
|
||||
.filter(key => JSON.stringify(this.settings[key]) !== JSON.stringify(oldSettings[key]));
|
||||
if (changedKeys.length > 0) {
|
||||
this.settingsSnapshot = JSON.stringify(this.settings);
|
||||
for (const ref of this.subscriberRefs) {
|
||||
const subscriber = ref.deref();
|
||||
if (!subscriber) {
|
||||
this.subscriberRefs.delete(ref);
|
||||
continue;
|
||||
}
|
||||
await this.subscribers.get(subscriber)?.(changedKeys);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue