mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 16:30:27 +00:00
Compare commits
44 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 |
121 changed files with 7254 additions and 3269 deletions
|
|
@ -16,6 +16,7 @@ import type { AbortService } from "Services/AbortService";
|
||||||
import type { IAIFileService } from "./IAIFileService";
|
import type { IAIFileService } from "./IAIFileService";
|
||||||
import { AgentType } from "Enums/AgentType";
|
import { AgentType } from "Enums/AgentType";
|
||||||
import { AIToolUsageMode } from "Enums/AIToolUsageMode";
|
import { AIToolUsageMode } from "Enums/AIToolUsageMode";
|
||||||
|
import type { MimeType } from "Enums/MimeType";
|
||||||
|
|
||||||
export abstract class BaseAIClass implements IAIClass {
|
export abstract class BaseAIClass implements IAIClass {
|
||||||
|
|
||||||
|
|
@ -89,12 +90,109 @@ export abstract class BaseAIClass implements IAIClass {
|
||||||
|
|
||||||
public abstract streamRequest(conversation: Conversation): AsyncGenerator<IStreamChunk, void, unknown>;
|
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 parseStreamChunk(chunk: string): IStreamChunk;
|
||||||
protected abstract extractContents(conversationContent: ConversationContent[]): unknown;
|
protected abstract extractContents(conversationContent: ConversationContent[]): unknown;
|
||||||
protected abstract mapFunctionDefinitions(aiToolDefinitions: IAIToolDefinition[]): object;
|
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 {
|
protected model(): string {
|
||||||
switch (this._agentType) {
|
switch (this._agentType) {
|
||||||
case AgentType.Main:
|
case AgentType.Main:
|
||||||
|
|
@ -177,7 +275,7 @@ export abstract class BaseAIClass implements IAIClass {
|
||||||
|
|
||||||
protected async processAttachments<T>(
|
protected async processAttachments<T>(
|
||||||
attachments: Attachment[],
|
attachments: Attachment[],
|
||||||
formatBinaryFiles: (attachments: Attachment[]) => string
|
formatBinaryFiles: (attachments: Attachment[]) => Promise<string>
|
||||||
): Promise<{ formattedParts: T[], uploadErrors: Error[] }> {
|
): Promise<{ formattedParts: T[], uploadErrors: Error[] }> {
|
||||||
const uploadErrors: Error[] = [];
|
const uploadErrors: Error[] = [];
|
||||||
|
|
||||||
|
|
@ -195,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[];
|
const formattedParts = JSON.parse(formattedContent) as T[];
|
||||||
|
|
||||||
return { formattedParts, uploadErrors };
|
return { formattedParts, uploadErrors };
|
||||||
|
|
|
||||||
|
|
@ -114,8 +114,8 @@ export abstract class BaseAIFileService implements IAIFileService {
|
||||||
return `----FormBoundary${Date.now()}${Math.random().toString(36).substring(2)}`;
|
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<ArrayBuffer>[] = [];
|
const parts: Uint8Array[] = [];
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
// Add the file field
|
// Add the file field
|
||||||
|
|
@ -146,20 +146,17 @@ export abstract class BaseAIFileService implements IAIFileService {
|
||||||
|
|
||||||
// Calculate total length and concatenate
|
// Calculate total length and concatenate
|
||||||
const totalLength = parts.reduce((sum, part) => sum + part.byteLength, 0);
|
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;
|
let offset = 0;
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
result.set(part, offset);
|
result.set(part, offset);
|
||||||
offset += part.byteLength;
|
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 {
|
protected getHeader(headerName: string, headers: Record<string, string>): string | undefined {
|
||||||
const header = Object.keys(headers).find(header => header.toLowerCase() === headerName.toLowerCase());
|
const header = Object.keys(headers).find(header => header.toLowerCase() === headerName.toLowerCase());
|
||||||
return header ? headers[header] : undefined;
|
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 { Resolve } from "Services/DependencyService";
|
||||||
import { Services } from "Services/Services";
|
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 type { AIProvider } from "Enums/ApiProvider";
|
||||||
import { Role } from "Enums/Role";
|
import { Role } from "Enums/Role";
|
||||||
import { NamePrompt } from "AIPrompts/NamePrompt";
|
import { NamePrompt } from "AIPrompts/NamePrompt";
|
||||||
import type { SettingsService } from "Services/SettingsService";
|
import type { SettingsService } from "Services/SettingsService";
|
||||||
import { Exception } from "Helpers/Exception";
|
import { Exception } from "Helpers/Exception";
|
||||||
import type { AbortService } from "Services/AbortService";
|
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 apiKey: string;
|
||||||
private readonly abortService: AbortService;
|
private readonly abortService: AbortService;
|
||||||
|
|
||||||
public constructor() {
|
protected constructor(provider: AIProvider) {
|
||||||
const settingsService = Resolve<SettingsService>(Services.SettingsService);
|
const settingsService = Resolve<SettingsService>(Services.SettingsService);
|
||||||
this.apiKey = settingsService.getApiKeyForProvider(AIProvider.Mistral);
|
this.apiKey = settingsService.getApiKeyForProvider(provider);
|
||||||
this.abortService = Resolve<AbortService>(Services.AbortService);
|
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> {
|
public async generateName(userPrompt: string): Promise<string> {
|
||||||
return await this.abortService.abortableOperation(async () => {
|
return await this.abortService.abortableOperation(async () => {
|
||||||
const requestBody = {
|
const requestBody = {
|
||||||
model: AIProviderModel.MistralNamer,
|
model: this.namerModel,
|
||||||
max_tokens: 100,
|
max_tokens: 100,
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
|
|
@ -37,7 +48,7 @@ export class MistralConversationNamingService implements IConversationNamingServ
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await fetch(AIProviderURL.Mistral, {
|
const response = await fetch(this.apiUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${this.apiKey}`,
|
'Authorization': `Bearer ${this.apiKey}`,
|
||||||
|
|
@ -48,10 +59,10 @@ export class MistralConversationNamingService implements IConversationNamingServ
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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];
|
const firstChoice = data.choices?.[0];
|
||||||
|
|
||||||
if (!firstChoice || !firstChoice.message?.content) {
|
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,9 +13,7 @@ import { Exception } from "Helpers/Exception";
|
||||||
import { MimeType, toMimeType } from "Enums/MimeType";
|
import { MimeType, toMimeType } from "Enums/MimeType";
|
||||||
import { isTextFile } from "Enums/FileType";
|
import { isTextFile } from "Enums/FileType";
|
||||||
import { MimeTypeToFileTypes } from "Enums/FileTypeMimeTypeMapping";
|
import { MimeTypeToFileTypes } from "Enums/FileTypeMimeTypeMapping";
|
||||||
import { ApiError, ApiErrorType } from "Types/ApiError";
|
|
||||||
import { parseToolCall, parseFunctionResponse } from "Helpers/ResponseHelper";
|
import { parseToolCall, parseFunctionResponse } from "Helpers/ResponseHelper";
|
||||||
import { AIToolUsageMode } from "Enums/AIToolUsageMode";
|
|
||||||
import { Copy } from "Enums/Copy";
|
import { Copy } from "Enums/Copy";
|
||||||
import { replaceCopy } from 'Helpers/Helpers';
|
import { replaceCopy } from 'Helpers/Helpers';
|
||||||
|
|
||||||
|
|
@ -32,6 +30,10 @@ export class Claude extends BaseAIClass {
|
||||||
MimeType.IMAGE_WEBP
|
MimeType.IMAGE_WEBP
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected get supportedMimeTypes(): MimeType[] {
|
||||||
|
return this.SUPPORTED_MIMETYPES;
|
||||||
|
}
|
||||||
|
|
||||||
private accumulatedFunctionName: string | null = null;
|
private accumulatedFunctionName: string | null = null;
|
||||||
private accumulatedFunctionArgs: string = "";
|
private accumulatedFunctionArgs: string = "";
|
||||||
private accumulatedFunctionId: string | null = null;
|
private accumulatedFunctionId: string | null = null;
|
||||||
|
|
@ -282,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 contentBlocks = attachments.flatMap(attachment => {
|
||||||
const fileID = attachment.getFileID(this.provider);
|
const fileID = attachment.getFileID(this.provider);
|
||||||
if (!fileID) {
|
if (!fileID) {
|
||||||
|
|
@ -299,7 +301,7 @@ export class Claude extends BaseAIClass {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isPlainText && !this.isSupportedMimeType(mimeType)) {
|
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 [
|
return [
|
||||||
|
|
@ -313,11 +315,7 @@ export class Claude extends BaseAIClass {
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
return JSON.stringify(contentBlocks);
|
return Promise.resolve(JSON.stringify(contentBlocks));
|
||||||
}
|
|
||||||
|
|
||||||
private isSupportedMimeType(mimeType: MimeType): boolean {
|
|
||||||
return this.SUPPORTED_MIMETYPES.includes(mimeType);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adds cache control to the last tool in the tools array.
|
// Adds cache control to the last tool in the tools array.
|
||||||
|
|
@ -384,41 +382,10 @@ export class Claude extends BaseAIClass {
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildClaudeToolChoice(): { type: string } {
|
private buildClaudeToolChoice(): { type: string } {
|
||||||
// If no tools defined, fall back to auto
|
return this.buildToolChoice<{ type: string }>({
|
||||||
if (this.aiToolDefinitions.length === 0) {
|
auto: { type: "auto" },
|
||||||
return { type: "auto" };
|
enabled: { type: "any" },
|
||||||
}
|
disabled: { type: "none" }
|
||||||
|
});
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Resolve } from "Services/DependencyService";
|
import { Resolve } from "Services/DependencyService";
|
||||||
import { Services } from "Services/Services";
|
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 { AIProvider, AIProviderURL, AIProviderModel } from "Enums/ApiProvider";
|
||||||
import { Role } from "Enums/Role";
|
import { Role } from "Enums/Role";
|
||||||
import { NamePrompt } from "AIPrompts/NamePrompt";
|
import { NamePrompt } from "AIPrompts/NamePrompt";
|
||||||
|
|
@ -9,7 +9,7 @@ import type Anthropic from '@anthropic-ai/sdk';
|
||||||
import { Exception } from "Helpers/Exception";
|
import { Exception } from "Helpers/Exception";
|
||||||
import type { AbortService } from "Services/AbortService";
|
import type { AbortService } from "Services/AbortService";
|
||||||
|
|
||||||
export class ClaudeConversationNamingService implements IConversationNamingService {
|
export class ClaudeConversationNamingAgent implements IConversationNamingAgent {
|
||||||
|
|
||||||
private readonly apiKey: string;
|
private readonly apiKey: string;
|
||||||
private readonly abortService: AbortService;
|
private readonly abortService: AbortService;
|
||||||
|
|
@ -17,7 +17,6 @@ import { Exception } from "Helpers/Exception";
|
||||||
import { ApiError, ApiErrorType } from "Types/ApiError";
|
import { ApiError, ApiErrorType } from "Types/ApiError";
|
||||||
import { parseToolCall, parseFunctionResponse } from "Helpers/ResponseHelper";
|
import { parseToolCall, parseFunctionResponse } from "Helpers/ResponseHelper";
|
||||||
import type { GeminiRetryInfo, GeminiErrorResponse } from "./GeminiTypes";
|
import type { GeminiRetryInfo, GeminiErrorResponse } from "./GeminiTypes";
|
||||||
import { AIToolUsageMode } from "Enums/AIToolUsageMode";
|
|
||||||
import { replaceCopy } from 'Helpers/Helpers';
|
import { replaceCopy } from 'Helpers/Helpers';
|
||||||
import { Copy } from "Enums/Copy";
|
import { Copy } from "Enums/Copy";
|
||||||
|
|
||||||
|
|
@ -73,6 +72,10 @@ export class Gemini extends BaseAIClass {
|
||||||
MimeType.APPLICATION_YAML
|
MimeType.APPLICATION_YAML
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected get supportedMimeTypes(): MimeType[] {
|
||||||
|
return this.SUPPORTED_MIMETYPES;
|
||||||
|
}
|
||||||
|
|
||||||
private accumulatedFunctionName: string | null = null;
|
private accumulatedFunctionName: string | null = null;
|
||||||
private accumulatedFunctionArgs: Record<string, unknown> = {};
|
private accumulatedFunctionArgs: Record<string, unknown> = {};
|
||||||
private accumulatedThoughtSignature: string | null = null;
|
private accumulatedThoughtSignature: string | null = null;
|
||||||
|
|
@ -148,6 +151,7 @@ export class Gemini extends BaseAIClass {
|
||||||
|
|
||||||
let text = "";
|
let text = "";
|
||||||
let toolCall: AIToolCall | undefined = undefined;
|
let toolCall: AIToolCall | undefined = undefined;
|
||||||
|
let toolCallStarted: string | undefined = undefined;
|
||||||
const candidate = data.candidates?.[0];
|
const candidate = data.candidates?.[0];
|
||||||
|
|
||||||
if (candidate) {
|
if (candidate) {
|
||||||
|
|
@ -160,6 +164,12 @@ export class Gemini extends BaseAIClass {
|
||||||
const parts = candidate.content?.parts || [];
|
const parts = candidate.content?.parts || [];
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
if (part.functionCall) {
|
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
|
// Accumulate function name
|
||||||
if (part.functionCall.name) {
|
if (part.functionCall.name) {
|
||||||
this.accumulatedFunctionName = part.functionCall.name;
|
this.accumulatedFunctionName = part.functionCall.name;
|
||||||
|
|
@ -201,6 +211,7 @@ export class Gemini extends BaseAIClass {
|
||||||
content: text,
|
content: text,
|
||||||
isComplete: isComplete,
|
isComplete: isComplete,
|
||||||
toolCall: toolCall,
|
toolCall: toolCall,
|
||||||
|
toolCallStarted: toolCallStarted,
|
||||||
shouldContinue: shouldContinue,
|
shouldContinue: shouldContinue,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -315,7 +326,7 @@ export class Gemini extends BaseAIClass {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected formatBinaryFiles(attachments: Attachment[]): string {
|
protected formatBinaryFiles(attachments: Attachment[]): Promise<string> {
|
||||||
const parts: unknown[] = [];
|
const parts: unknown[] = [];
|
||||||
|
|
||||||
for (const attachment of attachments) {
|
for (const attachment of attachments) {
|
||||||
|
|
@ -334,7 +345,7 @@ export class Gemini extends BaseAIClass {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isPlainText && !this.isSupportedMimeType(mimeType)) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -347,7 +358,7 @@ export class Gemini extends BaseAIClass {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return JSON.stringify(parts);
|
return Promise.resolve(JSON.stringify(parts));
|
||||||
}
|
}
|
||||||
|
|
||||||
private getTools(): { functionDeclarations: FunctionDeclaration[] } {
|
private getTools(): { functionDeclarations: FunctionDeclaration[] } {
|
||||||
|
|
@ -368,22 +379,16 @@ export class Gemini extends BaseAIClass {
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildGeminiToolConfig(): { function_calling_config: { mode: string } } {
|
private buildGeminiToolConfig(): { function_calling_config: { mode: string } } {
|
||||||
// If no tools defined, fall back to auto
|
return this.buildToolChoice<{ function_calling_config: { mode: string } }>({
|
||||||
if (this.aiToolDefinitions.length === 0) {
|
auto: { function_calling_config: { mode: "AUTO" } },
|
||||||
return { function_calling_config: { mode: "AUTO" } };
|
enabled: { function_calling_config: { mode: "ANY" } },
|
||||||
}
|
disabled: { function_calling_config: { mode: "NONE" } }
|
||||||
|
});
|
||||||
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" } };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
if (error.info.type !== ApiErrorType.RATE_LIMIT || !error.info.responseBody) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
@ -468,8 +473,4 @@ export class Gemini extends BaseAIClass {
|
||||||
? Math.ceil(value / 1000)
|
? Math.ceil(value / 1000)
|
||||||
: Math.ceil(value);
|
: Math.ceil(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
private isSupportedMimeType(mimeType: MimeType): boolean {
|
|
||||||
return this.SUPPORTED_MIMETYPES.includes(mimeType);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Resolve } from "Services/DependencyService";
|
import { Resolve } from "Services/DependencyService";
|
||||||
import { Services } from "Services/Services";
|
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 { AIProvider, AIProviderURL, AIProviderModel } from "Enums/ApiProvider";
|
||||||
import { Role } from "Enums/Role";
|
import { Role } from "Enums/Role";
|
||||||
import { NamePrompt } from "AIPrompts/NamePrompt";
|
import { NamePrompt } from "AIPrompts/NamePrompt";
|
||||||
|
|
@ -9,7 +9,7 @@ import type { SettingsService } from "Services/SettingsService";
|
||||||
import { Exception } from "Helpers/Exception";
|
import { Exception } from "Helpers/Exception";
|
||||||
import type { AbortService } from "Services/AbortService";
|
import type { AbortService } from "Services/AbortService";
|
||||||
|
|
||||||
export class GeminiConversationNamingService implements IConversationNamingService {
|
export class GeminiConversationNamingAgent implements IConversationNamingAgent {
|
||||||
|
|
||||||
private readonly apiKey: string;
|
private readonly apiKey: string;
|
||||||
private readonly abortService: AbortService;
|
private readonly abortService: AbortService;
|
||||||
|
|
@ -40,8 +40,8 @@ export class GeminiFileService extends BaseAIFileService {
|
||||||
|
|
||||||
protected async uploadFileToAPI(data: string, mimeType: string, displayName?: string): Promise<string> {
|
protected async uploadFileToAPI(data: string, mimeType: string, displayName?: string): Promise<string> {
|
||||||
return this.withRetry("Upload file", async () => {
|
return this.withRetry("Upload file", async () => {
|
||||||
const bytes = StringTools.toBytes(data);
|
const buffer = StringTools.toBuffer(data);
|
||||||
const numBytes = bytes.byteLength;
|
const numBytes = buffer.byteLength;
|
||||||
|
|
||||||
const metadata = displayName ? { file: { displayName } } : {};
|
const metadata = displayName ? { file: { displayName } } : {};
|
||||||
|
|
||||||
|
|
@ -78,7 +78,7 @@ export class GeminiFileService extends BaseAIFileService {
|
||||||
"X-Goog-Upload-Offset": "0",
|
"X-Goog-Upload-Offset": "0",
|
||||||
"X-Goog-Upload-Command": "upload, finalize"
|
"X-Goog-Upload-Command": "upload, finalize"
|
||||||
},
|
},
|
||||||
body: this.bytesToBuffer(bytes),
|
body: buffer,
|
||||||
throw: false
|
throw: false
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
export interface IConversationNamingService {
|
export interface IConversationNamingAgent {
|
||||||
generateName(userPrompt: string): Promise<string>;
|
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,33 +1,26 @@
|
||||||
import { BaseAIClass } from "AIClasses/BaseAIClass";
|
import { ChatCompletionsAIClass } from "AIClasses/ChatCompletions/ChatCompletionsAIClass";
|
||||||
import type { IStreamChunk } from "Services/StreamingService";
|
|
||||||
import type { Conversation } from "Conversations/Conversation";
|
|
||||||
import type { Attachment } from "Conversations/Attachment";
|
import type { Attachment } from "Conversations/Attachment";
|
||||||
import { AIProvider, AIProviderURL } from "Enums/ApiProvider";
|
import { AIProvider, AIProviderURL } from "Enums/ApiProvider";
|
||||||
import { AIToolCall } from "AIClasses/AIToolCall";
|
import { AIToolCall } from "AIClasses/AIToolCall";
|
||||||
import { AIToolResponse } from "AIClasses/ToolDefinitions/AIToolResponse";
|
import { AIToolResponse } from "AIClasses/ToolDefinitions/AIToolResponse";
|
||||||
import { fromString as aiToolFromString, AITool } from "Enums/AITool";
|
import { 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 { MimeType, toMimeType } from "Enums/MimeType";
|
import { MimeType, toMimeType } from "Enums/MimeType";
|
||||||
import { isTextFile } from "Enums/FileType";
|
import { isTextFile } from "Enums/FileType";
|
||||||
import { MimeTypeToFileTypes } from "Enums/FileTypeMimeTypeMapping";
|
import { MimeTypeToFileTypes } from "Enums/FileTypeMimeTypeMapping";
|
||||||
import { ApiError, ApiErrorType } from "Types/ApiError";
|
import type { ChatCompletionToolDefinition, ChatCompletionContentPart } from "AIClasses/ChatCompletions/ChatCompletionsTypes";
|
||||||
import { parseToolCall, parseFunctionResponse } from "Helpers/ResponseHelper";
|
|
||||||
import { AIToolUsageMode } from "Enums/AIToolUsageMode";
|
|
||||||
import type { MistralStreamChunk, MistralToolDefinition, MistralMessage, MistralContentPart } from "./MistralTypes";
|
|
||||||
import type { MistralFileService } from "./MistralFileService";
|
import type { MistralFileService } from "./MistralFileService";
|
||||||
import { replaceCopy } from 'Helpers/Helpers';
|
import { replaceCopy } from 'Helpers/Helpers';
|
||||||
import { Copy } from "Enums/Copy";
|
import { Copy } from "Enums/Copy";
|
||||||
import { MistralAgent } from "./MistralAgent";
|
import { MistralAgent } from "./MistralAgent";
|
||||||
import { AIToolResponsePayload } from "AIClasses/ToolDefinitions/AIToolResponsePayload";
|
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.TEXT_PLAIN,
|
||||||
MimeType.APPLICATION_PDF,
|
MimeType.APPLICATION_PDF,
|
||||||
MimeType.IMAGE_JPEG,
|
MimeType.IMAGE_JPEG,
|
||||||
|
|
@ -38,42 +31,20 @@ export class Mistral extends BaseAIClass {
|
||||||
|
|
||||||
private readonly agent: MistralAgent = new MistralAgent();
|
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() {
|
public constructor() {
|
||||||
super(AIProvider.Mistral);
|
super(AIProvider.Mistral);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async* streamRequest(conversation: Conversation): AsyncGenerator<IStreamChunk, void, unknown> {
|
protected get apiUrl(): string {
|
||||||
const messages = await this.buildMessages(conversation);
|
return AIProviderURL.Mistral;
|
||||||
const tools = this.getTools();
|
}
|
||||||
|
|
||||||
const requestBody: Record<string, unknown> = {
|
protected get supportedMimeTypes(): MimeType[] {
|
||||||
model: this.model(),
|
return this.SUPPORTED_MIMETYPES;
|
||||||
max_tokens: 16384,
|
}
|
||||||
messages: messages,
|
|
||||||
stream: true
|
|
||||||
};
|
|
||||||
|
|
||||||
// Only include tools if there are definitions
|
protected isNativeToolCallId(id: string): boolean {
|
||||||
if (tools.length > 0) {
|
return Mistral.NATIVE_TOOL_CALL_ID.test(id);
|
||||||
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)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async resolveToolCall(toolCall: AIToolCall): Promise<AIToolResponse | null> {
|
public async resolveToolCall(toolCall: AIToolCall): Promise<AIToolResponse | null> {
|
||||||
|
|
@ -85,264 +56,8 @@ export class Mistral extends BaseAIClass {
|
||||||
return new AIToolResponse(toolCall.name, new AIToolResponsePayload({ result }), toolCall.toolId);
|
return new AIToolResponse(toolCall.name, new AIToolResponsePayload({ result }), toolCall.toolId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async buildMessages(conversation: Conversation): Promise<MistralMessage[]> {
|
protected formatBinaryFiles(attachments: Attachment[]): Promise<string> {
|
||||||
this.accumulatedToolCalls.clear();
|
const contentParts: ChatCompletionContentPart[] = [];
|
||||||
|
|
||||||
// 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[] = [];
|
|
||||||
const fileService = this.aiFileService as MistralFileService;
|
const fileService = this.aiFileService as MistralFileService;
|
||||||
|
|
||||||
for (const attachment of attachments) {
|
for (const attachment of attachments) {
|
||||||
|
|
@ -361,7 +76,7 @@ export class Mistral extends BaseAIClass {
|
||||||
if (!isPlainText && !this.isSupportedMimeType(mimeType)) {
|
if (!isPlainText && !this.isSupportedMimeType(mimeType)) {
|
||||||
contentParts.push({
|
contentParts.push({
|
||||||
type: "text",
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -390,7 +105,7 @@ export class Mistral extends BaseAIClass {
|
||||||
{ type: "text", text: replaceCopy(Copy.AttachedFile, [attachment.fileName]) },
|
{ type: "text", text: replaceCopy(Copy.AttachedFile, [attachment.fileName]) },
|
||||||
{
|
{
|
||||||
type: "image_url",
|
type: "image_url",
|
||||||
image_url: signedUrl
|
image_url: { url: signedUrl }
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -401,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) {
|
if (this.settingsService.settings.enableWebSearch) {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|
@ -426,45 +141,4 @@ export class Mistral extends BaseAIClass {
|
||||||
}
|
}
|
||||||
return this.mapFunctionDefinitions(this.aiToolDefinitions);
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 {
|
// Agents API types (web search)
|
||||||
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
|
|
||||||
export interface MistralAgentCreateRequest {
|
export interface MistralAgentCreateRequest {
|
||||||
model: string;
|
model: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|
|
||||||
|
|
@ -9,18 +9,17 @@ import { fromString as aiToolFromString } from "Enums/AITool";
|
||||||
import type { IAIToolDefinition } from "AIClasses/ToolDefinitions/IAIToolDefinition";
|
import type { IAIToolDefinition } from "AIClasses/ToolDefinitions/IAIToolDefinition";
|
||||||
import type { ResponseEvent, ResponseOutputTextDelta, ResponseOutputItemAdded, ResponseOutputItemDone, ResponseErrorEvent, ResponseFailedEvent, OpenAIToolTool, ResponsesAPIInput } from "./OpenAITypes";
|
import type { ResponseEvent, ResponseOutputTextDelta, ResponseOutputItemAdded, ResponseOutputItemDone, ResponseErrorEvent, ResponseFailedEvent, OpenAIToolTool, ResponsesAPIInput } from "./OpenAITypes";
|
||||||
import { Exception } from "Helpers/Exception";
|
import { Exception } from "Helpers/Exception";
|
||||||
import { ApiError, ApiErrorType } from "Types/ApiError";
|
import { ApiErrorType } from "Types/ApiError";
|
||||||
import { MimeType, toMimeType } from "Enums/MimeType";
|
import { MimeType, toMimeType } from "Enums/MimeType";
|
||||||
import { isTextFile } from "Enums/FileType";
|
import { isTextFile } from "Enums/FileType";
|
||||||
import { MimeTypeToFileTypes } from "Enums/FileTypeMimeTypeMapping";
|
import { MimeTypeToFileTypes } from "Enums/FileTypeMimeTypeMapping";
|
||||||
import { parseToolCall, parseFunctionResponse } from "Helpers/ResponseHelper";
|
import { parseToolCall, parseFunctionResponse } from "Helpers/ResponseHelper";
|
||||||
import { AIToolUsageMode } from "Enums/AIToolUsageMode";
|
|
||||||
import { replaceCopy } from 'Helpers/Helpers';
|
import { replaceCopy } from 'Helpers/Helpers';
|
||||||
import { Copy } from "Enums/Copy";
|
import { Copy } from "Enums/Copy";
|
||||||
|
|
||||||
export class OpenAI extends BaseAIClass {
|
export class OpenAI extends BaseAIClass {
|
||||||
|
|
||||||
private readonly SUPPORTED_MIMETYPES = [
|
protected readonly SUPPORTED_MIMETYPES = [
|
||||||
MimeType.TEXT_PLAIN,
|
MimeType.TEXT_PLAIN,
|
||||||
MimeType.APPLICATION_PDF,
|
MimeType.APPLICATION_PDF,
|
||||||
MimeType.IMAGE_JPEG,
|
MimeType.IMAGE_JPEG,
|
||||||
|
|
@ -32,6 +31,10 @@ export class OpenAI extends BaseAIClass {
|
||||||
super(AIProvider.OpenAI);
|
super(AIProvider.OpenAI);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected get supportedMimeTypes(): MimeType[] {
|
||||||
|
return this.SUPPORTED_MIMETYPES;
|
||||||
|
}
|
||||||
|
|
||||||
public async* streamRequest(conversation: Conversation): AsyncGenerator<IStreamChunk, void, unknown> {
|
public async* streamRequest(conversation: Conversation): AsyncGenerator<IStreamChunk, void, unknown> {
|
||||||
|
|
||||||
// Refresh file cache only if conversation has attachments
|
// Refresh file cache only if conversation has attachments
|
||||||
|
|
@ -328,7 +331,7 @@ export class OpenAI extends BaseAIClass {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected formatBinaryFiles(attachments: Attachment[]): string {
|
protected formatBinaryFiles(attachments: Attachment[]): Promise<string> {
|
||||||
const contentBlocks: unknown[] = [];
|
const contentBlocks: unknown[] = [];
|
||||||
|
|
||||||
for (const attachment of attachments) {
|
for (const attachment of attachments) {
|
||||||
|
|
@ -347,7 +350,7 @@ export class OpenAI extends BaseAIClass {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isPlainText && !this.isSupportedMimeType(mimeType)) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -360,10 +363,10 @@ export class OpenAI extends BaseAIClass {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return JSON.stringify([{
|
return Promise.resolve(JSON.stringify([{
|
||||||
role: "user",
|
role: "user",
|
||||||
content: contentBlocks
|
content: contentBlocks
|
||||||
}]);
|
}]));
|
||||||
}
|
}
|
||||||
|
|
||||||
private getTools(): (OpenAIToolTool | { type: string })[] {
|
private getTools(): (OpenAIToolTool | { type: string })[] {
|
||||||
|
|
@ -376,80 +379,10 @@ export class OpenAI extends BaseAIClass {
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildOpenAIToolChoice(): string {
|
private buildOpenAIToolChoice(): string {
|
||||||
// If no tools defined, fall back to auto
|
return this.buildToolChoice<string>({
|
||||||
if (this.aiToolDefinitions.length === 0) {
|
auto: "auto",
|
||||||
return "auto";
|
enabled: "required",
|
||||||
}
|
disabled: "none"
|
||||||
|
});
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Resolve } from "Services/DependencyService";
|
import { Resolve } from "Services/DependencyService";
|
||||||
import { Services } from "Services/Services";
|
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 { AIProvider, AIProviderURL, AIProviderModel } from "Enums/ApiProvider";
|
||||||
import { Role } from "Enums/Role";
|
import { Role } from "Enums/Role";
|
||||||
import { NamePrompt } from "AIPrompts/NamePrompt";
|
import { NamePrompt } from "AIPrompts/NamePrompt";
|
||||||
|
|
@ -9,7 +9,7 @@ import { Exception } from "Helpers/Exception";
|
||||||
import type { AbortService } from "Services/AbortService";
|
import type { AbortService } from "Services/AbortService";
|
||||||
import type { ResponsesAPINonStreamingResponse } from "./OpenAITypes";
|
import type { ResponsesAPINonStreamingResponse } from "./OpenAITypes";
|
||||||
|
|
||||||
export class OpenAIConversationNamingService implements IConversationNamingService {
|
export class OpenAIConversationNamingAgent implements IConversationNamingAgent {
|
||||||
private readonly apiKey: string;
|
private readonly apiKey: string;
|
||||||
private readonly abortService: AbortService;
|
private readonly abortService: AbortService;
|
||||||
|
|
||||||
|
|
@ -10,25 +10,25 @@ export class AIToolResponse {
|
||||||
public readonly toolId?: string;
|
public readonly toolId?: string;
|
||||||
|
|
||||||
public static readonly UserRejectionMessage: string = `The user has explicitly rejected this change.
|
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:**
|
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:**
|
**Critical Instructions:**
|
||||||
1. This is NOT an error or failure - this is valuable user guidance that should be taken seriously
|
1. This is NOT an error or failure - this is valuable user guidance that should be taken seriously
|
||||||
2. The user may want to:
|
2. The user may want to:
|
||||||
- Adjust the SAME action with different parameters (e.g., write to a different file)
|
- 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)
|
- Change to a DIFFERENT action entirely (e.g., delete instead of write)
|
||||||
- Add context or constraints you didn't initially consider
|
- Add context or constraints you didn't initially consider
|
||||||
3. Carefully analyze the user's suggestion below to understand their true intent
|
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
|
4. Acknowledge their feedback and explain how you'll adjust your approach
|
||||||
5. Then proceed with the modified action that aligns with their guidance
|
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) {
|
constructor(name: AITool, payload: AIToolResponsePayload, toolId?: string) {
|
||||||
this.name = name;
|
this.name = name;
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
|
import type { Artifact } from "Conversations/Artifact";
|
||||||
import type { Attachment } from "Conversations/Attachment";
|
import type { Attachment } from "Conversations/Attachment";
|
||||||
|
|
||||||
export class AIToolResponsePayload {
|
export class AIToolResponsePayload {
|
||||||
public readonly response: object;
|
public readonly response: object;
|
||||||
|
public readonly artifacts: Artifact[];
|
||||||
public readonly attachments: Attachment[];
|
public readonly attachments: Attachment[];
|
||||||
|
|
||||||
constructor(response: object, attachments: Attachment[] = []) {
|
constructor(response: object, artifacts: Artifact[] = [], attachments: Attachment[] = []) {
|
||||||
this.response = response;
|
this.response = response;
|
||||||
|
this.artifacts = artifacts;
|
||||||
this.attachments = attachments;
|
this.attachments = attachments;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3,7 +3,7 @@ import type { IAIToolDefinition } from "../IAIToolDefinition";
|
||||||
|
|
||||||
export const SubmitPlan: IAIToolDefinition = {
|
export const SubmitPlan: IAIToolDefinition = {
|
||||||
name: AITool.SubmitPlan,
|
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:
|
Call this function:
|
||||||
- After analyzing the goal and vault context to provide a structured plan
|
- After analyzing the goal and vault context to provide a structured plan
|
||||||
|
|
@ -26,11 +26,11 @@ Do NOT use this function:
|
||||||
properties: {
|
properties: {
|
||||||
description: {
|
description: {
|
||||||
type: "string",
|
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: {
|
instruction: {
|
||||||
type: "string",
|
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: {
|
context: {
|
||||||
type: "string",
|
type: "string",
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import type { MemoriesService } from "Services/MemoriesService";
|
||||||
import { replaceCopy } from 'Helpers/Helpers';
|
import { replaceCopy } from 'Helpers/Helpers';
|
||||||
import { Copy } from "Enums/Copy";
|
import { Copy } from "Enums/Copy";
|
||||||
import { ChatMode } from "Enums/ChatMode";
|
import { ChatMode } from "Enums/ChatMode";
|
||||||
|
import { AIProvider } from "Enums/ApiProvider";
|
||||||
|
|
||||||
export interface IPrompt {
|
export interface IPrompt {
|
||||||
systemInstruction(): Promise<string>;
|
systemInstruction(): Promise<string>;
|
||||||
|
|
@ -77,7 +78,7 @@ export class AIPrompt implements IPrompt {
|
||||||
? Copy.DirectiveMemoriesEnabled
|
? Copy.DirectiveMemoriesEnabled
|
||||||
: Copy.DirectiveMemoriesReadOnly;
|
: Copy.DirectiveMemoriesReadOnly;
|
||||||
|
|
||||||
const webSearchDirective = s.enableWebSearch
|
const webSearchDirective = s.enableWebSearch && s.provider !== AIProvider.Local
|
||||||
? Copy.DirectiveWebSearchEnabled
|
? Copy.DirectiveWebSearchEnabled
|
||||||
: Copy.DirectiveWebSearchDisabled;
|
: Copy.DirectiveWebSearchDisabled;
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.2 MiB After Width: | Height: | Size: 196 KiB |
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,16 +1,15 @@
|
||||||
<script lang="ts">
|
<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 ThoughtIndicator from "./ThoughtIndicator.svelte";
|
||||||
import StreamingIndicator from "./StreamingIndicator.svelte";
|
import StreamingIndicator from "./StreamingIndicator.svelte";
|
||||||
|
import UserMessage from "./UserMessage.svelte";
|
||||||
|
import AssistantMessage from "./AssistantMessage.svelte";
|
||||||
import { Greeting } from "Enums/Greeting";
|
import { Greeting } from "Enums/Greeting";
|
||||||
import { Role } from "Enums/Role";
|
import { Role } from "Enums/Role";
|
||||||
import type { ConversationContent } from "Conversations/ConversationContent";
|
import type { ConversationContent } from "Conversations/ConversationContent";
|
||||||
import { tick } from "svelte";
|
import { tick } from "svelte";
|
||||||
import { getOuterHeight, setElementIcon } from "Helpers/ElementHelper";
|
|
||||||
import { setIcon } from "obsidian";
|
import { setIcon } from "obsidian";
|
||||||
import { fade } from "svelte/transition";
|
import { fade } from "svelte/transition";
|
||||||
|
import GraphAnimation from "./GraphAnimation.svelte";
|
||||||
|
|
||||||
export let messages: ConversationContent[] = [];
|
export let messages: ConversationContent[] = [];
|
||||||
export let currentThought: string | null = null;
|
export let currentThought: string | null = null;
|
||||||
|
|
@ -18,100 +17,63 @@
|
||||||
export let chatContainer: HTMLDivElement;
|
export let chatContainer: HTMLDivElement;
|
||||||
|
|
||||||
export function resetChatArea() {
|
export function resetChatArea() {
|
||||||
messageElements = [];
|
|
||||||
if (chatAreaPaddingElement) {
|
|
||||||
chatAreaPaddingElement.style.padding = "0px";
|
|
||||||
}
|
|
||||||
chatContainer.scroll({ top: 0, behavior: "instant" });
|
chatContainer.scroll({ top: 0, behavior: "instant" });
|
||||||
|
tick().then(updateScrolledState);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateChatAreaLayout(behavior: ScrollBehavior | undefined = undefined, shouldSettle: boolean = false) {
|
export async function updateChatAreaLayout(behavior: ScrollBehavior | undefined = undefined) {
|
||||||
await tick();
|
await tick();
|
||||||
|
|
||||||
if (!chatAreaPaddingElement) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (messageElements.length <= 0) {
|
|
||||||
chatAreaPaddingElement.style.paddingBottom = "0px";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
applyLayout(behavior, shouldSettle);
|
if (behavior) {
|
||||||
updateScrolledState();
|
scrollToLatestTurn(behavior);
|
||||||
|
}
|
||||||
|
tick().then(updateScrolledState);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyLayout(behavior: ScrollBehavior | undefined, shouldSettle: boolean) {
|
function scrollToLatestTurn(behavior: ScrollBehavior) {
|
||||||
if (!chatAreaPaddingElement || messageElements.length <= 0) {
|
const latestTurn = chatContainer.querySelector<HTMLElement>(".message-group.latest");
|
||||||
|
if (!latestTurn) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const paddingTop = parseFloat(getComputedStyle(chatContainer).paddingTop) || 0;
|
||||||
const styles = getComputedStyle(chatContainer);
|
chatContainer.scroll({ top: latestTurn.offsetTop - paddingTop, behavior });
|
||||||
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) {
|
|
||||||
chatContainer.scroll({ top: chatContainer.scrollHeight, behavior });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function calculateMessageHeight(sortedMessages: { element: HTMLElement, index: number, role: Role }[]): { count: number, height: number } {
|
function scrollToBottom(behavior: ScrollBehavior) {
|
||||||
const lastMessage = sortedMessages[sortedMessages.length - 1];
|
chatContainer.scroll({ top: chatContainer.scrollHeight, behavior });
|
||||||
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 updateScrolledState() {
|
function updateScrolledState() {
|
||||||
scrolledToBottom = chatContainer && Math.abs(chatContainer.scrollHeight - chatContainer.scrollTop - chatContainer.clientHeight) < 100;
|
if (!chatContainer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isScrollable = chatContainer.scrollHeight > chatContainer.clientHeight;
|
||||||
|
scrolledToBottom = !isScrollable || Math.abs(chatContainer.scrollHeight - chatContainer.scrollTop - chatContainer.clientHeight) < 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
let scrolledToBottom: boolean = true;
|
let scrolledToBottom: boolean = true;
|
||||||
|
|
||||||
let scrollToBottomButton: HTMLButtonElement;
|
let scrollToBottomButton: HTMLButtonElement;
|
||||||
let chatAreaPaddingElement: HTMLElement | undefined;
|
|
||||||
let thoughtIndicatorElement: HTMLElement | undefined;
|
|
||||||
let streamingIndicatorElement: HTMLElement | undefined;
|
|
||||||
|
|
||||||
let streamingMarkdownService: StreamingMarkdownService = Resolve<StreamingMarkdownService>(Services.StreamingMarkdownService);
|
type Turn = { id: number, messages: ConversationContent[] };
|
||||||
|
|
||||||
let messageElements: { element: HTMLElement, index: number, role: Role }[] = [];
|
$: 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 {
|
function getGreetingByTime(): string {
|
||||||
const hour = new Date().getHours();
|
const hour = new Date().getHours();
|
||||||
|
|
@ -134,28 +96,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function messageRenderAction(element: HTMLElement, message: ConversationContent) {
|
|
||||||
streamingMarkdownService.render(message.getDisplayContent(), element);
|
|
||||||
return {
|
|
||||||
update(newMessage: ConversationContent) {
|
|
||||||
streamingMarkdownService.render(newMessage.getDisplayContent(), element, !isSubmitting);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function trackingAction(element: HTMLElement, { index, role }: { index: number, role: Role }) {
|
|
||||||
messageElements.push({ index: index, element: element, role: role });
|
|
||||||
}
|
|
||||||
|
|
||||||
$: if (scrollToBottomButton) {
|
$: if (scrollToBottomButton) {
|
||||||
setIcon(scrollToBottomButton, "arrow-down");
|
setIcon(scrollToBottomButton, "arrow-down");
|
||||||
}
|
}
|
||||||
|
|
||||||
$: {
|
|
||||||
if (messages.length === 0 && chatAreaPaddingElement) {
|
|
||||||
chatAreaPaddingElement.style.padding = "0px";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="chat-area-wrapper">
|
<div class="chat-area-wrapper">
|
||||||
|
|
@ -163,59 +106,40 @@
|
||||||
<div class="top-fade"></div>
|
<div class="top-fade"></div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="chat-area" bind:this={chatContainer} on:scroll={updateScrolledState}>
|
<div class="chat-area" bind:this={chatContainer} on:scroll={updateScrolledState}>
|
||||||
{#each messages as message, index}
|
{#each turns as turn, turnIndex (turn.id)}
|
||||||
{@const content = message.getDisplayContent()}
|
{@const isLatestTurn = turnIndex === turns.length - 1}
|
||||||
{#if message.shouldDisplayContent && content.trim() !== ""}
|
<div class="message-group" class:latest={isLatestTurn}>
|
||||||
{#if message.role === Role.User}
|
{#each turn.messages as message (message.id)}
|
||||||
<div class="message-container {Role.User}" use:trackingAction={{ index, role: Role.User }}>
|
{@const content = message.getDisplayContent()}
|
||||||
<div class="message-bubble {Role.User}">
|
{#if message.shouldDisplayContent && content.trim() !== ""}
|
||||||
<div class="message-text-user-container" contenteditable="false">
|
{#if message.role === Role.User}
|
||||||
<div class="message-text-user">
|
<UserMessage {message} />
|
||||||
{@html content}
|
{:else}
|
||||||
</div>
|
<AssistantMessage {message} {isSubmitting} />
|
||||||
</div>
|
{/if}
|
||||||
{#if message.references.length > 0}
|
{/if}
|
||||||
<hr class="message-attachment-break"/>
|
{/each}
|
||||||
<div class="message-attachments-container">
|
|
||||||
{#each message.references as reference}
|
{#if isLatestTurn}
|
||||||
<div class="message-attachmanet" aria-label="{reference.fileName}">
|
<ThoughtIndicator thought={currentThought}/>
|
||||||
<div
|
{#if isSubmitting}
|
||||||
class="message-attachment-icon"
|
<div transition:fade={{ duration: 300 }}>
|
||||||
use:setElementIcon={reference.getIconName()}
|
<StreamingIndicator/>
|
||||||
></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>
|
||||||
</div>
|
{/if}
|
||||||
{: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">
|
|
||||||
<div use:messageRenderAction={message} class="streaming-content"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
</div>
|
||||||
{/each}
|
{/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}
|
{#if messages.length === 0}
|
||||||
<div class="conversation-empty-state">
|
<div class="conversation-empty-container">
|
||||||
<div class="typing-in">{getGreetingByTime()}</div>
|
<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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
@ -230,7 +154,7 @@
|
||||||
<button
|
<button
|
||||||
id="scroll-to-bottom-button"
|
id="scroll-to-bottom-button"
|
||||||
bind:this={scrollToBottomButton}
|
bind:this={scrollToBottomButton}
|
||||||
on:click={() => updateChatAreaLayout("smooth")}
|
on:click={() => scrollToBottom("smooth")}
|
||||||
aria-label="Scroll to bottom">
|
aria-label="Scroll to bottom">
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -294,68 +218,46 @@
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-container {
|
.message-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
text-align: left;
|
flex-direction: column;
|
||||||
margin: 0;
|
flex-shrink: 0;
|
||||||
|
gap: var(--size-4-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-container.user {
|
.message-group.latest {
|
||||||
justify-content: flex-end;
|
margin-top: var(--size-4-2);
|
||||||
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-container.assistant {
|
.conversation-empty-container {
|
||||||
justify-content: flex-start;
|
display: grid;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-container {
|
.conversation-empty-animation {
|
||||||
animation: fadeIn 0.5s ease-out forwards;
|
grid-row: 1;
|
||||||
|
grid-column: 1;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeIn {
|
.conversation-empty-greeting-backdrop {
|
||||||
from {
|
position: absolute;
|
||||||
opacity: 0;
|
inset: -48px;
|
||||||
transform: translateY(5px);
|
background: radial-gradient(
|
||||||
}
|
ellipse closest-side,
|
||||||
to {
|
var(--background-secondary) 0%,
|
||||||
opacity: 1;
|
color-mix(in srgb, var(--background-secondary) 80%, transparent) 65%,
|
||||||
transform: translateY(0);
|
transparent 100%
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-bubble {
|
.conversation-empty-greeting {
|
||||||
word-wrap: break-word;
|
grid-row: 1;
|
||||||
}
|
grid-column: 1;
|
||||||
|
position: relative;
|
||||||
.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-bubble.assistant {
|
|
||||||
word-wrap: break-word;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-text-user-container {
|
|
||||||
max-height: 15vh;
|
|
||||||
overflow: scroll;
|
|
||||||
padding-top: var(--size-4-2);
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-text-user-container::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-text-user-container {
|
|
||||||
padding-bottom: var(--size-4-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.conversation-empty-state {
|
|
||||||
margin: auto;
|
margin: auto;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
font-size: var(--font-ui-medium);
|
font-size: var(--font-ui-medium);
|
||||||
|
|
@ -363,83 +265,10 @@
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.streaming-content {
|
.conversation-empty-greeting-label {
|
||||||
justify-content: left;
|
position: relative;
|
||||||
min-height: 1em; /* Ensure the element exists for binding */
|
overflow: hidden;
|
||||||
}
|
white-space: nowrap;
|
||||||
|
padding: var(--size-2-2);
|
||||||
/* 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);
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount, tick } from "svelte";
|
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 { UserInputService } from "Services/UserInputService";
|
||||||
import type { ISearchState, SearchStateStore } from "Stores/SearchStateStore";
|
import type { ISearchState, SearchStateStore } from "Stores/SearchStateStore";
|
||||||
import { Resolve } from "Services/DependencyService";
|
import { Resolve } from "Services/DependencyService";
|
||||||
|
|
@ -12,9 +12,11 @@
|
||||||
import UserInstruction from "./UserInstruction.svelte";
|
import UserInstruction from "./UserInstruction.svelte";
|
||||||
import ChatModeSelector from "./ChatModeSelector.svelte";
|
import ChatModeSelector from "./ChatModeSelector.svelte";
|
||||||
import DiffControls from "./DiffControls.svelte";
|
import DiffControls from "./DiffControls.svelte";
|
||||||
|
import PlanApprovalControls from "./PlanApprovalControls.svelte";
|
||||||
import type { EventService } from "Services/EventService";
|
import type { EventService } from "Services/EventService";
|
||||||
import { Event } from "Enums/Event";
|
import { Event } from "Enums/Event";
|
||||||
import type { DiffService } from "Services/DiffService";
|
import type { DiffService } from "Services/DiffService";
|
||||||
|
import type { PlanApprovalService } from "Services/PlanApprovalService";
|
||||||
import type { Attachment } from "Conversations/Attachment";
|
import type { Attachment } from "Conversations/Attachment";
|
||||||
import ChatAttachments from "./ChatAttachments.svelte";
|
import ChatAttachments from "./ChatAttachments.svelte";
|
||||||
import InputDisplay from "./InputDisplay.svelte";
|
import InputDisplay from "./InputDisplay.svelte";
|
||||||
|
|
@ -26,6 +28,7 @@
|
||||||
import { ChatMode, chatModeAllowsEdits, iconForChatMode } from "Enums/ChatMode";
|
import { ChatMode, chatModeAllowsEdits, iconForChatMode } from "Enums/ChatMode";
|
||||||
import { hideDrawerElements, restoreDrawerElements } from "Helpers/ElementHelper";
|
import { hideDrawerElements, restoreDrawerElements } from "Helpers/ElementHelper";
|
||||||
import { replaceCopy } from "Helpers/Helpers";
|
import { replaceCopy } from "Helpers/Helpers";
|
||||||
|
import { AIProvider } from "Enums/ApiProvider";
|
||||||
|
|
||||||
export let attachments: Attachment[] = [];
|
export let attachments: Attachment[] = [];
|
||||||
|
|
||||||
|
|
@ -39,6 +42,7 @@
|
||||||
const userInputService: UserInputService = Resolve<UserInputService>(Services.UserInputService);
|
const userInputService: UserInputService = Resolve<UserInputService>(Services.UserInputService);
|
||||||
const searchStateStore: SearchStateStore = Resolve<SearchStateStore>(Services.SearchStateStore);
|
const searchStateStore: SearchStateStore = Resolve<SearchStateStore>(Services.SearchStateStore);
|
||||||
const diffService: DiffService = Resolve<DiffService>(Services.DiffService);
|
const diffService: DiffService = Resolve<DiffService>(Services.DiffService);
|
||||||
|
const planApprovalService: PlanApprovalService = Resolve<PlanApprovalService>(Services.PlanApprovalService);
|
||||||
const eventService: EventService = Resolve<EventService>(Services.EventService);
|
const eventService: EventService = Resolve<EventService>(Services.EventService);
|
||||||
const aiPrompt: IPrompt = Resolve<IPrompt>(Services.IPrompt);
|
const aiPrompt: IPrompt = Resolve<IPrompt>(Services.IPrompt);
|
||||||
|
|
||||||
|
|
@ -51,27 +55,30 @@
|
||||||
let submitButton: HTMLButtonElement;
|
let submitButton: HTMLButtonElement;
|
||||||
let attachmentButton: HTMLButtonElement;
|
let attachmentButton: HTMLButtonElement;
|
||||||
let chatModeButton: HTMLButtonElement;
|
let chatModeButton: HTMLButtonElement;
|
||||||
|
let freeEditButton: HTMLButtonElement;
|
||||||
|
|
||||||
let chatModeSelectionAreaActive: boolean = false;
|
let chatModeSelectionAreaActive: boolean = false;
|
||||||
let userInstructionAreaActive: boolean = false;
|
let userInstructionAreaActive: boolean = false;
|
||||||
let userInstructionActive: boolean = true;
|
let userInstructionActive: boolean = true;
|
||||||
let stacked: boolean = false;
|
|
||||||
|
|
||||||
let userRequest: string = "";
|
let userRequest: string = "";
|
||||||
|
|
||||||
|
let freeEdit: boolean = settingsService.settings.freeEdit;
|
||||||
let chatMode: ChatMode = settingsService.settings.chatMode;
|
let chatMode: ChatMode = settingsService.settings.chatMode;
|
||||||
let inputMode: InputMode = InputMode.Normal;
|
let inputMode: InputMode = InputMode.Normal;
|
||||||
let questionResolver: ((answer: string) => void) | null = null;
|
let questionResolver: ((answer: string) => void) | null = null;
|
||||||
|
|
||||||
let webSearchActive: boolean = settingsService.settings.enableWebSearch;
|
let webSearchActive: boolean = settingsService.settings.enableWebSearch;
|
||||||
|
let webSearchUnavailable: boolean = settingsService.settings.provider === AIProvider.Local;
|
||||||
let editsAllowed: boolean = chatModeAllowsEdits(chatMode);
|
let editsAllowed: boolean = chatModeAllowsEdits(chatMode);
|
||||||
|
|
||||||
let countdownIntervalId: ReturnType<typeof setInterval> | null = null;
|
let countdownIntervalId: ReturnType<typeof setInterval> | null = null;
|
||||||
let countdownSecondsRemaining: number = 0;
|
let countdownSecondsRemaining: number = 0;
|
||||||
let inputInitialHeight: number = 0;
|
|
||||||
|
|
||||||
const diffOpenedRef: EventRef = eventService.on(Event.DiffOpened, () => { inputMode = InputMode.Diff; focusInput(); });
|
const diffOpenedRef: EventRef = eventService.on(Event.DiffOpened, () => { inputMode = InputMode.Diff; focusInput(); });
|
||||||
const diffClosedRef: EventRef = eventService.on(Event.DiffClosed, () => { inputMode = InputMode.Normal; 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 rateLimitCountdownRef: EventRef = eventService.on(Event.RateLimitCountdown, (delayMs: number) => { startCountdown(delayMs); });
|
||||||
|
|
||||||
const settingsSubscription: object = settingsService.subscribeToSettingsChanged(changed => {
|
const settingsSubscription: object = settingsService.subscribeToSettingsChanged(changed => {
|
||||||
|
|
@ -82,37 +89,42 @@
|
||||||
setIcon(chatModeButton, iconForChatMode(chatMode));
|
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 () => {
|
onMount(async () => {
|
||||||
userInstructionActive = (await aiPrompt.userInstruction()).trim() !== "";
|
userInstructionActive = (await aiPrompt.userInstruction()).trim() !== "";
|
||||||
inputInitialHeight = textareaElement.innerHeight;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
eventService.offref(diffOpenedRef);
|
eventService.offref(diffOpenedRef);
|
||||||
eventService.offref(diffClosedRef);
|
eventService.offref(diffClosedRef);
|
||||||
|
eventService.offref(planApprovalOpenedRef);
|
||||||
|
eventService.offref(planApprovalClosedRef);
|
||||||
eventService.offref(rateLimitCountdownRef);
|
eventService.offref(rateLimitCountdownRef);
|
||||||
settingsService.unsubscribe(settingsSubscription);
|
settingsService.unsubscribe(settingsSubscription);
|
||||||
stopCountdown();
|
stopCountdown();
|
||||||
});
|
});
|
||||||
|
|
||||||
function checkStacked() {
|
export function focusInput(force: boolean = false) {
|
||||||
// Mobile already uses the 'stacked' layout
|
// Generally don't focus on mobile, it's annoying
|
||||||
if (Platform.isMobile || textareaElement.textContent.trim() === "") {
|
if (force || !Platform.isMobile) {
|
||||||
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) {
|
|
||||||
tick().then(() => {
|
tick().then(() => {
|
||||||
textareaElement?.focus();
|
textareaElement?.focus();
|
||||||
});
|
});
|
||||||
|
|
@ -200,7 +212,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
$: if (webSearchButton) {
|
$: if (webSearchButton) {
|
||||||
setIcon(webSearchButton, "globe");
|
setIcon(webSearchButton, webSearchUnavailable ? "globe-lock" : "globe");
|
||||||
}
|
}
|
||||||
|
|
||||||
$: userInstructionAreaActive, (() => {
|
$: userInstructionAreaActive, (() => {
|
||||||
|
|
@ -210,7 +222,7 @@
|
||||||
})();
|
})();
|
||||||
|
|
||||||
$: if (submitButton) {
|
$: 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");
|
setIcon(submitButton, userRequest.trim() === "" ? "square" : "send-horizontal");
|
||||||
} else {
|
} else {
|
||||||
setIcon(submitButton, isSubmitting ? "square" : "send-horizontal");
|
setIcon(submitButton, isSubmitting ? "square" : "send-horizontal");
|
||||||
|
|
@ -225,14 +237,19 @@
|
||||||
setIcon(chatModeButton, iconForChatMode(chatMode));
|
setIcon(chatModeButton, iconForChatMode(chatMode));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$: if (freeEditButton) {
|
||||||
|
setIcon(freeEditButton, iconForFreeEdit(freeEdit))
|
||||||
|
}
|
||||||
|
|
||||||
$: inputPlaceholder = (() => {
|
$: inputPlaceholder = (() => {
|
||||||
if (inputMode === InputMode.Question) return Copy.InputPlaceholderQuestion;
|
if (inputMode === InputMode.Question) return Copy.InputPlaceholderQuestion;
|
||||||
if (inputMode === InputMode.Diff) return Copy.InputPlaceholderDiff;
|
if (inputMode === InputMode.Diff) return Copy.InputPlaceholderDiff;
|
||||||
|
if (inputMode === InputMode.PlanApproval) return Copy.InputPlaceholderPlanApproval;
|
||||||
return Copy.InputPlaceholderNormal;
|
return Copy.InputPlaceholderNormal;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
$: submitDisabled = (() => {
|
$: submitDisabled = (() => {
|
||||||
if (inputMode === InputMode.Diff || inputMode === InputMode.Question) {
|
if (inputMode === InputMode.Diff || inputMode === InputMode.Question || inputMode === InputMode.PlanApproval) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return !isSubmitting && userRequest.trim() === "";
|
return !isSubmitting && userRequest.trim() === "";
|
||||||
|
|
@ -245,9 +262,16 @@
|
||||||
if (inputMode === InputMode.Diff) {
|
if (inputMode === InputMode.Diff) {
|
||||||
return userRequest.trim() === "" ? Copy.ButtonCancel : Copy.ButtonMakeSuggestion;
|
return userRequest.trim() === "" ? Copy.ButtonCancel : Copy.ButtonMakeSuggestion;
|
||||||
}
|
}
|
||||||
|
if (inputMode === InputMode.PlanApproval) {
|
||||||
|
return userRequest.trim() === "" ? Copy.ButtonCancel : Copy.ButtonMakeSuggestion;
|
||||||
|
}
|
||||||
return isSubmitting ? Copy.ButtonCancel : Copy.ButtonSendMessage;
|
return isSubmitting ? Copy.ButtonCancel : Copy.ButtonSendMessage;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
function iconForFreeEdit(freeEdit: boolean) {
|
||||||
|
return freeEdit ? "file-pen" : "file-lock"
|
||||||
|
}
|
||||||
|
|
||||||
function handleStop() {
|
function handleStop() {
|
||||||
onStop();
|
onStop();
|
||||||
}
|
}
|
||||||
|
|
@ -268,6 +292,14 @@
|
||||||
diffService.onSuggest(suggestion.formattedRequest);
|
diffService.onSuggest(suggestion.formattedRequest);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handlePlanSuggestion() {
|
||||||
|
if (userRequest.trim() === "" || inputMode !== InputMode.PlanApproval) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const suggestion = requestFromInput();
|
||||||
|
planApprovalService.onSuggest(suggestion.formattedRequest);
|
||||||
|
}
|
||||||
|
|
||||||
function handleAnswer() {
|
function handleAnswer() {
|
||||||
if (userRequest.trim() === "" || inputMode !== InputMode.Question) {
|
if (userRequest.trim() === "" || inputMode !== InputMode.Question) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -288,7 +320,6 @@
|
||||||
|
|
||||||
textareaElement.textContent = "";
|
textareaElement.textContent = "";
|
||||||
userRequest = "";
|
userRequest = "";
|
||||||
checkStacked();
|
|
||||||
|
|
||||||
if (Platform.isMobile) {
|
if (Platform.isMobile) {
|
||||||
textareaElement.blur();
|
textareaElement.blur();
|
||||||
|
|
@ -305,6 +336,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleWebSearch() {
|
function toggleWebSearch() {
|
||||||
|
if (webSearchUnavailable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const newState = !settingsService.settings.enableWebSearch;
|
const newState = !settingsService.settings.enableWebSearch;
|
||||||
settingsService.updateSettings(settings => {
|
settingsService.updateSettings(settings => {
|
||||||
settings.enableWebSearch = newState;
|
settings.enableWebSearch = newState;
|
||||||
|
|
@ -314,7 +348,13 @@
|
||||||
|
|
||||||
function toggleChatModeSelectionArea() {
|
function toggleChatModeSelectionArea() {
|
||||||
chatModeSelectionAreaActive = !chatModeSelectionAreaActive;
|
chatModeSelectionAreaActive = !chatModeSelectionAreaActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFreeEdit() {
|
||||||
|
const newState = !settingsService.settings.freeEdit;
|
||||||
|
settingsService.updateSettings(settings => {
|
||||||
|
settings.freeEdit = newState;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleKeydown(e: KeyboardEvent) {
|
async function handleKeydown(e: KeyboardEvent) {
|
||||||
|
|
@ -333,6 +373,8 @@
|
||||||
handleAnswer();
|
handleAnswer();
|
||||||
} else if (inputMode === InputMode.Diff) {
|
} else if (inputMode === InputMode.Diff) {
|
||||||
handleSuggestion();
|
handleSuggestion();
|
||||||
|
} else if (inputMode === InputMode.PlanApproval) {
|
||||||
|
handlePlanSuggestion();
|
||||||
} else {
|
} else {
|
||||||
handleSubmit();
|
handleSubmit();
|
||||||
}
|
}
|
||||||
|
|
@ -434,8 +476,6 @@
|
||||||
if (userRequest.trim() === "") {
|
if (userRequest.trim() === "") {
|
||||||
textareaElement.textContent = "";
|
textareaElement.textContent = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
checkStacked();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -538,7 +578,7 @@
|
||||||
}
|
}
|
||||||
</script>
|
</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}>
|
<div id="input-display-container" style:padding-top={attachments.length > 0 ? "var(--size-4-2)" : 0}>
|
||||||
<InputDisplay bind:this={inputDisplay}/>
|
<InputDisplay bind:this={inputDisplay}/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -547,8 +587,9 @@
|
||||||
<ChatAttachments bind:attachments={attachments}/>
|
<ChatAttachments bind:attachments={attachments}/>
|
||||||
</div>
|
</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}/>
|
<DiffControls diffOpen={inputMode === InputMode.Diff}/>
|
||||||
|
<PlanApprovalControls planApprovalOpen={inputMode === InputMode.PlanApproval}/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="input-search-results-container" style:padding-top={$searchState.results.length > 0 ? "var(--size-4-2)" : 0}>
|
<div id="input-search-results-container" style:padding-top={$searchState.results.length > 0 ? "var(--size-4-2)" : 0}>
|
||||||
|
|
@ -573,10 +614,11 @@
|
||||||
|
|
||||||
<button
|
<button
|
||||||
id="web-search-button"
|
id="web-search-button"
|
||||||
class:input-button-highlight={webSearchActive}
|
class:input-button-highlight={webSearchActive && !webSearchUnavailable}
|
||||||
bind:this={webSearchButton}
|
bind:this={webSearchButton}
|
||||||
|
disabled={webSearchUnavailable}
|
||||||
on:click={toggleWebSearch}
|
on:click={toggleWebSearch}
|
||||||
aria-label={webSearchActive ? Copy.ButtonTurnOffWebSearch : Copy.ButtonTurnOnWebSearch}>
|
aria-label={webSearchUnavailable ? Copy.ButtonWebSearchUnavailable : (webSearchActive ? Copy.ButtonTurnOffWebSearch : Copy.ButtonTurnOnWebSearch)}>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|
@ -620,6 +662,15 @@
|
||||||
aria-label={Copy.ButtonChangeChatMode}>
|
aria-label={Copy.ButtonChangeChatMode}>
|
||||||
</button>
|
</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
|
<button
|
||||||
id="submit-button"
|
id="submit-button"
|
||||||
bind:this={submitButton}
|
bind:this={submitButton}
|
||||||
|
|
@ -628,6 +679,8 @@
|
||||||
userRequest.trim() === "" ? handleStop() : handleAnswer();
|
userRequest.trim() === "" ? handleStop() : handleAnswer();
|
||||||
} else if (inputMode === InputMode.Diff) {
|
} else if (inputMode === InputMode.Diff) {
|
||||||
userRequest.trim() === "" ? handleStop() : handleSuggestion();
|
userRequest.trim() === "" ? handleStop() : handleSuggestion();
|
||||||
|
} else if (inputMode === InputMode.PlanApproval) {
|
||||||
|
userRequest.trim() === "" ? handleStop() : handlePlanSuggestion();
|
||||||
} else {
|
} else {
|
||||||
isSubmitting ? handleStop() : handleSubmit();
|
isSubmitting ? handleStop() : handleSubmit();
|
||||||
}
|
}
|
||||||
|
|
@ -642,8 +695,8 @@
|
||||||
grid-row: 3;
|
grid-row: 3;
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto auto auto auto auto var(--size-4-3) 1fr 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 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-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);
|
border-radius: var(--radius-l);
|
||||||
background-color: var(--background-primary);
|
background-color: var(--background-primary);
|
||||||
}
|
}
|
||||||
|
|
@ -678,35 +731,9 @@
|
||||||
grid-column: 2 / 13;
|
grid-column: 2 / 13;
|
||||||
}
|
}
|
||||||
|
|
||||||
#user-instruction-button {
|
|
||||||
grid-row: 7;
|
|
||||||
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: 7;
|
|
||||||
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 {
|
#input-field {
|
||||||
grid-row: 7;
|
grid-row: 7;
|
||||||
grid-column: 6;
|
grid-column: 2 / 13;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
max-height: 30vh;
|
max-height: 30vh;
|
||||||
border-radius: var(--radius-m);
|
border-radius: var(--radius-m);
|
||||||
|
|
@ -759,9 +786,35 @@
|
||||||
outline: none;
|
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 {
|
#chat-attachment-button {
|
||||||
grid-row: 7;
|
grid-row: 9;
|
||||||
grid-column: 8;
|
grid-column: 6;
|
||||||
border-radius: var(--radius-xl);
|
border-radius: var(--radius-xl);
|
||||||
padding: var(--size-4-2);
|
padding: var(--size-4-2);
|
||||||
align-self: end;
|
align-self: end;
|
||||||
|
|
@ -773,8 +826,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
#chat-mode-button {
|
#chat-mode-button {
|
||||||
grid-row: 7;
|
grid-row: 9;
|
||||||
grid-column: 10;
|
grid-column: 8;
|
||||||
border-radius: var(--radius-xl);
|
border-radius: var(--radius-xl);
|
||||||
padding: var(--size-4-2);
|
padding: var(--size-4-2);
|
||||||
align-self: end;
|
align-self: end;
|
||||||
|
|
@ -785,18 +838,31 @@
|
||||||
max-height: 2rem;
|
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 {
|
.input-button-highlight {
|
||||||
box-shadow: 0px 0px 2px 1px var(--color-accent);
|
box-shadow: 0px 0px 2px 1px var(--color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
#submit-button {
|
#submit-button {
|
||||||
grid-row: 7;
|
grid-row: 9;
|
||||||
grid-column: 12;
|
grid-column: 12;
|
||||||
border-radius: var(--radius-xl);
|
border-radius: var(--radius-xl);
|
||||||
padding-left: var(--size-4-2);
|
padding-left: var(--size-4-2);
|
||||||
padding-right: var(--size-4-2);
|
padding-right: var(--size-4-2);
|
||||||
align-self: end;
|
align-self: end;
|
||||||
transition-duration: 0.5s;
|
transition: background-color 0.2s ease-out;
|
||||||
background-color: var(--interactive-accent);
|
background-color: var(--interactive-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -808,78 +874,4 @@
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background-color: var(--interactive-accent-hover);
|
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 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: 9;
|
|
||||||
}
|
|
||||||
|
|
||||||
#input-container.stacked #web-search-button {
|
|
||||||
grid-row: 9;
|
|
||||||
}
|
|
||||||
|
|
||||||
#input-container.stacked #chat-attachment-button {
|
|
||||||
grid-row: 9;
|
|
||||||
}
|
|
||||||
|
|
||||||
#input-container.stacked #chat-mode-button {
|
|
||||||
grid-row: 9;
|
|
||||||
}
|
|
||||||
|
|
||||||
#input-container.stacked #submit-button {
|
|
||||||
grid-row: 9;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Narrow/mobile layout: input above, buttons below */
|
|
||||||
:global(.is-mobile) #input-container {
|
|
||||||
grid-template-rows: auto 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: 7;
|
|
||||||
grid-column: 2 / 11;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.is-mobile) #user-instruction-button {
|
|
||||||
grid-row: 9;
|
|
||||||
grid-column: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.is-mobile) #web-search-button {
|
|
||||||
grid-row: 9;
|
|
||||||
grid-column: 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.is-mobile) #chat-attachment-button {
|
|
||||||
grid-row: 9;
|
|
||||||
grid-column: 6;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.is-mobile) #chat-mode-button {
|
|
||||||
grid-row: 9;
|
|
||||||
grid-column: 8;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.is-mobile) #submit-button {
|
|
||||||
grid-row: 9;
|
|
||||||
grid-column: 10;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@
|
||||||
let stepElements: (HTMLDivElement | null)[] = [];
|
let stepElements: (HTMLDivElement | null)[] = [];
|
||||||
let isTransitioning = false;
|
let isTransitioning = false;
|
||||||
let resizeObserver: ResizeObserver | null = null;
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
let isScrolledToTop = true;
|
||||||
|
let isScrolledToBottom = true;
|
||||||
|
|
||||||
$: steps = $executionPlanState.plan?.executionSteps;
|
$: steps = $executionPlanState.plan?.executionSteps;
|
||||||
$: activeStepIndex = $executionPlanState.currentStepIndex;
|
$: activeStepIndex = $executionPlanState.currentStepIndex;
|
||||||
|
|
@ -39,20 +41,6 @@
|
||||||
scrollToActiveStep();
|
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() {
|
function setupResizeObserver() {
|
||||||
if (resizeObserver) {
|
if (resizeObserver) {
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
|
|
@ -79,6 +67,8 @@
|
||||||
|
|
||||||
const stepsToShow = Math.min(3, steps.length);
|
const stepsToShow = Math.min(3, steps.length);
|
||||||
collapsedHeight = (stepsToShow * stepHeight) + (Math.max(0, stepsToShow - 1) * separatorHeight);
|
collapsedHeight = (stepsToShow * stepHeight) + (Math.max(0, stepsToShow - 1) * separatorHeight);
|
||||||
|
|
||||||
|
updateScrollFade();
|
||||||
}
|
}
|
||||||
|
|
||||||
onDestroy(() => {
|
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() {
|
async function scrollToActiveStep() {
|
||||||
if (!wrapperDiv || !$executionPlanState.plan || activeStepIndex === -1) {
|
if (!wrapperDiv || !$executionPlanState.plan || activeStepIndex === -1) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -110,6 +109,17 @@
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
updateHeight();
|
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>
|
</script>
|
||||||
|
|
||||||
|
|
@ -127,7 +137,7 @@
|
||||||
aria-label={expanded ? "Collapse planned steps" : "Expand planned steps"}
|
aria-label={expanded ? "Collapse planned steps" : "Expand planned steps"}
|
||||||
role="button"
|
role="button"
|
||||||
tabindex=0>
|
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}>
|
<div id="chat-plan-steps" bind:this={contentDiv}>
|
||||||
{#each steps as step, index }
|
{#each steps as step, index }
|
||||||
<div class="chat-plan-step" bind:this={stepElements[index]}>
|
<div class="chat-plan-step" bind:this={stepElements[index]}>
|
||||||
|
|
@ -156,8 +166,8 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if steps.length > 2}
|
{#if steps.length > 2}
|
||||||
<div class="chat-plan-fade top-fade" transition:fade></div>
|
<div class="chat-plan-fade top-fade" class:hidden={isScrolledToTop}></div>
|
||||||
<div class="chat-plan-fade bottom-fade" transition:fade></div>
|
<div class="chat-plan-fade bottom-fade" class:hidden={isScrolledToBottom}></div>
|
||||||
{/if}
|
{/if}
|
||||||
<div id="chat-plan-chevron"
|
<div id="chat-plan-chevron"
|
||||||
class="transparent-button"
|
class="transparent-button"
|
||||||
|
|
@ -254,6 +264,12 @@
|
||||||
border-radius: var(--radius-m);
|
border-radius: var(--radius-m);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-plan-fade.hidden {
|
||||||
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-fade {
|
.top-fade {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { ARTIFACT_ACTION_RANK, ArtifactAction } from "Enums/ArtifactAction";
|
||||||
|
|
||||||
import { Resolve } from "Services/DependencyService";
|
import { Resolve } from "Services/DependencyService";
|
||||||
import { Services } from "Services/Services";
|
import { Services } from "Services/Services";
|
||||||
import ChatArea from "./ChatArea.svelte";
|
import ChatArea from "./ChatArea.svelte";
|
||||||
|
|
@ -19,16 +21,25 @@
|
||||||
import type { ExecutionPlanStore } from "Stores/ExecutionPlanStore";
|
import type { ExecutionPlanStore } from "Stores/ExecutionPlanStore";
|
||||||
import type { StreamingMarkdownService } from "Services/StreamingMarkdownService";
|
import type { StreamingMarkdownService } from "Services/StreamingMarkdownService";
|
||||||
import { AITool, fromString } from "Enums/AITool";
|
import { AITool, fromString } from "Enums/AITool";
|
||||||
|
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 plugin: VaultkeeperAIPlugin = Resolve<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin);
|
||||||
const executionPlanStore: ExecutionPlanStore = Resolve<ExecutionPlanStore>(Services.ExecutionPlanStore);
|
const executionPlanStore: ExecutionPlanStore = Resolve<ExecutionPlanStore>(Services.ExecutionPlanStore);
|
||||||
const settingsService: SettingsService = Resolve<SettingsService>(Services.SettingsService);
|
const settingsService: SettingsService = Resolve<SettingsService>(Services.SettingsService);
|
||||||
const chatService: ChatService = Resolve<ChatService>(Services.ChatService);
|
const chatService: ChatService = Resolve<ChatService>(Services.ChatService);
|
||||||
|
const planApprovalService: PlanApprovalService = Resolve<PlanApprovalService>(Services.PlanApprovalService);
|
||||||
const workSpaceService: WorkSpaceService = Resolve<WorkSpaceService>(Services.WorkSpaceService);
|
const workSpaceService: WorkSpaceService = Resolve<WorkSpaceService>(Services.WorkSpaceService);
|
||||||
const conversationService: ConversationFileSystemService = Resolve<ConversationFileSystemService>(Services.ConversationFileSystemService);
|
const conversationService: ConversationFileSystemService = Resolve<ConversationFileSystemService>(Services.ConversationFileSystemService);
|
||||||
const streamingMarkdownService: StreamingMarkdownService = Resolve<StreamingMarkdownService>(Services.StreamingMarkdownService);
|
const streamingMarkdownService: StreamingMarkdownService = Resolve<StreamingMarkdownService>(Services.StreamingMarkdownService);
|
||||||
const abortService: AbortService = Resolve<AbortService>(Services.AbortService);
|
const abortService: AbortService = Resolve<AbortService>(Services.AbortService);
|
||||||
|
|
||||||
|
let collectedArtifacts: Artifact[] = [];
|
||||||
|
|
||||||
let chatContainer: HTMLDivElement;
|
let chatContainer: HTMLDivElement;
|
||||||
let chatArea: ChatArea;
|
let chatArea: ChatArea;
|
||||||
let chatInput: ChatInput;
|
let chatInput: ChatInput;
|
||||||
|
|
@ -42,8 +53,8 @@
|
||||||
|
|
||||||
let currentThought: string | null = null;
|
let currentThought: string | null = null;
|
||||||
|
|
||||||
export function focusInput() {
|
export function focusInput(force: boolean = false) {
|
||||||
chatInput?.focusInput();
|
chatInput?.focusInput(force);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resetChatArea() {
|
export function resetChatArea() {
|
||||||
|
|
@ -76,10 +87,13 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNoApiKey(): boolean {
|
function handleNoApiKey(): boolean {
|
||||||
hasNoApiKey = settingsService.getApiKeyForCurrentModel().trim() == "";
|
hasNoApiKey = settingsService.settings.provider !== AIProvider.Local
|
||||||
|
&& settingsService.getApiKeyForCurrentProvider().trim() == "";
|
||||||
|
|
||||||
if (hasNoApiKey) {
|
if (hasNoApiKey) {
|
||||||
openPluginSettings(plugin);
|
openPluginSettings(plugin);
|
||||||
}
|
}
|
||||||
|
|
||||||
return hasNoApiKey;
|
return hasNoApiKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,6 +106,7 @@
|
||||||
if (handleNoApiKey()) {
|
if (handleNoApiKey()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
collectedArtifacts = [];
|
||||||
|
|
||||||
const currentRequest = userRequest;
|
const currentRequest = userRequest;
|
||||||
|
|
||||||
|
|
@ -126,6 +141,14 @@
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onArtifactProduced: (artifact: Artifact) => {
|
||||||
|
const collectedArtifact = collectedArtifacts.find(a => a.filePath === artifact.filePath);
|
||||||
|
if (!collectedArtifact) {
|
||||||
|
collectedArtifacts.push(artifact);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
collectedArtifact.updatedContent = artifact.updatedContent;
|
||||||
|
},
|
||||||
onPlanningStarted: () => {
|
onPlanningStarted: () => {
|
||||||
busyPlanning = true;
|
busyPlanning = true;
|
||||||
},
|
},
|
||||||
|
|
@ -140,6 +163,9 @@
|
||||||
chatInput.enterQuestionMode(resolve);
|
chatInput.enterQuestionMode(resolve);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
onPlanApprovalRequest: async (plan) => {
|
||||||
|
return planApprovalService.requestApproval(plan);
|
||||||
|
},
|
||||||
onPlanUpdate: (executionPlan) => {
|
onPlanUpdate: (executionPlan) => {
|
||||||
executionPlanStore.setPlan(executionPlan);
|
executionPlanStore.setPlan(executionPlan);
|
||||||
},
|
},
|
||||||
|
|
@ -150,6 +176,9 @@
|
||||||
executionPlanStore.clearPlan();
|
executionPlanStore.clearPlan();
|
||||||
},
|
},
|
||||||
onComplete: async () => {
|
onComplete: async () => {
|
||||||
|
saveCollectedArtifects(conversation);
|
||||||
|
conversation = conversation;
|
||||||
|
conversationService.saveConversation(conversation);
|
||||||
isSubmitting = false;
|
isSubmitting = false;
|
||||||
busyPlanning = false;
|
busyPlanning = false;
|
||||||
currentThought = null;
|
currentThought = null;
|
||||||
|
|
@ -161,6 +190,17 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
$: if ($conversationStore.shouldReset) {
|
||||||
conversation = new Conversation();
|
conversation = new Conversation();
|
||||||
conversationService.resetCurrentConversation();
|
conversationService.resetCurrentConversation();
|
||||||
|
|
@ -168,6 +208,8 @@
|
||||||
isSubmitting = false;
|
isSubmitting = false;
|
||||||
currentThought = null;
|
currentThought = null;
|
||||||
|
|
||||||
|
chatArea?.resetChatArea();
|
||||||
|
|
||||||
chatService.onNameChanged?.("");
|
chatService.onNameChanged?.("");
|
||||||
conversationStore.clearResetFlag();
|
conversationStore.clearResetFlag();
|
||||||
}
|
}
|
||||||
|
|
@ -187,7 +229,7 @@
|
||||||
conversationService.setCurrentConversationPath(filePath);
|
conversationService.setCurrentConversationPath(filePath);
|
||||||
chatService.onNameChanged?.(loadedConversation.title);
|
chatService.onNameChanged?.(loadedConversation.title);
|
||||||
conversationStore.clearLoadFlag();
|
conversationStore.clearLoadFlag();
|
||||||
chatArea.updateChatAreaLayout("instant", true);
|
chatArea.updateChatAreaLayout("instant");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,52 +45,45 @@
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
#diff-controls-wrapper {
|
#diff-controls-wrapper {
|
||||||
transition: height 0.2s ease-out;
|
transition: height 0.2s ease-out;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
#diff-controls {
|
#diff-controls {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr var(--size-4-2) 1fr;
|
grid-template-columns: 1fr var(--size-4-2) 1fr;
|
||||||
grid-template-rows: auto;
|
grid-template-rows: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
#diff-accept {
|
#diff-accept {
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
background-color: color-mix(
|
color: white;
|
||||||
in srgb,
|
background-color: #38533a;
|
||||||
var(--color-green) 75%,
|
|
||||||
var(--background-primary) 25%
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#diff-accept:hover {
|
#diff-accept:hover {
|
||||||
background-color: var(--color-green);
|
background-color: #537555;
|
||||||
}
|
}
|
||||||
|
|
||||||
#diff-accept:focus {
|
#diff-accept:focus {
|
||||||
background-color: var(--color-green);
|
background-color: #537555;
|
||||||
}
|
}
|
||||||
|
|
||||||
#diff-reject {
|
#diff-reject {
|
||||||
grid-column: 3;
|
grid-column: 3;
|
||||||
background-color: color-mix(
|
color: white;
|
||||||
in srgb,
|
background-color: #593030;
|
||||||
var(--color-red) 75%,
|
|
||||||
var(--background-primary) 25%
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#diff-reject:hover {
|
#diff-reject:hover {
|
||||||
background-color: var(--color-red);
|
background-color: #774545;
|
||||||
}
|
}
|
||||||
|
|
||||||
#diff-reject:focus {
|
#diff-reject:focus {
|
||||||
background-color: var(--color-red);
|
background-color: #774545;
|
||||||
}
|
}
|
||||||
|
|
||||||
.diff-button {
|
.diff-button {
|
||||||
border-radius: var(--button-radius);
|
transition: background-color 0.2s ease-out;
|
||||||
transition-duration: 0.5s;
|
|
||||||
}
|
}
|
||||||
</style>
|
</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">
|
<script lang="ts">
|
||||||
import { fade } from "svelte/transition";
|
import { fade } from "svelte/transition";
|
||||||
import Spinner from "./Spinner.svelte";
|
|
||||||
export let thought: string | null = null;
|
export let thought: string | null = null;
|
||||||
export let thoughtIndicatorElement: HTMLElement | undefined = undefined;
|
export let thoughtIndicatorElement: HTMLElement | undefined = undefined;
|
||||||
|
|
||||||
|
|
@ -9,9 +8,10 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if isVisible}
|
{#if isVisible}
|
||||||
<div class="ai-thought-container" in:fade={{ duration: 200 }} out:fade={{ duration: 200 }} bind:this={thoughtIndicatorElement}>
|
<div class="ai-thought-container" bind:this={thoughtIndicatorElement} transition:fade={{ duration: 300 }}>
|
||||||
<div class="ai-thought-bubble">
|
<div class="ai-thought-line">
|
||||||
<span><Spinner/> {thought}</span>
|
<span class="ai-thought-dot"></span>
|
||||||
|
<span class="ai-thought-text">{thought}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -22,59 +22,40 @@
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-thought-bubble {
|
.ai-thought-line {
|
||||||
--border-width: 1px;
|
display: flex;
|
||||||
position: relative;
|
align-items: flex-start;
|
||||||
display: inline-flex;
|
gap: 0.5rem;
|
||||||
align-items: center;
|
padding-left: 0.125rem;
|
||||||
border-radius: 10px;
|
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-thought-bubble span {
|
.ai-thought-dot {
|
||||||
position: relative;
|
flex-shrink: 0;
|
||||||
z-index: 1;
|
width: 6px;
|
||||||
background: var(--background-primary);
|
height: 6px;
|
||||||
border-radius: 10px;
|
margin-top: 5px;
|
||||||
padding: 0.55rem 0.7rem;
|
border-radius: 50%;
|
||||||
|
background: var(--text-accent, #a78bfa);
|
||||||
|
animation: breathe 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-thought-text {
|
||||||
font-size: var(--font-smallest);
|
font-size: var(--font-smallest);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
|
line-height: 1.5;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
width: 100%;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-thought-bubble::after {
|
@keyframes breathe {
|
||||||
position: absolute;
|
0%, 100% {
|
||||||
content: "";
|
opacity: 0.35;
|
||||||
top: calc(-1 * var(--border-width));
|
transform: scale(0.85);
|
||||||
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 {
|
|
||||||
50% {
|
50% {
|
||||||
background-position: 100% 50%;
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
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 { MimeTypeToFileTypes } from "Enums/FileTypeMimeTypeMapping";
|
||||||
import { isImageMimeType, isTextMimeType, MimeType, toMimeType } from "Enums/MimeType";
|
import { isImageMimeType, isTextMimeType, MimeType, toMimeType } from "Enums/MimeType";
|
||||||
import { StringTools } from "Helpers/StringTools";
|
import { StringTools } from "Helpers/StringTools";
|
||||||
|
import type { IBinaryFile } from "Conversations/IBinaryFile";
|
||||||
|
|
||||||
export class Attachment {
|
export class Attachment implements IBinaryFile {
|
||||||
|
|
||||||
public fileName: string;
|
public fileName: string;
|
||||||
public mimeType: string;
|
public mimeType: string;
|
||||||
|
|
@ -41,6 +42,14 @@ export class Attachment {
|
||||||
return this.base64;
|
return this.base64;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getStoragePath(): string | undefined {
|
||||||
|
return this.filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setStoragePath(path: string): void {
|
||||||
|
this.filePath = path;
|
||||||
|
}
|
||||||
|
|
||||||
public getFileID(provider: AIProvider): string | undefined {
|
public getFileID(provider: AIProvider): string | undefined {
|
||||||
return this.fileID[provider];
|
return this.fileID[provider];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { ApiErrorType } from "Types/ApiError";
|
||||||
import type { Attachment } from "./Attachment";
|
import type { Attachment } from "./Attachment";
|
||||||
import type { Reference } from "./Reference";
|
import type { Reference } from "./Reference";
|
||||||
import { Copy } from "Enums/Copy";
|
import { Copy } from "Enums/Copy";
|
||||||
|
import type { Artifact } from "./Artifact";
|
||||||
|
|
||||||
type ConversationContentInit = {
|
type ConversationContentInit = {
|
||||||
role: Role;
|
role: Role;
|
||||||
|
|
@ -11,6 +12,7 @@ type ConversationContentInit = {
|
||||||
displayContent?: string;
|
displayContent?: string;
|
||||||
toolCall?: string;
|
toolCall?: string;
|
||||||
functionResponse?: string;
|
functionResponse?: string;
|
||||||
|
artifacts?: Artifact[];
|
||||||
attachments?: Attachment[];
|
attachments?: Attachment[];
|
||||||
references?: Reference[];
|
references?: Reference[];
|
||||||
shouldDisplayContent?: boolean;
|
shouldDisplayContent?: boolean;
|
||||||
|
|
@ -20,12 +22,17 @@ type ConversationContentInit = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export class ConversationContent {
|
export class ConversationContent {
|
||||||
|
// Runtime-only counter for Svelte {#each} keying
|
||||||
|
private static nextId: number = 0;
|
||||||
|
|
||||||
|
public readonly id: number;
|
||||||
public role: Role;
|
public role: Role;
|
||||||
public timestamp: Date;
|
public timestamp: Date;
|
||||||
public content: string | undefined;
|
public content: string | undefined;
|
||||||
public displayContent: string | undefined;
|
public displayContent: string | undefined;
|
||||||
public toolCall: string | undefined;
|
public toolCall: string | undefined;
|
||||||
public functionResponse: string | undefined;
|
public functionResponse: string | undefined;
|
||||||
|
public artifacts: Artifact[];
|
||||||
public attachments: Attachment[];
|
public attachments: Attachment[];
|
||||||
public references: Reference[];
|
public references: Reference[];
|
||||||
public shouldDisplayContent: boolean;
|
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.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.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.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.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.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)
|
* @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
|
* @param init.errorType - Indicates that this contains an error of the given type
|
||||||
*/
|
*/
|
||||||
constructor(init: ConversationContentInit) {
|
constructor(init: ConversationContentInit) {
|
||||||
|
this.id = ConversationContent.nextId++;
|
||||||
this.role = init.role;
|
this.role = init.role;
|
||||||
this.timestamp = init.timestamp ?? new Date();
|
this.timestamp = init.timestamp ?? new Date();
|
||||||
this.content = init.content;
|
this.content = init.content;
|
||||||
this.displayContent = init.displayContent;
|
this.displayContent = init.displayContent;
|
||||||
this.toolCall = init.toolCall;
|
this.toolCall = init.toolCall;
|
||||||
this.functionResponse = init.functionResponse;
|
this.functionResponse = init.functionResponse;
|
||||||
|
this.artifacts = init.artifacts ?? [];
|
||||||
this.attachments = init.attachments ?? [];
|
this.attachments = init.attachments ?? [];
|
||||||
this.references = init.references ?? [];
|
this.references = init.references ?? [];
|
||||||
this.shouldDisplayContent = init.shouldDisplayContent ?? true;
|
this.shouldDisplayContent = init.shouldDisplayContent ?? true;
|
||||||
|
|
@ -79,6 +89,7 @@ export class ConversationContent {
|
||||||
displayContent?: string;
|
displayContent?: string;
|
||||||
toolCall?: string;
|
toolCall?: string;
|
||||||
functionResponse?: string;
|
functionResponse?: string;
|
||||||
|
artifacts?: unknown[];
|
||||||
attachments?: unknown[];
|
attachments?: unknown[];
|
||||||
references?: unknown[];
|
references?: unknown[];
|
||||||
shouldDisplayContent?: boolean;
|
shouldDisplayContent?: boolean;
|
||||||
|
|
@ -98,6 +109,7 @@ export class ConversationContent {
|
||||||
(!("displayContent" in data) || typeof data.displayContent === "string") &&
|
(!("displayContent" in data) || typeof data.displayContent === "string") &&
|
||||||
(!("toolCall" in data) || typeof data.toolCall === "string") &&
|
(!("toolCall" in data) || typeof data.toolCall === "string") &&
|
||||||
(!("functionResponse" in data) || typeof data.functionResponse === "string") &&
|
(!("functionResponse" in data) || typeof data.functionResponse === "string") &&
|
||||||
|
(!("artifacts" in data) || Array.isArray(data.artifacts)) &&
|
||||||
(!("attachments" in data) || Array.isArray(data.attachments)) &&
|
(!("attachments" in data) || Array.isArray(data.attachments)) &&
|
||||||
(!("references" in data) || Array.isArray(data.references)) &&
|
(!("references" in data) || Array.isArray(data.references)) &&
|
||||||
(!("shouldDisplayContent" in data) || typeof data.shouldDisplayContent === "boolean") &&
|
(!("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);
|
return isOpenAIModel(model);
|
||||||
case AIProvider.Mistral:
|
case AIProvider.Mistral:
|
||||||
return isMistralModel(model);
|
return isMistralModel(model);
|
||||||
|
case AIProvider.Local:
|
||||||
|
return true; // Local models are freely typed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,12 +60,14 @@ export enum AIProvider {
|
||||||
Claude = "Claude",
|
Claude = "Claude",
|
||||||
Gemini = "Gemini",
|
Gemini = "Gemini",
|
||||||
OpenAI = "OpenAI",
|
OpenAI = "OpenAI",
|
||||||
Mistral = "Mistral"
|
Mistral = "Mistral",
|
||||||
|
Local = "Local"
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum AIProviderModel {
|
export enum AIProviderModel {
|
||||||
// Claude models
|
// Claude models
|
||||||
ClaudeSonnet_4_6 = "claude-sonnet-4-6",
|
ClaudeFable_5 = "claude-fable-5",
|
||||||
|
ClaudeSonnet_5 = "claude-sonnet-5",
|
||||||
ClaudeOpus_4_8 = "claude-opus-4-8",
|
ClaudeOpus_4_8 = "claude-opus-4-8",
|
||||||
ClaudeHaiku_4_5 = "claude-haiku-4-5-20251001",
|
ClaudeHaiku_4_5 = "claude-haiku-4-5-20251001",
|
||||||
|
|
||||||
|
|
@ -74,9 +78,9 @@ export enum AIProviderModel {
|
||||||
GeminiPro_3_1_Preview = "gemini-3.1-pro-preview",
|
GeminiPro_3_1_Preview = "gemini-3.1-pro-preview",
|
||||||
|
|
||||||
// OpenAI models
|
// OpenAI models
|
||||||
GPT_5_5 = "gpt-5.5",
|
GPT_5_6_Sol = "gpt-5.6-sol",
|
||||||
GPT_5_4_Mini = "gpt-5.4-mini",
|
GPT_5_6_Terra = "gpt-5.6-terra",
|
||||||
GPT_5_4_Nano = "gpt-5.4-nano",
|
GPT_5_6_Luna = "gpt-5.6-luna",
|
||||||
|
|
||||||
// Mistral models
|
// Mistral models
|
||||||
MistralMedium = "mistral-medium-3-5",
|
MistralMedium = "mistral-medium-3-5",
|
||||||
|
|
@ -85,8 +89,11 @@ export enum AIProviderModel {
|
||||||
// Conversation naming models (aliases to existing models)
|
// Conversation naming models (aliases to existing models)
|
||||||
ClaudeNamer = ClaudeHaiku_4_5,
|
ClaudeNamer = ClaudeHaiku_4_5,
|
||||||
GeminiNamer = GeminiFlash_3_1_Lite,
|
GeminiNamer = GeminiFlash_3_1_Lite,
|
||||||
OpenAINamer = GPT_5_4_Nano,
|
OpenAINamer = GPT_5_6_Luna,
|
||||||
MistralNamer = MistralSmall
|
MistralNamer = MistralSmall,
|
||||||
|
|
||||||
|
// Local models are freely typed so no default is given
|
||||||
|
None = "none"
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum AIProviderURL {
|
export enum AIProviderURL {
|
||||||
|
|
@ -112,20 +119,23 @@ export enum MistralAgentEndpoint {
|
||||||
export const DEFAULT_QUICK_MODEL_BY_PROVIDER: Record<AIProvider, AIProviderModel> = {
|
export const DEFAULT_QUICK_MODEL_BY_PROVIDER: Record<AIProvider, AIProviderModel> = {
|
||||||
[AIProvider.Claude]: AIProviderModel.ClaudeHaiku_4_5,
|
[AIProvider.Claude]: AIProviderModel.ClaudeHaiku_4_5,
|
||||||
[AIProvider.Gemini]: AIProviderModel.GeminiFlash_3_1_Lite,
|
[AIProvider.Gemini]: AIProviderModel.GeminiFlash_3_1_Lite,
|
||||||
[AIProvider.OpenAI]: AIProviderModel.GPT_5_4_Nano,
|
[AIProvider.OpenAI]: AIProviderModel.GPT_5_6_Luna,
|
||||||
[AIProvider.Mistral]: AIProviderModel.MistralSmall,
|
[AIProvider.Mistral]: AIProviderModel.MistralSmall,
|
||||||
|
[AIProvider.Local]: AIProviderModel.None
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_MODEL_BY_PROVIDER: Record<AIProvider, AIProviderModel> = {
|
export const DEFAULT_MODEL_BY_PROVIDER: Record<AIProvider, AIProviderModel> = {
|
||||||
[AIProvider.Claude]: AIProviderModel.ClaudeSonnet_4_6,
|
[AIProvider.Claude]: AIProviderModel.ClaudeSonnet_5,
|
||||||
[AIProvider.Gemini]: AIProviderModel.GeminiFlash_3_5_Flash,
|
[AIProvider.Gemini]: AIProviderModel.GeminiFlash_3_5_Flash,
|
||||||
[AIProvider.OpenAI]: AIProviderModel.GPT_5_5,
|
[AIProvider.OpenAI]: AIProviderModel.GPT_5_6_Terra,
|
||||||
[AIProvider.Mistral]: AIProviderModel.MistralMedium,
|
[AIProvider.Mistral]: AIProviderModel.MistralMedium,
|
||||||
|
[AIProvider.Local]: AIProviderModel.None
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_PLANNING_MODEL_BY_PROVIDER: Record<AIProvider, AIProviderModel> = {
|
export const DEFAULT_PLANNING_MODEL_BY_PROVIDER: Record<AIProvider, AIProviderModel> = {
|
||||||
[AIProvider.Claude]: AIProviderModel.ClaudeOpus_4_8,
|
[AIProvider.Claude]: AIProviderModel.ClaudeOpus_4_8,
|
||||||
[AIProvider.Gemini]: AIProviderModel.GeminiFlash_3_5_Flash,
|
[AIProvider.Gemini]: AIProviderModel.GeminiFlash_3_5_Flash,
|
||||||
[AIProvider.OpenAI]: AIProviderModel.GPT_5_5,
|
[AIProvider.OpenAI]: AIProviderModel.GPT_5_6_Sol,
|
||||||
[AIProvider.Mistral]: AIProviderModel.MistralMedium,
|
[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;
|
||||||
|
}
|
||||||
|
}
|
||||||
106
Enums/Copy.ts
106
Enums/Copy.ts
|
|
@ -6,7 +6,8 @@ export enum Copy {
|
||||||
NoUserInstruction = "No custom instructions",
|
NoUserInstruction = "No custom instructions",
|
||||||
|
|
||||||
// Model Display Names
|
// Model Display Names
|
||||||
ClaudeSonnet_4_6 = "Claude Sonnet 4.6",
|
ClaudeFable_5 = "Claude Fable 5",
|
||||||
|
ClaudeSonnet_5 = "Claude Sonnet 5",
|
||||||
ClaudeOpus_4_8 = "Claude Opus 4.8",
|
ClaudeOpus_4_8 = "Claude Opus 4.8",
|
||||||
ClaudeHaiku_4_5 = "Claude Haiku 4.5",
|
ClaudeHaiku_4_5 = "Claude Haiku 4.5",
|
||||||
|
|
||||||
|
|
@ -15,62 +16,79 @@ export enum Copy {
|
||||||
GeminiFlash_3_5_Flash = "Gemini 3.5 Flash",
|
GeminiFlash_3_5_Flash = "Gemini 3.5 Flash",
|
||||||
GeminiPro_3_1_Preview = "Gemini 3.1 Pro Preview",
|
GeminiPro_3_1_Preview = "Gemini 3.1 Pro Preview",
|
||||||
|
|
||||||
GPT_5_5 = "GPT-5.5",
|
GPT_5_6_Sol = "GPT-5.6 Sol",
|
||||||
GPT_5_4_Mini = "GPT-5.4 Mini",
|
GPT_5_6_Terra = "GPT-5.6 Terra",
|
||||||
GPT_5_4_Nano = "GPT-5.4 Nano",
|
GPT_5_6_Luna = "GPT-5.6 Luna",
|
||||||
|
|
||||||
MistralMedium = "Mistral Medium 3.5",
|
MistralMedium = "Mistral Medium 3.5",
|
||||||
MistralSmall = "Mistral Small 4",
|
MistralSmall = "Mistral Small 4",
|
||||||
|
|
||||||
// AI Provider Groups
|
// AI Provider Groups
|
||||||
|
CloudProvider = "Cloud Provider",
|
||||||
|
LocalProvider = "Self Hosted",
|
||||||
ProviderClaude = "Claude",
|
ProviderClaude = "Claude",
|
||||||
ProviderOpenAI = "OpenAI",
|
ProviderOpenAI = "OpenAI",
|
||||||
ProviderGemini = "Gemini",
|
ProviderGemini = "Gemini",
|
||||||
ProviderMistral = "Mistral",
|
ProviderMistral = "Mistral",
|
||||||
|
ProviderLocal = "Local",
|
||||||
|
|
||||||
// Settings Copy
|
// Settings Copy
|
||||||
|
SettingProvider = "Provider",
|
||||||
SettingModel = "Model",
|
SettingModel = "Model",
|
||||||
SettingPlanningModel = "Planning Model",
|
SettingPlanningModel = "Planning model",
|
||||||
SettingApiKey = "API Key",
|
SettingQuickActionModel = "Quick actions model",
|
||||||
SettingFileExclusions = "File Exclusions",
|
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",
|
SettingContext = "Context",
|
||||||
SettingSearchResultsLimit = "Search Results Limit",
|
SettingSearchResultsLimit = "Search results limit",
|
||||||
SettingSnippetSizeLimit = "Snippet Size Limit",
|
SettingSnippetSizeLimit = "Snippet size limit",
|
||||||
SettingFileMonitoringHeading = "File Monitoring Guidelines",
|
SettingFileMonitoringHeading = "File monitoring guidelines",
|
||||||
SettingMemories = "Memories",
|
SettingMemories = "Memories",
|
||||||
SettingEnableMemories = "Enable Memories",
|
SettingEnableMemories = "Enable memories",
|
||||||
SettingEnableMemoriesDesc = "Allow the AI to recall memories from previous conversations.",
|
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.",
|
SettingAllowUpdatingMemoriesDesc = "Allow the AI to save and update memories during conversations.",
|
||||||
|
|
||||||
SettingWebViewerAccess = "Web Viewer Access",
|
SettingWebViewerAccess = "Web viewer access",
|
||||||
SettingEnableWebViewer = "Enable 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.",
|
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
|
// Settings Descriptions
|
||||||
|
SettingProviderDesc = "Select the AI provider to use.",
|
||||||
SettingModelDesc = "Select the AI model 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.",
|
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.",
|
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.",
|
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.",
|
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",
|
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.",
|
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.",
|
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.",
|
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.",
|
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 (https://platform.openai.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 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.",
|
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.",
|
SettingAccessMemories = "Memories let the AI retain preferences and context across conversations. You can view and edit them at any time.",
|
||||||
|
|
||||||
// Settings Placeholders
|
// Settings Placeholders
|
||||||
PlaceholderEnterApiKey = "Enter your API key",
|
PlaceholderEnterApiKey = "Enter your API key",
|
||||||
PlaceholderFileExclusions = "Examples:\n\nprivate/**\n*.secret.md\njournal/personal/**",
|
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
|
// Settings Tooltips
|
||||||
TooltipShowApiKey = "Show API Key",
|
TooltipShowApiKey = "Show API Key",
|
||||||
TooltipHideApiKey = "Hide API Key",
|
TooltipHideApiKey = "Hide API Key",
|
||||||
TooltipLearnMoreFileMonitoring = "Learn more in Plugin Guide",
|
|
||||||
TooltipAccessMemories = "View Memories",
|
TooltipAccessMemories = "View Memories",
|
||||||
|
|
||||||
SettingAdvancedSettings = "Advanced Settings",
|
SettingAdvancedSettings = "Advanced Settings",
|
||||||
|
|
@ -107,6 +125,7 @@ export enum Copy {
|
||||||
// Chat Input Placeholders
|
// Chat Input Placeholders
|
||||||
InputPlaceholderQuestion = "Provide an answer...",
|
InputPlaceholderQuestion = "Provide an answer...",
|
||||||
InputPlaceholderDiff = "Make a suggestion...",
|
InputPlaceholderDiff = "Make a suggestion...",
|
||||||
|
InputPlaceholderPlanApproval = "Suggest a change to the plan...",
|
||||||
InputPlaceholderNormal = "Type a message...",
|
InputPlaceholderNormal = "Type a message...",
|
||||||
|
|
||||||
// Chat Input Button Labels
|
// Chat Input Button Labels
|
||||||
|
|
@ -115,13 +134,24 @@ export enum Copy {
|
||||||
ButtonMakeSuggestion = "Make Suggestion",
|
ButtonMakeSuggestion = "Make Suggestion",
|
||||||
ButtonSendMessage = "Send Message",
|
ButtonSendMessage = "Send Message",
|
||||||
ButtonChangeChatMode = "Change the Chat Mode",
|
ButtonChangeChatMode = "Change the Chat Mode",
|
||||||
|
ButtonFreeEdit = "Allow changes without asking",
|
||||||
|
ButtonFreeEditDisabled = "Read-only mode enabled",
|
||||||
ButtonTurnOffPlanningMode = "Turn off Planning Mode",
|
ButtonTurnOffPlanningMode = "Turn off Planning Mode",
|
||||||
ButtonTurnOnPlanningMode = "Turn on Planning Mode",
|
ButtonTurnOnPlanningMode = "Turn on Planning Mode",
|
||||||
ButtonTurnOffWebSearch = "Turn off Web Search",
|
ButtonTurnOffWebSearch = "Turn off Web Search",
|
||||||
ButtonTurnOnWebSearch = "Turn on Web Search",
|
ButtonTurnOnWebSearch = "Turn on Web Search",
|
||||||
|
ButtonWebSearchUnavailable = "Web search unavailable",
|
||||||
ButtonUserInstruction = "User Instruction",
|
ButtonUserInstruction = "User Instruction",
|
||||||
ButtonAttachFiles = "Attach Files",
|
ButtonAttachFiles = "Attach Files",
|
||||||
|
|
||||||
|
ButtonApprove = "Approve",
|
||||||
|
ButtonReject = "Reject",
|
||||||
|
ButtonDiscuss = "Discuss",
|
||||||
|
|
||||||
|
ButtonRestore = "Restore this version",
|
||||||
|
ButtonRestorePrevious = "Restore previous version",
|
||||||
|
ButtonConfirm = "Confirm?",
|
||||||
|
|
||||||
// Agent file message
|
// Agent file message
|
||||||
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.`,
|
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.`,
|
||||||
|
|
||||||
|
|
@ -151,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.",
|
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.",
|
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}",
|
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!",
|
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.",
|
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}`,
|
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.",
|
MemoriesEmpty = "No memories have been created yet.",
|
||||||
|
|
@ -182,7 +215,6 @@ The following context explains why you are doing the task. It is NOT an instruct
|
||||||
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",
|
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 — the web viewer tool is unavailable; if the user requests it, inform them it is currently turned off in settings",
|
DirectiveWebViewerDisabled = "- **Web Viewer**: DISABLED — the web viewer tool is unavailable; if the user requests it, inform them it is currently turned off in settings",
|
||||||
|
|
||||||
PlanSubmissionRequired = "Error: Attempted to exit planning but plan has not yet been submitted!",
|
|
||||||
MaxExecutionDepthReached = "Exceeded maximum plan execution attempts - consult with the user on how to continue.",
|
MaxExecutionDepthReached = "Exceeded maximum plan execution attempts - consult with the user on how to continue.",
|
||||||
|
|
||||||
// Execution Plan Request Templates
|
// Execution Plan Request Templates
|
||||||
|
|
@ -221,7 +253,7 @@ If you find any issues or have a feature request, please feel free to raise them
|
||||||
HelpModalGettingStartedTitle = "Getting started",
|
HelpModalGettingStartedTitle = "Getting started",
|
||||||
HelpModalGettingStartedContent = `#### Getting started
|
HelpModalGettingStartedContent = `#### Getting started
|
||||||
|
|
||||||
1. **Add an API key**: Go to Settings and add at least one API key (Claude, Gemini, OpenAI, or Mistral)
|
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
|
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`,
|
3. **Open the chat**: Click the plugin icon in the sidebar to start chatting`,
|
||||||
|
|
||||||
|
|
@ -307,7 +339,9 @@ When you upload files (PDFs, images) to conversations, they are stored by your A
|
||||||
- Claude: [Anthropic Console](https://console.anthropic.com/)
|
- Claude: [Anthropic Console](https://console.anthropic.com/)
|
||||||
- Gemini: [Google AI Studio](https://aistudio.google.com/)
|
- Gemini: [Google AI Studio](https://aistudio.google.com/)
|
||||||
- OpenAI: [OpenAI Platform](https://platform.openai.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",
|
HelpModalTroubleshootTitle = "Troubleshooting",
|
||||||
HelpModalTroubleshootContent = `#### Common issues & solutions
|
HelpModalTroubleshootContent = `#### Common issues & solutions
|
||||||
|
|
@ -357,7 +391,23 @@ This error indicates a temporary issue with the AI provider's servers.
|
||||||
**How to resolve:**
|
**How to resolve:**
|
||||||
- Wait a few minutes and try again
|
- 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
|
- 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",
|
HelpModalPrivacyTitle = "Privacy",
|
||||||
HelpModalPrivacyContent = `#### Privacy & security
|
HelpModalPrivacyContent = `#### Privacy & security
|
||||||
|
|
@ -380,6 +430,7 @@ This error indicates a temporary issue with the AI provider's servers.
|
||||||
- **Gemini**: Google's API
|
- **Gemini**: Google's API
|
||||||
- **OpenAI**: OpenAI's API
|
- **OpenAI**: OpenAI's API
|
||||||
- **Mistral**: Mistral AI'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**:
|
**What gets sent**:
|
||||||
- Your messages and referenced file contents (including binary files like PDFs, Office documents, and images)
|
- Your messages and referenced file contents (including binary files like PDFs, Office documents, and images)
|
||||||
|
|
@ -419,6 +470,11 @@ Each AI provider has their own data policies:
|
||||||
// Conversation Modal Copy
|
// Conversation Modal Copy
|
||||||
NoConversationsFound = "No conversations match your search.",
|
NoConversationsFound = "No conversations match your search.",
|
||||||
|
|
||||||
|
// Artifact Copy
|
||||||
|
ArtifactActionCreated = "CREATED",
|
||||||
|
ArtifactActionModified = "MODIFIED",
|
||||||
|
ArtifactActionDeleted = "DELETED",
|
||||||
|
|
||||||
// Help Modal Additional Copy
|
// Help Modal Additional Copy
|
||||||
HelpModalCloseAriaLabel = "Close Help Modal",
|
HelpModalCloseAriaLabel = "Close Help Modal",
|
||||||
PluginVersionPrefix = "Plugin version: ",
|
PluginVersionPrefix = "Plugin version: ",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
export enum Event {
|
export enum Event {
|
||||||
DiffOpened = "diffOpened",
|
DiffOpened = "diffOpened",
|
||||||
DiffClosed = "diffClosed",
|
DiffClosed = "diffClosed",
|
||||||
|
PlanApprovalOpened = "planApprovalOpened",
|
||||||
|
PlanApprovalClosed = "planApprovalClosed",
|
||||||
RateLimitCountdown = "rateLimitCountdown"
|
RateLimitCountdown = "rateLimitCountdown"
|
||||||
}
|
}
|
||||||
|
|
@ -151,8 +151,9 @@ export enum FileType {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toFileType(fileType: string): FileType {
|
export function toFileType(fileType: string): FileType {
|
||||||
if (isKnownFileType(fileType)) {
|
const normalized = fileType.startsWith('.') ? fileType.slice(1) : fileType;
|
||||||
return fileType;
|
if (isKnownFileType(normalized)) {
|
||||||
|
return normalized;
|
||||||
}
|
}
|
||||||
return FileType.UNKNOWN;
|
return FileType.UNKNOWN;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
export enum InputMode {
|
export enum InputMode {
|
||||||
Normal = "normal",
|
Normal = "normal",
|
||||||
Diff = "diff",
|
Diff = "diff",
|
||||||
Question = "question"
|
Question = "question",
|
||||||
|
PlanApproval = "planApproval"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ export enum Path {
|
||||||
VaultkeeperAIDir = "Vaultkeeper AI",
|
VaultkeeperAIDir = "Vaultkeeper AI",
|
||||||
Conversations = `${Path.VaultkeeperAIDir}/Conversations`,
|
Conversations = `${Path.VaultkeeperAIDir}/Conversations`,
|
||||||
Attachments = `${Path.Conversations}/Attachments`,
|
Attachments = `${Path.Conversations}/Attachments`,
|
||||||
|
Artifacts = `${Path.Conversations}/Artifacts`,
|
||||||
UserInstructions = `${Path.VaultkeeperAIDir}/User Instructions`,
|
UserInstructions = `${Path.VaultkeeperAIDir}/User Instructions`,
|
||||||
ExampleUserInstructions = `${Path.UserInstructions}/EXAMPLE_INSTRUCTIONS.md`,
|
ExampleUserInstructions = `${Path.UserInstructions}/EXAMPLE_INSTRUCTIONS.md`,
|
||||||
Memories = `${Path.VaultkeeperAIDir}/Memories.md`
|
Memories = `${Path.VaultkeeperAIDir}/Memories.md`
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ export function toNode(trigger: SearchTrigger, content: string): Node {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const node = createEl("span", {
|
const node = createSpan({
|
||||||
text: text,
|
text: text,
|
||||||
cls: "search-trigger",
|
cls: "search-trigger",
|
||||||
attr: {
|
attr: {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,16 @@
|
||||||
export enum Selector {
|
export enum Selector {
|
||||||
MarkDownLink = "vaultkeeper-ai-internal-markdown-link",
|
MarkDownLink = "vaultkeeper-ai-internal-markdown-link",
|
||||||
AIExclusionsInput = "ai-exclusions-input",
|
AIExclusionsInput = "ai-exclusions-input",
|
||||||
|
LocalUrlInput = "local-url-input",
|
||||||
ApiKeySettingOk = "api-key-setting-ok",
|
ApiKeySettingOk = "api-key-setting-ok",
|
||||||
ApiKeySettingError = "api-key-setting-error",
|
ApiKeySettingError = "api-key-setting-error",
|
||||||
ConversationHistoryModal = "conversation-history-modal",
|
ConversationHistoryModal = "conversation-history-modal",
|
||||||
HelpModal = "help-modal",
|
HelpModal = "help-modal",
|
||||||
ContextSettingItemDescription = "context-setting-item-description",
|
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"
|
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 type { IPageText } from '../Types/SearchTypes';
|
||||||
import { Exception } from './Exception';
|
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[]> {
|
export async function readPDF(arrayBuffer: ArrayBuffer): Promise<IPageText[]> {
|
||||||
try {
|
try {
|
||||||
const pdf = await getDocumentProxy(new Uint8Array(arrayBuffer), { isEvalSupported: false });
|
const pdfjs = (await loadPdfJs()) as PdfJs;
|
||||||
const pages = (await extractText(pdf, { mergePages: false })).text;
|
const pdf = await pdfjs.getDocument({ data: new Uint8Array(arrayBuffer) }).promise;
|
||||||
|
|
||||||
const pageTexts: IPageText[] = pages.map((pageText, index) => ({
|
const pageTexts: IPageText[] = [];
|
||||||
text: pageText,
|
for (let i = 1; i <= pdf.numPages; i++) {
|
||||||
pageNumber: index + 1
|
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;
|
return pageTexts;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -31,18 +71,223 @@ export async function readPDF(arrayBuffer: ArrayBuffer): Promise<IPageText[]> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handles document formats: DOCX, PPTX, XLSX, ODT, ODP, ODS
|
// Best effort attempt to convert PDF pages to images
|
||||||
export async function readDocument(arrayBuffer: ArrayBuffer): Promise<IPageText[]> {
|
export async function pdfToImages(arrayBuffer: ArrayBuffer,
|
||||||
try {
|
options: { scale?: number; mimeType?: 'image/png' | 'image/jpeg'; quality?: number } = {}
|
||||||
const ast = await parseOffice(arrayBuffer, {
|
): Promise<IPageImage[]> {
|
||||||
extractAttachments: true,
|
const { scale = 2, mimeType = 'image/png', quality = 0.92 } = options;
|
||||||
ocr: true,
|
|
||||||
ocrLanguage: "eng+esp"
|
|
||||||
});
|
|
||||||
|
|
||||||
// OfficeParser doesn't currently expose page data (page number etc)
|
try {
|
||||||
return [{ text: (await ast.to('text')).value, pageNumber: 1 }] as IPageText[];
|
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) {
|
} catch (error) {
|
||||||
return [{ text: `Failed to read document: ${Exception.messageFrom(error)}`, pageNumber: 1 }] as IPageText[];
|
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');
|
||||||
|
}
|
||||||
|
|
@ -77,18 +77,23 @@ export abstract class StringTools {
|
||||||
return btoa(binary);
|
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 binaryString = atob(input);
|
||||||
const bytes = new Uint8Array(new ArrayBuffer(binaryString.length));
|
const buffer = new ArrayBuffer(binaryString.length);
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
for (let i = 0; i < binaryString.length; i++) {
|
for (let i = 0; i < binaryString.length; i++) {
|
||||||
bytes[i] = binaryString.charCodeAt(i);
|
bytes[i] = binaryString.charCodeAt(i);
|
||||||
}
|
}
|
||||||
return bytes;
|
return buffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async computeSHA256Hash(base64: string): Promise<string> {
|
public static async computeSHA256Hash(base64: string): Promise<string> {
|
||||||
const bytes = this.toBytes(base64);
|
const buffer = this.toBuffer(base64);
|
||||||
const hashBuffer = await crypto.subtle.digest('SHA-256', bytes);
|
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
|
||||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||||
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||||
}
|
}
|
||||||
|
|
@ -112,7 +117,7 @@ export abstract class StringTools {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const canvas = activeDocument.createEl("canvas");
|
const canvas = createEl("canvas");
|
||||||
canvas.width = width;
|
canvas.width = width;
|
||||||
canvas.height = height;
|
canvas.height = height;
|
||||||
const context = canvas.getContext("2d");
|
const context = canvas.getContext("2d");
|
||||||
|
|
|
||||||
|
|
@ -351,6 +351,11 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile styles */
|
/* 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 {
|
:global(.is-mobile) .help-modal-body {
|
||||||
grid-template-rows: auto var(--size-4-2) 1fr var(--size-4-2) auto;
|
grid-template-rows: auto var(--size-4-2) 1fr var(--size-4-2) auto;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|
@ -385,5 +390,6 @@
|
||||||
grid-row: 3;
|
grid-row: 3;
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
padding: var(--size-4-2);
|
padding: var(--size-4-2);
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
119
README.md
119
README.md
|
|
@ -1,6 +1,6 @@
|
||||||
# Vaultkeeper AI for Obsidian
|
# 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://opensource.org/licenses/MIT)
|
||||||
[](https://obsidian.md)
|
[](https://obsidian.md)
|
||||||
|
|
@ -9,14 +9,18 @@
|
||||||
<img width="1280" height="640" alt="vaultkeeper-social-1280x640" src="https://github.com/user-attachments/assets/47a5ba6c-e59a-4f95-895a-8abc988369dd" />
|
<img width="1280" height="640" alt="vaultkeeper-social-1280x640" src="https://github.com/user-attachments/assets/47a5ba6c-e59a-4f95-895a-8abc988369dd" />
|
||||||
</p>
|
</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
|
## 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**
|
- **Three Chat Modes**
|
||||||
- 🔍 **Read-Only Mode**: AI can search, read, and list your notes safely
|
- 🔍 **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)
|
- ✏️ **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
|
- 📋 **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
|
- **Smart Reference System** - Mention tags (`#`), files (`@`), and folders (`/`) with autocomplete
|
||||||
- **Custom System Instructions** - Create and switch between personalized AI behaviors
|
- **Custom System Instructions** - Create and switch between personalized AI behaviors
|
||||||
- **Conversation Management** - Persistent chat history with automatic conversation naming
|
- **Conversation Management** - Persistent chat history with automatic conversation naming
|
||||||
|
|
@ -28,6 +32,7 @@
|
||||||
- **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
|
- **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
|
- **Mobile Compatible** - Full functionality on mobile devices with touch-friendly controls
|
||||||
- **Streaming Responses** - See AI responses as they're generated
|
- **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
|
- **Local & Private** - API keys stored locally, no data sent to third parties
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
@ -48,11 +53,12 @@
|
||||||
|
|
||||||
## Quick Start
|
## 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/)
|
- **Claude**: Get from [Anthropic Console](https://console.anthropic.com/)
|
||||||
- **Gemini**: Get from [Google AI Studio](https://aistudio.google.com/)
|
- **Gemini**: Get from [Google AI Studio](https://aistudio.google.com/)
|
||||||
- **OpenAI**: Get from [OpenAI Platform](https://platform.openai.com/)
|
- **OpenAI**: Get from [OpenAI Platform](https://platform.openai.com/)
|
||||||
- **Mistral**: Get from [Mistral Console](https://console.mistral.ai/)
|
- **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
|
2. **Select a Model**: Choose your preferred AI model from the dropdown
|
||||||
|
|
||||||
|
|
@ -65,34 +71,6 @@
|
||||||
|
|
||||||
## Usage
|
## 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.
|
Switch models anytime in the settings without losing your conversation context.
|
||||||
|
|
||||||
### Chat Modes
|
### 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.
|
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
|
||||||
|
|
||||||
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.
|
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
|
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
|
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
|
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
|
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. **Completion**: All steps are marked complete when finished
|
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**
|
**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
|
- The view auto-scrolls to keep the active step visible
|
||||||
- Expand/collapse to see the full plan or a compact view
|
- 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**
|
**When to Use Planning Mode**
|
||||||
|
|
||||||
Planning mode is especially useful for:
|
Planning mode is especially useful for:
|
||||||
|
|
@ -209,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.
|
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.
|
You can also disable web search access entirely from Settings if you prefer the AI to stay within your vault.
|
||||||
|
|
||||||
|
|
@ -219,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.
|
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
|
### Reference System
|
||||||
|
|
||||||
Quickly provide context to the AI using the reference system:
|
Quickly provide context to the AI using the reference system:
|
||||||
|
|
@ -282,15 +300,23 @@ See `EXAMPLE_INSTRUCTIONS.md` in your vault for a template.
|
||||||
- Keys stored locally in your vault
|
- Keys stored locally in your vault
|
||||||
- Never transmitted except to respective AI providers
|
- 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**
|
**Model Selection**
|
||||||
|
|
||||||
- Choose from 15+ supported models
|
- Choose from 15+ supported cloud models, or connect your own local model
|
||||||
- Switch anytime without conversation loss
|
- Switch anytime without conversation loss
|
||||||
|
|
||||||
**Planning Model**
|
**Planning Model**
|
||||||
|
|
||||||
- Select a separate model for the planning agent (used in Planning Mode)
|
- 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
|
- 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
|
- The planning model dropdown updates to match your selected provider
|
||||||
|
|
||||||
|
|
@ -388,6 +414,7 @@ I'm currently **not accepting contributions** (pull requests are disabled), but
|
||||||
|
|
||||||
- **API Keys**: Stored locally in your Obsidian vault, never transmitted to third parties
|
- **API Keys**: Stored locally in your Obsidian vault, never transmitted to third parties
|
||||||
- **No External Servers**: Direct communication with AI providers only
|
- **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
|
- **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
|
- **Local Storage**: All conversations and settings stored in your vault
|
||||||
- **Open Source**: Fully auditable codebase
|
- **Open Source**: Fully auditable codebase
|
||||||
|
|
@ -406,28 +433,19 @@ This plugin is built on the shoulders of many excellent projects:
|
||||||
|
|
||||||
**Platform & AI**
|
**Platform & AI**
|
||||||
- Built for [Obsidian](https://obsidian.md)
|
- 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)
|
- 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**
|
**Document Processing**
|
||||||
- [unpdf](https://github.com/unjs/unpdf) - PDF parsing and text extraction
|
- [fflate](https://github.com/101arrowz/fflate) - Unzipping Office Open XML / OpenDocument files for text extraction (DOCX, PPTX, XLSX, ODT, ODP, ODS)
|
||||||
- [officeparser](https://github.com/nicktomlin/officeparser) - Office document parsing (DOCX, PPTX, XLSX, ODT, ODP, ODS)
|
- PDF text extraction via Obsidian's bundled [PDF.js](https://mozilla.github.io/pdf.js/) (`loadPdfJs()`)
|
||||||
|
|
||||||
**UI Framework**
|
**UI Framework**
|
||||||
- [Svelte](https://svelte.dev) - Reactive UI components
|
- [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**
|
**Rich Content Rendering**
|
||||||
- [KaTeX](https://katex.org/) - Mathematical notation rendering
|
- [KaTeX](https://katex.org/) - Mathematical notation rendering
|
||||||
- [Shiki](https://shiki.style/) - Modern syntax highlighting
|
- [Shiki](https://shiki.style/) - Modern syntax highlighting
|
||||||
- [rehype-sanitize](https://github.com/rehypejs/rehype-sanitize) - HTML sanitization for security
|
|
||||||
|
|
||||||
**Diff & Code Review**
|
**Diff & Code Review**
|
||||||
- [diff](https://github.com/kpdecker/jsdiff) - Text diffing library for change detection
|
- [diff](https://github.com/kpdecker/jsdiff) - Text diffing library for change detection
|
||||||
|
|
@ -445,9 +463,8 @@ This plugin is built on the shoulders of many excellent projects:
|
||||||
|
|
||||||
**CSS**
|
**CSS**
|
||||||
- [Loader](https://uiverse.io/Li-Deheng/bright-firefox-37) - Animated streaming indicator adapted from original by Li-Deheng
|
- [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
|
- [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.
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { AIToolResponse } from "AIClasses/ToolDefinitions/AIToolResponse";
|
||||||
import { AIToolCall } from "AIClasses/AIToolCall";
|
import { AIToolCall } from "AIClasses/AIToolCall";
|
||||||
import type { ISearchMatch } from "../../Types/SearchTypes";
|
import type { ISearchMatch } from "../../Types/SearchTypes";
|
||||||
import { AbortService } from "../AbortService";
|
import { AbortService } from "../AbortService";
|
||||||
import { normalizePath, TAbstractFile, TFile } from "obsidian";
|
import { arrayBufferToBase64, normalizePath, TAbstractFile, TFile } from "obsidian";
|
||||||
import { Exception } from "Helpers/Exception";
|
import { Exception } from "Helpers/Exception";
|
||||||
import { Copy } from "Enums/Copy";
|
import { Copy } from "Enums/Copy";
|
||||||
import { pathExtname, replaceCopy } from "Helpers/Helpers";
|
import { pathExtname, replaceCopy } from "Helpers/Helpers";
|
||||||
|
|
@ -16,7 +16,7 @@ import type { WebViewerService } from "Services/WebViewerService";
|
||||||
import { AIToolResponsePayload } from "AIClasses/ToolDefinitions/AIToolResponsePayload";
|
import { AIToolResponsePayload } from "AIClasses/ToolDefinitions/AIToolResponsePayload";
|
||||||
import { Attachment } from "Conversations/Attachment";
|
import { Attachment } from "Conversations/Attachment";
|
||||||
import { isDocumentMimeType, MimeType } from "Enums/MimeType";
|
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 { FileTypeToMimeType } from "Enums/FileTypeMimeTypeMapping";
|
||||||
import { StringTools } from "Helpers/StringTools";
|
import { StringTools } from "Helpers/StringTools";
|
||||||
import { AIToolDefinitions } from "AIClasses/ToolDefinitions/AIToolDefinitions";
|
import { AIToolDefinitions } from "AIClasses/ToolDefinitions/AIToolDefinitions";
|
||||||
|
|
@ -36,6 +36,9 @@ import {
|
||||||
DeleteVaultFolderArgsSchema,
|
DeleteVaultFolderArgsSchema,
|
||||||
MoveVaultFolderArgsSchema
|
MoveVaultFolderArgsSchema
|
||||||
} from "AIClasses/Schemas/AIToolSchemas";
|
} from "AIClasses/Schemas/AIToolSchemas";
|
||||||
|
import { Artifact } from "Conversations/Artifact";
|
||||||
|
import { extname } from "path-browserify";
|
||||||
|
import { ArtifactAction } from "Enums/ArtifactAction";
|
||||||
|
|
||||||
export class AIToolService {
|
export class AIToolService {
|
||||||
|
|
||||||
|
|
@ -230,10 +233,11 @@ export class AIToolService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// This is only used by gemini
|
// This is only used by gemini
|
||||||
case AITool.RequestWebSearch:
|
case AITool.RequestWebSearch: {
|
||||||
return new AIToolResponse(toolCall.name, new AIToolResponsePayload({}), toolCall.toolId)
|
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.ExecuteWorkflow:
|
||||||
case AITool.ContinuePlanExecution:
|
case AITool.ContinuePlanExecution:
|
||||||
case AITool.SubmitPlan:
|
case AITool.SubmitPlan:
|
||||||
|
|
@ -336,23 +340,19 @@ export class AIToolService {
|
||||||
count: binaryResults.length
|
count: binaryResults.length
|
||||||
};
|
};
|
||||||
|
|
||||||
return new AIToolResponsePayload(response, attachments);
|
return new AIToolResponsePayload(response, [], attachments);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async writeVaultFile(filePath: string, content: string): Promise<AIToolResponsePayload> {
|
private async writeVaultFile(filePath: string, content: string): Promise<AIToolResponsePayload> {
|
||||||
const result = await this.fileSystemService.writeToFilePath(normalizePath(filePath), content);
|
return await this.asTrackedAction(filePath, async () => {
|
||||||
if (result instanceof Error) {
|
return await this.fileSystemService.writeToFilePath(normalizePath(filePath), content);
|
||||||
return new AIToolResponsePayload({ success: false, error: result.message });
|
});
|
||||||
}
|
|
||||||
return new AIToolResponsePayload({ success: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async patchVaultFile(filePath: string, oldContent: string[], newContent: string[]): Promise<AIToolResponsePayload> {
|
private async patchVaultFile(filePath: string, oldContent: string[], newContent: string[]): Promise<AIToolResponsePayload> {
|
||||||
const result = await this.fileSystemService.patchFileAtPath(normalizePath(filePath), oldContent, newContent);
|
return await this.asTrackedAction(filePath, async () => {
|
||||||
if (result instanceof Error) {
|
return await this.fileSystemService.patchFileAtPath(normalizePath(filePath), oldContent, newContent);
|
||||||
return new AIToolResponsePayload({ success: false, error: result.message });
|
});
|
||||||
}
|
|
||||||
return new AIToolResponsePayload({ success: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async deleteVaultFiles(filePaths: string[], confirmation: boolean): Promise<AIToolResponsePayload> {
|
private async deleteVaultFiles(filePaths: string[], confirmation: boolean): Promise<AIToolResponsePayload> {
|
||||||
|
|
@ -360,15 +360,19 @@ export class AIToolService {
|
||||||
return new AIToolResponsePayload({ error: "Confirmation was false, no action taken" });
|
return new AIToolResponsePayload({ error: "Confirmation was false, no action taken" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = await Promise.all(filePaths.map(async filePath => {
|
const results: object[] = [];
|
||||||
const result = await this.fileSystemService.deleteFile(filePath);
|
const artifacts = await this.collectDeletionCandidatesArtifacts(filePaths);
|
||||||
if (result instanceof Error) {
|
|
||||||
return { path: filePath, success: false, error: result.message };
|
|
||||||
}
|
|
||||||
return { path: filePath, success: true };
|
|
||||||
}));
|
|
||||||
|
|
||||||
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> {
|
private async moveVaultFiles(sourcePaths: string[], destinationPaths: string[]): Promise<AIToolResponsePayload> {
|
||||||
|
|
@ -400,10 +404,16 @@ export class AIToolService {
|
||||||
if (!confirmation) {
|
if (!confirmation) {
|
||||||
return new AIToolResponsePayload({ error: "Confirmation was false, no action taken" });
|
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);
|
const result = await this.fileSystemService.deleteFolder(path);
|
||||||
|
|
||||||
return result instanceof Error
|
return result instanceof Error
|
||||||
? new AIToolResponsePayload({ path: path, success: false, error: result.message })
|
? new AIToolResponsePayload({ path: path, success: false, error: result.message }, artifacts)
|
||||||
: new AIToolResponsePayload({ path: path, success: true });
|
: new AIToolResponsePayload({ path: path, success: true }, artifacts);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async moveVaultFolder(sourcePath: string, destinationPath: string): Promise<AIToolResponsePayload> {
|
private async moveVaultFolder(sourcePath: string, destinationPath: string): Promise<AIToolResponsePayload> {
|
||||||
|
|
@ -436,7 +446,7 @@ export class AIToolService {
|
||||||
|
|
||||||
return format === "text"
|
return format === "text"
|
||||||
? new AIToolResponsePayload({ content: result })
|
? 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> {
|
private async readMemories(): Promise<AIToolResponsePayload> {
|
||||||
|
|
@ -459,4 +469,56 @@ export class AIToolService {
|
||||||
|
|
||||||
return new AIToolResponsePayload({ result: await this.memoriesService.updateMemories(content) });
|
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)]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -208,12 +208,18 @@ export abstract class BaseAgent {
|
||||||
return { toolCall: capturedToolCall, shouldContinue: capturedShouldContinue };
|
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;
|
const providerResult = await this.ai?.resolveToolCall?.(toolCall) ?? null;
|
||||||
if (providerResult !== null) {
|
if (providerResult !== null) {
|
||||||
return providerResult;
|
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> {
|
private async withToolCallingDisabled<T>(callback: () => Promise<T>): Promise<T> {
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@ export class ExecutionAgent extends BaseAgent {
|
||||||
|
|
||||||
this.debugService?.log("ExecutionAgent", `Executing function: ${toolCall.name}`);
|
this.debugService?.log("ExecutionAgent", `Executing function: ${toolCall.name}`);
|
||||||
this.updateThought(toolCall, callbacks);
|
this.updateThought(toolCall, callbacks);
|
||||||
const functionResponse = await this.performAITool(toolCall);
|
const functionResponse = await this.performAITool(toolCall, callbacks);
|
||||||
this.conversation.addFunctionResponse(functionResponse);
|
this.conversation.addFunctionResponse(functionResponse);
|
||||||
return { shouldExit: false };
|
return { shouldExit: false };
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ export class MainAgent extends BaseAgent {
|
||||||
|
|
||||||
this.debugService?.log("MainAgent", `Executing function: ${toolCall.name}`);
|
this.debugService?.log("MainAgent", `Executing function: ${toolCall.name}`);
|
||||||
this.updateThought(toolCall, callbacks);
|
this.updateThought(toolCall, callbacks);
|
||||||
const functionResponse = await this.performAITool(toolCall);
|
const functionResponse = await this.performAITool(toolCall, callbacks);
|
||||||
conversation.addFunctionResponse(functionResponse);
|
conversation.addFunctionResponse(functionResponse);
|
||||||
return { shouldExit: false };
|
return { shouldExit: false };
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { DebugColor } from "Enums/DebugColor";
|
||||||
import { AIToolUsageMode } from "Enums/AIToolUsageMode";
|
import { AIToolUsageMode } from "Enums/AIToolUsageMode";
|
||||||
import { AIToolResponsePayload } from "AIClasses/ToolDefinitions/AIToolResponsePayload";
|
import { AIToolResponsePayload } from "AIClasses/ToolDefinitions/AIToolResponsePayload";
|
||||||
import type { IAIToolDefinition } from "AIClasses/ToolDefinitions/IAIToolDefinition";
|
import type { IAIToolDefinition } from "AIClasses/ToolDefinitions/IAIToolDefinition";
|
||||||
|
import type { ExecutionPlan } from "Types/ExecutionPlan";
|
||||||
|
|
||||||
export class OrchestrationAgent extends BaseAgent {
|
export class OrchestrationAgent extends BaseAgent {
|
||||||
|
|
||||||
|
|
@ -39,13 +40,49 @@ export class OrchestrationAgent extends BaseAgent {
|
||||||
callbacks.onPlanReset();
|
callbacks.onPlanReset();
|
||||||
callbacks.onPlanningStarted();
|
callbacks.onPlanningStarted();
|
||||||
this.debugService?.log("OrchestrationAgent", "Spawning PlanningAgent to generate execution plan");
|
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) {
|
if (!executionPlan) {
|
||||||
|
callbacks.onPlanningFinished();
|
||||||
this.debugService?.log("OrchestrationAgent", "Planning failed - no execution plan generated");
|
this.debugService?.log("OrchestrationAgent", "Planning failed - no execution plan generated");
|
||||||
return new AIToolResponsePayload({ message: Copy.PlanningFailedNoSteps });
|
return new AIToolResponsePayload({ message: Copy.PlanningFailedNoSteps });
|
||||||
}
|
}
|
||||||
|
callbacks.onPlanningFinished();
|
||||||
|
|
||||||
this.debugService?.log("OrchestrationAgent", `Execution plan received with ${executionPlan.executionSteps.length} steps`);
|
this.debugService?.log("OrchestrationAgent", `Execution plan received with ${executionPlan.executionSteps.length} steps`);
|
||||||
callbacks.onPlanUpdate(executionPlan);
|
callbacks.onPlanUpdate(executionPlan);
|
||||||
|
|
||||||
|
|
@ -93,6 +130,7 @@ export class OrchestrationAgent extends BaseAgent {
|
||||||
: orchestrationResult.continueContext;
|
: orchestrationResult.continueContext;
|
||||||
}
|
}
|
||||||
stepIndex++;
|
stepIndex++;
|
||||||
|
callbacks.onPlanStepUpdate(stepIndex);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -125,6 +163,7 @@ export class OrchestrationAgent extends BaseAgent {
|
||||||
content: `Step ${stepIndex + 1} was skipped. Reason: ${orchestrationResult.skipReason}`
|
content: `Step ${stepIndex + 1} was skipped. Reason: ${orchestrationResult.skipReason}`
|
||||||
}));
|
}));
|
||||||
stepIndex++;
|
stepIndex++;
|
||||||
|
callbacks.onPlanStepUpdate(stepIndex);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -181,7 +220,7 @@ export class OrchestrationAgent extends BaseAgent {
|
||||||
isAITool(toolCallName, AITool.ListVaultFiles)) {
|
isAITool(toolCallName, AITool.ListVaultFiles)) {
|
||||||
this.debugService?.log("Orchestration", `Vault tool called for recovery: ${toolCallName}`);
|
this.debugService?.log("Orchestration", `Vault tool called for recovery: ${toolCallName}`);
|
||||||
this.updateThought(toolCall, callbacks);
|
this.updateThought(toolCall, callbacks);
|
||||||
const toolResponse = await this.performAITool(toolCall);
|
const toolResponse = await this.performAITool(toolCall, callbacks);
|
||||||
planningConversation.addFunctionResponse(toolResponse);
|
planningConversation.addFunctionResponse(toolResponse);
|
||||||
return Promise.resolve({ shouldExit: false });
|
return Promise.resolve({ shouldExit: false });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,7 @@ export class PlanningAgent extends BaseAgent {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.updateThought(toolCall, callbacks);
|
this.updateThought(toolCall, callbacks);
|
||||||
const functionResponse = await this.performAITool(toolCall);
|
const functionResponse = await this.performAITool(toolCall, callbacks);
|
||||||
conversation.addFunctionResponse(functionResponse);
|
conversation.addFunctionResponse(functionResponse);
|
||||||
return { shouldExit: false };
|
return { shouldExit: false };
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -44,9 +44,11 @@ export class QuickAgent extends BaseAgent {
|
||||||
onStreamingUpdate: () => {},
|
onStreamingUpdate: () => {},
|
||||||
onThoughtUpdate: () => {},
|
onThoughtUpdate: () => {},
|
||||||
onToolCallStarted: () => {},
|
onToolCallStarted: () => {},
|
||||||
|
onArtifactProduced: () => {},
|
||||||
onPlanningStarted: () => {},
|
onPlanningStarted: () => {},
|
||||||
onPlanningFinished: () => {},
|
onPlanningFinished: () => {},
|
||||||
onUserQuestion: async () => new Promise<string>(() => {}),
|
onUserQuestion: async () => new Promise<string>(() => {}),
|
||||||
|
onPlanApprovalRequest: async () => new Promise(() => {}),
|
||||||
onPlanUpdate: () => {},
|
onPlanUpdate: () => {},
|
||||||
onPlanStepUpdate: () => {},
|
onPlanStepUpdate: () => {},
|
||||||
onPlanReset: () => {},
|
onPlanReset: () => {},
|
||||||
|
|
|
||||||
|
|
@ -17,15 +17,19 @@ import type { WorkSpaceService } from "./WorkSpaceService";
|
||||||
import type { ExecutionPlan } from "Types/ExecutionPlan";
|
import type { ExecutionPlan } from "Types/ExecutionPlan";
|
||||||
import type { MainAgent } from "./AIServices/MainAgent";
|
import type { MainAgent } from "./AIServices/MainAgent";
|
||||||
import type { ChatMode } from "Enums/ChatMode";
|
import type { ChatMode } from "Enums/ChatMode";
|
||||||
|
import type { PlanApprovalResponse } from "Types/PlanApprovalResponse";
|
||||||
|
import type { Artifact } from "Conversations/Artifact";
|
||||||
|
|
||||||
export interface IChatServiceCallbacks {
|
export interface IChatServiceCallbacks {
|
||||||
onSubmit: () => void;
|
onSubmit: () => void;
|
||||||
onStreamingUpdate: () => void;
|
onStreamingUpdate: () => void;
|
||||||
onThoughtUpdate: (thought: string | null) => void;
|
onThoughtUpdate: (thought: string | null) => void;
|
||||||
onToolCallStarted: (toolName: string) => void;
|
onToolCallStarted: (toolName: string) => void;
|
||||||
|
onArtifactProduced: (artifact: Artifact) => void;
|
||||||
onPlanningStarted: () => void;
|
onPlanningStarted: () => void;
|
||||||
onPlanningFinished: () => void;
|
onPlanningFinished: () => void;
|
||||||
onUserQuestion: (question: string) => Promise<string>;
|
onUserQuestion: (question: string) => Promise<string>;
|
||||||
|
onPlanApprovalRequest: (plan: ExecutionPlan) => Promise<PlanApprovalResponse>
|
||||||
onPlanUpdate: (executionPlan: ExecutionPlan) => void;
|
onPlanUpdate: (executionPlan: ExecutionPlan) => void;
|
||||||
onPlanStepUpdate: (currentStepIndex: number) => void;
|
onPlanStepUpdate: (currentStepIndex: number) => void;
|
||||||
onPlanReset: () => void;
|
onPlanReset: () => void;
|
||||||
|
|
@ -123,13 +127,12 @@ export class ChatService {
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
this.eventService.trigger(Event.DiffClosed);
|
this.eventService.trigger(Event.DiffClosed);
|
||||||
await this.saveConversation(conversation);
|
callbacks.onThoughtUpdate(null);
|
||||||
|
callbacks.onComplete();
|
||||||
if (this.semaphoreHeld) {
|
if (this.semaphoreHeld) {
|
||||||
this.semaphoreHeld = false;
|
this.semaphoreHeld = false;
|
||||||
this.semaphore.release();
|
this.semaphore.release();
|
||||||
}
|
}
|
||||||
callbacks.onThoughtUpdate(null);
|
|
||||||
callbacks.onComplete();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -144,9 +147,6 @@ export class ChatService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async saveConversation(conversation: Conversation) {
|
private async saveConversation(conversation: Conversation) {
|
||||||
const result = await this.conversationService.saveConversation(conversation);
|
await this.conversationService.saveConversation(conversation);
|
||||||
if (result instanceof Error) {
|
|
||||||
new Notice(`Failed to save conversation data for '${conversation.title}'`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,9 @@ import { Attachment } from "Conversations/Attachment";
|
||||||
import { Exception } from "Helpers/Exception";
|
import { Exception } from "Helpers/Exception";
|
||||||
import type { IAIFileService } from "AIClasses/IAIFileService";
|
import type { IAIFileService } from "AIClasses/IAIFileService";
|
||||||
import { Reference } from "Conversations/Reference";
|
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";
|
import { StringTools } from "Helpers/StringTools";
|
||||||
|
|
||||||
export class ConversationFileSystemService {
|
export class ConversationFileSystemService {
|
||||||
|
|
@ -32,9 +34,9 @@ export class ConversationFileSystemService {
|
||||||
return `${Path.Conversations}/${conversation.title}.json`;
|
return `${Path.Conversations}/${conversation.title}.json`;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async saveConversation(conversation: Conversation): Promise<string | Error> {
|
public async saveConversation(conversation: Conversation): Promise<void> {
|
||||||
if (this.isDeleted) {
|
if (this.isDeleted) {
|
||||||
return ""; // Return empty string to indicate silent skip (not an error)
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.currentConversationPath) {
|
if (!this.currentConversationPath) {
|
||||||
|
|
@ -43,21 +45,19 @@ export class ConversationFileSystemService {
|
||||||
// can happen if the conversation is deleted during an active request
|
// can happen if the conversation is deleted during an active request
|
||||||
const fileExists = await this.fileSystemService.exists(this.currentConversationPath, true);
|
const fileExists = await this.fileSystemService.exists(this.currentConversationPath, true);
|
||||||
if (!fileExists) {
|
if (!fileExists) {
|
||||||
return this.currentConversationPath;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
conversation.updated = new Date();
|
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 content of conversation.contents) {
|
||||||
for (const attachment of content.attachments) {
|
for (const attachment of content.attachments) {
|
||||||
if (!attachment.filePath && attachment.base64) {
|
await this.saveBinaryFile(attachment, Path.Attachments);
|
||||||
const filePath = await this.saveAttachmentFile(attachment);
|
}
|
||||||
if (!(filePath instanceof Error)) {
|
for (const artifact of content.artifacts) {
|
||||||
attachment.filePath = filePath.replace(`${Path.Conversations}/`, '');
|
await this.saveBinaryFile(artifact, Path.Artifacts);
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,6 +73,14 @@ export class ConversationFileSystemService {
|
||||||
displayContent: content.displayContent,
|
displayContent: content.displayContent,
|
||||||
toolCall: content.toolCall,
|
toolCall: content.toolCall,
|
||||||
functionResponse: content.functionResponse,
|
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 => ({
|
attachments: content.attachments.map(att => ({
|
||||||
fileName: att.fileName,
|
fileName: att.fileName,
|
||||||
mimeType: att.mimeType,
|
mimeType: att.mimeType,
|
||||||
|
|
@ -90,10 +98,8 @@ export class ConversationFileSystemService {
|
||||||
const result = await this.fileSystemService.writeObjectToFile(this.currentConversationPath, conversationData, true, false);
|
const result = await this.fileSystemService.writeObjectToFile(this.currentConversationPath, conversationData, true, false);
|
||||||
|
|
||||||
if (result instanceof Error) {
|
if (result instanceof Error) {
|
||||||
return result;
|
new Notice(`Failed to save conversation data for '${conversation.title}'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.currentConversationPath;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public resetCurrentConversation() {
|
public resetCurrentConversation() {
|
||||||
|
|
@ -137,6 +143,7 @@ export class ConversationFileSystemService {
|
||||||
// Queue garbage collection after AI file deletion
|
// Queue garbage collection after AI file deletion
|
||||||
this.deletionQueue = this.deletionQueue.then(async () => {
|
this.deletionQueue = this.deletionQueue.then(async () => {
|
||||||
await this.garbageCollectAttachments();
|
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> {
|
public async updateConversationTitle(oldPath: string, newTitle: string): Promise<void | Error> {
|
||||||
const newPath = `${Path.Conversations}/${newTitle}.json`;
|
const newPath = `${Path.Conversations}/${newTitle}.json`;
|
||||||
|
|
||||||
|
|
@ -219,31 +277,31 @@ export class ConversationFileSystemService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async saveAttachmentFile(attachment: Attachment): Promise<string | Error> {
|
private async saveBinaryFile(file: IBinaryFile, storageFolder: Path): Promise<void> {
|
||||||
const hash = await StringTools.computeSHA256Hash(attachment.base64);
|
if (file.getStoragePath() || !file.base64) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = await StringTools.computeSHA256Hash(file.base64);
|
||||||
const fileName = `${hash}.bin`;
|
const fileName = `${hash}.bin`;
|
||||||
const filePath = `${Path.Attachments}/${fileName}`;
|
const filePath = `${storageFolder}/${fileName}`;
|
||||||
|
|
||||||
const exists = await this.fileSystemService.exists(filePath, true);
|
const exists = await this.fileSystemService.exists(filePath, true);
|
||||||
if (exists) {
|
if (!exists) {
|
||||||
return filePath;
|
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);
|
file.setStoragePath(filePath.replace(`${Path.Conversations}/`, ""));
|
||||||
const arrayBuffer = bytes.buffer;
|
|
||||||
|
|
||||||
const result = await this.fileSystemService.writeBinaryFile(filePath, arrayBuffer, true);
|
|
||||||
|
|
||||||
if (result instanceof Error) {
|
|
||||||
Exception.log(result);
|
|
||||||
return filePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
return filePath;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadAttachmentFile(filePath: string): Promise<string> {
|
private async loadBinaryFile(storagePath: string): Promise<string> {
|
||||||
const fullPath = `${Path.Conversations}/${filePath}`;
|
const fullPath = `${Path.Conversations}/${storagePath}`;
|
||||||
const arrayBuffer = await this.fileSystemService.readBinaryFile(fullPath, true);
|
const arrayBuffer = await this.fileSystemService.readBinaryFile(fullPath, true);
|
||||||
|
|
||||||
if (arrayBuffer instanceof Error) {
|
if (arrayBuffer instanceof Error) {
|
||||||
|
|
@ -272,6 +330,7 @@ export class ConversationFileSystemService {
|
||||||
const contentPromises = result.contents.map(async content => {
|
const contentPromises = result.contents.map(async content => {
|
||||||
const attachments = await this.deserializeAttachments(content.attachments);
|
const attachments = await this.deserializeAttachments(content.attachments);
|
||||||
const references = this.deserializeReferences(content.references);
|
const references = this.deserializeReferences(content.references);
|
||||||
|
const artifacts = await this.deserializeArtifacts(content.artifacts);
|
||||||
|
|
||||||
return new ConversationContent({
|
return new ConversationContent({
|
||||||
role: content.role,
|
role: content.role,
|
||||||
|
|
@ -280,6 +339,7 @@ export class ConversationFileSystemService {
|
||||||
displayContent: content.displayContent,
|
displayContent: content.displayContent,
|
||||||
toolCall: content.toolCall,
|
toolCall: content.toolCall,
|
||||||
functionResponse: content.functionResponse,
|
functionResponse: content.functionResponse,
|
||||||
|
artifacts: artifacts,
|
||||||
attachments: attachments,
|
attachments: attachments,
|
||||||
references: references,
|
references: references,
|
||||||
shouldDisplayContent: content.shouldDisplayContent,
|
shouldDisplayContent: content.shouldDisplayContent,
|
||||||
|
|
@ -307,7 +367,7 @@ export class ConversationFileSystemService {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const base64 = await this.loadAttachmentFile(attachmentData.filePath);
|
const base64 = await this.loadBinaryFile(attachmentData.filePath);
|
||||||
|
|
||||||
if (!base64) {
|
if (!base64) {
|
||||||
Exception.warn(`Skipping attachment with missing file: ${attachmentData.fileName} (${attachmentData.filePath})`);
|
Exception.warn(`Skipping attachment with missing file: ${attachmentData.fileName} (${attachmentData.filePath})`);
|
||||||
|
|
@ -328,6 +388,36 @@ export class ConversationFileSystemService {
|
||||||
return attachments;
|
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[] {
|
private deserializeReferences(referencesData: unknown): Reference[] {
|
||||||
if (!Array.isArray(referencesData)) {
|
if (!Array.isArray(referencesData)) {
|
||||||
return [];
|
return [];
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Resolve } from "./DependencyService";
|
import { Resolve } from "./DependencyService";
|
||||||
import { Services } from "./Services";
|
import { Services } from "./Services";
|
||||||
import type { IConversationNamingService } from "AIClasses/IConversationNamingService";
|
import type { IConversationNamingAgent } from "AIClasses/IConversationNamingAgent";
|
||||||
import type { ConversationFileSystemService } from "./ConversationFileSystemService";
|
import type { ConversationFileSystemService } from "./ConversationFileSystemService";
|
||||||
import type { Conversation } from "Conversations/Conversation";
|
import type { Conversation } from "Conversations/Conversation";
|
||||||
import type { VaultService } from "./VaultService";
|
import type { VaultService } from "./VaultService";
|
||||||
|
|
@ -12,7 +12,7 @@ import { AbortService } from "./AbortService";
|
||||||
export class ConversationNamingService {
|
export class ConversationNamingService {
|
||||||
private readonly stackLimit: number = 1000;
|
private readonly stackLimit: number = 1000;
|
||||||
|
|
||||||
private namingProvider: IConversationNamingService | undefined;
|
private namingProvider: IConversationNamingAgent | undefined;
|
||||||
private conversationService: ConversationFileSystemService;
|
private conversationService: ConversationFileSystemService;
|
||||||
private vaultService: VaultService;
|
private vaultService: VaultService;
|
||||||
private abortService: AbortService;
|
private abortService: AbortService;
|
||||||
|
|
@ -24,7 +24,7 @@ export class ConversationNamingService {
|
||||||
}
|
}
|
||||||
|
|
||||||
public resolveNamingProvider() {
|
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) {
|
public async requestName(conversation: Conversation, userPrompt: string, onNameChanged: ((name: string) => void) | undefined) {
|
||||||
|
|
@ -55,11 +55,7 @@ export class ConversationNamingService {
|
||||||
}
|
}
|
||||||
|
|
||||||
conversation.title = validatedName;
|
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);
|
onNameChanged?.(conversation.title);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import type { Diff2HtmlUIConfig } from 'diff2html/lib/ui/js/diff2html-ui-base';
|
||||||
import { ColorSchemeType, OutputFormatType } from 'diff2html/lib/types';
|
import { ColorSchemeType, OutputFormatType } from 'diff2html/lib/types';
|
||||||
import { Component } from 'obsidian';
|
import { Component } from 'obsidian';
|
||||||
import { AbortService } from './AbortService';
|
import { AbortService } from './AbortService';
|
||||||
|
import type { Artifact } from 'Conversations/Artifact';
|
||||||
|
|
||||||
interface DiffResult {
|
interface DiffResult {
|
||||||
accepted: boolean;
|
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> {
|
public async requestDiff(oldFileName: string, newFileName: string, oldContent: string, newContent: string): Promise<DiffResult> {
|
||||||
const diffString = this.createDiffString(oldFileName, newFileName, oldContent, newContent);
|
const diffString = this.createDiffString(oldFileName, newFileName, oldContent, newContent);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ export class EventService extends Events {
|
||||||
|
|
||||||
public on(name: Event.DiffOpened, callback: () => void): EventRef;
|
public on(name: Event.DiffOpened, callback: () => void): EventRef;
|
||||||
public on(name: Event.DiffClosed, 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(name: Event.RateLimitCountdown, callback: (delayMs: number) => void): EventRef;
|
||||||
|
|
||||||
public on<T extends unknown[]>(name: string, callback: (...data: T) => unknown): EventRef {
|
public on<T extends unknown[]>(name: string, callback: (...data: T) => unknown): EventRef {
|
||||||
|
|
@ -13,6 +15,8 @@ export class EventService extends Events {
|
||||||
|
|
||||||
public trigger(name: Event.DiffOpened, data?: unknown): void;
|
public trigger(name: Event.DiffOpened, data?: unknown): void;
|
||||||
public trigger(name: Event.DiffClosed, 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: Event.RateLimitCountdown, delayMs: number): void;
|
||||||
|
|
||||||
public trigger(name: string, ...data: unknown[]): void {
|
public trigger(name: string, ...data: unknown[]): void {
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ export class FileSystemService {
|
||||||
if (file instanceof TFile) {
|
if (file instanceof TFile) {
|
||||||
const arrayBuffer = await this.vaultService.readBinaryData(file, allowAccessToPluginRoot);
|
const arrayBuffer = await this.vaultService.readBinaryData(file, allowAccessToPluginRoot);
|
||||||
if (!arrayBuffer) {
|
if (!arrayBuffer) {
|
||||||
return Exception.new(`Failed to read binary dta for: ${filePath}`);
|
return Exception.new(`Failed to read binary data for: ${filePath}`);
|
||||||
}
|
}
|
||||||
return arrayBuffer;
|
return arrayBuffer;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ export class HTMLService {
|
||||||
|
|
||||||
public parseHTMLString(htmlString: string): DocumentFragment {
|
public parseHTMLString(htmlString: string): DocumentFragment {
|
||||||
const parser = new DOMParser();
|
const parser = new DOMParser();
|
||||||
const fragment = activeDocument.createDocumentFragment();
|
const fragment = createFragment();
|
||||||
const doc = parser.parseFromString(htmlString, "text/html");
|
const doc = parser.parseFromString(htmlString, "text/html");
|
||||||
|
|
||||||
// Transfer all nodes from the parsed body to the fragment
|
// 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.
|
// Creates a temporary container, parses HTML, and returns the container.
|
||||||
// Useful for parsing HTML when you need to traverse the resulting DOM structure.
|
// Useful for parsing HTML when you need to traverse the resulting DOM structure.
|
||||||
public parseHTMLToContainer(htmlString: string): HTMLDivElement {
|
public parseHTMLToContainer(htmlString: string): HTMLDivElement {
|
||||||
const container = activeDocument.createElement("div");
|
const container = createDiv();
|
||||||
const fragment = this.parseHTMLString(htmlString);
|
const fragment = this.parseHTMLString(htmlString);
|
||||||
container.appendChild(fragment);
|
container.appendChild(fragment);
|
||||||
return container;
|
return container;
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ export class InputService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDocumentFile(fileType)) {
|
if (isDocumentFile(fileType)) {
|
||||||
const content = await readDocument(await file.arrayBuffer());
|
const content = readDocument(await file.arrayBuffer(), fileType);
|
||||||
attachments.push(new Attachment(
|
attachments.push(new Attachment(
|
||||||
file.name,
|
file.name,
|
||||||
MimeType.TEXT_PLAIN,
|
MimeType.TEXT_PLAIN,
|
||||||
|
|
|
||||||
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,7 @@ import { ApplyTagsPrompt } from "AIPrompts/QuickActionPrompts/ApplyTagsPrompt";
|
||||||
import { GenerateFrontmatterPrompt } from "AIPrompts/QuickActionPrompts/GenerateFrontmatterPrompt";
|
import { GenerateFrontmatterPrompt } from "AIPrompts/QuickActionPrompts/GenerateFrontmatterPrompt";
|
||||||
import { Semaphore } from "Helpers/Semaphore";
|
import { Semaphore } from "Helpers/Semaphore";
|
||||||
import { SuggestTagsPrompt } from "AIPrompts/QuickActionPrompts/SuggestTagsPrompt";
|
import { SuggestTagsPrompt } from "AIPrompts/QuickActionPrompts/SuggestTagsPrompt";
|
||||||
|
import { AIProvider } from "Enums/ApiProvider";
|
||||||
|
|
||||||
export class QuickActionsDefinitionsService {
|
export class QuickActionsDefinitionsService {
|
||||||
|
|
||||||
|
|
@ -364,7 +365,7 @@ export class QuickActionsDefinitionsService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async performAction(action: string, context: string): Promise<string | null> {
|
private async performAction(action: string, context: string): Promise<string | null> {
|
||||||
if (this.settingsService.getApiKeyForCurrentModel().trim() == "") {
|
if (this.settingsService.settings.provider !== AIProvider.Local && this.settingsService.getApiKeyForCurrentProvider().trim() == "") {
|
||||||
openPluginSettings(this.plugin);
|
openPluginSettings(this.plugin);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// Core and Enums
|
// Core and Enums
|
||||||
import { AIProvider, fromModel } from "Enums/ApiProvider";
|
import { AIProvider } from "Enums/ApiProvider";
|
||||||
import { Environment } from "Enums/Environment";
|
import { Environment } from "Enums/Environment";
|
||||||
import type VaultkeeperAIPlugin from "main";
|
import type VaultkeeperAIPlugin from "main";
|
||||||
import { AssetsService } from "./AssetsService";
|
import { AssetsService } from "./AssetsService";
|
||||||
|
|
@ -15,6 +15,7 @@ import { ConversationNamingService } from "./ConversationNamingService";
|
||||||
import { DebugService } from "./DebugService";
|
import { DebugService } from "./DebugService";
|
||||||
import { DiffService } from "./DiffService";
|
import { DiffService } from "./DiffService";
|
||||||
import { EventService } from "./EventService";
|
import { EventService } from "./EventService";
|
||||||
|
import { PlanApprovalService } from "./PlanApprovalService";
|
||||||
import { FileSystemService } from "./FileSystemService";
|
import { FileSystemService } from "./FileSystemService";
|
||||||
import { HTMLService } from "./HTMLService";
|
import { HTMLService } from "./HTMLService";
|
||||||
import { InputService } from "./InputService";
|
import { InputService } from "./InputService";
|
||||||
|
|
@ -41,19 +42,22 @@ import { HelpModal } from "Modals/HelpModal";
|
||||||
// AI Classes
|
// AI Classes
|
||||||
import type { IAIClass } from "AIClasses/IAIClass";
|
import type { IAIClass } from "AIClasses/IAIClass";
|
||||||
import type { IAIFileService } from "AIClasses/IAIFileService";
|
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 { Claude } from "AIClasses/Claude/Claude";
|
||||||
import { ClaudeConversationNamingService } from "AIClasses/Claude/ClaudeConversationNamingService";
|
import { ClaudeConversationNamingAgent } from "AIClasses/Claude/ClaudeConversationNamingAgent";
|
||||||
import { ClaudeFileService } from "AIClasses/Claude/ClaudeFileService";
|
import { ClaudeFileService } from "AIClasses/Claude/ClaudeFileService";
|
||||||
import { Gemini } from "AIClasses/Gemini/Gemini";
|
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 { GeminiFileService } from "AIClasses/Gemini/GeminiFileService";
|
||||||
import { Mistral } from "AIClasses/Mistral/Mistral";
|
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 { MistralFileService } from "AIClasses/Mistral/MistralFileService";
|
||||||
import { OpenAI } from "AIClasses/OpenAI/OpenAI";
|
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 { OpenAIFileService } from "AIClasses/OpenAI/OpenAIFileService";
|
||||||
|
import { Local } from "AIClasses/Local/Local";
|
||||||
|
import { LocalConversationNamingAgent } from "AIClasses/Local/LocalConversationNamingAgent";
|
||||||
|
import { LocalFileService } from "AIClasses/Local/LocalFileService";
|
||||||
|
|
||||||
// Prompts
|
// Prompts
|
||||||
import { AIPrompt, type IPrompt } from "AIPrompts/IPrompt";
|
import { AIPrompt, type IPrompt } from "AIPrompts/IPrompt";
|
||||||
|
|
@ -78,6 +82,7 @@ export function RegisterDependencies() {
|
||||||
RegisterSingleton<HTMLService>(Services.HTMLService, new HTMLService());
|
RegisterSingleton<HTMLService>(Services.HTMLService, new HTMLService());
|
||||||
RegisterSingleton<SanitiserService>(Services.SanitiserService, new SanitiserService());
|
RegisterSingleton<SanitiserService>(Services.SanitiserService, new SanitiserService());
|
||||||
RegisterSingleton<DiffService>(Services.DiffService, new DiffService());
|
RegisterSingleton<DiffService>(Services.DiffService, new DiffService());
|
||||||
|
RegisterSingleton<PlanApprovalService>(Services.PlanApprovalService, new PlanApprovalService());
|
||||||
RegisterSingleton<VaultService>(Services.VaultService, new VaultService());
|
RegisterSingleton<VaultService>(Services.VaultService, new VaultService());
|
||||||
RegisterSingleton<VaultCacheService>(Services.VaultCacheService, new VaultCacheService());
|
RegisterSingleton<VaultCacheService>(Services.VaultCacheService, new VaultCacheService());
|
||||||
RegisterSingleton<FileSystemService>(Services.FileSystemService, new FileSystemService());
|
RegisterSingleton<FileSystemService>(Services.FileSystemService, new FileSystemService());
|
||||||
|
|
@ -109,27 +114,32 @@ export function RegisterDependencies() {
|
||||||
|
|
||||||
export function RegisterAiProvider() {
|
export function RegisterAiProvider() {
|
||||||
const settingsService = Resolve<SettingsService>(Services.SettingsService);
|
const settingsService = Resolve<SettingsService>(Services.SettingsService);
|
||||||
const provider = fromModel(settingsService.settings.model);
|
const provider = settingsService.settings.provider;
|
||||||
|
|
||||||
if (provider == AIProvider.Claude) {
|
if (provider == AIProvider.Claude) {
|
||||||
RegisterSingleton<IAIFileService>(Services.IAIFileService, new ClaudeFileService());
|
RegisterSingleton<IAIFileService>(Services.IAIFileService, new ClaudeFileService());
|
||||||
RegisterSingleton<IAIClass>(Services.IAIClass, new Claude());
|
RegisterSingleton<IAIClass>(Services.IAIClass, new Claude());
|
||||||
RegisterSingleton<IConversationNamingService>(Services.IConversationNamingService, new ClaudeConversationNamingService());
|
RegisterSingleton<IConversationNamingAgent>(Services.IConversationNamingService, new ClaudeConversationNamingAgent());
|
||||||
}
|
}
|
||||||
else if (provider == AIProvider.Gemini) {
|
else if (provider == AIProvider.Gemini) {
|
||||||
RegisterSingleton<IAIFileService>(Services.IAIFileService, new GeminiFileService());
|
RegisterSingleton<IAIFileService>(Services.IAIFileService, new GeminiFileService());
|
||||||
RegisterSingleton<IAIClass>(Services.IAIClass, new Gemini());
|
RegisterSingleton<IAIClass>(Services.IAIClass, new Gemini());
|
||||||
RegisterSingleton<IConversationNamingService>(Services.IConversationNamingService, new GeminiConversationNamingService());
|
RegisterSingleton<IConversationNamingAgent>(Services.IConversationNamingService, new GeminiConversationNamingAgent());
|
||||||
}
|
}
|
||||||
else if (provider == AIProvider.OpenAI) {
|
else if (provider == AIProvider.OpenAI) {
|
||||||
RegisterSingleton<IAIFileService>(Services.IAIFileService, new OpenAIFileService());
|
RegisterSingleton<IAIFileService>(Services.IAIFileService, new OpenAIFileService());
|
||||||
RegisterSingleton<IAIClass>(Services.IAIClass, new OpenAI());
|
RegisterSingleton<IAIClass>(Services.IAIClass, new OpenAI());
|
||||||
RegisterSingleton<IConversationNamingService>(Services.IConversationNamingService, new OpenAIConversationNamingService());
|
RegisterSingleton<IConversationNamingAgent>(Services.IConversationNamingService, new OpenAIConversationNamingAgent());
|
||||||
}
|
}
|
||||||
else if (provider == AIProvider.Mistral) {
|
else if (provider == AIProvider.Mistral) {
|
||||||
RegisterSingleton<IAIFileService>(Services.IAIFileService, new MistralFileService());
|
RegisterSingleton<IAIFileService>(Services.IAIFileService, new MistralFileService());
|
||||||
RegisterSingleton<IAIClass>(Services.IAIClass, new Mistral());
|
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();
|
Resolve<MainAgent>(Services.MainAgent).resolveAIProvider();
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ export class Services {
|
||||||
static InputService = Symbol("InputService");
|
static InputService = Symbol("InputService");
|
||||||
static WebViewerService = Symbol("WebViewerService");
|
static WebViewerService = Symbol("WebViewerService");
|
||||||
static DiffService = Symbol("DiffService");
|
static DiffService = Symbol("DiffService");
|
||||||
|
static PlanApprovalService = Symbol("PlanApprovalService");
|
||||||
static MemoriesService = Symbol("MemoriesService");
|
static MemoriesService = Symbol("MemoriesService");
|
||||||
static DebugService = Symbol("DebugService");
|
static DebugService = Symbol("DebugService");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,28 +8,65 @@ import {
|
||||||
DEFAULT_MODEL_BY_PROVIDER,
|
DEFAULT_MODEL_BY_PROVIDER,
|
||||||
DEFAULT_PLANNING_MODEL_BY_PROVIDER,
|
DEFAULT_PLANNING_MODEL_BY_PROVIDER,
|
||||||
DEFAULT_QUICK_MODEL_BY_PROVIDER,
|
DEFAULT_QUICK_MODEL_BY_PROVIDER,
|
||||||
fromModel,
|
|
||||||
isvalidProvider,
|
isvalidProvider,
|
||||||
isValidProviderModel,
|
isValidProviderModel,
|
||||||
modelMatchesProvider
|
modelMatchesProvider
|
||||||
} from "Enums/ApiProvider";
|
} from "Enums/ApiProvider";
|
||||||
|
|
||||||
const DEFAULT_SETTINGS: IVaultkeeperAISettings = {
|
export const DEFAULT_SETTINGS: IVaultkeeperAISettings = {
|
||||||
firstTimeStart: true,
|
firstTimeStart: true,
|
||||||
|
|
||||||
chatMode: ChatMode.ReadOnly,
|
chatMode: ChatMode.ReadOnly,
|
||||||
|
freeEdit: false,
|
||||||
userInstruction: "",
|
userInstruction: "",
|
||||||
|
|
||||||
provider: AIProvider.Claude,
|
provider: AIProvider.Local,
|
||||||
model: AIProviderModel.ClaudeSonnet_4_6,
|
model: AIProviderModel.ClaudeSonnet_5,
|
||||||
planningModel: AIProviderModel.ClaudeOpus_4_8,
|
planningModel: AIProviderModel.ClaudeOpus_4_8,
|
||||||
quickActionModel: AIProviderModel.ClaudeHaiku_4_5,
|
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: {
|
apiKeys: {
|
||||||
claude: "",
|
claude: "",
|
||||||
openai: "",
|
openai: "",
|
||||||
gemini: "",
|
gemini: "",
|
||||||
mistral: ""
|
mistral: "",
|
||||||
|
local: ""
|
||||||
},
|
},
|
||||||
exclusions: [],
|
exclusions: [],
|
||||||
|
|
||||||
|
|
@ -52,6 +89,7 @@ export interface IVaultkeeperAISettings {
|
||||||
firstTimeStart: boolean;
|
firstTimeStart: boolean;
|
||||||
|
|
||||||
chatMode: ChatMode;
|
chatMode: ChatMode;
|
||||||
|
freeEdit: boolean;
|
||||||
userInstruction: string;
|
userInstruction: string;
|
||||||
|
|
||||||
provider: AIProvider;
|
provider: AIProvider;
|
||||||
|
|
@ -59,12 +97,23 @@ export interface IVaultkeeperAISettings {
|
||||||
planningModel: AIProviderModel;
|
planningModel: AIProviderModel;
|
||||||
quickActionModel: AIProviderModel;
|
quickActionModel: AIProviderModel;
|
||||||
|
|
||||||
|
cachedModelSettings: Record<AIProvider, ProviderModelCache>;
|
||||||
|
|
||||||
|
localUrl: string;
|
||||||
|
|
||||||
|
localModels: {
|
||||||
|
model: string;
|
||||||
|
planningModel: string;
|
||||||
|
quickActionModel: string;
|
||||||
|
}
|
||||||
|
|
||||||
apiKeys: {
|
apiKeys: {
|
||||||
claude: string;
|
claude: string;
|
||||||
openai: string;
|
openai: string;
|
||||||
gemini: string;
|
gemini: string;
|
||||||
mistral: string;
|
mistral: string;
|
||||||
};
|
local: string;
|
||||||
|
}
|
||||||
exclusions: string[];
|
exclusions: string[];
|
||||||
|
|
||||||
searchResultsLimit: number;
|
searchResultsLimit: number;
|
||||||
|
|
@ -82,6 +131,12 @@ export interface IVaultkeeperAISettings {
|
||||||
hideDrawerElements: boolean;
|
hideDrawerElements: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProviderModelCache {
|
||||||
|
model: AIProviderModel;
|
||||||
|
planningModel: AIProviderModel;
|
||||||
|
quickActionModel: AIProviderModel;
|
||||||
|
}
|
||||||
|
|
||||||
type SettingKey = keyof IVaultkeeperAISettings;
|
type SettingKey = keyof IVaultkeeperAISettings;
|
||||||
type SettingsChangedCallback = ((changedKeys: SettingKey[]) => void) | ((changedKeys: SettingKey[]) => Promise<void>);
|
type SettingsChangedCallback = ((changedKeys: SettingKey[]) => void) | ((changedKeys: SettingKey[]) => Promise<void>);
|
||||||
|
|
||||||
|
|
@ -95,11 +150,17 @@ export class SettingsService {
|
||||||
|
|
||||||
private settingsSnapshot: string;
|
private settingsSnapshot: string;
|
||||||
|
|
||||||
public constructor(loadedSettings: Partial<IVaultkeeperAISettings>) {
|
public constructor(loadedSettings: Partial<IVaultkeeperAISettings> | null) {
|
||||||
this.plugin = Resolve<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin);
|
this.plugin = Resolve<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin);
|
||||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, loadedSettings);
|
|
||||||
|
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);
|
this.settingsSnapshot = JSON.stringify(this.settings);
|
||||||
this.ensureValidModels();
|
void this.ensureValidModels();
|
||||||
}
|
}
|
||||||
|
|
||||||
public subscribeToSettingsChanged(callback: SettingsChangedCallback): object {
|
public subscribeToSettingsChanged(callback: SettingsChangedCallback): object {
|
||||||
|
|
@ -118,9 +179,8 @@ export class SettingsService {
|
||||||
await this.saveSettings();
|
await this.saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
public getApiKeyForCurrentModel(): string {
|
public getApiKeyForCurrentProvider(): string {
|
||||||
const provider = fromModel(this.settings.model);
|
return this.getApiKeyForProvider(this.settings.provider);
|
||||||
return this.getApiKeyForProvider(provider);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public getApiKeyForProvider(provider: AIProvider): string {
|
public getApiKeyForProvider(provider: AIProvider): string {
|
||||||
|
|
@ -133,6 +193,8 @@ export class SettingsService {
|
||||||
return this.settings.apiKeys.gemini;
|
return this.settings.apiKeys.gemini;
|
||||||
case AIProvider.Mistral:
|
case AIProvider.Mistral:
|
||||||
return this.settings.apiKeys.mistral;
|
return this.settings.apiKeys.mistral;
|
||||||
|
case AIProvider.Local:
|
||||||
|
return this.settings.apiKeys.local;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -150,9 +212,43 @@ export class SettingsService {
|
||||||
case AIProvider.Mistral:
|
case AIProvider.Mistral:
|
||||||
await this.updateSettings(settings => settings.apiKeys.mistral = key);
|
await this.updateSettings(settings => settings.apiKeys.mistral = key);
|
||||||
break;
|
break;
|
||||||
|
case AIProvider.Local:
|
||||||
|
await this.updateSettings(settings => settings.apiKeys.local = key);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async saveSettings() {
|
private async saveSettings() {
|
||||||
const oldSettings = JSON.parse(this.settingsSnapshot) as IVaultkeeperAISettings;
|
const oldSettings = JSON.parse(this.settingsSnapshot) as IVaultkeeperAISettings;
|
||||||
await this.plugin.saveData(this.settings);
|
await this.plugin.saveData(this.settings);
|
||||||
|
|
@ -171,26 +267,4 @@ export class SettingsService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private ensureValidModels(): void {
|
|
||||||
void this.updateSettings(settings => {
|
|
||||||
let provider = settings.provider;
|
|
||||||
|
|
||||||
if (!isvalidProvider(provider)) {
|
|
||||||
provider = DEFAULT_SETTINGS.provider;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isValidProviderModel(this.settings.model) || !modelMatchesProvider(this.settings.model, provider)) {
|
|
||||||
settings.model = DEFAULT_MODEL_BY_PROVIDER[provider];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isValidProviderModel(this.settings.planningModel) || !modelMatchesProvider(this.settings.planningModel, provider)) {
|
|
||||||
settings.planningModel = DEFAULT_PLANNING_MODEL_BY_PROVIDER[provider];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isValidProviderModel(this.settings.quickActionModel) || !modelMatchesProvider(this.settings.quickActionModel, provider)) {
|
|
||||||
settings.quickActionModel = DEFAULT_QUICK_MODEL_BY_PROVIDER[provider];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -16,6 +16,8 @@ export class StreamingMarkdownService {
|
||||||
private readonly states = new WeakMap<HTMLElement, RenderState>();
|
private readonly states = new WeakMap<HTMLElement, RenderState>();
|
||||||
|
|
||||||
public async render(markdown: string, container: HTMLElement, isFinal: boolean = false): Promise<void> {
|
public async render(markdown: string, container: HTMLElement, isFinal: boolean = false): Promise<void> {
|
||||||
|
markdown = this.neutraliseFrontmatter(markdown);
|
||||||
|
|
||||||
if (isFinal) {
|
if (isFinal) {
|
||||||
const existing = this.states.get(container);
|
const existing = this.states.get(container);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
|
@ -37,7 +39,7 @@ export class StreamingMarkdownService {
|
||||||
|
|
||||||
if (frozenCandidate.length > state.frozenUpTo) {
|
if (frozenCandidate.length > state.frozenUpTo) {
|
||||||
const newSlice = frozenCandidate.slice(state.frozenUpTo);
|
const newSlice = frozenCandidate.slice(state.frozenUpTo);
|
||||||
const tempDiv = activeDocument.createElement("div");
|
const tempDiv = createDiv();
|
||||||
await MarkdownRenderer.render(this.plugin.app, newSlice, tempDiv, "", state.component);
|
await MarkdownRenderer.render(this.plugin.app, newSlice, tempDiv, "", state.component);
|
||||||
while (tempDiv.firstChild) {
|
while (tempDiv.firstChild) {
|
||||||
state.frozenContainer.appendChild(tempDiv.firstChild);
|
state.frozenContainer.appendChild(tempDiv.firstChild);
|
||||||
|
|
@ -52,14 +54,22 @@ export class StreamingMarkdownService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Obsidian's renderer treats a leading "---" line as the start of YAML
|
||||||
|
// frontmatter and hides everything up to the closing "---". Swap it for
|
||||||
|
// "***" — an identical <hr> that can't open frontmatter. Same length, so
|
||||||
|
// frozenUpTo offsets from earlier incremental renders stay valid.
|
||||||
|
private neutraliseFrontmatter(markdown: string): string {
|
||||||
|
return markdown.replace(/^---(?=[ \t]*(?:\r?\n|$))/, "***");
|
||||||
|
}
|
||||||
|
|
||||||
private getOrCreateState(container: HTMLElement): RenderState {
|
private getOrCreateState(container: HTMLElement): RenderState {
|
||||||
if (this.states.has(container)) {
|
if (this.states.has(container)) {
|
||||||
return this.states.get(container)!;
|
return this.states.get(container)!;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.empty();
|
container.empty();
|
||||||
const frozenContainer = activeDocument.createElement("div");
|
const frozenContainer = createDiv();
|
||||||
const liveContainer = activeDocument.createElement("div");
|
const liveContainer = createDiv();
|
||||||
container.appendChild(frozenContainer);
|
container.appendChild(frozenContainer);
|
||||||
container.appendChild(liveContainer);
|
container.appendChild(liveContainer);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,12 +42,15 @@ export class VaultCacheService {
|
||||||
this.metaDataCache = this.plugin.app.metadataCache;
|
this.metaDataCache = this.plugin.app.metadataCache;
|
||||||
this.registerFileEvents();
|
this.registerFileEvents();
|
||||||
|
|
||||||
this.plugin.app.metadataCache.on("resolved", async () => {
|
const tryInitialise = async () => {
|
||||||
if (!this.initialised) {
|
if (!this.initialised) {
|
||||||
await this.setupCaches();
|
|
||||||
this.initialised = true;
|
this.initialised = true;
|
||||||
|
await this.setupCaches();
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
|
this.plugin.app.metadataCache.on("resolved", tryInitialise);
|
||||||
|
void tryInitialise();
|
||||||
}
|
}
|
||||||
|
|
||||||
public matchTag(input: string): Fuzzysort.KeyResults<{ prepared: Fuzzysort.Prepared, tag: string }> {
|
public matchTag(input: string): Fuzzysort.KeyResults<{ prepared: Fuzzysort.Prepared, tag: string }> {
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,7 @@ export class VaultService {
|
||||||
if (isDocumentFile(fileExtension)) {
|
if (isDocumentFile(fileExtension)) {
|
||||||
const arrayBuffer = await this.readBinaryData(file, allowAccessToPluginRoot);
|
const arrayBuffer = await this.readBinaryData(file, allowAccessToPluginRoot);
|
||||||
if (arrayBuffer) {
|
if (arrayBuffer) {
|
||||||
return (await readDocument(arrayBuffer))[0].text;
|
return (readDocument(arrayBuffer, fileExtension))[0].text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -116,13 +116,15 @@ export class VaultService {
|
||||||
|
|
||||||
public async create(filePath: string, content: string, allowAccessToPluginRoot: boolean = false, requiresConfirmation: boolean = true): Promise<TFile | Error> {
|
public async create(filePath: string, content: string, allowAccessToPluginRoot: boolean = false, requiresConfirmation: boolean = true): Promise<TFile | Error> {
|
||||||
filePath = this.sanitiserService.sanitize(filePath);
|
filePath = this.sanitiserService.sanitize(filePath);
|
||||||
|
const fileExtension = pathExtname(filePath);
|
||||||
|
|
||||||
if (this.isExclusion(filePath, allowAccessToPluginRoot)) {
|
if (this.isExclusion(filePath, allowAccessToPluginRoot)) {
|
||||||
Exception.log(`Plugin attempted to create a file that is in the exclusion list: ${filePath}`);
|
Exception.log(`Plugin attempted to create a file that is in the exclusion list: ${filePath}`);
|
||||||
return Exception.new(`Failed to create file, permission denied: ${filePath}`);
|
return Exception.new(`Failed to create file, permission denied: ${filePath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isFileType(pathExtname(filePath), FileType.PDF)) {
|
if (isBinaryFile(pathExtname(fileExtension)) || isDocumentFile(pathExtname(fileExtension))) {
|
||||||
return Exception.new("Creating PDF files is not supported");
|
return Exception.new(`Creating ${pathExtname(filePath)} files is not supported`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileName = path.basename(filePath);
|
const fileName = path.basename(filePath);
|
||||||
|
|
@ -134,13 +136,15 @@ export class VaultService {
|
||||||
|
|
||||||
public async modify(file: TFile, content: string, allowAccessToPluginRoot: boolean = false, requiresConfirmation: boolean = true): Promise<TFile | Error> {
|
public async modify(file: TFile, content: string, allowAccessToPluginRoot: boolean = false, requiresConfirmation: boolean = true): Promise<TFile | Error> {
|
||||||
const filePath = this.sanitiserService.sanitize(file.path);
|
const filePath = this.sanitiserService.sanitize(file.path);
|
||||||
|
const fileExtension = pathExtname(filePath);
|
||||||
|
|
||||||
if (this.isExclusion(file.path, allowAccessToPluginRoot)) {
|
if (this.isExclusion(file.path, allowAccessToPluginRoot)) {
|
||||||
Exception.log(`Plugin attempted to modify a file that is in the exclusion list: ${filePath}`);
|
Exception.log(`Plugin attempted to modify a file that is in the exclusion list: ${filePath}`);
|
||||||
return Exception.new(`File does not exist: ${filePath}`);
|
return Exception.new(`File does not exist: ${filePath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isFileType(file.extension.toLocaleLowerCase(), FileType.PDF)) {
|
if (isBinaryFile(pathExtname(filePath)) || isDocumentFile(pathExtname(fileExtension))) {
|
||||||
return Exception.new("Modifying PDF files is not supported");
|
return Exception.new(`Modifying ${pathExtname(filePath)} files is not supported`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentContent = await this.read(file, allowAccessToPluginRoot);
|
const currentContent = await this.read(file, allowAccessToPluginRoot);
|
||||||
|
|
@ -157,13 +161,15 @@ export class VaultService {
|
||||||
|
|
||||||
public async updateFrontmatter(file: TFile, mutate: (frontmatter: Record<string, unknown>) => void, allowAccessToPluginRoot: boolean = false): Promise<TFile | Error> {
|
public async updateFrontmatter(file: TFile, mutate: (frontmatter: Record<string, unknown>) => void, allowAccessToPluginRoot: boolean = false): Promise<TFile | Error> {
|
||||||
const filePath = this.sanitiserService.sanitize(file.path);
|
const filePath = this.sanitiserService.sanitize(file.path);
|
||||||
|
const fileExtension = pathExtname(filePath);
|
||||||
|
|
||||||
if (this.isExclusion(file.path, allowAccessToPluginRoot)) {
|
if (this.isExclusion(file.path, allowAccessToPluginRoot)) {
|
||||||
Exception.log(`Plugin attempted to update frontmatter of a file that is in the exclusion list: ${filePath}`);
|
Exception.log(`Plugin attempted to update frontmatter of a file that is in the exclusion list: ${filePath}`);
|
||||||
return Exception.new(`File does not exist: ${filePath}`);
|
return Exception.new(`File does not exist: ${filePath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isFileType(file.extension.toLocaleLowerCase(), FileType.PDF)) {
|
if (isBinaryFile(pathExtname(filePath)) || isDocumentFile(pathExtname(fileExtension))) {
|
||||||
return Exception.new("Modifying PDF files is not supported");
|
return Exception.new(`Modifying ${pathExtname(filePath)} files is not supported`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -183,8 +189,8 @@ export class VaultService {
|
||||||
return Exception.new(`File does not exist: ${filePath}`);
|
return Exception.new(`File does not exist: ${filePath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isFileType(pathExtname(filePath), FileType.PDF)) {
|
if (isBinaryFile(pathExtname(filePath))) {
|
||||||
return Exception.new("Patching PDF files is not supported");
|
return Exception.new(`Patching ${pathExtname(filePath)} files is not supported`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (oldContent.length !== newContent.length) {
|
if (oldContent.length !== newContent.length) {
|
||||||
|
|
@ -236,10 +242,6 @@ export class VaultService {
|
||||||
return currentContent;
|
return currentContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isFileType(pathExtname(filePath), FileType.PDF)) {
|
|
||||||
await this.fileManager.trashFile(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.proposeChange(file.name, file.name, currentContent, "", requiresConfirmation, async () => {
|
return this.proposeChange(file.name, file.name, currentContent, "", requiresConfirmation, async () => {
|
||||||
await this.fileManager.trashFile(file);
|
await this.fileManager.trashFile(file);
|
||||||
});
|
});
|
||||||
|
|
@ -418,7 +420,7 @@ export class VaultService {
|
||||||
content = await readPDF(arrayBuffer);
|
content = await readPDF(arrayBuffer);
|
||||||
} else if (isDocumentFile(fileExtension)) {
|
} else if (isDocumentFile(fileExtension)) {
|
||||||
const arrayBuffer = await this.vault.readBinary(file);
|
const arrayBuffer = await this.vault.readBinary(file);
|
||||||
content = await readDocument(arrayBuffer);
|
content = readDocument(arrayBuffer, fileExtension);
|
||||||
} else {
|
} else {
|
||||||
content = [{ text: await this.vault.cachedRead(file), pageNumber: 1 }] as IPageText[];
|
content = [{ text: await this.vault.cachedRead(file), pageNumber: 1 }] as IPageText[];
|
||||||
}
|
}
|
||||||
|
|
@ -607,8 +609,8 @@ export class VaultService {
|
||||||
private async proposeChange<T>(oldFileName: string, newFileName: string, oldContent: string, newContent: string,
|
private async proposeChange<T>(oldFileName: string, newFileName: string, oldContent: string, newContent: string,
|
||||||
requiresConfirmation: boolean = true, performChange: () => Promise<T>): Promise<T | Error> {
|
requiresConfirmation: boolean = true, performChange: () => Promise<T>): Promise<T | Error> {
|
||||||
try {
|
try {
|
||||||
const result = requiresConfirmation ?
|
const result = this.settingsService.settings.freeEdit || !requiresConfirmation ? { accepted: true } :
|
||||||
await this.diffService.requestDiff(oldFileName, newFileName, oldContent, newContent) : { accepted: true };
|
await this.diffService.requestDiff(oldFileName, newFileName, oldContent, newContent);
|
||||||
|
|
||||||
if (result.accepted) {
|
if (result.accepted) {
|
||||||
return await performChange();
|
return await performChange();
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,26 @@ body:not(.is-tablet) .workspace-drawer.mod-right {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================== */
|
||||||
|
/* Plan Approval View Customization */
|
||||||
|
/* ============================== */
|
||||||
|
|
||||||
|
.workspace-leaf-content[data-type="vaultkeeper-ai-plan-approval-view"] .view-content {
|
||||||
|
container-type: size;
|
||||||
|
container-name: plan-approval-container;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================== */
|
||||||
|
/* Artifact View Customization */
|
||||||
|
/* ============================== */
|
||||||
|
|
||||||
|
.workspace-leaf-content[data-type="vaultkeeper-ai-artifact-view"] .view-content {
|
||||||
|
container-type: size;
|
||||||
|
container-name: artifact-container;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================== */
|
/* ============================== */
|
||||||
/* Settings Styles */
|
/* Settings Styles */
|
||||||
/* ============================== */
|
/* ============================== */
|
||||||
|
|
@ -119,6 +139,10 @@ body:not(.is-tablet) .workspace-drawer.mod-right {
|
||||||
resize: none;
|
resize: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.local-url-input {
|
||||||
|
min-width: 80%;
|
||||||
|
}
|
||||||
|
|
||||||
.setting-item-memories-disabled-accent .checkbox-container {
|
.setting-item-memories-disabled-accent .checkbox-container {
|
||||||
background-color: var(--interactive-accent-disabled-accent);
|
background-color: var(--interactive-accent-disabled-accent);
|
||||||
}
|
}
|
||||||
|
|
@ -208,6 +232,36 @@ body:not(.is-tablet) .workspace-drawer.mod-right {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.setting-desc-icon-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: var(--size-4-2);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-warning-icon {
|
||||||
|
display: flex;
|
||||||
|
align-self: anchor-center;
|
||||||
|
margin: var(--size-4-1);
|
||||||
|
color: var(--text-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-disclaimer-icon {
|
||||||
|
display: flex;
|
||||||
|
align-self: anchor-center;
|
||||||
|
margin: var(--size-4-1);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-disclaimer-link {
|
||||||
|
color: var(--text-accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-disclaimer-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.top-bar-button {
|
.top-bar-button {
|
||||||
margin: var(--size-4-2) 0px var(--size-4-2) 0px;
|
margin: var(--size-4-2) 0px var(--size-4-2) 0px;
|
||||||
padding: var(--size-4-1) var(--size-4-2) var(--size-4-1) var(--size-4-2);
|
padding: var(--size-4-1) var(--size-4-2) var(--size-4-1) var(--size-4-2);
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,7 @@
|
||||||
|
|
||||||
.diff-view-mobile .diff-mobile-controls {
|
.diff-view-mobile .diff-mobile-controls {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
gap: var(--size-4-2);
|
gap: var(--size-4-2);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
|
|
@ -128,38 +128,38 @@
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.diff-mobile-button {
|
.diff-mobile-controls .diff-mobile-button {
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
font-size: var(--font-ui-medium);
|
font-size: var(--font-ui-medium);
|
||||||
font-weight: var(--font-semibold);
|
|
||||||
border-radius: var(--button-radius);
|
border-radius: var(--button-radius);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: none;
|
transition: background-color 0.2s ease-out;
|
||||||
color: var(--text-on-accent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.diff-mobile-accept {
|
.diff-mobile-controls .diff-mobile-accept {
|
||||||
background-color: color-mix(
|
color: white;
|
||||||
in srgb,
|
background-color: #38533a;
|
||||||
var(--color-green) 75%,
|
|
||||||
var(--background-primary) 25%
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.diff-mobile-accept:active {
|
.diff-mobile-controls .diff-mobile-accept:hover,
|
||||||
background-color: var(--color-green);
|
.diff-mobile-controls .diff-mobile-accept:focus,
|
||||||
|
.diff-mobile-controls .diff-mobile-accept:active {
|
||||||
|
background-color: #537555;
|
||||||
}
|
}
|
||||||
|
|
||||||
.diff-mobile-reject {
|
.diff-mobile-controls .diff-mobile-discuss {
|
||||||
background-color: color-mix(
|
color: white;
|
||||||
in srgb,
|
|
||||||
var(--color-red) 75%,
|
|
||||||
var(--background-primary) 25%
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.diff-mobile-reject:active {
|
.diff-mobile-controls .diff-mobile-reject {
|
||||||
background-color: var(--color-red);
|
color: white;
|
||||||
|
background-color: #593030;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-mobile-controls .diff-mobile-reject:hover,
|
||||||
|
.diff-mobile-controls .diff-mobile-reject:focus,
|
||||||
|
.diff-mobile-controls .diff-mobile-reject:active {
|
||||||
|
background-color: #774545;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Adjust diff height on mobile to account for buttons */
|
/* Adjust diff height on mobile to account for buttons */
|
||||||
|
|
@ -173,3 +173,73 @@
|
||||||
max-height: calc(100cqh - 37px - 120px);
|
max-height: calc(100cqh - 37px - 120px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================== */
|
||||||
|
/* Artifact View Controls */
|
||||||
|
/* (shown on desktop AND mobile) */
|
||||||
|
/* ============================== */
|
||||||
|
|
||||||
|
.artifact-view-controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--size-4-2);
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: var(--size-4-3);
|
||||||
|
z-index: 10;
|
||||||
|
background-color: var(--background-primary);
|
||||||
|
border-top: 1px solid var(--background-modifier-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-view-controls .artifact-view-button {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Avoid the floating obsidian controls on mobile */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.artifact-view-controls {
|
||||||
|
margin-bottom: 85px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-view-controls .artifact-view-button {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-view-controls .artifact-view-button {
|
||||||
|
min-height: 44px;
|
||||||
|
font-size: var(--font-ui-medium);
|
||||||
|
border-radius: var(--button-radius);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-view-controls .artifact-view-button-confirming {
|
||||||
|
min-width: var(--artifact-view-button-width);
|
||||||
|
color: white;
|
||||||
|
background-color: #38533a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-view-controls .artifact-view-button-confirming:hover,
|
||||||
|
.artifact-view-controls .artifact-view-button-confirming:focus-visible,
|
||||||
|
.artifact-view-controls .artifact-view-button-confirming:active {
|
||||||
|
background-color: #537555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-view-controls .artifact-view-close {
|
||||||
|
color: var(--interactive-accent);
|
||||||
|
border-color: var(--interactive-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-leaf-content[data-type="vaultkeeper-ai-artifact-view"] .d2h-file-diff {
|
||||||
|
max-height: calc(100cqh - 37px - 40px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.workspace-leaf-content[data-type="vaultkeeper-ai-artifact-view"] .d2h-file-diff {
|
||||||
|
max-height: calc(100cqh - 37px - 120px);
|
||||||
|
}
|
||||||
|
}
|
||||||
84
Styles/plan_approval_styles.css
Normal file
84
Styles/plan_approval_styles.css
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
/* ============================== */
|
||||||
|
/* Structural / layout fixes */
|
||||||
|
/* ============================== */
|
||||||
|
|
||||||
|
.plan-approval-wrapper {
|
||||||
|
container-type: size;
|
||||||
|
container-name: plan-approval-wrapper;
|
||||||
|
transform: translateZ(0);
|
||||||
|
height: 100cqh;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================== */
|
||||||
|
/* Mobile Controls */
|
||||||
|
/* ============================== */
|
||||||
|
|
||||||
|
.plan-approval-mobile-controls {
|
||||||
|
display: none; /* Hidden by default (desktop) */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Avoid the floating obsidian controls */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.plan-approval-mobile-controls {
|
||||||
|
margin-bottom: 85px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-approval-view-mobile .plan-approval-mobile-controls {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
gap: var(--size-4-2);
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: var(--size-4-3);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-approval-mobile-controls .plan-approval-mobile-button {
|
||||||
|
min-height: 44px;
|
||||||
|
font-size: var(--font-ui-medium);
|
||||||
|
border-radius: var(--button-radius);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-approval-mobile-controls .plan-approval-mobile-approve {
|
||||||
|
color: white;
|
||||||
|
background-color: #38533a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-approval-mobile-controls .plan-approval-mobile-approve:hover,
|
||||||
|
.plan-approval-mobile-controls .plan-approval-mobile-approve:focus,
|
||||||
|
.plan-approval-mobile-controls .plan-approval-mobile-approve:active {
|
||||||
|
background-color: #537555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-approval-mobile-controls .plan-approval-mobile-discuss {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-approval-mobile-controls .plan-approval-mobile-reject {
|
||||||
|
color: white;
|
||||||
|
background-color: #593030;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-approval-mobile-controls .plan-approval-mobile-reject:hover,
|
||||||
|
.plan-approval-mobile-controls .plan-approval-mobile-reject:focus,
|
||||||
|
.plan-approval-mobile-controls .plan-approval-mobile-reject:active {
|
||||||
|
background-color: #774545;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Adjust plan height on mobile to account for buttons */
|
||||||
|
.plan-approval-view-mobile .plan-approval-wrapper {
|
||||||
|
height: calc(100cqh - 40px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Avoid the floating obsidian controls */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.plan-approval-view-mobile .plan-approval-wrapper {
|
||||||
|
height: calc(100cqh - 120px);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
Types/PlanApprovalResponse.ts
Normal file
11
Types/PlanApprovalResponse.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
export class PlanApprovalResponse {
|
||||||
|
|
||||||
|
public approved: boolean;
|
||||||
|
public suggestion: string;
|
||||||
|
|
||||||
|
public constructor(approved: boolean, suggestion: string = "") {
|
||||||
|
this.approved = approved;
|
||||||
|
this.suggestion = suggestion;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
209
Views/ArtifactView.ts
Normal file
209
Views/ArtifactView.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
import { Diff2HtmlUI, type Diff2HtmlUIConfig } from "diff2html/lib/ui/js/diff2html-ui-base";
|
||||||
|
import { base64ToArrayBuffer, ItemView, WorkspaceLeaf, type ViewStateResult } from "obsidian";
|
||||||
|
import type { Artifact } from "Conversations/Artifact";
|
||||||
|
import { Copy } from "Enums/Copy";
|
||||||
|
import { ArtifactAction } from "Enums/ArtifactAction";
|
||||||
|
import type { FileSystemService } from "Services/FileSystemService";
|
||||||
|
import { Resolve } from "Services/DependencyService";
|
||||||
|
import { Services } from "Services/Services";
|
||||||
|
import type { WorkSpaceService } from "Services/WorkSpaceService";
|
||||||
|
import { isDocumentMimeType, toMimeType } from "Enums/MimeType";
|
||||||
|
|
||||||
|
export const VIEW_TYPE_ARTIFACT = 'vaultkeeper-ai-artifact-view';
|
||||||
|
|
||||||
|
interface ArtifactViewState {
|
||||||
|
artifact: Artifact;
|
||||||
|
diffString: string;
|
||||||
|
config: Diff2HtmlUIConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONFIRM_TIMEOUT_MS = 3000;
|
||||||
|
|
||||||
|
export class ArtifactView extends ItemView {
|
||||||
|
|
||||||
|
private readonly fileSystemService: FileSystemService;
|
||||||
|
private readonly workSpaceService: WorkSpaceService;
|
||||||
|
|
||||||
|
private artifact: Artifact | undefined;
|
||||||
|
private diffString: string = "";
|
||||||
|
private config: Diff2HtmlUIConfig = {};
|
||||||
|
|
||||||
|
private diffContainer: HTMLElement | null = null;
|
||||||
|
private buttonsContainer: HTMLElement | null = null;
|
||||||
|
|
||||||
|
constructor(leaf: WorkspaceLeaf) {
|
||||||
|
super(leaf);
|
||||||
|
this.fileSystemService = Resolve<FileSystemService>(Services.FileSystemService);
|
||||||
|
this.workSpaceService = Resolve<WorkSpaceService>(Services.WorkSpaceService);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getViewType(): string {
|
||||||
|
return VIEW_TYPE_ARTIFACT;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getDisplayText(): string {
|
||||||
|
return "Vaultkeeper AI artifact viewer";
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setState(state: ArtifactViewState, result: ViewStateResult): Promise<void> {
|
||||||
|
this.artifact = state.artifact;
|
||||||
|
this.diffString = state.diffString;
|
||||||
|
this.config = state.config;
|
||||||
|
|
||||||
|
this.renderDiff();
|
||||||
|
|
||||||
|
return super.setState(state, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getState(): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
artifact: this.artifact,
|
||||||
|
diffString: this.diffString,
|
||||||
|
config: this.config
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderDiff() {
|
||||||
|
if (!this.artifact) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = this.resetContainer();
|
||||||
|
|
||||||
|
this.diffContainer = container.createDiv({ cls: 'd2h-wrapper' });
|
||||||
|
const diff2htmlUi = new Diff2HtmlUI(this.diffContainer, this.diffString, this.config);
|
||||||
|
|
||||||
|
diff2htmlUi.draw();
|
||||||
|
|
||||||
|
const buttonsContainer = this.createButtons();
|
||||||
|
if (buttonsContainer) {
|
||||||
|
this.buttonsContainer = buttonsContainer;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
const firstChange = this.diffContainer?.querySelector('.d2h-change, .d2h-ins, .d2h-del');
|
||||||
|
firstChange?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetContainer(): HTMLElement {
|
||||||
|
const container = this.contentEl;
|
||||||
|
container.empty();
|
||||||
|
|
||||||
|
if (this.diffContainer) {
|
||||||
|
this.diffContainer.remove();
|
||||||
|
this.diffContainer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.buttonsContainer) {
|
||||||
|
this.buttonsContainer.remove();
|
||||||
|
this.buttonsContainer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createButtons(): HTMLElement | undefined {
|
||||||
|
if (!this.artifact) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = this.contentEl.createDiv({ cls: 'artifact-view-controls' });
|
||||||
|
|
||||||
|
// If a file was modified then we have the option of restoring original content or the updated content
|
||||||
|
// If the operation was create / delete then we only restore updated or original respectively
|
||||||
|
// Binary / office file formats cannot be modified or created so we only ever restore the original for these
|
||||||
|
|
||||||
|
// We only need the restore previous button if the action was a modify
|
||||||
|
if (this.artifact.action === ArtifactAction.Modify) {
|
||||||
|
const restorePreviousButton = container.createEl('button', {
|
||||||
|
cls: 'artifact-view-button',
|
||||||
|
text: Copy.ButtonRestorePrevious
|
||||||
|
});
|
||||||
|
|
||||||
|
this.registerConfirmButton(restorePreviousButton, Copy.ButtonRestorePrevious, async () => {
|
||||||
|
if (!this.artifact) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.fileSystemService.writeToFilePath(this.artifact.filePath, this.artifact.originalContent, false, false);
|
||||||
|
await this.closeArtifactView();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoreButton = container.createEl('button', {
|
||||||
|
cls: 'artifact-view-button',
|
||||||
|
text: Copy.ButtonRestore
|
||||||
|
});
|
||||||
|
|
||||||
|
this.registerConfirmButton(restoreButton, Copy.ButtonRestore, async () => {
|
||||||
|
if (!this.artifact) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.artifact.base64) {
|
||||||
|
const arrayBuffer = base64ToArrayBuffer(this.artifact.base64);
|
||||||
|
await this.fileSystemService.writeBinaryFile(this.artifact.filePath, arrayBuffer, false);
|
||||||
|
await this.closeArtifactView();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the action is delete then we restore original otherwise restore updated
|
||||||
|
const restoreOriginal = this.artifact.action === ArtifactAction.Delete;
|
||||||
|
|
||||||
|
await this.fileSystemService.writeToFilePath(this.artifact.filePath, restoreOriginal
|
||||||
|
? this.artifact.originalContent : this.artifact.updatedContent, false, false);
|
||||||
|
|
||||||
|
await this.closeArtifactView();
|
||||||
|
});
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerConfirmButton(button: HTMLButtonElement, defaultLabel: string, onConfirm: () => Promise<void>): void {
|
||||||
|
button.setAttribute('aria-label', defaultLabel);
|
||||||
|
|
||||||
|
let resetTimeoutId: number | undefined;
|
||||||
|
let awaitingConfirmation = false;
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
awaitingConfirmation = false;
|
||||||
|
button.setText(defaultLabel);
|
||||||
|
button.setAttribute('aria-label', defaultLabel);
|
||||||
|
button.removeClass('artifact-view-button-confirming');
|
||||||
|
if (resetTimeoutId !== undefined) {
|
||||||
|
window.clearTimeout(resetTimeoutId);
|
||||||
|
resetTimeoutId = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.registerDomEvent(button, 'click', async () => {
|
||||||
|
if (!awaitingConfirmation) {
|
||||||
|
awaitingConfirmation = true;
|
||||||
|
button.setCssProps({ '--artifact-view-button-width': `${button.offsetWidth}px` });
|
||||||
|
button.addClass('artifact-view-button-confirming');
|
||||||
|
button.setText(Copy.ButtonConfirm);
|
||||||
|
button.setAttribute('aria-label', Copy.ButtonConfirm);
|
||||||
|
button.blur();
|
||||||
|
resetTimeoutId = window.setTimeout(reset, CONFIRM_TIMEOUT_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reset();
|
||||||
|
await onConfirm();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.register(reset);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async closeArtifactView(): Promise<void> {
|
||||||
|
if (!this.artifact) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDocumentMimeType(toMimeType(this.artifact.mimeType))) {
|
||||||
|
await this.workSpaceService.openNoteByPath(this.artifact.filePath);
|
||||||
|
}
|
||||||
|
this.leaf.detach();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,8 +5,9 @@ import { Resolve } from "Services/DependencyService";
|
||||||
import type { EventService } from "Services/EventService";
|
import type { EventService } from "Services/EventService";
|
||||||
import type { DiffService } from "Services/DiffService";
|
import type { DiffService } from "Services/DiffService";
|
||||||
import { Services } from "Services/Services";
|
import { Services } from "Services/Services";
|
||||||
import { VIEW_TYPE_MAIN } from "./MainView";
|
import { VIEW_TYPE_MAIN, type MainView } from "./MainView";
|
||||||
import { tick } from "svelte";
|
import { tick } from "svelte";
|
||||||
|
import { Copy } from "Enums/Copy";
|
||||||
|
|
||||||
export const VIEW_TYPE_DIFF = 'vaultkeeper-ai-diff-view';
|
export const VIEW_TYPE_DIFF = 'vaultkeeper-ai-diff-view';
|
||||||
|
|
||||||
|
|
@ -48,7 +49,7 @@ export class DiffView extends ItemView {
|
||||||
}
|
}
|
||||||
|
|
||||||
public getDisplayText(): string {
|
public getDisplayText(): string {
|
||||||
return "Vaultkeeper AI diff";
|
return "Vaultkeeper AI diff viewer";
|
||||||
}
|
}
|
||||||
|
|
||||||
public async setState(state: DiffViewState, result: ViewStateResult): Promise<void> {
|
public async setState(state: DiffViewState, result: ViewStateResult): Promise<void> {
|
||||||
|
|
@ -111,21 +112,31 @@ export class DiffView extends ItemView {
|
||||||
|
|
||||||
const acceptButton = container.createEl('button', {
|
const acceptButton = container.createEl('button', {
|
||||||
cls: 'diff-mobile-button diff-mobile-accept',
|
cls: 'diff-mobile-button diff-mobile-accept',
|
||||||
text: 'Accept'
|
text: Copy.ButtonApprove
|
||||||
});
|
});
|
||||||
acceptButton.setAttribute('aria-label', 'Accept changes');
|
acceptButton.setAttribute('aria-label', Copy.ButtonApprove);
|
||||||
|
|
||||||
|
const discussButton = container.createEl('button', {
|
||||||
|
cls: 'diff-mobile-button diff-mobile-discuss',
|
||||||
|
text: Copy.ButtonDiscuss
|
||||||
|
});
|
||||||
|
discussButton.setAttribute('aria-label', Copy.ButtonDiscuss);
|
||||||
|
|
||||||
const rejectButton = container.createEl('button', {
|
const rejectButton = container.createEl('button', {
|
||||||
cls: 'diff-mobile-button diff-mobile-reject',
|
cls: 'diff-mobile-button diff-mobile-reject',
|
||||||
text: 'Reject'
|
text: Copy.ButtonReject
|
||||||
});
|
});
|
||||||
rejectButton.setAttribute('aria-label', 'Reject changes');
|
rejectButton.setAttribute('aria-label', Copy.ButtonReject);
|
||||||
|
|
||||||
this.registerDomEvent(acceptButton, 'click', async () => {
|
this.registerDomEvent(acceptButton, 'click', async () => {
|
||||||
this.diffService.onAccept();
|
this.diffService.onAccept();
|
||||||
await this.refocusMainView();
|
await this.refocusMainView();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.registerDomEvent(discussButton, 'click', async () => {
|
||||||
|
await this.refocusMainView(true);
|
||||||
|
});
|
||||||
|
|
||||||
this.registerDomEvent(rejectButton, 'click', async () => {
|
this.registerDomEvent(rejectButton, 'click', async () => {
|
||||||
this.diffService.onReject();
|
this.diffService.onReject();
|
||||||
await this.refocusMainView();
|
await this.refocusMainView();
|
||||||
|
|
@ -134,7 +145,7 @@ export class DiffView extends ItemView {
|
||||||
return container;
|
return container;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async refocusMainView(): Promise<void> {
|
private async refocusMainView(focusChatInput: boolean = false): Promise<void> {
|
||||||
await tick().then(async () => {
|
await tick().then(async () => {
|
||||||
const { workspace } = this.app;
|
const { workspace } = this.app;
|
||||||
const leaves = workspace.getLeavesOfType(VIEW_TYPE_MAIN);
|
const leaves = workspace.getLeavesOfType(VIEW_TYPE_MAIN);
|
||||||
|
|
@ -142,6 +153,10 @@ export class DiffView extends ItemView {
|
||||||
if (leaves.length > 0) {
|
if (leaves.length > 0) {
|
||||||
await workspace.revealLeaf(leaves[0]);
|
await workspace.revealLeaf(leaves[0]);
|
||||||
workspace.setActiveLeaf(leaves[0], { focus: true });
|
workspace.setActiveLeaf(leaves[0], { focus: true });
|
||||||
|
|
||||||
|
if (focusChatInput) {
|
||||||
|
(leaves[0].view as MainView).input?.focusInput(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { Services } from 'Services/Services';
|
||||||
export const VIEW_TYPE_MAIN = 'vaultkeeper-ai-main-view';
|
export const VIEW_TYPE_MAIN = 'vaultkeeper-ai-main-view';
|
||||||
|
|
||||||
interface ChatWindowComponent {
|
interface ChatWindowComponent {
|
||||||
focusInput: () => void;
|
focusInput: (force?: boolean) => void;
|
||||||
resetChatArea: () => void;
|
resetChatArea: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
177
Views/PlanApprovalView.ts
Normal file
177
Views/PlanApprovalView.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
import { Event } from "Enums/Event";
|
||||||
|
import { Copy } from "Enums/Copy";
|
||||||
|
import { ItemView, WorkspaceLeaf, Platform, type ViewStateResult } from "obsidian";
|
||||||
|
import { mount, unmount } from "svelte";
|
||||||
|
import { Resolve } from "Services/DependencyService";
|
||||||
|
import type { EventService } from "Services/EventService";
|
||||||
|
import type { PlanApprovalService } from "Services/PlanApprovalService";
|
||||||
|
import { Services } from "Services/Services";
|
||||||
|
import { VIEW_TYPE_MAIN, type MainView } from "./MainView";
|
||||||
|
import PlanApprovalWindow from "Components/PlanApprovalWindow.svelte";
|
||||||
|
import type { ExecutionPlan } from "Types/ExecutionPlan";
|
||||||
|
import { tick } from "svelte";
|
||||||
|
|
||||||
|
export const VIEW_TYPE_PLAN_APPROVAL = 'vaultkeeper-ai-plan-approval-view';
|
||||||
|
|
||||||
|
interface PlanApprovalViewState {
|
||||||
|
plan: ExecutionPlan;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PlanApprovalView extends ItemView {
|
||||||
|
|
||||||
|
private readonly eventService: EventService;
|
||||||
|
private readonly planApprovalService: PlanApprovalService;
|
||||||
|
|
||||||
|
private plan: ExecutionPlan | undefined;
|
||||||
|
|
||||||
|
private planWindow: ReturnType<typeof PlanApprovalWindow> | undefined;
|
||||||
|
private planContainer: HTMLElement | null = null;
|
||||||
|
private mobileControlsContainer: HTMLElement | null = null;
|
||||||
|
|
||||||
|
constructor(leaf: WorkspaceLeaf) {
|
||||||
|
super(leaf);
|
||||||
|
|
||||||
|
this.eventService = Resolve<EventService>(Services.EventService);
|
||||||
|
this.planApprovalService = Resolve<PlanApprovalService>(Services.PlanApprovalService);
|
||||||
|
|
||||||
|
this.registerEvent(this.eventService.on(Event.PlanApprovalClosed, () => {
|
||||||
|
this.leaf.detach();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override onClose(): Promise<void> {
|
||||||
|
// trigger PlanApprovalClosed event in case the user closed the tab
|
||||||
|
this.eventService.trigger(Event.PlanApprovalClosed);
|
||||||
|
|
||||||
|
if (this.planWindow) {
|
||||||
|
void unmount(this.planWindow);
|
||||||
|
this.planWindow = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
public getViewType(): string {
|
||||||
|
return VIEW_TYPE_PLAN_APPROVAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getDisplayText(): string {
|
||||||
|
return "Vaultkeeper AI plan";
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setState(state: PlanApprovalViewState, result: ViewStateResult): Promise<void> {
|
||||||
|
this.plan = state.plan;
|
||||||
|
|
||||||
|
this.renderPlan();
|
||||||
|
|
||||||
|
return super.setState(state, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getState(): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
plan: this.plan
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderPlan() {
|
||||||
|
const container = this.resetContainer();
|
||||||
|
|
||||||
|
if (!this.plan) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Platform.isMobile) {
|
||||||
|
container.addClass('plan-approval-view-mobile');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.planContainer = container.createDiv({ cls: 'plan-approval-wrapper' });
|
||||||
|
|
||||||
|
this.planWindow = mount(PlanApprovalWindow, {
|
||||||
|
target: this.planContainer,
|
||||||
|
props: {
|
||||||
|
plan: this.plan
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Platform.isMobile) {
|
||||||
|
this.mobileControlsContainer = this.createMobileButtons();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetContainer(): HTMLElement {
|
||||||
|
const container = this.contentEl;
|
||||||
|
container.empty();
|
||||||
|
|
||||||
|
if (this.planWindow) {
|
||||||
|
void unmount(this.planWindow);
|
||||||
|
this.planWindow = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.planContainer) {
|
||||||
|
this.planContainer.remove();
|
||||||
|
this.planContainer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.mobileControlsContainer) {
|
||||||
|
this.mobileControlsContainer.remove();
|
||||||
|
this.mobileControlsContainer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createMobileButtons(): HTMLElement {
|
||||||
|
const container = this.contentEl.createDiv({ cls: 'plan-approval-mobile-controls' });
|
||||||
|
|
||||||
|
const approveButton = container.createEl('button', {
|
||||||
|
cls: 'plan-approval-mobile-button plan-approval-mobile-approve',
|
||||||
|
text: Copy.ButtonApprove
|
||||||
|
});
|
||||||
|
approveButton.setAttribute('aria-label', Copy.ButtonApprove);
|
||||||
|
|
||||||
|
const discussButton = container.createEl('button', {
|
||||||
|
cls: 'plan-approval-mobile-button plan-approval-mobile-discuss',
|
||||||
|
text: Copy.ButtonDiscuss
|
||||||
|
});
|
||||||
|
discussButton.setAttribute('aria-label', Copy.ButtonDiscuss);
|
||||||
|
|
||||||
|
const rejectButton = container.createEl('button', {
|
||||||
|
cls: 'plan-approval-mobile-button plan-approval-mobile-reject',
|
||||||
|
text: Copy.ButtonReject
|
||||||
|
});
|
||||||
|
rejectButton.setAttribute('aria-label', Copy.ButtonReject);
|
||||||
|
|
||||||
|
this.registerDomEvent(approveButton, 'click', async () => {
|
||||||
|
this.planApprovalService.onApprove();
|
||||||
|
await this.refocusMainView();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.registerDomEvent(discussButton, 'click', async () => {
|
||||||
|
await this.refocusMainView(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.registerDomEvent(rejectButton, 'click', async () => {
|
||||||
|
this.planApprovalService.onReject();
|
||||||
|
await this.refocusMainView();
|
||||||
|
});
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refocusMainView(focusChatInput: boolean = false): Promise<void> {
|
||||||
|
await tick().then(async () => {
|
||||||
|
const { workspace } = this.app;
|
||||||
|
const leaves = workspace.getLeavesOfType(VIEW_TYPE_MAIN);
|
||||||
|
|
||||||
|
if (leaves.length > 0) {
|
||||||
|
await workspace.revealLeaf(leaves[0]);
|
||||||
|
workspace.setActiveLeaf(leaves[0], { focus: true });
|
||||||
|
|
||||||
|
if (focusChatInput) {
|
||||||
|
(leaves[0].view as MainView).input?.focusInput(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
import { AIProvider, AIProviderModel, fromModel, isValidProviderModel } from "Enums/ApiProvider";
|
import { AIProvider, AIProviderModel, DEFAULT_PLANNING_MODEL_BY_PROVIDER, DEFAULT_QUICK_MODEL_BY_PROVIDER, fromModel, isvalidProvider, isValidProviderModel } from "Enums/ApiProvider";
|
||||||
import { Copy } from "Enums/Copy";
|
import { Copy } from "Enums/Copy";
|
||||||
import { Selector } from "Enums/Selector";
|
import { Selector } from "Enums/Selector";
|
||||||
import type VaultkeeperAIPlugin from "main";
|
import type VaultkeeperAIPlugin from "main";
|
||||||
import { HelpModal } from "Modals/HelpModal";
|
import { HelpModal } from "Modals/HelpModal";
|
||||||
import { DropdownComponent, PluginSettingTab, Setting, ToggleComponent, setIcon, setTooltip } from "obsidian";
|
import { DropdownComponent, PluginSettingTab, Setting, ToggleComponent, setIcon, setTooltip } from "obsidian";
|
||||||
import { Resolve } from "Services/DependencyService";
|
import { Resolve } from "Services/DependencyService";
|
||||||
import type { EventService } from "Services/EventService";
|
|
||||||
import type { SettingsService } from "Services/SettingsService";
|
import type { SettingsService } from "Services/SettingsService";
|
||||||
import { Services } from "Services/Services";
|
import { Services } from "Services/Services";
|
||||||
import { closePluginSettings } from "Helpers/Helpers";
|
import { closePluginSettings } from "Helpers/Helpers";
|
||||||
|
|
@ -16,11 +15,13 @@ export class VaultkeeperAISettingTab extends PluginSettingTab {
|
||||||
private readonly plugin: VaultkeeperAIPlugin;
|
private readonly plugin: VaultkeeperAIPlugin;
|
||||||
private readonly settingsService: SettingsService;
|
private readonly settingsService: SettingsService;
|
||||||
private readonly memoriesService: MemoriesService;
|
private readonly memoriesService: MemoriesService;
|
||||||
private readonly eventService: EventService;
|
|
||||||
|
|
||||||
private apiKeySetting: Setting | null = null;
|
private apiKeySetting: Setting | null = null;
|
||||||
private apiKeyInputEl: HTMLInputElement | null = null;
|
private apiKeyInputEl: HTMLInputElement | null = null;
|
||||||
|
private localApiKeyInputEl: HTMLInputElement | null = null;
|
||||||
private fileDisclaimerSetting: Setting | null = null;
|
private fileDisclaimerSetting: Setting | null = null;
|
||||||
|
private providerSectionEl: HTMLElement | null = null;
|
||||||
|
private modelDropdown: DropdownComponent | null = null;
|
||||||
private planningModelDropdown: DropdownComponent | null = null;
|
private planningModelDropdown: DropdownComponent | null = null;
|
||||||
private quickActionModelDropdown: DropdownComponent | null = null;
|
private quickActionModelDropdown: DropdownComponent | null = null;
|
||||||
private allowUpdatingMemoriesSetting: Setting | null = null;
|
private allowUpdatingMemoriesSetting: Setting | null = null;
|
||||||
|
|
@ -34,7 +35,6 @@ export class VaultkeeperAISettingTab extends PluginSettingTab {
|
||||||
|
|
||||||
this.settingsService = Resolve<SettingsService>(Services.SettingsService);
|
this.settingsService = Resolve<SettingsService>(Services.SettingsService);
|
||||||
this.memoriesService = Resolve<MemoriesService>(Services.MemoriesService);
|
this.memoriesService = Resolve<MemoriesService>(Services.MemoriesService);
|
||||||
this.eventService = Resolve<EventService>(Services.EventService);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public display() {
|
public display() {
|
||||||
|
|
@ -42,123 +42,47 @@ export class VaultkeeperAISettingTab extends PluginSettingTab {
|
||||||
|
|
||||||
containerEl.empty();
|
containerEl.empty();
|
||||||
|
|
||||||
/* Model Selection Setting */
|
/* Provider Selection Setting */
|
||||||
new Setting(containerEl)
|
new Setting(containerEl)
|
||||||
.setName(Copy.SettingModel)
|
.setName(Copy.SettingProvider)
|
||||||
.setDesc(Copy.SettingModelDesc)
|
.setDesc(Copy.SettingProviderDesc)
|
||||||
.addDropdown((dropdown) => {
|
.addDropdown((dropdown) => {
|
||||||
this.populateModelDropdown(dropdown);
|
this.populateProviderDropdown(dropdown);
|
||||||
dropdown.setValue(this.settingsService.settings.model);
|
dropdown.setValue(this.settingsService.settings.provider);
|
||||||
dropdown.onChange(async (value) => {
|
dropdown.onChange(async value => {
|
||||||
if (!isValidProviderModel(value)) {
|
if (!isvalidProvider(value)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.settingsService.updateSettings(settings => {
|
await this.settingsService.updateSettings(settings => {
|
||||||
settings.model = value;
|
settings.provider = value;
|
||||||
settings.provider = fromModel(value);
|
const cached = settings.cachedModelSettings[settings.provider];
|
||||||
});
|
if (cached.model) {
|
||||||
if (this.apiKeyInputEl) {
|
settings.model = cached.model;
|
||||||
this.apiKeyInputEl.value = this.settingsService.getApiKeyForCurrentModel();
|
}
|
||||||
this.highlightApiKey();
|
if (cached.planningModel) {
|
||||||
}
|
settings.planningModel = cached.planningModel;
|
||||||
this.updateFileDisclaimer();
|
}
|
||||||
await this.updateModelDropdowns();
|
if (cached.quickActionModel) {
|
||||||
RegisterAiProvider();
|
settings.quickActionModel = cached.quickActionModel;
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/* Planning Model Selection Setting */
|
|
||||||
const currentProvider = fromModel(this.settingsService.settings.model);
|
|
||||||
const planningModelDescFragment = createFragment();
|
|
||||||
planningModelDescFragment.appendText(Copy.SettingPlanningModelDesc);
|
|
||||||
planningModelDescFragment.createEl("br");
|
|
||||||
planningModelDescFragment.createEl("br");
|
|
||||||
planningModelDescFragment.createSpan({ text: Copy.SettingPlanningModelTip, cls: "planning-model-description-tip" });
|
|
||||||
new Setting(containerEl)
|
|
||||||
.setName(Copy.SettingPlanningModel)
|
|
||||||
.setDesc(planningModelDescFragment)
|
|
||||||
.addDropdown((dropdown) => {
|
|
||||||
this.planningModelDropdown = dropdown;
|
|
||||||
this.populateModelDropdown(dropdown, currentProvider);
|
|
||||||
dropdown.setValue(this.settingsService.settings.planningModel);
|
|
||||||
dropdown.onChange(async (value) => {
|
|
||||||
if (!isValidProviderModel(value)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await this.settingsService.updateSettings(settings => {
|
|
||||||
settings.planningModel = value;
|
|
||||||
});
|
|
||||||
RegisterAiProvider();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/* Quick Action Model Selection Setting */
|
|
||||||
new Setting(containerEl)
|
|
||||||
.setName(Copy.SettingQuickActionModel)
|
|
||||||
.setDesc(Copy.SettingQuickActionModelDesc)
|
|
||||||
.addDropdown((dropdown) => {
|
|
||||||
this.quickActionModelDropdown = dropdown;
|
|
||||||
this.populateModelDropdown(dropdown);
|
|
||||||
dropdown.setValue(this.settingsService.settings.quickActionModel);
|
|
||||||
dropdown.onChange(async (value) => {
|
|
||||||
if (!isValidProviderModel(value)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await this.settingsService.updateSettings(settings => {
|
|
||||||
settings.quickActionModel = value;
|
|
||||||
});
|
|
||||||
RegisterAiProvider();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/* API Key Setting */
|
|
||||||
this.apiKeySetting = new Setting(containerEl)
|
|
||||||
.setName(Copy.SettingApiKey)
|
|
||||||
.setDesc(Copy.SettingApiKeyDesc)
|
|
||||||
.addText(text => {
|
|
||||||
text.setPlaceholder(Copy.PlaceholderEnterApiKey)
|
|
||||||
.setValue(this.settingsService.getApiKeyForCurrentModel())
|
|
||||||
.onChange(async (value) => {
|
|
||||||
await this.settingsService.updateSettings(async settings => {
|
|
||||||
await this.settingsService.setApiKeyForProvider(fromModel(settings.model), value);
|
|
||||||
});
|
|
||||||
this.highlightApiKey();
|
|
||||||
RegisterAiProvider();
|
|
||||||
});
|
|
||||||
text.inputEl.type = "password";
|
|
||||||
this.apiKeyInputEl = text.inputEl;
|
|
||||||
})
|
|
||||||
.addExtraButton(button => {
|
|
||||||
button
|
|
||||||
.setTooltip(Copy.TooltipShowApiKey)
|
|
||||||
.onClick(() => {
|
|
||||||
if (this.apiKeyInputEl && this.apiKeyInputEl.type === "password") {
|
|
||||||
this.apiKeyInputEl.type = "text";
|
|
||||||
setIcon(button.extraSettingsEl, "eye-off");
|
|
||||||
setTooltip(button.extraSettingsEl, Copy.TooltipHideApiKey);
|
|
||||||
} else if (this.apiKeyInputEl) {
|
|
||||||
this.apiKeyInputEl.type = "password";
|
|
||||||
setIcon(button.extraSettingsEl, "eye");
|
|
||||||
setTooltip(button.extraSettingsEl, Copy.TooltipShowApiKey);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
setIcon(button.extraSettingsEl, "eye");
|
await this.settingsService.ensureValidModels();
|
||||||
});
|
if (value !== AIProvider.Local) {
|
||||||
this.highlightApiKey();
|
await this.updateModelDropdowns();
|
||||||
|
this.updateFileDisclaimer();
|
||||||
/* Model files API disclaimer */
|
}
|
||||||
this.fileDisclaimerSetting = new Setting(containerEl)
|
RegisterAiProvider();
|
||||||
.setDesc(Copy.SettingFileMonitoringClaude)
|
this.renderProviderSection();
|
||||||
.addExtraButton(button => {
|
|
||||||
button
|
|
||||||
.setTooltip(Copy.TooltipLearnMoreFileMonitoring)
|
|
||||||
.onClick(() => {
|
|
||||||
const modal = Resolve<HelpModal>(Services.HelpModal);
|
|
||||||
modal.open(7); // Opens HelpModal to "Uploaded Files" (topic 7)
|
|
||||||
});
|
});
|
||||||
setIcon(button.extraSettingsEl, "help-circle");
|
});
|
||||||
});
|
|
||||||
this.updateFileDisclaimer();
|
this.providerSectionEl = containerEl.createDiv();
|
||||||
|
this.renderProviderSection();
|
||||||
|
|
||||||
|
/* Exclusions Header */
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setHeading()
|
||||||
|
.setName(Copy.SettingExclusionsHeading);
|
||||||
|
|
||||||
/* Exclusions Setting */
|
/* Exclusions Setting */
|
||||||
new Setting(containerEl)
|
new Setting(containerEl)
|
||||||
|
|
@ -167,7 +91,7 @@ export class VaultkeeperAISettingTab extends PluginSettingTab {
|
||||||
.addTextArea(text => {
|
.addTextArea(text => {
|
||||||
text.setPlaceholder(Copy.PlaceholderFileExclusions)
|
text.setPlaceholder(Copy.PlaceholderFileExclusions)
|
||||||
.setValue(this.settingsService.settings.exclusions.join("\n"))
|
.setValue(this.settingsService.settings.exclusions.join("\n"))
|
||||||
.onChange(async (value) => {
|
.onChange(async value => {
|
||||||
await this.settingsService.updateSettings(settings => {
|
await this.settingsService.updateSettings(settings => {
|
||||||
settings.exclusions = value.split("\n").map(line => line.trim()).filter(line => line.length > 0);
|
settings.exclusions = value.split("\n").map(line => line.trim()).filter(line => line.length > 0);
|
||||||
});
|
});
|
||||||
|
|
@ -188,8 +112,7 @@ export class VaultkeeperAISettingTab extends PluginSettingTab {
|
||||||
slider
|
slider
|
||||||
.setLimits(5, 40, 1)
|
.setLimits(5, 40, 1)
|
||||||
.setValue(this.settingsService.settings.searchResultsLimit)
|
.setValue(this.settingsService.settings.searchResultsLimit)
|
||||||
.setDynamicTooltip()
|
.onChange(async value => {
|
||||||
.onChange(async (value) => {
|
|
||||||
await this.settingsService.updateSettings(settings => {
|
await this.settingsService.updateSettings(settings => {
|
||||||
settings.searchResultsLimit = value;
|
settings.searchResultsLimit = value;
|
||||||
});
|
});
|
||||||
|
|
@ -204,8 +127,7 @@ export class VaultkeeperAISettingTab extends PluginSettingTab {
|
||||||
slider
|
slider
|
||||||
.setLimits(50, 1000, 10)
|
.setLimits(50, 1000, 10)
|
||||||
.setValue(this.settingsService.settings.snippetSizeLimit)
|
.setValue(this.settingsService.settings.snippetSizeLimit)
|
||||||
.setDynamicTooltip()
|
.onChange(async value => {
|
||||||
.onChange(async (value) => {
|
|
||||||
await this.settingsService.updateSettings(settings => {
|
await this.settingsService.updateSettings(settings => {
|
||||||
settings.snippetSizeLimit = value;
|
settings.snippetSizeLimit = value;
|
||||||
});
|
});
|
||||||
|
|
@ -224,7 +146,7 @@ export class VaultkeeperAISettingTab extends PluginSettingTab {
|
||||||
.addToggle(toggle => {
|
.addToggle(toggle => {
|
||||||
toggle
|
toggle
|
||||||
.setValue(this.settingsService.settings.enableWebViewer)
|
.setValue(this.settingsService.settings.enableWebViewer)
|
||||||
.onChange(async (value) => {
|
.onChange(async value => {
|
||||||
await this.settingsService.updateSettings(settings => {
|
await this.settingsService.updateSettings(settings => {
|
||||||
settings.enableWebViewer = value;
|
settings.enableWebViewer = value;
|
||||||
});
|
});
|
||||||
|
|
@ -243,7 +165,7 @@ export class VaultkeeperAISettingTab extends PluginSettingTab {
|
||||||
.addToggle(toggle => {
|
.addToggle(toggle => {
|
||||||
toggle
|
toggle
|
||||||
.setValue(this.settingsService.settings.enableMemories)
|
.setValue(this.settingsService.settings.enableMemories)
|
||||||
.onChange(async (value) => {
|
.onChange(async value => {
|
||||||
await this.settingsService.updateSettings(settings => {
|
await this.settingsService.updateSettings(settings => {
|
||||||
settings.enableMemories = value;
|
settings.enableMemories = value;
|
||||||
});
|
});
|
||||||
|
|
@ -259,7 +181,7 @@ export class VaultkeeperAISettingTab extends PluginSettingTab {
|
||||||
this.allowUpdatingMemoriesToggleComponent = toggle;
|
this.allowUpdatingMemoriesToggleComponent = toggle;
|
||||||
toggle
|
toggle
|
||||||
.setValue(this.settingsService.settings.allowUpdatingMemories)
|
.setValue(this.settingsService.settings.allowUpdatingMemories)
|
||||||
.onChange(async (value) => {
|
.onChange(async value => {
|
||||||
await this.settingsService.updateSettings(settings => {
|
await this.settingsService.updateSettings(settings => {
|
||||||
settings.allowUpdatingMemories = value;
|
settings.allowUpdatingMemories = value;
|
||||||
});
|
});
|
||||||
|
|
@ -293,7 +215,7 @@ export class VaultkeeperAISettingTab extends PluginSettingTab {
|
||||||
.addToggle(toggle => {
|
.addToggle(toggle => {
|
||||||
toggle
|
toggle
|
||||||
.setValue(this.settingsService.settings.enableContextMenuActions)
|
.setValue(this.settingsService.settings.enableContextMenuActions)
|
||||||
.onChange(async (value) => {
|
.onChange(async value => {
|
||||||
await this.settingsService.updateSettings(settings => {
|
await this.settingsService.updateSettings(settings => {
|
||||||
settings.enableContextMenuActions = value;
|
settings.enableContextMenuActions = value;
|
||||||
});
|
});
|
||||||
|
|
@ -307,7 +229,7 @@ export class VaultkeeperAISettingTab extends PluginSettingTab {
|
||||||
.addToggle(toggle => {
|
.addToggle(toggle => {
|
||||||
toggle
|
toggle
|
||||||
.setValue(this.settingsService.settings.enableToolbarActions)
|
.setValue(this.settingsService.settings.enableToolbarActions)
|
||||||
.onChange(async (value) => {
|
.onChange(async value => {
|
||||||
await this.settingsService.updateSettings(settings => {
|
await this.settingsService.updateSettings(settings => {
|
||||||
settings.enableToolbarActions = value;
|
settings.enableToolbarActions = value;
|
||||||
});
|
});
|
||||||
|
|
@ -326,7 +248,7 @@ export class VaultkeeperAISettingTab extends PluginSettingTab {
|
||||||
.addToggle(toggle => {
|
.addToggle(toggle => {
|
||||||
toggle
|
toggle
|
||||||
.setValue(this.settingsService.settings.hideDrawerElements)
|
.setValue(this.settingsService.settings.hideDrawerElements)
|
||||||
.onChange(async (value) => {
|
.onChange(async value => {
|
||||||
await this.settingsService.updateSettings(settings => {
|
await this.settingsService.updateSettings(settings => {
|
||||||
settings.hideDrawerElements = value;
|
settings.hideDrawerElements = value;
|
||||||
});
|
});
|
||||||
|
|
@ -334,45 +256,285 @@ export class VaultkeeperAISettingTab extends PluginSettingTab {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private populateModelDropdown(dropdown: DropdownComponent, providerFilter?: AIProvider): void {
|
private renderProviderSection(): void {
|
||||||
|
if (!this.providerSectionEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerEl = this.providerSectionEl;
|
||||||
|
containerEl.empty();
|
||||||
|
|
||||||
|
/* Local Server URL Setting */
|
||||||
|
if (this.settingsService.settings.provider === AIProvider.Local) {
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName(Copy.SettingLocalUrl)
|
||||||
|
.setDesc(Copy.SettingLocalUrlDesc)
|
||||||
|
.addText(text => {
|
||||||
|
text.setPlaceholder(Copy.PlaceholderLocalUrl)
|
||||||
|
.setValue(this.settingsService.settings.localUrl)
|
||||||
|
.onChange(async value => {
|
||||||
|
await this.settingsService.updateSettings(settings => {
|
||||||
|
settings.localUrl = value;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
text.inputEl.classList.add(Selector.LocalUrlInput);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Local API Key Setting */
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName(Copy.SettingApiKey)
|
||||||
|
.setDesc(Copy.SettingApiKeyLocalDesc)
|
||||||
|
.addText(text => {
|
||||||
|
text.setPlaceholder(Copy.PlaceholderApiKey)
|
||||||
|
.setValue(this.settingsService.settings.apiKeys.local)
|
||||||
|
.onChange(async value => {
|
||||||
|
await this.settingsService.setApiKeyForProvider(this.settingsService.settings.provider, value);
|
||||||
|
});
|
||||||
|
text.inputEl.type = "password";
|
||||||
|
this.localApiKeyInputEl = text.inputEl;
|
||||||
|
})
|
||||||
|
.addExtraButton(button => {
|
||||||
|
button
|
||||||
|
.setTooltip(Copy.TooltipShowApiKey)
|
||||||
|
.onClick(() => {
|
||||||
|
if (this.localApiKeyInputEl && this.localApiKeyInputEl.type === "password") {
|
||||||
|
this.localApiKeyInputEl.type = "text";
|
||||||
|
setIcon(button.extraSettingsEl, "eye-off");
|
||||||
|
setTooltip(button.extraSettingsEl, Copy.TooltipHideApiKey);
|
||||||
|
} else if (this.localApiKeyInputEl) {
|
||||||
|
this.localApiKeyInputEl.type = "password";
|
||||||
|
setIcon(button.extraSettingsEl, "eye");
|
||||||
|
setTooltip(button.extraSettingsEl, Copy.TooltipShowApiKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setIcon(button.extraSettingsEl, "eye");
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Local Model Selection Setting */
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName(Copy.SettingModel)
|
||||||
|
.setDesc(Copy.SettingLocalModelDesc)
|
||||||
|
.addText(text => {
|
||||||
|
text.setPlaceholder(Copy.PlaceholderModelName)
|
||||||
|
.setValue(this.settingsService.settings.localModels.model)
|
||||||
|
.onChange(async value => {
|
||||||
|
await this.settingsService.updateSettings(settings => {
|
||||||
|
settings.localModels.model = value;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Local Planning Model Selection Setting */
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName(Copy.SettingPlanningModel)
|
||||||
|
.setDesc(Copy.SettingLocalPlanningModelDesc)
|
||||||
|
.addText(text => {
|
||||||
|
text.setPlaceholder(Copy.PlaceholderModelName)
|
||||||
|
.setValue(this.settingsService.settings.localModels.planningModel)
|
||||||
|
.onChange(async value => {
|
||||||
|
await this.settingsService.updateSettings(settings => {
|
||||||
|
settings.localModels.planningModel = value;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Local Quick Action Model Selection Setting */
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName(Copy.SettingQuickActionModel)
|
||||||
|
.setDesc(Copy.SettingLocalQuickActionModelDesc)
|
||||||
|
.addText(text => {
|
||||||
|
text.setPlaceholder(Copy.PlaceholderModelName)
|
||||||
|
.setValue(this.settingsService.settings.localModels.quickActionModel)
|
||||||
|
.onChange(async value => {
|
||||||
|
await this.settingsService.updateSettings(settings => {
|
||||||
|
settings.localModels.quickActionModel = value;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const templateWarningDescFragment = createFragment();
|
||||||
|
const templateWarningGridEl = templateWarningDescFragment.createDiv({ cls: Selector.SettingDescIconGrid });
|
||||||
|
setIcon(templateWarningGridEl.createDiv({ cls: Selector.TemplateWarningIcon }), "circle-alert");
|
||||||
|
const templateWarningTextEl = templateWarningGridEl.createDiv();
|
||||||
|
templateWarningTextEl.appendText(Copy.SettingLocalModelTemplateWarning);
|
||||||
|
templateWarningTextEl.createEl("a", {
|
||||||
|
text: Copy.SettingLocalModelTemplateWarningLinkText,
|
||||||
|
href: "https://lmstudio.ai/docs/app/advanced/prompt-template",
|
||||||
|
cls: Selector.FileDisclaimerLink
|
||||||
|
});
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setDesc(templateWarningDescFragment);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
/* API Key Setting */
|
||||||
|
this.apiKeySetting = new Setting(containerEl)
|
||||||
|
.setName(Copy.SettingApiKey)
|
||||||
|
.setDesc(Copy.SettingApiKeyDesc)
|
||||||
|
.addText(text => {
|
||||||
|
text.setPlaceholder(Copy.PlaceholderEnterApiKey)
|
||||||
|
.setValue(this.settingsService.getApiKeyForCurrentProvider())
|
||||||
|
.onChange(async value => {
|
||||||
|
await this.settingsService.setApiKeyForProvider(this.settingsService.settings.provider, value);
|
||||||
|
this.highlightApiKey();
|
||||||
|
RegisterAiProvider();
|
||||||
|
});
|
||||||
|
text.inputEl.type = "password";
|
||||||
|
this.apiKeyInputEl = text.inputEl;
|
||||||
|
})
|
||||||
|
.addExtraButton(button => {
|
||||||
|
button
|
||||||
|
.setTooltip(Copy.TooltipShowApiKey)
|
||||||
|
.onClick(() => {
|
||||||
|
if (this.apiKeyInputEl && this.apiKeyInputEl.type === "password") {
|
||||||
|
this.apiKeyInputEl.type = "text";
|
||||||
|
setIcon(button.extraSettingsEl, "eye-off");
|
||||||
|
setTooltip(button.extraSettingsEl, Copy.TooltipHideApiKey);
|
||||||
|
} else if (this.apiKeyInputEl) {
|
||||||
|
this.apiKeyInputEl.type = "password";
|
||||||
|
setIcon(button.extraSettingsEl, "eye");
|
||||||
|
setTooltip(button.extraSettingsEl, Copy.TooltipShowApiKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setIcon(button.extraSettingsEl, "eye");
|
||||||
|
});
|
||||||
|
this.highlightApiKey();
|
||||||
|
|
||||||
|
/* Model Selection Setting */
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName(Copy.SettingModel)
|
||||||
|
.setDesc(Copy.SettingModelDesc)
|
||||||
|
.addDropdown((dropdown) => {
|
||||||
|
this.modelDropdown = dropdown;
|
||||||
|
this.populateModelDropdown(dropdown, this.settingsService.settings.provider);
|
||||||
|
dropdown.setValue(this.settingsService.settings.model);
|
||||||
|
dropdown.onChange(async value => {
|
||||||
|
if (!isValidProviderModel(value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.settingsService.updateSettings(settings => {
|
||||||
|
settings.model = value;
|
||||||
|
settings.cachedModelSettings[settings.provider].model = value;
|
||||||
|
});
|
||||||
|
RegisterAiProvider();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Planning Model Selection Setting */
|
||||||
|
const planningModelDescFragment = createFragment();
|
||||||
|
planningModelDescFragment.appendText(Copy.SettingPlanningModelDesc);
|
||||||
|
planningModelDescFragment.createEl("br");
|
||||||
|
planningModelDescFragment.createEl("br");
|
||||||
|
planningModelDescFragment.createSpan({ text: Copy.SettingPlanningModelTip, cls: "planning-model-description-tip" });
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName(Copy.SettingPlanningModel)
|
||||||
|
.setDesc(planningModelDescFragment)
|
||||||
|
.addDropdown((dropdown) => {
|
||||||
|
this.planningModelDropdown = dropdown;
|
||||||
|
this.populateModelDropdown(dropdown, this.settingsService.settings.provider);
|
||||||
|
dropdown.setValue(this.settingsService.settings.planningModel);
|
||||||
|
dropdown.onChange(async value => {
|
||||||
|
if (!isValidProviderModel(value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.settingsService.updateSettings(settings => {
|
||||||
|
settings.planningModel = value;
|
||||||
|
settings.cachedModelSettings[settings.provider].planningModel = value;
|
||||||
|
});
|
||||||
|
RegisterAiProvider();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Quick Action Model Selection Setting */
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName(Copy.SettingQuickActionModel)
|
||||||
|
.setDesc(Copy.SettingQuickActionModelDesc)
|
||||||
|
.addDropdown((dropdown) => {
|
||||||
|
this.quickActionModelDropdown = dropdown;
|
||||||
|
this.populateModelDropdown(dropdown, this.settingsService.settings.provider);
|
||||||
|
dropdown.setValue(this.settingsService.settings.quickActionModel);
|
||||||
|
dropdown.onChange(async value => {
|
||||||
|
if (!isValidProviderModel(value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.settingsService.updateSettings(settings => {
|
||||||
|
settings.quickActionModel = value;
|
||||||
|
settings.cachedModelSettings[settings.provider].quickActionModel = value;
|
||||||
|
});
|
||||||
|
RegisterAiProvider();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Model files API disclaimer */
|
||||||
|
this.fileDisclaimerSetting = new Setting(containerEl);
|
||||||
|
this.updateFileDisclaimer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private populateProviderDropdown(dropdown: DropdownComponent) {
|
||||||
const select = dropdown.selectEl;
|
const select = dropdown.selectEl;
|
||||||
|
|
||||||
// Claude models
|
const localGroup = select.createEl("optgroup", { attr: { label: Copy.LocalProvider } });
|
||||||
if (!providerFilter || providerFilter === AIProvider.Claude) {
|
localGroup.createEl("option", { value: AIProvider.Local, text: Copy.ProviderLocal });
|
||||||
const claudeGroup = select.createEl("optgroup", { attr: { label: Copy.ProviderClaude } });
|
|
||||||
claudeGroup.createEl("option", { value: AIProviderModel.ClaudeSonnet_4_6, text: Copy.ClaudeSonnet_4_6 });
|
|
||||||
claudeGroup.createEl("option", { value: AIProviderModel.ClaudeOpus_4_8, text: Copy.ClaudeOpus_4_8 });
|
|
||||||
claudeGroup.createEl("option", { value: AIProviderModel.ClaudeHaiku_4_5, text: Copy.ClaudeHaiku_4_5 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// OpenAI models
|
const cloudGroup = select.createEl("optgroup", { attr: { label: Copy.CloudProvider } });
|
||||||
if (!providerFilter || providerFilter === AIProvider.OpenAI) {
|
cloudGroup.createEl("option", { value: AIProvider.Claude, text: Copy.ProviderClaude });
|
||||||
const openaiGroup = select.createEl("optgroup", { attr: { label: Copy.ProviderOpenAI } });
|
cloudGroup.createEl("option", { value: AIProvider.OpenAI, text: Copy.ProviderOpenAI });
|
||||||
openaiGroup.createEl("option", { value: AIProviderModel.GPT_5_5, text: Copy.GPT_5_5 });
|
cloudGroup.createEl("option", { value: AIProvider.Gemini, text: Copy.ProviderGemini });
|
||||||
openaiGroup.createEl("option", { value: AIProviderModel.GPT_5_4_Mini, text: Copy.GPT_5_4_Mini });
|
cloudGroup.createEl("option", { value: AIProvider.Mistral, text: Copy.ProviderMistral });
|
||||||
openaiGroup.createEl("option", { value: AIProviderModel.GPT_5_4_Nano, text: Copy.GPT_5_4_Nano });
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Gemini models
|
private populateModelDropdown(dropdown: DropdownComponent, providerFilter: AIProvider): void {
|
||||||
if (!providerFilter || providerFilter === AIProvider.Gemini) {
|
switch (providerFilter) {
|
||||||
const geminiGroup = select.createEl("optgroup", { attr: { label: Copy.ProviderGemini } });
|
case AIProvider.Claude:
|
||||||
geminiGroup.createEl("option", { value: AIProviderModel.GeminiFlash_3_1_Lite, text: Copy.GeminiPro_3_1_Preview });
|
dropdown.addOptions({
|
||||||
geminiGroup.createEl("option", { value: AIProviderModel.GeminiFlash_3_Flash, text: Copy.GeminiPro_3_1_Preview });
|
[AIProviderModel.ClaudeFable_5]: Copy.ClaudeFable_5,
|
||||||
geminiGroup.createEl("option", { value: AIProviderModel.GeminiFlash_3_5_Flash, text: Copy.GeminiPro_3_1_Preview });
|
[AIProviderModel.ClaudeSonnet_5]: Copy.ClaudeSonnet_5,
|
||||||
geminiGroup.createEl("option", { value: AIProviderModel.GeminiPro_3_1_Preview, text: Copy.GeminiPro_3_1_Preview });
|
[AIProviderModel.ClaudeOpus_4_8]: Copy.ClaudeOpus_4_8,
|
||||||
}
|
[AIProviderModel.ClaudeHaiku_4_5]: Copy.ClaudeHaiku_4_5
|
||||||
|
});
|
||||||
// Mistral models
|
break;
|
||||||
if (!providerFilter || providerFilter === AIProvider.Mistral) {
|
case AIProvider.OpenAI:
|
||||||
const mistralGroup = select.createEl("optgroup", { attr: { label: Copy.ProviderMistral } });
|
dropdown.addOptions({
|
||||||
mistralGroup.createEl("option", { value: AIProviderModel.MistralMedium, text: Copy.MistralMedium });
|
[AIProviderModel.GPT_5_6_Sol]: Copy.GPT_5_6_Sol,
|
||||||
mistralGroup.createEl("option", { value: AIProviderModel.MistralSmall, text: Copy.MistralSmall });
|
[AIProviderModel.GPT_5_6_Terra]: Copy.GPT_5_6_Terra,
|
||||||
|
[AIProviderModel.GPT_5_6_Luna]: Copy.GPT_5_6_Luna
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case AIProvider.Gemini:
|
||||||
|
dropdown.addOptions({
|
||||||
|
[AIProviderModel.GeminiFlash_3_1_Lite]: Copy.GeminiFlash_3_1_Lite,
|
||||||
|
[AIProviderModel.GeminiFlash_3_Flash]: Copy.GeminiFlash_3_Flash,
|
||||||
|
[AIProviderModel.GeminiFlash_3_5_Flash]: Copy.GeminiFlash_3_5_Flash,
|
||||||
|
[AIProviderModel.GeminiPro_3_1_Preview]: Copy.GeminiPro_3_1_Preview
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case AIProvider.Mistral:
|
||||||
|
dropdown.addOptions({
|
||||||
|
[AIProviderModel.MistralMedium]: Copy.MistralMedium,
|
||||||
|
[AIProviderModel.MistralSmall]: Copy.MistralSmall
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case AIProvider.Local:
|
||||||
|
// Local models are handled with a free text entry
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async updateModelDropdowns(): Promise<void> {
|
private async updateModelDropdowns(): Promise<void> {
|
||||||
await this.settingsService.updateSettings(settings => {
|
await this.settingsService.updateSettings(settings => {
|
||||||
const currentProvider = fromModel(settings.model);
|
const currentProvider = settings.provider;
|
||||||
|
|
||||||
|
if (this.modelDropdown) {
|
||||||
|
const modelProvider = fromModel(settings.model);
|
||||||
|
this.modelDropdown.selectEl.empty();
|
||||||
|
this.populateModelDropdown(this.modelDropdown, currentProvider);
|
||||||
|
|
||||||
|
if (modelProvider !== currentProvider) {
|
||||||
|
settings.model = settings.cachedModelSettings[currentProvider].model ?? DEFAULT_PLANNING_MODEL_BY_PROVIDER[currentProvider];
|
||||||
|
}
|
||||||
|
|
||||||
|
this.modelDropdown.setValue(settings.model);
|
||||||
|
}
|
||||||
|
|
||||||
if (this.planningModelDropdown) {
|
if (this.planningModelDropdown) {
|
||||||
const planningProvider = fromModel(settings.planningModel);
|
const planningProvider = fromModel(settings.planningModel);
|
||||||
|
|
@ -380,7 +542,7 @@ export class VaultkeeperAISettingTab extends PluginSettingTab {
|
||||||
this.populateModelDropdown(this.planningModelDropdown, currentProvider);
|
this.populateModelDropdown(this.planningModelDropdown, currentProvider);
|
||||||
|
|
||||||
if (planningProvider !== currentProvider) {
|
if (planningProvider !== currentProvider) {
|
||||||
settings.planningModel = settings.model;
|
settings.planningModel = settings.cachedModelSettings[currentProvider].planningModel ?? DEFAULT_PLANNING_MODEL_BY_PROVIDER[currentProvider];
|
||||||
}
|
}
|
||||||
|
|
||||||
this.planningModelDropdown.setValue(settings.planningModel);
|
this.planningModelDropdown.setValue(settings.planningModel);
|
||||||
|
|
@ -389,10 +551,10 @@ export class VaultkeeperAISettingTab extends PluginSettingTab {
|
||||||
if (this.quickActionModelDropdown) {
|
if (this.quickActionModelDropdown) {
|
||||||
const quickActionProvider = fromModel(settings.quickActionModel);
|
const quickActionProvider = fromModel(settings.quickActionModel);
|
||||||
this.quickActionModelDropdown.selectEl.empty();
|
this.quickActionModelDropdown.selectEl.empty();
|
||||||
this.populateModelDropdown(this.quickActionModelDropdown);
|
this.populateModelDropdown(this.quickActionModelDropdown, currentProvider);
|
||||||
|
|
||||||
if (quickActionProvider !== currentProvider) {
|
if (quickActionProvider !== currentProvider) {
|
||||||
settings.quickActionModel = settings.model;
|
settings.quickActionModel = settings.cachedModelSettings[currentProvider].quickActionModel ?? DEFAULT_QUICK_MODEL_BY_PROVIDER[currentProvider];
|
||||||
}
|
}
|
||||||
|
|
||||||
this.quickActionModelDropdown.setValue(settings.quickActionModel);
|
this.quickActionModelDropdown.setValue(settings.quickActionModel);
|
||||||
|
|
@ -402,7 +564,7 @@ export class VaultkeeperAISettingTab extends PluginSettingTab {
|
||||||
|
|
||||||
private highlightApiKey() {
|
private highlightApiKey() {
|
||||||
if (this.apiKeySetting) {
|
if (this.apiKeySetting) {
|
||||||
const currentApiKey = this.settingsService.getApiKeyForCurrentModel();
|
const currentApiKey = this.settingsService.getApiKeyForCurrentProvider();
|
||||||
if (currentApiKey.trim() === "") {
|
if (currentApiKey.trim() === "") {
|
||||||
this.apiKeySetting.settingEl.removeClass(Selector.ApiKeySettingOk);
|
this.apiKeySetting.settingEl.removeClass(Selector.ApiKeySettingOk);
|
||||||
this.apiKeySetting.settingEl.addClass(Selector.ApiKeySettingError);
|
this.apiKeySetting.settingEl.addClass(Selector.ApiKeySettingError);
|
||||||
|
|
@ -425,8 +587,8 @@ export class VaultkeeperAISettingTab extends PluginSettingTab {
|
||||||
|
|
||||||
private updateFileDisclaimer() {
|
private updateFileDisclaimer() {
|
||||||
if (this.fileDisclaimerSetting) {
|
if (this.fileDisclaimerSetting) {
|
||||||
const provider = fromModel(this.settingsService.settings.model);
|
const provider = this.settingsService.settings.provider;
|
||||||
let disclaimerText;
|
let disclaimerText: string | null;
|
||||||
|
|
||||||
switch(provider) {
|
switch(provider) {
|
||||||
case AIProvider.Gemini:
|
case AIProvider.Gemini:
|
||||||
|
|
@ -441,9 +603,29 @@ export class VaultkeeperAISettingTab extends PluginSettingTab {
|
||||||
case AIProvider.Mistral:
|
case AIProvider.Mistral:
|
||||||
disclaimerText = Copy.SettingFileMonitoringMistral;
|
disclaimerText = Copy.SettingFileMonitoringMistral;
|
||||||
break;
|
break;
|
||||||
|
case AIProvider.Local:
|
||||||
|
disclaimerText = null; // Not shown when a local model is being used
|
||||||
}
|
}
|
||||||
|
|
||||||
this.fileDisclaimerSetting.setDesc(disclaimerText);
|
if (disclaimerText === null) {
|
||||||
|
this.fileDisclaimerSetting.setDesc("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const disclaimerFragment = createFragment();
|
||||||
|
const disclaimerGridEl = disclaimerFragment.createDiv({ cls: Selector.SettingDescIconGrid });
|
||||||
|
setIcon(disclaimerGridEl.createDiv({ cls: Selector.FileDisclaimerIcon }), "help-circle");
|
||||||
|
const disclaimerTextEl = disclaimerGridEl.createDiv();
|
||||||
|
disclaimerTextEl.appendText(disclaimerText);
|
||||||
|
disclaimerTextEl.createEl("a", {
|
||||||
|
text: Copy.SettingFileMonitoringLinkText,
|
||||||
|
cls: Selector.FileDisclaimerLink
|
||||||
|
}).addEventListener("click", () => {
|
||||||
|
const modal = Resolve<HelpModal>(Services.HelpModal);
|
||||||
|
modal.open(7); // Opens HelpModal to "Uploaded Files" (topic 7)
|
||||||
|
});
|
||||||
|
|
||||||
|
this.fileDisclaimerSetting.setDesc(disclaimerFragment);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -115,6 +115,15 @@ export function normalizePath(path: string): string {
|
||||||
return path.replace(/\\/g, '/');
|
return path.replace(/\\/g, '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||||
|
return Buffer.from(buffer).toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function base64ToArrayBuffer(base64: string): ArrayBuffer {
|
||||||
|
const buffer = Buffer.from(base64, 'base64');
|
||||||
|
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
|
||||||
|
}
|
||||||
|
|
||||||
export const requestUrl = vi.fn(() => Promise.resolve({
|
export const requestUrl = vi.fn(() => Promise.resolve({
|
||||||
status: 200,
|
status: 200,
|
||||||
text: '',
|
text: '',
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ describe('BaseAIClass Shared Methods', () => {
|
||||||
if (provider === AIProvider.Gemini) return 'test-gemini-key';
|
if (provider === AIProvider.Gemini) return 'test-gemini-key';
|
||||||
return '';
|
return '';
|
||||||
}),
|
}),
|
||||||
getApiKeyForCurrentModel: vi.fn(() => 'test-claude-key'),
|
getApiKeyForCurrentProvider: vi.fn(() => 'test-claude-key'),
|
||||||
subscribeToSettingsChanged: vi.fn()
|
subscribeToSettingsChanged: vi.fn()
|
||||||
};
|
};
|
||||||
RegisterSingleton(Services.SettingsService, mockSettingsService);
|
RegisterSingleton(Services.SettingsService, mockSettingsService);
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ describe('Claude', () => {
|
||||||
if (provider === AIProvider.Gemini) return 'test-gemini-key';
|
if (provider === AIProvider.Gemini) return 'test-gemini-key';
|
||||||
return '';
|
return '';
|
||||||
}),
|
}),
|
||||||
getApiKeyForCurrentModel: vi.fn(() => 'test-claude-key'),
|
getApiKeyForCurrentProvider: vi.fn(() => 'test-claude-key'),
|
||||||
subscribeToSettingsChanged: vi.fn()
|
subscribeToSettingsChanged: vi.fn()
|
||||||
};
|
};
|
||||||
RegisterSingleton(Services.SettingsService, mockSettingsService);
|
RegisterSingleton(Services.SettingsService, mockSettingsService);
|
||||||
|
|
@ -1227,7 +1227,7 @@ describe('Claude', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('formatBinaryFiles', () => {
|
describe('formatBinaryFiles', () => {
|
||||||
it('should format PDF files with document type', () => {
|
it('should format PDF files with document type', async () => {
|
||||||
const attachment = {
|
const attachment = {
|
||||||
fileName: 'report.pdf',
|
fileName: 'report.pdf',
|
||||||
mimeType: 'application/pdf',
|
mimeType: 'application/pdf',
|
||||||
|
|
@ -1238,7 +1238,7 @@ describe('Claude', () => {
|
||||||
deleteFileID: vi.fn()
|
deleteFileID: vi.fn()
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = (claude as any).formatBinaryFiles([attachment as any]);
|
const result = await (claude as any).formatBinaryFiles([attachment as any]);
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
expect(parsed).toHaveLength(2);
|
expect(parsed).toHaveLength(2);
|
||||||
|
|
@ -1255,7 +1255,7 @@ describe('Claude', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should format JPEG images with image type', () => {
|
it('should format JPEG images with image type', async () => {
|
||||||
const attachment = {
|
const attachment = {
|
||||||
fileName: 'photo.jpg',
|
fileName: 'photo.jpg',
|
||||||
mimeType: 'image/jpeg',
|
mimeType: 'image/jpeg',
|
||||||
|
|
@ -1266,7 +1266,7 @@ describe('Claude', () => {
|
||||||
deleteFileID: vi.fn()
|
deleteFileID: vi.fn()
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = (claude as any).formatBinaryFiles([attachment as any]);
|
const result = await (claude as any).formatBinaryFiles([attachment as any]);
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
expect(parsed).toHaveLength(2);
|
expect(parsed).toHaveLength(2);
|
||||||
|
|
@ -1283,7 +1283,7 @@ describe('Claude', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should format PNG images with image type', () => {
|
it('should format PNG images with image type', async () => {
|
||||||
const attachment = {
|
const attachment = {
|
||||||
fileName: 'diagram.png',
|
fileName: 'diagram.png',
|
||||||
mimeType: 'image/png',
|
mimeType: 'image/png',
|
||||||
|
|
@ -1294,7 +1294,7 @@ describe('Claude', () => {
|
||||||
deleteFileID: vi.fn()
|
deleteFileID: vi.fn()
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = (claude as any).formatBinaryFiles([attachment as any]);
|
const result = await (claude as any).formatBinaryFiles([attachment as any]);
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
expect(parsed).toHaveLength(2);
|
expect(parsed).toHaveLength(2);
|
||||||
|
|
@ -1307,7 +1307,7 @@ describe('Claude', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should format GIF images with image type', () => {
|
it('should format GIF images with image type', async () => {
|
||||||
const attachment = {
|
const attachment = {
|
||||||
fileName: 'animation.gif',
|
fileName: 'animation.gif',
|
||||||
mimeType: 'image/gif',
|
mimeType: 'image/gif',
|
||||||
|
|
@ -1318,7 +1318,7 @@ describe('Claude', () => {
|
||||||
deleteFileID: vi.fn()
|
deleteFileID: vi.fn()
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = (claude as any).formatBinaryFiles([attachment as any]);
|
const result = await (claude as any).formatBinaryFiles([attachment as any]);
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
expect(parsed).toHaveLength(2);
|
expect(parsed).toHaveLength(2);
|
||||||
|
|
@ -1331,7 +1331,7 @@ describe('Claude', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should format WebP images with image type', () => {
|
it('should format WebP images with image type', async () => {
|
||||||
const attachment = {
|
const attachment = {
|
||||||
fileName: 'modern.webp',
|
fileName: 'modern.webp',
|
||||||
mimeType: 'image/webp',
|
mimeType: 'image/webp',
|
||||||
|
|
@ -1342,7 +1342,7 @@ describe('Claude', () => {
|
||||||
deleteFileID: vi.fn()
|
deleteFileID: vi.fn()
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = (claude as any).formatBinaryFiles([attachment as any]);
|
const result = await (claude as any).formatBinaryFiles([attachment as any]);
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
expect(parsed).toHaveLength(2);
|
expect(parsed).toHaveLength(2);
|
||||||
|
|
@ -1355,7 +1355,7 @@ describe('Claude', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle unsupported image formats with error message', () => {
|
it('should handle unsupported image formats with error message', async () => {
|
||||||
const attachment = {
|
const attachment = {
|
||||||
fileName: 'photo.bmp',
|
fileName: 'photo.bmp',
|
||||||
mimeType: 'image/bmp',
|
mimeType: 'image/bmp',
|
||||||
|
|
@ -1366,17 +1366,17 @@ describe('Claude', () => {
|
||||||
deleteFileID: vi.fn()
|
deleteFileID: vi.fn()
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = (claude as any).formatBinaryFiles([attachment as any]);
|
const result = await (claude as any).formatBinaryFiles([attachment as any]);
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
expect(parsed).toHaveLength(1);
|
expect(parsed).toHaveLength(1);
|
||||||
expect(parsed[0]).toEqual({
|
expect(parsed[0]).toEqual({
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text: 'Unsupported mime type \'image/bmp\': photo.bmp'
|
text: 'User attempted to share a file with an unsupported mime type \'image/bmp\': photo.bmp'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle multiple files of different types', () => {
|
it('should handle multiple files of different types', async () => {
|
||||||
const attachments = [
|
const attachments = [
|
||||||
{
|
{
|
||||||
fileName: 'doc.pdf',
|
fileName: 'doc.pdf',
|
||||||
|
|
@ -1407,7 +1407,7 @@ describe('Claude', () => {
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const result = (claude as any).formatBinaryFiles(attachments as any);
|
const result = await (claude as any).formatBinaryFiles(attachments as any);
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
expect(parsed).toHaveLength(6);
|
expect(parsed).toHaveLength(6);
|
||||||
|
|
@ -1443,7 +1443,7 @@ describe('Claude', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle mixed supported and unsupported files', () => {
|
it('should handle mixed supported and unsupported files', async () => {
|
||||||
const attachments = [
|
const attachments = [
|
||||||
{
|
{
|
||||||
fileName: 'good.jpg',
|
fileName: 'good.jpg',
|
||||||
|
|
@ -1474,7 +1474,7 @@ describe('Claude', () => {
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const result = (claude as any).formatBinaryFiles(attachments as any);
|
const result = await (claude as any).formatBinaryFiles(attachments as any);
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
expect(parsed).toHaveLength(5);
|
expect(parsed).toHaveLength(5);
|
||||||
|
|
@ -1484,14 +1484,14 @@ describe('Claude', () => {
|
||||||
expect(parsed[1].type).toBe('image');
|
expect(parsed[1].type).toBe('image');
|
||||||
expect(parsed[2]).toEqual({
|
expect(parsed[2]).toEqual({
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text: 'Unsupported mime type \'image/bmp\': bad.bmp'
|
text: 'User attempted to share a file with an unsupported mime type \'image/bmp\': bad.bmp'
|
||||||
});
|
});
|
||||||
expect(parsed[3].type).toBe('text');
|
expect(parsed[3].type).toBe('text');
|
||||||
expect(parsed[3].text).toBe(replaceCopy(Copy.AttachedFile, ["doc.pdf"]));
|
expect(parsed[3].text).toBe(replaceCopy(Copy.AttachedFile, ["doc.pdf"]));
|
||||||
expect(parsed[4].type).toBe('document');
|
expect(parsed[4].type).toBe('document');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should skip files without file IDs', () => {
|
it('should skip files without file IDs', async () => {
|
||||||
const attachment = {
|
const attachment = {
|
||||||
fileName: 'image.png',
|
fileName: 'image.png',
|
||||||
mimeType: 'image/png',
|
mimeType: 'image/png',
|
||||||
|
|
@ -1502,14 +1502,14 @@ describe('Claude', () => {
|
||||||
deleteFileID: vi.fn()
|
deleteFileID: vi.fn()
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = (claude as any).formatBinaryFiles([attachment as any]);
|
const result = await (claude as any).formatBinaryFiles([attachment as any]);
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
// Should return empty array when no file ID is available
|
// Should return empty array when no file ID is available
|
||||||
expect(parsed).toHaveLength(0);
|
expect(parsed).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle files with uppercase extensions', () => {
|
it('should handle files with uppercase extensions', async () => {
|
||||||
const attachments = [
|
const attachments = [
|
||||||
{
|
{
|
||||||
fileName: 'document.PDF',
|
fileName: 'document.PDF',
|
||||||
|
|
@ -1531,7 +1531,7 @@ describe('Claude', () => {
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const result = (claude as any).formatBinaryFiles(attachments as any);
|
const result = await (claude as any).formatBinaryFiles(attachments as any);
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
expect(parsed).toHaveLength(4);
|
expect(parsed).toHaveLength(4);
|
||||||
|
|
@ -1541,16 +1541,16 @@ describe('Claude', () => {
|
||||||
expect(parsed[3].type).toBe('image');
|
expect(parsed[3].type).toBe('image');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle empty files array', () => {
|
it('should handle empty files array', async () => {
|
||||||
const attachments: any[] = [];
|
const attachments: any[] = [];
|
||||||
|
|
||||||
const result = (claude as any).formatBinaryFiles(attachments);
|
const result = await (claude as any).formatBinaryFiles(attachments);
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
expect(parsed).toHaveLength(0);
|
expect(parsed).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should properly encode filenames with special characters', () => {
|
it('should properly encode filenames with special characters', async () => {
|
||||||
const attachment = {
|
const attachment = {
|
||||||
fileName: 'report (final) v2.pdf',
|
fileName: 'report (final) v2.pdf',
|
||||||
mimeType: 'application/pdf',
|
mimeType: 'application/pdf',
|
||||||
|
|
@ -1561,13 +1561,13 @@ describe('Claude', () => {
|
||||||
deleteFileID: vi.fn()
|
deleteFileID: vi.fn()
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = (claude as any).formatBinaryFiles([attachment as any]);
|
const result = await (claude as any).formatBinaryFiles([attachment as any]);
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
expect(parsed[0].text).toBe(replaceCopy(Copy.AttachedFile, ["report (final) v2.pdf"]));
|
expect(parsed[0].text).toBe(replaceCopy(Copy.AttachedFile, ["report (final) v2.pdf"]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle JPEG files with .jpeg extension', () => {
|
it('should handle JPEG files with .jpeg extension', async () => {
|
||||||
const attachment = {
|
const attachment = {
|
||||||
fileName: 'photo.jpeg',
|
fileName: 'photo.jpeg',
|
||||||
mimeType: 'image/jpeg',
|
mimeType: 'image/jpeg',
|
||||||
|
|
@ -1578,7 +1578,7 @@ describe('Claude', () => {
|
||||||
deleteFileID: vi.fn()
|
deleteFileID: vi.fn()
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = (claude as any).formatBinaryFiles([attachment as any]);
|
const result = await (claude as any).formatBinaryFiles([attachment as any]);
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
expect(parsed[1]).toEqual({
|
expect(parsed[1]).toEqual({
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||||
import { ClaudeConversationNamingService } from '../../AIClasses/Claude/ClaudeConversationNamingService';
|
import { ClaudeConversationNamingAgent } from '../../AIClasses/Claude/ClaudeConversationNamingAgent';
|
||||||
import { RegisterSingleton, DeregisterAllServices } from '../../Services/DependencyService';
|
import { RegisterSingleton, DeregisterAllServices } from '../../Services/DependencyService';
|
||||||
import { Services } from '../../Services/Services';
|
import { Services } from '../../Services/Services';
|
||||||
import { AIProvider, AIProviderModel } from '../../Enums/ApiProvider';
|
import { AIProvider, AIProviderModel } from '../../Enums/ApiProvider';
|
||||||
import { Role } from '../../Enums/Role';
|
import { Role } from '../../Enums/Role';
|
||||||
|
|
||||||
describe('ClaudeConversationNamingService', () => {
|
describe('ClaudeConversationNamingAgent', () => {
|
||||||
let service: ClaudeConversationNamingService;
|
let service: ClaudeConversationNamingAgent;
|
||||||
let mockPlugin: any;
|
let mockPlugin: any;
|
||||||
let mockSettingsService: any;
|
let mockSettingsService: any;
|
||||||
let mockAbortService: any;
|
let mockAbortService: any;
|
||||||
|
|
@ -19,7 +19,7 @@ describe('ClaudeConversationNamingService', () => {
|
||||||
// Mock SettingsService
|
// Mock SettingsService
|
||||||
mockSettingsService = {
|
mockSettingsService = {
|
||||||
settings: {
|
settings: {
|
||||||
model: AIProviderModel.ClaudeSonnet_4_6,
|
model: AIProviderModel.ClaudeSonnet_5,
|
||||||
apiKeys: {
|
apiKeys: {
|
||||||
claude: 'test-claude-key',
|
claude: 'test-claude-key',
|
||||||
openai: 'test-openai-key',
|
openai: 'test-openai-key',
|
||||||
|
|
@ -32,7 +32,7 @@ describe('ClaudeConversationNamingService', () => {
|
||||||
if (provider === AIProvider.Gemini) return 'test-gemini-key';
|
if (provider === AIProvider.Gemini) return 'test-gemini-key';
|
||||||
return '';
|
return '';
|
||||||
}),
|
}),
|
||||||
getApiKeyForCurrentModel: vi.fn(() => 'test-claude-key')
|
getApiKeyForCurrentProvider: vi.fn(() => 'test-claude-key')
|
||||||
};
|
};
|
||||||
RegisterSingleton(Services.SettingsService, mockSettingsService);
|
RegisterSingleton(Services.SettingsService, mockSettingsService);
|
||||||
|
|
||||||
|
|
@ -47,7 +47,7 @@ describe('ClaudeConversationNamingService', () => {
|
||||||
fetchMock = vi.fn();
|
fetchMock = vi.fn();
|
||||||
global.fetch = fetchMock;
|
global.fetch = fetchMock;
|
||||||
|
|
||||||
service = new ClaudeConversationNamingService();
|
service = new ClaudeConversationNamingAgent();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
@ -61,7 +61,7 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
|
||||||
if (provider === AIProvider.Gemini) return 'test-gemini-key';
|
if (provider === AIProvider.Gemini) return 'test-gemini-key';
|
||||||
return '';
|
return '';
|
||||||
}),
|
}),
|
||||||
getApiKeyForCurrentModel: vi.fn(() => 'test-gemini-key'),
|
getApiKeyForCurrentProvider: vi.fn(() => 'test-gemini-key'),
|
||||||
subscribeToSettingsChanged: vi.fn()
|
subscribeToSettingsChanged: vi.fn()
|
||||||
};
|
};
|
||||||
RegisterSingleton(Services.SettingsService, mockSettingsService);
|
RegisterSingleton(Services.SettingsService, mockSettingsService);
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ describe('Gemini', () => {
|
||||||
if (provider === AIProvider.Gemini) return 'test-gemini-key';
|
if (provider === AIProvider.Gemini) return 'test-gemini-key';
|
||||||
return '';
|
return '';
|
||||||
}),
|
}),
|
||||||
getApiKeyForCurrentModel: vi.fn(() => 'test-gemini-key'),
|
getApiKeyForCurrentProvider: vi.fn(() => 'test-gemini-key'),
|
||||||
subscribeToSettingsChanged: vi.fn()
|
subscribeToSettingsChanged: vi.fn()
|
||||||
};
|
};
|
||||||
RegisterSingleton(Services.SettingsService, mockSettingsService);
|
RegisterSingleton(Services.SettingsService, mockSettingsService);
|
||||||
|
|
@ -160,6 +160,41 @@ describe('Gemini', () => {
|
||||||
expect((gemini as any).accumulatedFunctionArgs).toEqual({ query: 'test' });
|
expect((gemini as any).accumulatedFunctionArgs).toEqual({ query: 'test' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should emit toolCallStarted on the first chunk revealing the function name, and not again for subsequent chunks', () => {
|
||||||
|
const startChunk = JSON.stringify({
|
||||||
|
candidates: [{
|
||||||
|
content: {
|
||||||
|
parts: [{
|
||||||
|
functionCall: {
|
||||||
|
name: 'search_vault_files',
|
||||||
|
args: { query: 'test' }
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
const startResult = (gemini as any).parseStreamChunk(startChunk);
|
||||||
|
expect(startResult.toolCallStarted).toBe('search_vault_files');
|
||||||
|
expect(startResult.isComplete).toBe(false);
|
||||||
|
|
||||||
|
const continuationChunk = JSON.stringify({
|
||||||
|
candidates: [{
|
||||||
|
content: {
|
||||||
|
parts: [{
|
||||||
|
functionCall: {
|
||||||
|
name: 'search_vault_files',
|
||||||
|
args: { limit: 10 }
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
const continuationResult = (gemini as any).parseStreamChunk(continuationChunk);
|
||||||
|
expect(continuationResult.toolCallStarted).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('should capture thoughtSignature when present in part', () => {
|
it('should capture thoughtSignature when present in part', () => {
|
||||||
const signature = 'base64EncodedSignature==';
|
const signature = 'base64EncodedSignature==';
|
||||||
const chunk = JSON.stringify({
|
const chunk = JSON.stringify({
|
||||||
|
|
@ -1149,7 +1184,7 @@ describe('Gemini', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('formatBinaryFiles', () => {
|
describe('formatBinaryFiles', () => {
|
||||||
it('should format PDF files with fileData', () => {
|
it('should format PDF files with fileData', async () => {
|
||||||
const attachment = {
|
const attachment = {
|
||||||
fileName: 'report.pdf',
|
fileName: 'report.pdf',
|
||||||
mimeType: 'application/pdf',
|
mimeType: 'application/pdf',
|
||||||
|
|
@ -1160,7 +1195,7 @@ describe('Gemini', () => {
|
||||||
deleteFileID: vi.fn()
|
deleteFileID: vi.fn()
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = (gemini as any).formatBinaryFiles([attachment as any]);
|
const result = await (gemini as any).formatBinaryFiles([attachment as any]);
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
expect(parsed).toHaveLength(2);
|
expect(parsed).toHaveLength(2);
|
||||||
|
|
@ -1175,7 +1210,7 @@ describe('Gemini', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should format JPEG images with fileData', () => {
|
it('should format JPEG images with fileData', async () => {
|
||||||
const attachment = {
|
const attachment = {
|
||||||
fileName: 'photo.jpg',
|
fileName: 'photo.jpg',
|
||||||
mimeType: 'image/jpeg',
|
mimeType: 'image/jpeg',
|
||||||
|
|
@ -1186,7 +1221,7 @@ describe('Gemini', () => {
|
||||||
deleteFileID: vi.fn()
|
deleteFileID: vi.fn()
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = (gemini as any).formatBinaryFiles([attachment as any]);
|
const result = await (gemini as any).formatBinaryFiles([attachment as any]);
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
expect(parsed).toHaveLength(2);
|
expect(parsed).toHaveLength(2);
|
||||||
|
|
@ -1201,7 +1236,7 @@ describe('Gemini', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should format PNG images with fileData', () => {
|
it('should format PNG images with fileData', async () => {
|
||||||
const attachment = {
|
const attachment = {
|
||||||
fileName: 'diagram.png',
|
fileName: 'diagram.png',
|
||||||
mimeType: 'image/png',
|
mimeType: 'image/png',
|
||||||
|
|
@ -1212,7 +1247,7 @@ describe('Gemini', () => {
|
||||||
deleteFileID: vi.fn()
|
deleteFileID: vi.fn()
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = (gemini as any).formatBinaryFiles([attachment as any]);
|
const result = await (gemini as any).formatBinaryFiles([attachment as any]);
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
expect(parsed).toHaveLength(2);
|
expect(parsed).toHaveLength(2);
|
||||||
|
|
@ -1224,7 +1259,7 @@ describe('Gemini', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle unsupported image formats (GIF) with error message', () => {
|
it('should handle unsupported image formats (GIF) with error message', async () => {
|
||||||
const attachment = {
|
const attachment = {
|
||||||
fileName: 'animation.gif',
|
fileName: 'animation.gif',
|
||||||
mimeType: 'image/gif',
|
mimeType: 'image/gif',
|
||||||
|
|
@ -1235,16 +1270,16 @@ describe('Gemini', () => {
|
||||||
deleteFileID: vi.fn()
|
deleteFileID: vi.fn()
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = (gemini as any).formatBinaryFiles([attachment as any]);
|
const result = await (gemini as any).formatBinaryFiles([attachment as any]);
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
expect(parsed).toHaveLength(1);
|
expect(parsed).toHaveLength(1);
|
||||||
expect(parsed[0]).toEqual({
|
expect(parsed[0]).toEqual({
|
||||||
text: 'Unsupported mime type \'image/gif\': animation.gif'
|
text: 'User attempted to share a file with an unsupported mime type \'image/gif\': animation.gif'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle unsupported image formats (BMP) with error message', () => {
|
it('should handle unsupported image formats (BMP) with error message', async () => {
|
||||||
const attachment = {
|
const attachment = {
|
||||||
fileName: 'photo.bmp',
|
fileName: 'photo.bmp',
|
||||||
mimeType: 'image/bmp',
|
mimeType: 'image/bmp',
|
||||||
|
|
@ -1255,16 +1290,16 @@ describe('Gemini', () => {
|
||||||
deleteFileID: vi.fn()
|
deleteFileID: vi.fn()
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = (gemini as any).formatBinaryFiles([attachment as any]);
|
const result = await (gemini as any).formatBinaryFiles([attachment as any]);
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
expect(parsed).toHaveLength(1);
|
expect(parsed).toHaveLength(1);
|
||||||
expect(parsed[0]).toEqual({
|
expect(parsed[0]).toEqual({
|
||||||
text: 'Unsupported mime type \'image/bmp\': photo.bmp'
|
text: 'User attempted to share a file with an unsupported mime type \'image/bmp\': photo.bmp'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle multiple files of different types', () => {
|
it('should handle multiple files of different types', async () => {
|
||||||
const attachments = [
|
const attachments = [
|
||||||
{
|
{
|
||||||
fileName: 'doc.pdf',
|
fileName: 'doc.pdf',
|
||||||
|
|
@ -1295,7 +1330,7 @@ describe('Gemini', () => {
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const result = (gemini as any).formatBinaryFiles(attachments as any);
|
const result = await (gemini as any).formatBinaryFiles(attachments as any);
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
expect(parsed).toHaveLength(6);
|
expect(parsed).toHaveLength(6);
|
||||||
|
|
@ -1328,7 +1363,7 @@ describe('Gemini', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle mixed supported and unsupported files', () => {
|
it('should handle mixed supported and unsupported files', async () => {
|
||||||
const attachments = [
|
const attachments = [
|
||||||
{
|
{
|
||||||
fileName: 'good.jpg',
|
fileName: 'good.jpg',
|
||||||
|
|
@ -1359,7 +1394,7 @@ describe('Gemini', () => {
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const result = (gemini as any).formatBinaryFiles(attachments as any);
|
const result = await (gemini as any).formatBinaryFiles(attachments as any);
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
expect(parsed).toHaveLength(5);
|
expect(parsed).toHaveLength(5);
|
||||||
|
|
@ -1367,13 +1402,13 @@ describe('Gemini', () => {
|
||||||
expect(parsed[0]).toEqual({ text: replaceCopy(Copy.AttachedFile, ["good.jpg"]) });
|
expect(parsed[0]).toEqual({ text: replaceCopy(Copy.AttachedFile, ["good.jpg"]) });
|
||||||
expect(parsed[1]).toHaveProperty('fileData');
|
expect(parsed[1]).toHaveProperty('fileData');
|
||||||
expect(parsed[2]).toEqual({
|
expect(parsed[2]).toEqual({
|
||||||
text: 'Unsupported mime type \'image/bmp\': bad.bmp'
|
text: 'User attempted to share a file with an unsupported mime type \'image/bmp\': bad.bmp'
|
||||||
});
|
});
|
||||||
expect(parsed[3]).toEqual({ text: replaceCopy(Copy.AttachedFile, ["doc.pdf"]) });
|
expect(parsed[3]).toEqual({ text: replaceCopy(Copy.AttachedFile, ["doc.pdf"]) });
|
||||||
expect(parsed[4]).toHaveProperty('fileData');
|
expect(parsed[4]).toHaveProperty('fileData');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should skip files without file IDs (failed uploads)', () => {
|
it('should skip files without file IDs (failed uploads)', async () => {
|
||||||
const attachments = [
|
const attachments = [
|
||||||
{
|
{
|
||||||
fileName: 'success.pdf',
|
fileName: 'success.pdf',
|
||||||
|
|
@ -1395,7 +1430,7 @@ describe('Gemini', () => {
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const result = (gemini as any).formatBinaryFiles(attachments as any);
|
const result = await (gemini as any).formatBinaryFiles(attachments as any);
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
expect(parsed).toHaveLength(2); // Only successful upload
|
expect(parsed).toHaveLength(2); // Only successful upload
|
||||||
|
|
@ -1408,14 +1443,14 @@ describe('Gemini', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle empty attachments array', () => {
|
it('should handle empty attachments array', async () => {
|
||||||
const result = (gemini as any).formatBinaryFiles([]);
|
const result = await (gemini as any).formatBinaryFiles([]);
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
expect(parsed).toHaveLength(0);
|
expect(parsed).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should properly encode filenames with special characters', () => {
|
it('should properly encode filenames with special characters', async () => {
|
||||||
const attachment = {
|
const attachment = {
|
||||||
fileName: 'report (final) v2.pdf',
|
fileName: 'report (final) v2.pdf',
|
||||||
mimeType: 'application/pdf',
|
mimeType: 'application/pdf',
|
||||||
|
|
@ -1426,13 +1461,13 @@ describe('Gemini', () => {
|
||||||
deleteFileID: vi.fn()
|
deleteFileID: vi.fn()
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = (gemini as any).formatBinaryFiles([attachment as any]);
|
const result = await (gemini as any).formatBinaryFiles([attachment as any]);
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
expect(parsed[0].text).toBe(replaceCopy(Copy.AttachedFile, ["report (final) v2.pdf"]));
|
expect(parsed[0].text).toBe(replaceCopy(Copy.AttachedFile, ["report (final) v2.pdf"]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle JPEG files with .jpeg extension', () => {
|
it('should handle JPEG files with .jpeg extension', async () => {
|
||||||
const attachment = {
|
const attachment = {
|
||||||
fileName: 'photo.jpeg',
|
fileName: 'photo.jpeg',
|
||||||
mimeType: 'image/jpeg',
|
mimeType: 'image/jpeg',
|
||||||
|
|
@ -1443,7 +1478,7 @@ describe('Gemini', () => {
|
||||||
deleteFileID: vi.fn()
|
deleteFileID: vi.fn()
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = (gemini as any).formatBinaryFiles([attachment as any]);
|
const result = await (gemini as any).formatBinaryFiles([attachment as any]);
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
expect(parsed[1]).toEqual({
|
expect(parsed[1]).toEqual({
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||||
import { GeminiConversationNamingService } from '../../AIClasses/Gemini/GeminiConversationNamingService';
|
import { GeminiConversationNamingAgent } from '../../AIClasses/Gemini/GeminiConversationNamingAgent';
|
||||||
import { RegisterSingleton, DeregisterAllServices } from '../../Services/DependencyService';
|
import { RegisterSingleton, DeregisterAllServices } from '../../Services/DependencyService';
|
||||||
import { Services } from '../../Services/Services';
|
import { Services } from '../../Services/Services';
|
||||||
import { AIProvider, AIProviderModel } from '../../Enums/ApiProvider';
|
import { AIProvider, AIProviderModel } from '../../Enums/ApiProvider';
|
||||||
import { Role } from '../../Enums/Role';
|
import { Role } from '../../Enums/Role';
|
||||||
|
|
||||||
describe('GeminiConversationNamingService', () => {
|
describe('GeminiConversationNamingAgent', () => {
|
||||||
let service: GeminiConversationNamingService;
|
let service: GeminiConversationNamingAgent;
|
||||||
let mockPlugin: any;
|
let mockPlugin: any;
|
||||||
let mockSettingsService: any;
|
let mockSettingsService: any;
|
||||||
let mockAbortService: any;
|
let mockAbortService: any;
|
||||||
|
|
@ -32,7 +32,7 @@ describe('GeminiConversationNamingService', () => {
|
||||||
if (provider === AIProvider.Gemini) return 'test-gemini-key';
|
if (provider === AIProvider.Gemini) return 'test-gemini-key';
|
||||||
return '';
|
return '';
|
||||||
}),
|
}),
|
||||||
getApiKeyForCurrentModel: vi.fn(() => 'test-gemini-key')
|
getApiKeyForCurrentProvider: vi.fn(() => 'test-gemini-key')
|
||||||
};
|
};
|
||||||
RegisterSingleton(Services.SettingsService, mockSettingsService);
|
RegisterSingleton(Services.SettingsService, mockSettingsService);
|
||||||
|
|
||||||
|
|
@ -47,7 +47,7 @@ describe('GeminiConversationNamingService', () => {
|
||||||
fetchMock = vi.fn();
|
fetchMock = vi.fn();
|
||||||
global.fetch = fetchMock;
|
global.fetch = fetchMock;
|
||||||
|
|
||||||
service = new GeminiConversationNamingService();
|
service = new GeminiConversationNamingAgent();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
460
__tests__/AIClasses/Local.test.ts
Normal file
460
__tests__/AIClasses/Local.test.ts
Normal file
|
|
@ -0,0 +1,460 @@
|
||||||
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||||
|
import { Local } from '../../AIClasses/Local/Local';
|
||||||
|
import { RegisterSingleton, Resolve, DeregisterAllServices } from '../../Services/DependencyService';
|
||||||
|
import { Services } from '../../Services/Services';
|
||||||
|
import { StreamingService } from '../../Services/StreamingService';
|
||||||
|
import type { IPrompt } from '../../AIPrompts/IPrompt';
|
||||||
|
import type VaultkeeperAIPlugin from '../../main';
|
||||||
|
import { Conversation } from '../../Conversations/Conversation';
|
||||||
|
import { ConversationContent } from '../../Conversations/ConversationContent';
|
||||||
|
import { Role } from '../../Enums/Role';
|
||||||
|
import { SettingsService } from '../../Services/SettingsService';
|
||||||
|
import { AIProvider } from '../../Enums/ApiProvider';
|
||||||
|
import { AgentType } from '../../Enums/AgentType';
|
||||||
|
import { AbortService } from '../../Services/AbortService';
|
||||||
|
import { Copy } from 'Enums/Copy';
|
||||||
|
import { AITool } from 'Enums/AITool';
|
||||||
|
import { replaceCopy } from 'Helpers/Helpers';
|
||||||
|
import { arrayBufferToBase64 } from 'obsidian';
|
||||||
|
|
||||||
|
vi.mock('Helpers/DocumentHelper', () => ({
|
||||||
|
pdfToImages: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { pdfToImages } from 'Helpers/DocumentHelper';
|
||||||
|
|
||||||
|
describe('Local', () => {
|
||||||
|
let local: Local;
|
||||||
|
let mockStreamingService: any;
|
||||||
|
let mockPrompt: any;
|
||||||
|
let mockPlugin: any;
|
||||||
|
let mockSettingsService: any;
|
||||||
|
let abortService: AbortService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPrompt = {
|
||||||
|
systemInstruction: vi.fn().mockReturnValue('System instruction'),
|
||||||
|
userInstruction: vi.fn().mockResolvedValue('User instruction')
|
||||||
|
};
|
||||||
|
RegisterSingleton(Services.IPrompt, mockPrompt);
|
||||||
|
|
||||||
|
mockPlugin = {};
|
||||||
|
RegisterSingleton(Services.VaultkeeperAIPlugin, mockPlugin);
|
||||||
|
|
||||||
|
mockSettingsService = {
|
||||||
|
settings: {
|
||||||
|
provider: AIProvider.Local,
|
||||||
|
localUrl: 'http://localhost:1234/v1/chat/completions',
|
||||||
|
localModels: {
|
||||||
|
model: 'local-main-model',
|
||||||
|
planningModel: 'local-planning-model',
|
||||||
|
quickActionModel: 'local-quick-model'
|
||||||
|
},
|
||||||
|
apiKeys: {
|
||||||
|
local: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getApiKeyForProvider: vi.fn(() => ''),
|
||||||
|
getApiKeyForCurrentProvider: vi.fn(() => ''),
|
||||||
|
subscribeToSettingsChanged: vi.fn()
|
||||||
|
};
|
||||||
|
RegisterSingleton(Services.SettingsService, mockSettingsService);
|
||||||
|
|
||||||
|
abortService = new AbortService();
|
||||||
|
RegisterSingleton(Services.AbortService, abortService);
|
||||||
|
|
||||||
|
mockStreamingService = {
|
||||||
|
streamRequest: vi.fn()
|
||||||
|
};
|
||||||
|
RegisterSingleton(Services.StreamingService, mockStreamingService);
|
||||||
|
|
||||||
|
const mockFileService = {
|
||||||
|
refreshCache: vi.fn().mockResolvedValue(undefined),
|
||||||
|
listFiles: vi.fn().mockReturnValue([]),
|
||||||
|
uploadFile: vi.fn().mockResolvedValue(undefined),
|
||||||
|
deleteFile: vi.fn().mockResolvedValue(undefined)
|
||||||
|
};
|
||||||
|
RegisterSingleton(Services.IAIFileService, mockFileService);
|
||||||
|
|
||||||
|
local = new Local();
|
||||||
|
|
||||||
|
vi.mocked(pdfToImages).mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
DeregisterAllServices();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Constructor and Dependencies', () => {
|
||||||
|
it('should initialize with dependencies from DependencyService', () => {
|
||||||
|
expect(local).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should resolve all required services', () => {
|
||||||
|
const prompt = Resolve<IPrompt>(Services.IPrompt);
|
||||||
|
const plugin = Resolve<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin);
|
||||||
|
const settingsService = Resolve<SettingsService>(Services.SettingsService);
|
||||||
|
const streaming = Resolve<StreamingService>(Services.StreamingService);
|
||||||
|
|
||||||
|
expect(prompt).toBe(mockPrompt);
|
||||||
|
expect(plugin).toBe(mockPlugin);
|
||||||
|
expect(settingsService).toBe(mockSettingsService);
|
||||||
|
expect(streaming).toBe(mockStreamingService);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('apiUrl', () => {
|
||||||
|
it('should return settings.localUrl verbatim', () => {
|
||||||
|
expect((local as any).apiUrl).toBe('http://localhost:1234/v1/chat/completions');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reflect updated localUrl values', () => {
|
||||||
|
mockSettingsService.settings.localUrl = 'http://127.0.0.1:11434/v1/chat/completions';
|
||||||
|
expect((local as any).apiUrl).toBe('http://127.0.0.1:11434/v1/chat/completions');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('model', () => {
|
||||||
|
it('should return localModels.model for Main agent type', () => {
|
||||||
|
local.agentType = AgentType.Main;
|
||||||
|
expect((local as any).model()).toBe('local-main-model');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return localModels.model for Execution agent type', () => {
|
||||||
|
local.agentType = AgentType.Execution;
|
||||||
|
expect((local as any).model()).toBe('local-main-model');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return localModels.planningModel for Orchestration agent type', () => {
|
||||||
|
local.agentType = AgentType.Orchestration;
|
||||||
|
expect((local as any).model()).toBe('local-planning-model');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return localModels.planningModel for Planning agent type', () => {
|
||||||
|
local.agentType = AgentType.Planning;
|
||||||
|
expect((local as any).model()).toBe('local-planning-model');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return localModels.quickActionModel for QuickAction agent type', () => {
|
||||||
|
local.agentType = AgentType.QuickAction;
|
||||||
|
expect((local as any).model()).toBe('local-quick-model');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatBinaryFiles', () => {
|
||||||
|
it('should inline plain text attachments as decoded text', async () => {
|
||||||
|
const text = 'hello from a text file';
|
||||||
|
const base64 = Buffer.from(text, 'utf-8').toString('base64');
|
||||||
|
|
||||||
|
const attachment = {
|
||||||
|
fileName: 'notes.txt',
|
||||||
|
mimeType: 'text/plain',
|
||||||
|
base64,
|
||||||
|
getMimeType: vi.fn(() => 'text/plain'),
|
||||||
|
getFileID: vi.fn(() => 'file_1'),
|
||||||
|
setFileID: vi.fn(),
|
||||||
|
deleteFileID: vi.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await (local as any).formatBinaryFiles([attachment as any]);
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
|
expect(parsed).toHaveLength(1);
|
||||||
|
expect(parsed[0]).toEqual({
|
||||||
|
type: 'text',
|
||||||
|
text: `${replaceCopy(Copy.AttachedFile, ['notes.txt'])}\n\n${text}`
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should inline JPEG images as base64 data URLs, not signed URLs', async () => {
|
||||||
|
const attachment = {
|
||||||
|
fileName: 'photo.jpg',
|
||||||
|
mimeType: 'image/jpeg',
|
||||||
|
base64: 'base64imagedata',
|
||||||
|
getMimeType: vi.fn(() => 'image/jpeg'),
|
||||||
|
getFileID: vi.fn(() => 'file_2'),
|
||||||
|
setFileID: vi.fn(),
|
||||||
|
deleteFileID: vi.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await (local as any).formatBinaryFiles([attachment as any]);
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
|
expect(parsed).toHaveLength(2);
|
||||||
|
expect(parsed[0]).toEqual({ type: 'text', text: replaceCopy(Copy.AttachedFile, ['photo.jpg']) });
|
||||||
|
expect(parsed[1]).toEqual({
|
||||||
|
type: 'image_url',
|
||||||
|
image_url: { url: 'data:image/jpeg;base64,base64imagedata' }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should inline PNG images as base64 data URLs', async () => {
|
||||||
|
const attachment = {
|
||||||
|
fileName: 'diagram.png',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
base64: 'base64pngdata',
|
||||||
|
getMimeType: vi.fn(() => 'image/png'),
|
||||||
|
getFileID: vi.fn(() => 'file_3'),
|
||||||
|
setFileID: vi.fn(),
|
||||||
|
deleteFileID: vi.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await (local as any).formatBinaryFiles([attachment as any]);
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
|
expect(parsed[1]).toEqual({
|
||||||
|
type: 'image_url',
|
||||||
|
image_url: { url: 'data:image/png;base64,base64pngdata' }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report the corrected unsupported mime type message (regression: no duplicated phrase)', async () => {
|
||||||
|
const attachment = {
|
||||||
|
fileName: 'photo.bmp',
|
||||||
|
mimeType: 'image/bmp',
|
||||||
|
base64: 'base64bmpdata',
|
||||||
|
getMimeType: vi.fn(() => 'image/bmp'),
|
||||||
|
getFileID: vi.fn(() => 'file_4'),
|
||||||
|
setFileID: vi.fn(),
|
||||||
|
deleteFileID: vi.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await (local as any).formatBinaryFiles([attachment as any]);
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
|
expect(parsed).toHaveLength(1);
|
||||||
|
expect(parsed[0]).toEqual({
|
||||||
|
type: 'text',
|
||||||
|
text: "User attempted to share a file with an unsupported mime type 'image/bmp': photo.bmp"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should rasterize PDF pages to image_url parts via pdfToImages', async () => {
|
||||||
|
const pageBuffer1 = new TextEncoder().encode('page1').buffer;
|
||||||
|
const pageBuffer2 = new TextEncoder().encode('page2').buffer;
|
||||||
|
|
||||||
|
vi.mocked(pdfToImages).mockResolvedValue([
|
||||||
|
{ image: pageBuffer1, pageNumber: 1, mimeType: 'image/png' },
|
||||||
|
{ image: pageBuffer2, pageNumber: 2, mimeType: 'image/png' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const attachment = {
|
||||||
|
fileName: 'document.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
base64: 'JVBERi0xLjQKJeLjz9MK',
|
||||||
|
getMimeType: vi.fn(() => 'application/pdf'),
|
||||||
|
getFileID: vi.fn(() => 'file_5'),
|
||||||
|
setFileID: vi.fn(),
|
||||||
|
deleteFileID: vi.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await (local as any).formatBinaryFiles([attachment as any]);
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
|
expect(parsed).toHaveLength(3);
|
||||||
|
expect(parsed[0]).toEqual({ type: 'text', text: replaceCopy(Copy.AttachedFile, ['document.pdf']) });
|
||||||
|
expect(parsed[1]).toEqual({
|
||||||
|
type: 'image_url',
|
||||||
|
image_url: { url: `data:image/png;base64,${arrayBufferToBase64(pageBuffer1)}` }
|
||||||
|
});
|
||||||
|
expect(parsed[2]).toEqual({
|
||||||
|
type: 'image_url',
|
||||||
|
image_url: { url: `data:image/png;base64,${arrayBufferToBase64(pageBuffer2)}` }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report a failure message when pdfToImages yields no pages', async () => {
|
||||||
|
vi.mocked(pdfToImages).mockResolvedValue([]);
|
||||||
|
|
||||||
|
const attachment = {
|
||||||
|
fileName: 'corrupt.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
base64: 'JVBERi0xLjQKJeLjz9MK',
|
||||||
|
getMimeType: vi.fn(() => 'application/pdf'),
|
||||||
|
getFileID: vi.fn(() => 'file_6'),
|
||||||
|
setFileID: vi.fn(),
|
||||||
|
deleteFileID: vi.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await (local as any).formatBinaryFiles([attachment as any]);
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
|
expect(parsed).toHaveLength(1);
|
||||||
|
expect(parsed[0]).toEqual({
|
||||||
|
type: 'text',
|
||||||
|
text: 'Failed to render any pages from corrupt.pdf'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple attachments of mixed types', async () => {
|
||||||
|
vi.mocked(pdfToImages).mockResolvedValue([]);
|
||||||
|
|
||||||
|
const attachments = [
|
||||||
|
{
|
||||||
|
fileName: 'image.jpg',
|
||||||
|
mimeType: 'image/jpeg',
|
||||||
|
base64: 'jpegdata',
|
||||||
|
getMimeType: vi.fn(() => 'image/jpeg'),
|
||||||
|
getFileID: vi.fn(() => 'file_a'),
|
||||||
|
setFileID: vi.fn(),
|
||||||
|
deleteFileID: vi.fn()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fileName: 'photo.bmp',
|
||||||
|
mimeType: 'image/bmp',
|
||||||
|
base64: 'bmpdata',
|
||||||
|
getMimeType: vi.fn(() => 'image/bmp'),
|
||||||
|
getFileID: vi.fn(() => 'file_b'),
|
||||||
|
setFileID: vi.fn(),
|
||||||
|
deleteFileID: vi.fn()
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await (local as any).formatBinaryFiles(attachments as any);
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
|
expect(parsed).toHaveLength(3);
|
||||||
|
expect(parsed[0]).toEqual({ type: 'text', text: replaceCopy(Copy.AttachedFile, ['image.jpg']) });
|
||||||
|
expect(parsed[1]).toEqual({
|
||||||
|
type: 'image_url',
|
||||||
|
image_url: { url: 'data:image/jpeg;base64,jpegdata' }
|
||||||
|
});
|
||||||
|
expect(parsed[2]).toEqual({
|
||||||
|
type: 'text',
|
||||||
|
text: "User attempted to share a file with an unsupported mime type 'image/bmp': photo.bmp"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle an empty attachments array', async () => {
|
||||||
|
const result = await (local as any).formatBinaryFiles([]);
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
|
expect(parsed).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not depend on the file service for signed URLs or uploads', async () => {
|
||||||
|
const fileService = Resolve<any>(Services.IAIFileService);
|
||||||
|
|
||||||
|
const attachment = {
|
||||||
|
fileName: 'photo.jpg',
|
||||||
|
mimeType: 'image/jpeg',
|
||||||
|
base64: 'jpegdata',
|
||||||
|
getMimeType: vi.fn(() => 'image/jpeg'),
|
||||||
|
getFileID: vi.fn(() => 'file_x'),
|
||||||
|
setFileID: vi.fn(),
|
||||||
|
deleteFileID: vi.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
await (local as any).formatBinaryFiles([attachment as any]);
|
||||||
|
|
||||||
|
expect(fileService.uploadFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getTools / isNativeToolCallId (inherited defaults, no web search)', () => {
|
||||||
|
it('should return an empty tools array when there are no tool definitions', () => {
|
||||||
|
local.aiToolDefinitions = [];
|
||||||
|
expect((local as any).getTools()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should map plain tool definitions without injecting a web-search tool', () => {
|
||||||
|
local.aiToolDefinitions = [
|
||||||
|
{
|
||||||
|
name: AITool.SearchVaultFiles,
|
||||||
|
description: 'Search for files',
|
||||||
|
parameters: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] }
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const tools = (local as any).getTools();
|
||||||
|
|
||||||
|
expect(tools).toHaveLength(1);
|
||||||
|
expect(tools[0].function.name).toBe(AITool.SearchVaultFiles);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should treat any non-empty id as native, unlike Mistral\'s stricter format check', () => {
|
||||||
|
// Local has no provider-specific id format to validate against, so a tool-call id
|
||||||
|
// shaped like another provider's (e.g. Claude's "toolu_" prefix) is still accepted.
|
||||||
|
expect((local as any).isNativeToolCallId('toolu_01AbCdEfGhIjKlMnOpQrStUv')).toBe(true);
|
||||||
|
expect((local as any).isNativeToolCallId('')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('streamRequest (inherited from ChatCompletionsAIClass)', () => {
|
||||||
|
it('should call streamingService with the local URL and free-text model', async () => {
|
||||||
|
const conversation = new Conversation();
|
||||||
|
conversation.contents.push(new ConversationContent({ role: Role.User, content: 'Test message' }));
|
||||||
|
|
||||||
|
local.systemPrompt = 'System instruction';
|
||||||
|
local.userInstruction = 'User instruction';
|
||||||
|
local.aiToolDefinitions = [];
|
||||||
|
local.agentType = AgentType.Main;
|
||||||
|
|
||||||
|
mockStreamingService.streamRequest.mockImplementation(async function* () {
|
||||||
|
yield { content: 'response', isComplete: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
const generator = local.streamRequest(conversation);
|
||||||
|
for await (const _chunk of generator) {
|
||||||
|
// consume
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(mockStreamingService.streamRequest).toHaveBeenCalledWith(
|
||||||
|
'http://localhost:1234/v1/chat/completions',
|
||||||
|
expect.objectContaining({
|
||||||
|
model: 'local-main-model',
|
||||||
|
max_tokens: 16384,
|
||||||
|
messages: expect.any(Array),
|
||||||
|
stream: true
|
||||||
|
}),
|
||||||
|
expect.any(Function),
|
||||||
|
expect.any(Object),
|
||||||
|
expect.any(Function)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse a simple text delta chunk', () => {
|
||||||
|
const chunk = JSON.stringify({
|
||||||
|
choices: [{ delta: { content: 'Hello world' }, finish_reason: null }]
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = (local as any).parseStreamChunk(chunk);
|
||||||
|
expect(result.content).toBe('Hello world');
|
||||||
|
expect(result.isComplete).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit toolCallStarted when a tool call delta first reveals a function name', () => {
|
||||||
|
const chunk = JSON.stringify({
|
||||||
|
choices: [{
|
||||||
|
delta: {
|
||||||
|
tool_calls: [{
|
||||||
|
index: 0,
|
||||||
|
id: 'call_123',
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'search_vault_files',
|
||||||
|
arguments: '{"query":'
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
finish_reason: null
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = (local as any).parseStreamChunk(chunk);
|
||||||
|
expect(result.toolCallStarted).toBe('search_vault_files');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract simple text content via the shared extractContents', async () => {
|
||||||
|
const contents = [
|
||||||
|
new ConversationContent({ role: Role.User, content: 'Hello', displayContent: 'Hello' })
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await (local as any).extractContents(contents);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]).toEqual({ role: Role.User, content: 'Hello' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
197
__tests__/AIClasses/LocalConversationNamingAgent.test.ts
Normal file
197
__tests__/AIClasses/LocalConversationNamingAgent.test.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||||
|
import { LocalConversationNamingAgent } from '../../AIClasses/Local/LocalConversationNamingAgent';
|
||||||
|
import { RegisterSingleton, DeregisterAllServices } from '../../Services/DependencyService';
|
||||||
|
import { Services } from '../../Services/Services';
|
||||||
|
import { Role } from '../../Enums/Role';
|
||||||
|
|
||||||
|
describe('LocalConversationNamingAgent', () => {
|
||||||
|
let agent: LocalConversationNamingAgent;
|
||||||
|
let mockPlugin: any;
|
||||||
|
let mockSettingsService: any;
|
||||||
|
let mockAbortService: any;
|
||||||
|
let fetchMock: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPlugin = {};
|
||||||
|
RegisterSingleton(Services.VaultkeeperAIPlugin, mockPlugin);
|
||||||
|
|
||||||
|
mockSettingsService = {
|
||||||
|
settings: {
|
||||||
|
localUrl: 'http://localhost:1234/v1/chat/completions',
|
||||||
|
localModels: {
|
||||||
|
model: 'local-main-model',
|
||||||
|
planningModel: 'local-planning-model',
|
||||||
|
quickActionModel: 'local-quick-model'
|
||||||
|
},
|
||||||
|
apiKeys: {
|
||||||
|
local: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getApiKeyForProvider: vi.fn(() => ''),
|
||||||
|
getApiKeyForCurrentProvider: vi.fn(() => '')
|
||||||
|
};
|
||||||
|
RegisterSingleton(Services.SettingsService, mockSettingsService);
|
||||||
|
|
||||||
|
mockAbortService = {
|
||||||
|
signal: vi.fn(() => new AbortController().signal),
|
||||||
|
abortableOperation: vi.fn((fn) => fn())
|
||||||
|
};
|
||||||
|
RegisterSingleton(Services.AbortService, mockAbortService);
|
||||||
|
|
||||||
|
fetchMock = vi.fn();
|
||||||
|
global.fetch = fetchMock;
|
||||||
|
|
||||||
|
agent = new LocalConversationNamingAgent();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
DeregisterAllServices();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('apiUrl / namerModel getters', () => {
|
||||||
|
it('should use settings.localUrl as the endpoint', async () => {
|
||||||
|
fetchMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ choices: [{ message: { content: 'Name' } }] })
|
||||||
|
});
|
||||||
|
|
||||||
|
await agent.generateName('User prompt');
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
'http://localhost:1234/v1/chat/completions',
|
||||||
|
expect.any(Object)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use localModels.quickActionModel as the namer model', async () => {
|
||||||
|
fetchMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ choices: [{ message: { content: 'Name' } }] })
|
||||||
|
});
|
||||||
|
|
||||||
|
await agent.generateName('User prompt');
|
||||||
|
|
||||||
|
const requestBody = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||||
|
expect(requestBody.model).toBe('local-quick-model');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateName', () => {
|
||||||
|
it('should make request with correct format', async () => {
|
||||||
|
fetchMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ choices: [{ message: { content: 'Test Conversation' } }] })
|
||||||
|
});
|
||||||
|
|
||||||
|
await agent.generateName('User prompt');
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer ',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: expect.any(String)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const requestBody = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||||
|
expect(requestBody.max_tokens).toBe(100);
|
||||||
|
expect(requestBody.messages).toHaveLength(2);
|
||||||
|
expect(requestBody.messages[0].role).toBe('system');
|
||||||
|
expect(requestBody.messages[1].role).toBe(Role.User);
|
||||||
|
expect(requestBody.messages[1].content).toBe('User prompt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return generated name from response', async () => {
|
||||||
|
fetchMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ choices: [{ message: { content: 'Generated Name' } }] })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await agent.generateName('Test prompt');
|
||||||
|
|
||||||
|
expect(result).toBe('Generated Name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error on API error response', async () => {
|
||||||
|
fetchMock.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
statusText: 'Unauthorized',
|
||||||
|
text: async () => 'Invalid API key'
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(agent.generateName('Test'))
|
||||||
|
.rejects.toThrow('Chat Completions API error: 401 Unauthorized - Invalid API key');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when response has no content', async () => {
|
||||||
|
fetchMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ choices: [] })
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(agent.generateName('Test'))
|
||||||
|
.rejects.toThrow('Failed to generate conversation name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when message content is missing', async () => {
|
||||||
|
fetchMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ choices: [{ message: {} }] })
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(agent.generateName('Test'))
|
||||||
|
.rejects.toThrow('Failed to generate conversation name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when message content is empty string', async () => {
|
||||||
|
fetchMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ choices: [{ message: { content: '' } }] })
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(agent.generateName('Test'))
|
||||||
|
.rejects.toThrow('Failed to generate conversation name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle malformed response structure', async () => {
|
||||||
|
fetchMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ other: 'data' })
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(agent.generateName('Test'))
|
||||||
|
.rejects.toThrow('Failed to generate conversation name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trim whitespace from generated name', async () => {
|
||||||
|
fetchMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ choices: [{ message: { content: ' Generated Name ' } }] })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await agent.generateName('Test');
|
||||||
|
|
||||||
|
expect(result).toBe('Generated Name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass abort signal to fetch', async () => {
|
||||||
|
fetchMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ choices: [{ message: { content: 'Name' } }] })
|
||||||
|
});
|
||||||
|
|
||||||
|
await agent.generateName('Test');
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
|
expect.objectContaining({ signal: expect.any(AbortSignal) })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
39
__tests__/AIClasses/LocalFileService.test.ts
Normal file
39
__tests__/AIClasses/LocalFileService.test.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { LocalFileService } from '../../AIClasses/Local/LocalFileService';
|
||||||
|
import { AIProvider } from '../../Enums/ApiProvider';
|
||||||
|
|
||||||
|
describe('LocalFileService', () => {
|
||||||
|
describe('uploadFile', () => {
|
||||||
|
it('should stamp a dummy inline fileID and resolve', async () => {
|
||||||
|
const setFileID = vi.fn();
|
||||||
|
const attachment = { setFileID } as any;
|
||||||
|
|
||||||
|
const service = new LocalFileService();
|
||||||
|
await service.uploadFile(attachment);
|
||||||
|
|
||||||
|
expect(setFileID).toHaveBeenCalledWith(AIProvider.Local, 'inline');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('refreshCache', () => {
|
||||||
|
it('should resolve without doing anything', async () => {
|
||||||
|
const service = new LocalFileService();
|
||||||
|
await expect(service.refreshCache()).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteFile', () => {
|
||||||
|
it('should resolve without doing anything', async () => {
|
||||||
|
const service = new LocalFileService();
|
||||||
|
const attachment = {} as any;
|
||||||
|
await expect(service.deleteFile(attachment)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listFiles', () => {
|
||||||
|
it('should return an empty array', () => {
|
||||||
|
const service = new LocalFileService();
|
||||||
|
expect(service.listFiles()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -54,7 +54,7 @@ describe('Mistral', () => {
|
||||||
if (provider === AIProvider.Mistral) return 'test-mistral-key';
|
if (provider === AIProvider.Mistral) return 'test-mistral-key';
|
||||||
return '';
|
return '';
|
||||||
}),
|
}),
|
||||||
getApiKeyForCurrentModel: vi.fn(() => 'test-mistral-key'),
|
getApiKeyForCurrentProvider: vi.fn(() => 'test-mistral-key'),
|
||||||
subscribeToSettingsChanged: vi.fn()
|
subscribeToSettingsChanged: vi.fn()
|
||||||
};
|
};
|
||||||
RegisterSingleton(Services.SettingsService, mockSettingsService);
|
RegisterSingleton(Services.SettingsService, mockSettingsService);
|
||||||
|
|
@ -563,7 +563,7 @@ describe('Mistral', () => {
|
||||||
|
|
||||||
const imagePart = contentParts.find(p => p.type === 'image_url');
|
const imagePart = contentParts.find(p => p.type === 'image_url');
|
||||||
expect(imagePart).toBeDefined();
|
expect(imagePart).toBeDefined();
|
||||||
expect(imagePart.image_url).toBe('https://signed-url.com/file_123');
|
expect(imagePart.image_url).toStrictEqual({ 'url': 'https://signed-url.com/file_123' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle attachments with PDFs correctly', async () => {
|
it('should handle attachments with PDFs correctly', async () => {
|
||||||
|
|
@ -624,7 +624,7 @@ describe('Mistral', () => {
|
||||||
expect(contentParts.length).toBeGreaterThan(1);
|
expect(contentParts.length).toBeGreaterThan(1);
|
||||||
|
|
||||||
// Should have text part with error message
|
// Should have text part with error message
|
||||||
const errorPart = contentParts.find(p => p.text?.includes('Unsupported mime type'));
|
const errorPart = contentParts.find(p => p.text?.includes('unsupported mime type'));
|
||||||
expect(errorPart).toBeDefined();
|
expect(errorPart).toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -691,7 +691,7 @@ describe('Mistral', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('formatBinaryFiles', () => {
|
describe('formatBinaryFiles', () => {
|
||||||
it('should format PDF files with document_url type', () => {
|
it('should format PDF files with document_url type', async () => {
|
||||||
const attachment = {
|
const attachment = {
|
||||||
fileName: 'report.pdf',
|
fileName: 'report.pdf',
|
||||||
mimeType: 'application/pdf',
|
mimeType: 'application/pdf',
|
||||||
|
|
@ -702,7 +702,7 @@ describe('Mistral', () => {
|
||||||
deleteFileID: vi.fn()
|
deleteFileID: vi.fn()
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = (mistral as any).formatBinaryFiles([attachment as any]);
|
const result = await (mistral as any).formatBinaryFiles([attachment as any]);
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
expect(parsed).toHaveLength(2);
|
expect(parsed).toHaveLength(2);
|
||||||
|
|
@ -716,7 +716,7 @@ describe('Mistral', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should format JPEG images with image_url type', () => {
|
it('should format JPEG images with image_url type', async () => {
|
||||||
const attachment = {
|
const attachment = {
|
||||||
fileName: 'photo.jpg',
|
fileName: 'photo.jpg',
|
||||||
mimeType: 'image/jpeg',
|
mimeType: 'image/jpeg',
|
||||||
|
|
@ -727,7 +727,7 @@ describe('Mistral', () => {
|
||||||
deleteFileID: vi.fn()
|
deleteFileID: vi.fn()
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = (mistral as any).formatBinaryFiles([attachment as any]);
|
const result = await (mistral as any).formatBinaryFiles([attachment as any]);
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
expect(parsed).toHaveLength(2);
|
expect(parsed).toHaveLength(2);
|
||||||
|
|
@ -737,11 +737,11 @@ describe('Mistral', () => {
|
||||||
});
|
});
|
||||||
expect(parsed[1]).toEqual({
|
expect(parsed[1]).toEqual({
|
||||||
type: 'image_url',
|
type: 'image_url',
|
||||||
image_url: 'https://signed-url.com/file_img_jpeg'
|
image_url: { 'url': 'https://signed-url.com/file_img_jpeg' }
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should format PNG images with image_url type', () => {
|
it('should format PNG images with image_url type', async () => {
|
||||||
const attachment = {
|
const attachment = {
|
||||||
fileName: 'diagram.png',
|
fileName: 'diagram.png',
|
||||||
mimeType: 'image/png',
|
mimeType: 'image/png',
|
||||||
|
|
@ -752,17 +752,17 @@ describe('Mistral', () => {
|
||||||
deleteFileID: vi.fn()
|
deleteFileID: vi.fn()
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = (mistral as any).formatBinaryFiles([attachment as any]);
|
const result = await (mistral as any).formatBinaryFiles([attachment as any]);
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
expect(parsed).toHaveLength(2);
|
expect(parsed).toHaveLength(2);
|
||||||
expect(parsed[1]).toEqual({
|
expect(parsed[1]).toEqual({
|
||||||
type: 'image_url',
|
type: 'image_url',
|
||||||
image_url: 'https://signed-url.com/file_img_png'
|
image_url: { 'url': 'https://signed-url.com/file_img_png' }
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle unsupported image formats with error message', () => {
|
it('should handle unsupported image formats with error message', async () => {
|
||||||
const attachment = {
|
const attachment = {
|
||||||
fileName: 'photo.bmp',
|
fileName: 'photo.bmp',
|
||||||
mimeType: 'image/bmp',
|
mimeType: 'image/bmp',
|
||||||
|
|
@ -773,17 +773,17 @@ describe('Mistral', () => {
|
||||||
deleteFileID: vi.fn()
|
deleteFileID: vi.fn()
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = (mistral as any).formatBinaryFiles([attachment as any]);
|
const result = await (mistral as any).formatBinaryFiles([attachment as any]);
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
expect(parsed).toHaveLength(1);
|
expect(parsed).toHaveLength(1);
|
||||||
expect(parsed[0]).toEqual({
|
expect(parsed[0]).toEqual({
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text: 'Unsupported mime type \'image/bmp\': photo.bmp'
|
text: 'User attempted to share a file with an unsupported mime type \'image/bmp\': photo.bmp'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle multiple files of different types', () => {
|
it('should handle multiple files of different types', async () => {
|
||||||
const attachments = [
|
const attachments = [
|
||||||
{
|
{
|
||||||
fileName: 'doc.pdf',
|
fileName: 'doc.pdf',
|
||||||
|
|
@ -805,7 +805,7 @@ describe('Mistral', () => {
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const result = (mistral as any).formatBinaryFiles(attachments as any);
|
const result = await (mistral as any).formatBinaryFiles(attachments as any);
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
expect(parsed).toHaveLength(4);
|
expect(parsed).toHaveLength(4);
|
||||||
|
|
@ -821,11 +821,11 @@ describe('Mistral', () => {
|
||||||
expect(parsed[2]).toEqual({ type: 'text', text: replaceCopy(Copy.AttachedFile, ["image.jpg"]) });
|
expect(parsed[2]).toEqual({ type: 'text', text: replaceCopy(Copy.AttachedFile, ["image.jpg"]) });
|
||||||
expect(parsed[3]).toEqual({
|
expect(parsed[3]).toEqual({
|
||||||
type: 'image_url',
|
type: 'image_url',
|
||||||
image_url: 'https://signed-url.com/file_2'
|
image_url: { 'url': 'https://signed-url.com/file_2' }
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should skip files without file IDs', () => {
|
it('should skip files without file IDs', async () => {
|
||||||
const attachment = {
|
const attachment = {
|
||||||
fileName: 'image.png',
|
fileName: 'image.png',
|
||||||
mimeType: 'image/png',
|
mimeType: 'image/png',
|
||||||
|
|
@ -836,17 +836,17 @@ describe('Mistral', () => {
|
||||||
deleteFileID: vi.fn()
|
deleteFileID: vi.fn()
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = (mistral as any).formatBinaryFiles([attachment as any]);
|
const result = await (mistral as any).formatBinaryFiles([attachment as any]);
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
// Should return empty array when no file ID is available
|
// Should return empty array when no file ID is available
|
||||||
expect(parsed).toHaveLength(0);
|
expect(parsed).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle empty files array', () => {
|
it('should handle empty files array', async () => {
|
||||||
const attachments: any[] = [];
|
const attachments: any[] = [];
|
||||||
|
|
||||||
const result = (mistral as any).formatBinaryFiles(attachments);
|
const result = await (mistral as any).formatBinaryFiles(attachments);
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
expect(parsed).toHaveLength(0);
|
expect(parsed).toHaveLength(0);
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||||
import { MistralConversationNamingService } from '../../AIClasses/Mistral/MistralConversationNamingService';
|
import { MistralConversationNamingAgent } from '../../AIClasses/Mistral/MistralConversationNamingAgent';
|
||||||
import { RegisterSingleton, DeregisterAllServices } from '../../Services/DependencyService';
|
import { RegisterSingleton, DeregisterAllServices } from '../../Services/DependencyService';
|
||||||
import { Services } from '../../Services/Services';
|
import { Services } from '../../Services/Services';
|
||||||
import { AIProvider, AIProviderModel } from '../../Enums/ApiProvider';
|
import { AIProvider, AIProviderModel } from '../../Enums/ApiProvider';
|
||||||
import { Role } from '../../Enums/Role';
|
import { Role } from '../../Enums/Role';
|
||||||
|
|
||||||
describe('MistralConversationNamingService', () => {
|
describe('MistralConversationNamingAgent', () => {
|
||||||
let service: MistralConversationNamingService;
|
let service: MistralConversationNamingAgent;
|
||||||
let mockPlugin: any;
|
let mockPlugin: any;
|
||||||
let mockSettingsService: any;
|
let mockSettingsService: any;
|
||||||
let mockAbortService: any;
|
let mockAbortService: any;
|
||||||
|
|
@ -34,7 +34,7 @@ describe('MistralConversationNamingService', () => {
|
||||||
if (provider === AIProvider.Mistral) return 'test-mistral-key';
|
if (provider === AIProvider.Mistral) return 'test-mistral-key';
|
||||||
return '';
|
return '';
|
||||||
}),
|
}),
|
||||||
getApiKeyForCurrentModel: vi.fn(() => 'test-mistral-key')
|
getApiKeyForCurrentProvider: vi.fn(() => 'test-mistral-key')
|
||||||
};
|
};
|
||||||
RegisterSingleton(Services.SettingsService, mockSettingsService);
|
RegisterSingleton(Services.SettingsService, mockSettingsService);
|
||||||
|
|
||||||
|
|
@ -49,7 +49,7 @@ describe('MistralConversationNamingService', () => {
|
||||||
fetchMock = vi.fn();
|
fetchMock = vi.fn();
|
||||||
global.fetch = fetchMock;
|
global.fetch = fetchMock;
|
||||||
|
|
||||||
service = new MistralConversationNamingService();
|
service = new MistralConversationNamingAgent();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
@ -120,7 +120,7 @@ describe('MistralConversationNamingService', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(service.generateName('Test'))
|
await expect(service.generateName('Test'))
|
||||||
.rejects.toThrow('Mistral API error: 401 Unauthorized - Invalid API key');
|
.rejects.toThrow('Chat Completions API error: 401 Unauthorized - Invalid API key');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw error when response has no content', async () => {
|
it('should throw error when response has no content', async () => {
|
||||||
|
|
@ -54,7 +54,7 @@ describe('OpenAI', () => {
|
||||||
if (provider === AIProvider.Gemini) return 'test-gemini-key';
|
if (provider === AIProvider.Gemini) return 'test-gemini-key';
|
||||||
return '';
|
return '';
|
||||||
}),
|
}),
|
||||||
getApiKeyForCurrentModel: vi.fn(() => 'test-openai-key'),
|
getApiKeyForCurrentProvider: vi.fn(() => 'test-openai-key'),
|
||||||
subscribeToSettingsChanged: vi.fn()
|
subscribeToSettingsChanged: vi.fn()
|
||||||
};
|
};
|
||||||
RegisterSingleton(Services.SettingsService, mockSettingsService);
|
RegisterSingleton(Services.SettingsService, mockSettingsService);
|
||||||
|
|
@ -130,6 +130,24 @@ describe('OpenAI', () => {
|
||||||
expect(result.isComplete).toBe(false);
|
expect(result.isComplete).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should emit toolCallStarted on output_item.added for a function_call item', () => {
|
||||||
|
const chunk = JSON.stringify({
|
||||||
|
type: 'response.output_item.added',
|
||||||
|
output_index: 0,
|
||||||
|
item: {
|
||||||
|
id: 'item_123',
|
||||||
|
type: 'function_call',
|
||||||
|
name: 'search_vault_files',
|
||||||
|
call_id: 'call_123'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = (openai as any).parseStreamChunk(chunk);
|
||||||
|
|
||||||
|
expect(result.toolCallStarted).toBe('search_vault_files');
|
||||||
|
expect(result.isComplete).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle complete function call in output_item.done event', () => {
|
it('should handle complete function call in output_item.done event', () => {
|
||||||
// Responses API provides the complete function call in response.output_item.done event
|
// Responses API provides the complete function call in response.output_item.done event
|
||||||
const chunk = JSON.stringify({
|
const chunk = JSON.stringify({
|
||||||
|
|
@ -1001,7 +1019,7 @@ describe('OpenAI', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('formatBinaryFiles', () => {
|
describe('formatBinaryFiles', () => {
|
||||||
it('should format PDF files with file_id reference', () => {
|
it('should format PDF files with file_id reference', async () => {
|
||||||
const attachment = {
|
const attachment = {
|
||||||
fileName: 'report.pdf',
|
fileName: 'report.pdf',
|
||||||
mimeType: 'application/pdf',
|
mimeType: 'application/pdf',
|
||||||
|
|
@ -1012,7 +1030,7 @@ describe('OpenAI', () => {
|
||||||
deleteFileID: vi.fn()
|
deleteFileID: vi.fn()
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = (openai as any).formatBinaryFiles([attachment as any]);
|
const result = await (openai as any).formatBinaryFiles([attachment as any]);
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
expect(parsed).toHaveLength(1);
|
expect(parsed).toHaveLength(1);
|
||||||
|
|
@ -1028,7 +1046,7 @@ describe('OpenAI', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should format JPEG images with file_id reference', () => {
|
it('should format JPEG images with file_id reference', async () => {
|
||||||
const attachment = {
|
const attachment = {
|
||||||
fileName: 'photo.jpg',
|
fileName: 'photo.jpg',
|
||||||
mimeType: 'image/jpeg',
|
mimeType: 'image/jpeg',
|
||||||
|
|
@ -1039,7 +1057,7 @@ describe('OpenAI', () => {
|
||||||
deleteFileID: vi.fn()
|
deleteFileID: vi.fn()
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = (openai as any).formatBinaryFiles([attachment as any]);
|
const result = await (openai as any).formatBinaryFiles([attachment as any]);
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
expect(parsed).toHaveLength(1);
|
expect(parsed).toHaveLength(1);
|
||||||
|
|
@ -1055,7 +1073,7 @@ describe('OpenAI', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should format PNG images with file_id reference', () => {
|
it('should format PNG images with file_id reference', async () => {
|
||||||
const attachment = {
|
const attachment = {
|
||||||
fileName: 'diagram.png',
|
fileName: 'diagram.png',
|
||||||
mimeType: 'image/png',
|
mimeType: 'image/png',
|
||||||
|
|
@ -1066,7 +1084,7 @@ describe('OpenAI', () => {
|
||||||
deleteFileID: vi.fn()
|
deleteFileID: vi.fn()
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = (openai as any).formatBinaryFiles([attachment as any]);
|
const result = await (openai as any).formatBinaryFiles([attachment as any]);
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
expect(parsed).toHaveLength(1);
|
expect(parsed).toHaveLength(1);
|
||||||
|
|
@ -1081,7 +1099,7 @@ describe('OpenAI', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should format WebP images with file_id reference', () => {
|
it('should format WebP images with file_id reference', async () => {
|
||||||
const attachment = {
|
const attachment = {
|
||||||
fileName: 'modern.webp',
|
fileName: 'modern.webp',
|
||||||
mimeType: 'image/webp',
|
mimeType: 'image/webp',
|
||||||
|
|
@ -1092,7 +1110,7 @@ describe('OpenAI', () => {
|
||||||
deleteFileID: vi.fn()
|
deleteFileID: vi.fn()
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = (openai as any).formatBinaryFiles([attachment as any]);
|
const result = await (openai as any).formatBinaryFiles([attachment as any]);
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
expect(parsed).toHaveLength(1);
|
expect(parsed).toHaveLength(1);
|
||||||
|
|
@ -1107,7 +1125,7 @@ describe('OpenAI', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle unsupported image formats with error message', () => {
|
it('should handle unsupported image formats with error message', async () => {
|
||||||
const attachment = {
|
const attachment = {
|
||||||
fileName: 'photo.gif',
|
fileName: 'photo.gif',
|
||||||
mimeType: 'image/gif',
|
mimeType: 'image/gif',
|
||||||
|
|
@ -1118,18 +1136,18 @@ describe('OpenAI', () => {
|
||||||
deleteFileID: vi.fn()
|
deleteFileID: vi.fn()
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = (openai as any).formatBinaryFiles([attachment as any]);
|
const result = await (openai as any).formatBinaryFiles([attachment as any]);
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
expect(parsed).toHaveLength(1);
|
expect(parsed).toHaveLength(1);
|
||||||
expect(parsed[0].content).toHaveLength(1);
|
expect(parsed[0].content).toHaveLength(1);
|
||||||
expect(parsed[0].content[0]).toEqual({
|
expect(parsed[0].content[0]).toEqual({
|
||||||
type: 'input_text',
|
type: 'input_text',
|
||||||
text: 'Unsupported mime type \'image/gif\': photo.gif'
|
text: 'User attempted to share a file with an unsupported mime type \'image/gif\': photo.gif'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should skip files without file IDs (failed uploads)', () => {
|
it('should skip files without file IDs (failed uploads)', async () => {
|
||||||
const attachment = {
|
const attachment = {
|
||||||
fileName: 'failed.pdf',
|
fileName: 'failed.pdf',
|
||||||
mimeType: 'application/pdf',
|
mimeType: 'application/pdf',
|
||||||
|
|
@ -1140,7 +1158,7 @@ describe('OpenAI', () => {
|
||||||
deleteFileID: vi.fn()
|
deleteFileID: vi.fn()
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = (openai as any).formatBinaryFiles([attachment as any]);
|
const result = await (openai as any).formatBinaryFiles([attachment as any]);
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
expect(parsed).toHaveLength(1);
|
expect(parsed).toHaveLength(1);
|
||||||
|
|
@ -1148,7 +1166,7 @@ describe('OpenAI', () => {
|
||||||
expect(parsed[0].content).toHaveLength(0); // No content blocks
|
expect(parsed[0].content).toHaveLength(0); // No content blocks
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle multiple files of different types', () => {
|
it('should handle multiple files of different types', async () => {
|
||||||
const attachments = [
|
const attachments = [
|
||||||
{
|
{
|
||||||
fileName: 'doc.pdf',
|
fileName: 'doc.pdf',
|
||||||
|
|
@ -1179,7 +1197,7 @@ describe('OpenAI', () => {
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const result = (openai as any).formatBinaryFiles(attachments as any);
|
const result = await (openai as any).formatBinaryFiles(attachments as any);
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
expect(parsed).toHaveLength(1);
|
expect(parsed).toHaveLength(1);
|
||||||
|
|
@ -1214,7 +1232,7 @@ describe('OpenAI', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle mixed supported and unsupported files', () => {
|
it('should handle mixed supported and unsupported files', async () => {
|
||||||
const attachments = [
|
const attachments = [
|
||||||
{
|
{
|
||||||
fileName: 'good.jpg',
|
fileName: 'good.jpg',
|
||||||
|
|
@ -1245,7 +1263,7 @@ describe('OpenAI', () => {
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const result = (openai as any).formatBinaryFiles(attachments as any);
|
const result = await (openai as any).formatBinaryFiles(attachments as any);
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
expect(parsed).toHaveLength(1);
|
expect(parsed).toHaveLength(1);
|
||||||
|
|
@ -1258,7 +1276,7 @@ describe('OpenAI', () => {
|
||||||
expect(parsed[0].content[1].type).toBe('input_image');
|
expect(parsed[0].content[1].type).toBe('input_image');
|
||||||
expect(parsed[0].content[2]).toEqual({
|
expect(parsed[0].content[2]).toEqual({
|
||||||
type: 'input_text',
|
type: 'input_text',
|
||||||
text: 'Unsupported mime type \'image/bmp\': bad.bmp'
|
text: 'User attempted to share a file with an unsupported mime type \'image/bmp\': bad.bmp'
|
||||||
});
|
});
|
||||||
expect(parsed[0].content[3]).toEqual({
|
expect(parsed[0].content[3]).toEqual({
|
||||||
type: 'input_text',
|
type: 'input_text',
|
||||||
|
|
@ -1267,7 +1285,7 @@ describe('OpenAI', () => {
|
||||||
expect(parsed[0].content[4].type).toBe('input_file');
|
expect(parsed[0].content[4].type).toBe('input_file');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle mixed successful and failed uploads', () => {
|
it('should handle mixed successful and failed uploads', async () => {
|
||||||
const attachments = [
|
const attachments = [
|
||||||
{
|
{
|
||||||
fileName: 'success.pdf',
|
fileName: 'success.pdf',
|
||||||
|
|
@ -1289,7 +1307,7 @@ describe('OpenAI', () => {
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const result = (openai as any).formatBinaryFiles(attachments as any);
|
const result = await (openai as any).formatBinaryFiles(attachments as any);
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
expect(parsed).toHaveLength(1);
|
expect(parsed).toHaveLength(1);
|
||||||
|
|
@ -1304,8 +1322,8 @@ describe('OpenAI', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle empty attachments array', () => {
|
it('should handle empty attachments array', async () => {
|
||||||
const result = (openai as any).formatBinaryFiles([]);
|
const result = await (openai as any).formatBinaryFiles([]);
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
expect(parsed).toHaveLength(1);
|
expect(parsed).toHaveLength(1);
|
||||||
|
|
@ -1313,7 +1331,7 @@ describe('OpenAI', () => {
|
||||||
expect(parsed[0].content).toHaveLength(0);
|
expect(parsed[0].content).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle all files with failed uploads', () => {
|
it('should handle all files with failed uploads', async () => {
|
||||||
const attachments = [
|
const attachments = [
|
||||||
{
|
{
|
||||||
fileName: 'failed1.pdf',
|
fileName: 'failed1.pdf',
|
||||||
|
|
@ -1335,7 +1353,7 @@ describe('OpenAI', () => {
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const result = (openai as any).formatBinaryFiles(attachments as any);
|
const result = await (openai as any).formatBinaryFiles(attachments as any);
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
expect(parsed).toHaveLength(1);
|
expect(parsed).toHaveLength(1);
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||||
import { OpenAIConversationNamingService } from '../../AIClasses/OpenAI/OpenAIConversationNamingService';
|
import { OpenAIConversationNamingAgent } from '../../AIClasses/OpenAI/OpenAIConversationNamingAgent';
|
||||||
import { RegisterSingleton, DeregisterAllServices } from '../../Services/DependencyService';
|
import { RegisterSingleton, DeregisterAllServices } from '../../Services/DependencyService';
|
||||||
import { Services } from '../../Services/Services';
|
import { Services } from '../../Services/Services';
|
||||||
import { AIProvider, AIProviderModel } from '../../Enums/ApiProvider';
|
import { AIProvider, AIProviderModel } from '../../Enums/ApiProvider';
|
||||||
import { Role } from '../../Enums/Role';
|
import { Role } from '../../Enums/Role';
|
||||||
|
|
||||||
describe('OpenAIConversationNamingService', () => {
|
describe('OpenAIConversationNamingAgent', () => {
|
||||||
let service: OpenAIConversationNamingService;
|
let service: OpenAIConversationNamingAgent;
|
||||||
let mockPlugin: any;
|
let mockPlugin: any;
|
||||||
let mockSettingsService: any;
|
let mockSettingsService: any;
|
||||||
let mockAbortService: any;
|
let mockAbortService: any;
|
||||||
|
|
@ -19,7 +19,7 @@ describe('OpenAIConversationNamingService', () => {
|
||||||
// Mock SettingsService
|
// Mock SettingsService
|
||||||
mockSettingsService = {
|
mockSettingsService = {
|
||||||
settings: {
|
settings: {
|
||||||
model: AIProviderModel.GPT_5_4_Nano,
|
model: AIProviderModel.GPT_5_6_Luna,
|
||||||
apiKeys: {
|
apiKeys: {
|
||||||
claude: 'test-claude-key',
|
claude: 'test-claude-key',
|
||||||
openai: 'test-openai-key',
|
openai: 'test-openai-key',
|
||||||
|
|
@ -32,7 +32,7 @@ describe('OpenAIConversationNamingService', () => {
|
||||||
if (provider === AIProvider.Gemini) return 'test-gemini-key';
|
if (provider === AIProvider.Gemini) return 'test-gemini-key';
|
||||||
return '';
|
return '';
|
||||||
}),
|
}),
|
||||||
getApiKeyForCurrentModel: vi.fn(() => 'test-openai-key')
|
getApiKeyForCurrentProvider: vi.fn(() => 'test-openai-key')
|
||||||
};
|
};
|
||||||
RegisterSingleton(Services.SettingsService, mockSettingsService);
|
RegisterSingleton(Services.SettingsService, mockSettingsService);
|
||||||
|
|
||||||
|
|
@ -47,7 +47,7 @@ describe('OpenAIConversationNamingService', () => {
|
||||||
fetchMock = vi.fn();
|
fetchMock = vi.fn();
|
||||||
global.fetch = fetchMock;
|
global.fetch = fetchMock;
|
||||||
|
|
||||||
service = new OpenAIConversationNamingService();
|
service = new OpenAIConversationNamingAgent();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
240
__tests__/Conversations/Artifact.test.ts
Normal file
240
__tests__/Conversations/Artifact.test.ts
Normal file
|
|
@ -0,0 +1,240 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { Artifact } from '../../Conversations/Artifact';
|
||||||
|
import { ArtifactAction } from '../../Enums/ArtifactAction';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UNIT TESTS - Artifact
|
||||||
|
*
|
||||||
|
* Tests the Artifact class that tracks file modifications made by the
|
||||||
|
* AI agent during a conversation turn (write/patch/delete tracking).
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe('Artifact', () => {
|
||||||
|
describe('constructor', () => {
|
||||||
|
it('should create an artifact with required fields', () => {
|
||||||
|
const artifact = new Artifact('notes/test.md', 'text/markdown', ArtifactAction.Modify, 'old content', 'new content');
|
||||||
|
|
||||||
|
expect(artifact.filePath).toBe('notes/test.md');
|
||||||
|
expect(artifact.mimeType).toBe('text/markdown');
|
||||||
|
expect(artifact.action).toBe(ArtifactAction.Modify);
|
||||||
|
expect(artifact.originalContent).toBe('old content');
|
||||||
|
expect(artifact.updatedContent).toBe('new content');
|
||||||
|
expect(artifact.base64).toBeUndefined();
|
||||||
|
expect(artifact.artifactPath).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create an artifact with optional base64 and artifactPath', () => {
|
||||||
|
const artifact = new Artifact(
|
||||||
|
'image.png',
|
||||||
|
'image/png',
|
||||||
|
ArtifactAction.Modify,
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'base64data==',
|
||||||
|
'Artifacts/hash123.bin'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(artifact.base64).toBe('base64data==');
|
||||||
|
expect(artifact.artifactPath).toBe('Artifacts/hash123.bin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow empty originalContent for newly created files', () => {
|
||||||
|
const artifact = new Artifact('new-file.md', 'text/markdown', ArtifactAction.Create, '', 'Some new content');
|
||||||
|
|
||||||
|
expect(artifact.originalContent).toBe('');
|
||||||
|
expect(artifact.updatedContent).toBe('Some new content');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow empty updatedContent for deleted files', () => {
|
||||||
|
const artifact = new Artifact('deleted-file.md', 'text/markdown', ArtifactAction.Delete, 'Content before deletion', '');
|
||||||
|
|
||||||
|
expect(artifact.originalContent).toBe('Content before deletion');
|
||||||
|
expect(artifact.updatedContent).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getStoragePath / setStoragePath', () => {
|
||||||
|
it('should return undefined when no storage path has been set', () => {
|
||||||
|
const artifact = new Artifact('test.md', 'text/markdown', ArtifactAction.Modify, 'a', 'b');
|
||||||
|
|
||||||
|
expect(artifact.getStoragePath()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the artifactPath passed to the constructor', () => {
|
||||||
|
const artifact = new Artifact('test.md', 'text/markdown', ArtifactAction.Modify, 'a', 'b', 'base64', 'Artifacts/existing.bin');
|
||||||
|
|
||||||
|
expect(artifact.getStoragePath()).toBe('Artifacts/existing.bin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update the storage path when set', () => {
|
||||||
|
const artifact = new Artifact('test.md', 'text/markdown', ArtifactAction.Modify, 'a', 'b');
|
||||||
|
|
||||||
|
artifact.setStoragePath('Artifacts/newly-saved.bin');
|
||||||
|
|
||||||
|
expect(artifact.getStoragePath()).toBe('Artifacts/newly-saved.bin');
|
||||||
|
expect(artifact.artifactPath).toBe('Artifacts/newly-saved.bin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should overwrite an existing storage path', () => {
|
||||||
|
const artifact = new Artifact('test.md', 'text/markdown', ArtifactAction.Modify, 'a', 'b', undefined, 'Artifacts/old.bin');
|
||||||
|
|
||||||
|
artifact.setStoragePath('Artifacts/new.bin');
|
||||||
|
|
||||||
|
expect(artifact.getStoragePath()).toBe('Artifacts/new.bin');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isArtifactData', () => {
|
||||||
|
it('should return true for valid minimal artifact data', () => {
|
||||||
|
const data = {
|
||||||
|
filePath: 'test.md',
|
||||||
|
mimeType: 'text/markdown',
|
||||||
|
action: ArtifactAction.Modify,
|
||||||
|
originalContent: 'old',
|
||||||
|
updatedContent: 'new'
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(Artifact.isArtifactData(data)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for valid artifact data with optional fields', () => {
|
||||||
|
const data = {
|
||||||
|
filePath: 'test.md',
|
||||||
|
mimeType: 'text/markdown',
|
||||||
|
action: ArtifactAction.Modify,
|
||||||
|
originalContent: 'old',
|
||||||
|
updatedContent: 'new',
|
||||||
|
base64: 'YWJj',
|
||||||
|
artifactPath: 'Artifacts/hash.bin'
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(Artifact.isArtifactData(data)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for null', () => {
|
||||||
|
expect(Artifact.isArtifactData(null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for non-object values', () => {
|
||||||
|
expect(Artifact.isArtifactData('a string')).toBe(false);
|
||||||
|
expect(Artifact.isArtifactData(42)).toBe(false);
|
||||||
|
expect(Artifact.isArtifactData(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when filePath is missing', () => {
|
||||||
|
const data = {
|
||||||
|
mimeType: 'text/markdown',
|
||||||
|
originalContent: 'old',
|
||||||
|
updatedContent: 'new'
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(Artifact.isArtifactData(data)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when mimeType is missing', () => {
|
||||||
|
const data = {
|
||||||
|
filePath: 'test.md',
|
||||||
|
action: ArtifactAction.Modify,
|
||||||
|
originalContent: 'old',
|
||||||
|
updatedContent: 'new'
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(Artifact.isArtifactData(data)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when action is missing', () => {
|
||||||
|
const data = {
|
||||||
|
filePath: 'test.md',
|
||||||
|
mimeType: 'text/markdown',
|
||||||
|
originalContent: 'old',
|
||||||
|
updatedContent: 'new'
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(Artifact.isArtifactData(data)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when action is not a valid ArtifactAction', () => {
|
||||||
|
const data = {
|
||||||
|
filePath: 'test.md',
|
||||||
|
mimeType: 'text/markdown',
|
||||||
|
action: 'not-a-valid-action',
|
||||||
|
originalContent: 'old',
|
||||||
|
updatedContent: 'new'
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(Artifact.isArtifactData(data)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when originalContent is missing', () => {
|
||||||
|
const data = {
|
||||||
|
filePath: 'test.md',
|
||||||
|
mimeType: 'text/markdown',
|
||||||
|
action: ArtifactAction.Modify,
|
||||||
|
updatedContent: 'new'
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(Artifact.isArtifactData(data)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when updatedContent is missing', () => {
|
||||||
|
const data = {
|
||||||
|
filePath: 'test.md',
|
||||||
|
mimeType: 'text/markdown',
|
||||||
|
action: ArtifactAction.Modify,
|
||||||
|
originalContent: 'old'
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(Artifact.isArtifactData(data)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when filePath has wrong type', () => {
|
||||||
|
const data = {
|
||||||
|
filePath: 123,
|
||||||
|
mimeType: 'text/markdown',
|
||||||
|
action: ArtifactAction.Modify,
|
||||||
|
originalContent: 'old',
|
||||||
|
updatedContent: 'new'
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(Artifact.isArtifactData(data)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when base64 has wrong type', () => {
|
||||||
|
const data = {
|
||||||
|
filePath: 'test.md',
|
||||||
|
mimeType: 'text/markdown',
|
||||||
|
action: ArtifactAction.Modify,
|
||||||
|
originalContent: 'old',
|
||||||
|
updatedContent: 'new',
|
||||||
|
base64: 12345
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(Artifact.isArtifactData(data)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when artifactPath has wrong type', () => {
|
||||||
|
const data = {
|
||||||
|
filePath: 'test.md',
|
||||||
|
mimeType: 'text/markdown',
|
||||||
|
action: ArtifactAction.Modify,
|
||||||
|
originalContent: 'old',
|
||||||
|
updatedContent: 'new',
|
||||||
|
artifactPath: true
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(Artifact.isArtifactData(data)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow empty string content fields', () => {
|
||||||
|
const data = {
|
||||||
|
filePath: 'new-file.md',
|
||||||
|
mimeType: 'text/markdown',
|
||||||
|
action: ArtifactAction.Create,
|
||||||
|
originalContent: '',
|
||||||
|
updatedContent: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(Artifact.isArtifactData(data)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -479,6 +479,7 @@ describe('Conversation', () => {
|
||||||
AITool.ReadVaultFiles,
|
AITool.ReadVaultFiles,
|
||||||
new AIToolResponsePayload(
|
new AIToolResponsePayload(
|
||||||
{ results: [{ path: 'image.png', error: 'File does not exist: image.png' }] },
|
{ results: [{ path: 'image.png', error: 'File does not exist: image.png' }] },
|
||||||
|
[],
|
||||||
[validAttachment]
|
[validAttachment]
|
||||||
),
|
),
|
||||||
'tool-123'
|
'tool-123'
|
||||||
|
|
@ -527,6 +528,7 @@ describe('Conversation', () => {
|
||||||
AITool.ReadVaultFiles,
|
AITool.ReadVaultFiles,
|
||||||
new AIToolResponsePayload(
|
new AIToolResponsePayload(
|
||||||
{ message: 'Files retrieved successfully. The contents of the files are included below.', count: 1 },
|
{ message: 'Files retrieved successfully. The contents of the files are included below.', count: 1 },
|
||||||
|
[],
|
||||||
[attachment]
|
[attachment]
|
||||||
),
|
),
|
||||||
'tool-123'
|
'tool-123'
|
||||||
|
|
@ -550,6 +552,7 @@ describe('Conversation', () => {
|
||||||
AITool.ReadVaultFiles,
|
AITool.ReadVaultFiles,
|
||||||
new AIToolResponsePayload(
|
new AIToolResponsePayload(
|
||||||
{ message: 'Files retrieved successfully. The contents of the files are included below.', count: 1 },
|
{ message: 'Files retrieved successfully. The contents of the files are included below.', count: 1 },
|
||||||
|
[],
|
||||||
[docAttachment]
|
[docAttachment]
|
||||||
),
|
),
|
||||||
'tool-123'
|
'tool-123'
|
||||||
|
|
@ -577,6 +580,7 @@ describe('Conversation', () => {
|
||||||
AITool.ReadVaultFiles,
|
AITool.ReadVaultFiles,
|
||||||
new AIToolResponsePayload(
|
new AIToolResponsePayload(
|
||||||
{ results: [{ type: 'md', path: 'notes.md', contents: '# Notes' }], message: 'The contents of the files are included below.' },
|
{ results: [{ type: 'md', path: 'notes.md', contents: '# Notes' }], message: 'The contents of the files are included below.' },
|
||||||
|
[],
|
||||||
[xlsxAttachment, imgAttachment]
|
[xlsxAttachment, imgAttachment]
|
||||||
),
|
),
|
||||||
'tool-123'
|
'tool-123'
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue