Compare commits

...

17 commits

Author SHA1 Message Date
Andrew Beal
bbf59ff911 test: add migration tests for stale/renamed model strings
Add comprehensive test coverage for ensureValidModels method to verify
proper handling of outdated model identifiers and provider mismatches.
Tests cover fallback to provider defaults when models are renamed,
removed, or no longer match the active provider.
2026-07-12 16:07:05 +01:00
Andrew Beal
cd6a74ae1c Bump plugin version to 1.4.9 2026-07-12 15:40:01 +01:00
Andrew Beal
79ab34e92d style: add smooth transition to artifact ellipse hover effect 2026-07-12 15:39:07 +01:00
Andrew Beal
6ac8a62f4a docs: update README to highlight restore file changes feature
Replace local model support announcement with new restore file
changes feature in the hero section. Add restore capability to the
features list to showcase the ability to revert AI-modified notes
(including binary files) to previous versions.
2026-07-12 15:21:58 +01:00
Andrew Beal
56f3ed57c1 chore: update AI SDK and dev dependencies to latest versions 2026-07-12 15:16:42 +01:00
Andrew Beal
82dab77d74 feat: add artifact diff viewer and improve button styling
- Add clickable artifact cards with keyboard support
- Create new ArtifactView for displaying file diffs
- Update button styles with solid backgrounds and faster transitions
- Add auto-cleanup of stale diff and plan approval views on startup
- Fix artifact deletion to preserve original file path
2026-07-12 15:14:44 +01:00
Andrew Beal
6b5d51df39 Add scroll fade indicators to artifact lists and plan steps
- Add fade gradient to artifacts list that hides when scrolled to bottom
- Update plan area fades to use scroll position instead of always showing
- Add hover effect to artifact cards with glowing ellipse indicator
- Fix indentation in UserMessage attachment styles
2026-07-11 14:46:39 +01:00
Andrew Beal
0b54cdcc10 refactor(ChatArea): extract message rendering into separate components
Extract user and assistant message rendering logic from ChatArea into dedicated UserMessage and AssistantMessage components, removing unused dependencies and styles.
2026-07-11 14:04:19 +01:00
Andrew Beal
f4c3b5b826 feat: add file change summary cards to assistant messages
Display artifact changes in chat with visual indicators for create/modify/delete actions, including file counts, color-coded status badges, and scrollable file list
2026-07-11 13:49:44 +01:00
Andrew Beal
cc45949eba Update OpenAI model identifiers from GPT-5.5/5.4 to GPT-5.6 series
Replace GPT-5.5-pro, GPT-5.5, GPT-5.4-mini, and GPT-5.4-nano with the new GPT-5.6 Sol, Terra, and Luna model variants across enum definitions, display copy, settings UI, tests, and default model configurations.
2026-07-10 23:06:16 +01:00
Andrew Beal
7757800127 refactor: simplify chat layout with turn-based grouping
Replace complex dynamic padding system with turn-based message grouping that scrolls to the latest user/assistant exchange. Remove manual height calculations and message element tracking in favor of CSS-based layout using .message-group containers. Neutralize leading frontmatter markers in streaming markdown to prevent Obsidian from hiding content.
2026-07-10 22:57:25 +01:00
Andrew Beal
6b31e3d4e9 test: add artifact tracking tests for file operations
Add comprehensive test coverage for artifact generation in AIToolService
and ConversationFileSystemService, including write/patch/delete operations,
binary file handling, serialization/deserialization, and garbage collection.
2026-07-10 21:44:40 +01:00
Andrew Beal
10ddb1da28 feat: add artifact tracking system for agent file operations
Implement comprehensive artifact tracking to record all file modifications made by the AI agent during conversations. Add artifact persistence, garbage collection, and UI updates with new "Discuss" button styling.
2026-07-10 21:23:44 +01:00
Andrew Beal
bfa8360037 fix: add stable keys and lifecycle methods to message tracking action
- Add unique IDs to ConversationContent for stable Svelte keying
- Implement update/destroy lifecycle methods in trackingAction
- Reset chat area when clearing conversation to prevent stale references
2026-07-09 19:10:53 +01:00
Andrew Beal
1662a7c671 chore: update dependencies to latest versions
Update @types/node to 26.1.1 and svelte-check to 4.7.2, along with their transitive dependencies including rolldown bindings, oxc-project types, and various other dev dependencies.
2026-07-09 19:08:26 +01:00
Andrew Beal
329052e032 Bump plugin version to 1.4.8 2026-07-06 20:38:24 +01:00
Andrew Beal
7b22c26718 fix: handle null settings for new users and reorganize CSS styles
- Add null check for loadedSettings in SettingsService constructor
- Reorder CSS rules in ChatInput to group related mobile styles together
2026-07-06 20:38:06 +01:00
58 changed files with 2668 additions and 691 deletions

View file

@ -10,25 +10,25 @@ export class AIToolResponse {
public readonly toolId?: string;
public static readonly UserRejectionMessage: string = `The user has explicitly rejected this change.
They may have changed their mind about the requested change.
They may have changed their mind about the requested change.
**CRITICAL:** Immediately stop all further actions and consult with the user`;
**CRITICAL:** Immediately stop all further actions and consult with the user`;
public static readonly UserSuggestionMessage: string = `**USER MODIFICATION REQUEST:**
The user has reviewed your proposed action and provided a modification or alternative direction.
The user has reviewed your proposed action and provided a modification or alternative direction.
**Critical Instructions:**
1. This is NOT an error or failure - this is valuable user guidance that should be taken seriously
2. The user may want to:
- Adjust the SAME action with different parameters (e.g., write to a different file)
- Change to a DIFFERENT action entirely (e.g., delete instead of write)
- Add context or constraints you didn't initially consider
3. Carefully analyze the user's suggestion below to understand their true intent
4. Acknowledge their feedback and explain how you'll adjust your approach
5. Then proceed with the modified action that aligns with their guidance
**Critical Instructions:**
1. This is NOT an error or failure - this is valuable user guidance that should be taken seriously
2. The user may want to:
- Adjust the SAME action with different parameters (e.g., write to a different file)
- Change to a DIFFERENT action entirely (e.g., delete instead of write)
- Add context or constraints you didn't initially consider
3. Carefully analyze the user's suggestion below to understand their true intent
4. Acknowledge their feedback and explain how you'll adjust your approach
5. Then proceed with the modified action that aligns with their guidance
**User's Suggestion:**`;
**User's Suggestion:**`;
constructor(name: AITool, payload: AIToolResponsePayload, toolId?: string) {
this.name = name;

View file

@ -1,11 +1,14 @@
import type { Artifact } from "Conversations/Artifact";
import type { Attachment } from "Conversations/Attachment";
export class AIToolResponsePayload {
public readonly response: object;
public readonly artifacts: Artifact[];
public readonly attachments: Attachment[];
constructor(response: object, attachments: Attachment[] = []) {
constructor(response: object, artifacts: Artifact[] = [], attachments: Attachment[] = []) {
this.response = response;
this.artifacts = artifacts;
this.attachments = attachments;
}
}

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

View file

@ -1,14 +1,12 @@
<script lang="ts">
import { Resolve } from "Services/DependencyService";
import { Services } from "Services/Services";
import type { StreamingMarkdownService } from "Services/StreamingMarkdownService";
import ThoughtIndicator from "./ThoughtIndicator.svelte";
import StreamingIndicator from "./StreamingIndicator.svelte";
import UserMessage from "./UserMessage.svelte";
import AssistantMessage from "./AssistantMessage.svelte";
import { Greeting } from "Enums/Greeting";
import { Role } from "Enums/Role";
import type { ConversationContent } from "Conversations/ConversationContent";
import { tick } from "svelte";
import { getOuterHeight, setElementIcon } from "Helpers/ElementHelper";
import { setIcon } from "obsidian";
import { fade } from "svelte/transition";
import GraphAnimation from "./GraphAnimation.svelte";
@ -19,85 +17,32 @@
export let chatContainer: HTMLDivElement;
export function resetChatArea() {
messageElements = [];
if (chatAreaPaddingElement) {
chatAreaPaddingElement.style.padding = "0px";
}
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();
if (!chatAreaPaddingElement) {
return;
}
if (messageElements.length <= 0) {
chatAreaPaddingElement.style.paddingBottom = "0px";
return;
}
requestAnimationFrame(() => {
applyLayout(behavior, shouldSettle);
updateScrolledState();
if (behavior) {
scrollToLatestTurn(behavior);
}
tick().then(updateScrolledState);
});
}
function applyLayout(behavior: ScrollBehavior | undefined, shouldSettle: boolean) {
if (!chatAreaPaddingElement || messageElements.length <= 0) {
function scrollToLatestTurn(behavior: ScrollBehavior) {
const latestTurn = chatContainer.querySelector<HTMLElement>(".message-group.latest");
if (!latestTurn) {
return;
}
const styles = getComputedStyle(chatContainer);
const gap = parseFloat(styles.gap) || 0;
const paddingTop = parseFloat(styles.paddingTop) || 0;
const paddingBottom = parseFloat(styles.paddingBottom) || 0;
const sortedMessages = messageElements.sort((a, b) => a.index - b.index);
let result = calculateMessageHeight(sortedMessages);
let contentHeight = result.height + (gap * (result.count - 1));
if (!shouldSettle) {
if (thoughtIndicatorElement) {
contentHeight += getOuterHeight(thoughtIndicatorElement) + gap;
}
if (streamingIndicatorElement) {
contentHeight += getOuterHeight(streamingIndicatorElement) + gap;
}
}
const availableHeight = chatContainer.offsetHeight - paddingTop - paddingBottom;
let padding = shouldSettle
? Math.max(0, availableHeight - contentHeight)
: Math.max(25, availableHeight - contentHeight);
chatAreaPaddingElement.style.paddingBottom = `${padding}px`;
if (behavior) {
chatContainer.scroll({ top: chatContainer.scrollHeight, behavior });
}
const paddingTop = parseFloat(getComputedStyle(chatContainer).paddingTop) || 0;
chatContainer.scroll({ top: latestTurn.offsetTop - paddingTop, behavior });
}
function calculateMessageHeight(sortedMessages: { element: HTMLElement, index: number, role: Role }[]): { count: number, height: number } {
const lastMessage = sortedMessages[sortedMessages.length - 1];
if (lastMessage.role === Role.User) {
return { count: 1, height: getOuterHeight(lastMessage.element) };
}
let count = 0;
let height = 0;
for (const message of sortedMessages.reverse()) {
if (message.role === Role.User) {
break;
}
height += getOuterHeight(message.element);
count++;
}
return { count: count, height: height };
function scrollToBottom(behavior: ScrollBehavior) {
chatContainer.scroll({ top: chatContainer.scrollHeight, behavior });
}
function updateScrolledState() {
@ -111,13 +56,24 @@
let scrolledToBottom: boolean = true;
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 {
const hour = new Date().getHours();
@ -140,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) {
setIcon(scrollToBottomButton, "arrow-down");
}
$: {
if (messages.length === 0 && chatAreaPaddingElement) {
chatAreaPaddingElement.style.padding = "0px";
}
}
</script>
<div class="chat-area-wrapper">
@ -169,56 +106,30 @@
<div class="top-fade"></div>
{/if}
<div class="chat-area" bind:this={chatContainer} on:scroll={updateScrolledState}>
{#each messages as message, index}
{@const content = message.getDisplayContent()}
{#if message.shouldDisplayContent && content.trim() !== ""}
{#if message.role === Role.User}
<div class="message-container {Role.User}" use:trackingAction={{ index, role: Role.User }}>
<div class="message-bubble {Role.User}">
<div class="message-text-user-container" 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}
{#each turns as turn, turnIndex (turn.id)}
{@const isLatestTurn = turnIndex === turns.length - 1}
<div class="message-group" class:latest={isLatestTurn}>
{#each turn.messages as message (message.id)}
{@const content = message.getDisplayContent()}
{#if message.shouldDisplayContent && content.trim() !== ""}
{#if message.role === Role.User}
<UserMessage {message} />
{:else}
<AssistantMessage {message} {isSubmitting} />
{/if}
{/if}
{/each}
{#if isLatestTurn}
<ThoughtIndicator thought={currentThought}/>
{#if isSubmitting}
<div transition:fade={{ duration: 300 }}>
<StreamingIndicator/>
</div>
</div>
{:else}
<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}
{/each}
<ThoughtIndicator thought={currentThought} bind:thoughtIndicatorElement={thoughtIndicatorElement}/>
{#if isSubmitting}
<div transition:fade={{ duration: 300 }}>
<StreamingIndicator bind:streamingIndicatorElement={streamingIndicatorElement}/>
</div>
{/if}
<div bind:this={chatAreaPaddingElement} style:user-select=none></div>
{/each}
{#if messages.length === 0}
<div class="conversation-empty-container">
@ -243,7 +154,7 @@
<button
id="scroll-to-bottom-button"
bind:this={scrollToBottomButton}
on:click={() => updateChatAreaLayout("smooth")}
on:click={() => scrollToBottom("smooth")}
aria-label="Scroll to bottom">
</button>
</div>
@ -307,67 +218,18 @@
display: none;
}
.message-container {
.message-group {
display: flex;
text-align: left;
margin: 0;
}
.message-container.user {
justify-content: flex-end;
}
.message-container.assistant {
justify-content: flex-start;
flex-direction: column;
flex-shrink: 0;
gap: var(--size-4-2);
}
.message-container {
animation: fadeIn 0.5s ease-out forwards;
.message-group.latest {
margin-top: var(--size-4-2);
min-height: 100%;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.message-bubble {
word-wrap: break-word;
}
.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-container {
display: grid;
height: 100%;
@ -409,84 +271,4 @@
white-space: nowrap;
padding: var(--size-2-2);
}
.streaming-content {
justify-content: left;
min-height: 1em; /* Ensure the element exists for binding */
}
/* Streaming message styles */
.content-fade-in {
animation: reveal-fade 0.5s ease-in-out forwards;
}
@keyframes reveal-fade {
0% {
opacity: 0;
transform: translateY(10px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
/* Message attachments styles */
.message-attachment-break {
color: var(--background-secondary-alt);
margin: 0 0 var(--size-4-2) 0;
opacity: 0.5;
}
.message-attachments-container {
display: flex;
overflow-x: auto;
overflow-y: hidden;
scroll-behavior: smooth;
gap: var(--size-4-2);
margin-bottom: var(--size-4-2);
}
.message-attachments-container::-webkit-scrollbar {
display: none;
}
.message-attachmanet {
display: grid;
grid-template-rows: var(--size-4-2) auto var(--size-4-2);
grid-template-columns: var(--size-4-2) auto var(--size-4-2) auto var(--size-4-2);
background-color: var(--background-secondary-alt);
border: var(--border-width) solid var(--background-modifier-border);
border-radius: var(--radius-m);
flex-shrink: 0;
}
.message-attachment-icon {
grid-row: 2;
grid-column: 2;
display: flex;
align-items: center;
justify-content: center;
}
.message-attachment-info {
grid-row: 2;
grid-column: 4;
min-width: 40px;
overflow: hidden;
}
.message-attachment-name {
display: inline-block;
white-space: nowrap;
width: 100%;
padding: 0;
font-size: var(--font-smaller);
}
.message-attachment-size {
padding: 0;
font-size: var(--font-smallest);
color: var(--text-muted);
}
</style>

View file

@ -838,10 +838,6 @@
max-height: 2rem;
}
.input-button-highlight {
box-shadow: 0px 0px 2px 1px var(--color-accent);
}
#free-edit-button {
grid-row: 9;
grid-column: 10;
@ -851,6 +847,14 @@
transition-duration: 0.5s;
}
:global(.is-mobile) #free-edit-button {
max-height: 2rem;
}
.input-button-highlight {
box-shadow: 0px 0px 2px 1px var(--color-accent);
}
#submit-button {
grid-row: 9;
grid-column: 12;
@ -858,7 +862,7 @@
padding-left: var(--size-4-2);
padding-right: var(--size-4-2);
align-self: end;
transition-duration: 0.5s;
transition: background-color 0.2s ease-out;
background-color: var(--interactive-accent);
}

View file

@ -19,6 +19,8 @@
let stepElements: (HTMLDivElement | null)[] = [];
let isTransitioning = false;
let resizeObserver: ResizeObserver | null = null;
let isScrolledToTop = true;
let isScrolledToBottom = true;
$: steps = $executionPlanState.plan?.executionSteps;
$: activeStepIndex = $executionPlanState.currentStepIndex;
@ -65,6 +67,8 @@
const stepsToShow = Math.min(3, steps.length);
collapsedHeight = (stepsToShow * stepHeight) + (Math.max(0, stepsToShow - 1) * separatorHeight);
updateScrollFade();
}
onDestroy(() => {
@ -73,6 +77,15 @@
}
});
function updateScrollFade() {
if (!wrapperDiv) {
return;
}
const { scrollTop, scrollHeight, clientHeight } = wrapperDiv;
isScrolledToTop = scrollTop < 1;
isScrolledToBottom = scrollHeight - scrollTop - clientHeight < 1;
}
async function scrollToActiveStep() {
if (!wrapperDiv || !$executionPlanState.plan || activeStepIndex === -1) {
return;
@ -124,7 +137,7 @@
aria-label={expanded ? "Collapse planned steps" : "Expand planned steps"}
role="button"
tabindex=0>
<div id="chat-plan-steps-wrapper" style:height="{expanded ? expandedHeight : collapsedHeight}px" bind:this={wrapperDiv}>
<div id="chat-plan-steps-wrapper" style:height="{expanded ? expandedHeight : collapsedHeight}px" bind:this={wrapperDiv} on:scroll={updateScrollFade}>
<div id="chat-plan-steps" bind:this={contentDiv}>
{#each steps as step, index }
<div class="chat-plan-step" bind:this={stepElements[index]}>
@ -153,8 +166,8 @@
</div>
</div>
{#if steps.length > 2}
<div class="chat-plan-fade top-fade" transition:fade></div>
<div class="chat-plan-fade bottom-fade" transition:fade></div>
<div class="chat-plan-fade top-fade" class:hidden={isScrolledToTop}></div>
<div class="chat-plan-fade bottom-fade" class:hidden={isScrolledToBottom}></div>
{/if}
<div id="chat-plan-chevron"
class="transparent-button"
@ -251,6 +264,12 @@
border-radius: var(--radius-m);
pointer-events: none;
z-index: 1;
opacity: 1;
transition: opacity 0.2s ease-out;
}
.chat-plan-fade.hidden {
opacity: 0;
}
.top-fade {

View file

@ -1,4 +1,6 @@
<script lang="ts">
import { ARTIFACT_ACTION_RANK, ArtifactAction } from "Enums/ArtifactAction";
import { Resolve } from "Services/DependencyService";
import { Services } from "Services/Services";
import ChatArea from "./ChatArea.svelte";
@ -21,6 +23,10 @@
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 executionPlanStore: ExecutionPlanStore = Resolve<ExecutionPlanStore>(Services.ExecutionPlanStore);
@ -32,6 +38,8 @@
const streamingMarkdownService: StreamingMarkdownService = Resolve<StreamingMarkdownService>(Services.StreamingMarkdownService);
const abortService: AbortService = Resolve<AbortService>(Services.AbortService);
let collectedArtifacts: Artifact[] = [];
let chatContainer: HTMLDivElement;
let chatArea: ChatArea;
let chatInput: ChatInput;
@ -98,6 +106,7 @@
if (handleNoApiKey()) {
return;
}
collectedArtifacts = [];
const currentRequest = userRequest;
@ -132,6 +141,14 @@
break;
}
},
onArtifactProduced: (artifact: Artifact) => {
const collectedArtifact = collectedArtifacts.find(a => a.filePath === artifact.filePath);
if (!collectedArtifact) {
collectedArtifacts.push(artifact);
return;
}
collectedArtifact.updatedContent = artifact.updatedContent;
},
onPlanningStarted: () => {
busyPlanning = true;
},
@ -159,6 +176,9 @@
executionPlanStore.clearPlan();
},
onComplete: async () => {
saveCollectedArtifects(conversation);
conversation = conversation;
conversationService.saveConversation(conversation);
isSubmitting = false;
busyPlanning = false;
currentThought = null;
@ -170,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) {
conversation = new Conversation();
conversationService.resetCurrentConversation();
@ -177,6 +208,8 @@
isSubmitting = false;
currentThought = null;
chatArea?.resetChatArea();
chatService.onNameChanged?.("");
conversationStore.clearResetFlag();
}
@ -196,7 +229,7 @@
conversationService.setCurrentConversationPath(filePath);
chatService.onNameChanged?.(loadedConversation.title);
conversationStore.clearLoadFlag();
chatArea.updateChatAreaLayout("instant", true);
chatArea.updateChatAreaLayout("instant");
}
});
}

View file

@ -45,52 +45,45 @@
<style>
#diff-controls-wrapper {
transition: height 0.2s ease-out;
overflow: hidden;
transition: height 0.2s ease-out;
overflow: hidden;
}
#diff-controls {
display: grid;
grid-template-columns: 1fr var(--size-4-2) 1fr;
grid-template-rows: auto;
display: grid;
grid-template-columns: 1fr var(--size-4-2) 1fr;
grid-template-rows: auto;
}
#diff-accept {
grid-column: 1;
background-color: color-mix(
in srgb,
var(--color-green) 75%,
var(--background-primary) 25%
);
grid-column: 1;
color: white;
background-color: #38533a;
}
#diff-accept:hover {
background-color: var(--color-green);
background-color: #537555;
}
#diff-accept:focus {
background-color: var(--color-green);
background-color: #537555;
}
#diff-reject {
grid-column: 3;
background-color: color-mix(
in srgb,
var(--color-red) 75%,
var(--background-primary) 25%
);
grid-column: 3;
color: white;
background-color: #593030;
}
#diff-reject:hover {
background-color: var(--color-red);
background-color: #774545;
}
#diff-reject:focus {
background-color: var(--color-red);
background-color: #774545;
}
.diff-button {
border-radius: var(--button-radius);
transition-duration: 0.5s;
transition: background-color 0.2s ease-out;
}
</style>

View file

@ -46,52 +46,45 @@
<style>
#plan-approval-controls-wrapper {
transition: height 0.2s ease-out;
overflow: hidden;
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;
display: grid;
grid-template-columns: 1fr var(--size-4-2) 1fr;
grid-template-rows: auto;
}
#plan-approve {
grid-column: 1;
background-color: color-mix(
in srgb,
var(--color-green) 75%,
var(--background-primary) 25%
);
grid-column: 1;
color: white;
background-color: #38533a;
}
#plan-approve:hover {
background-color: var(--color-green);
background-color: #537555;
}
#plan-approve:focus {
background-color: var(--color-green);
background-color: #537555;
}
#plan-reject {
grid-column: 3;
background-color: color-mix(
in srgb,
var(--color-red) 75%,
var(--background-primary) 25%
);
grid-column: 3;
color: white;
background-color: #593030;
}
#plan-reject:hover {
background-color: var(--color-red);
background-color: #774545;
}
#plan-reject:focus {
background-color: var(--color-red);
background-color: #774545;
}
.plan-approval-button {
border-radius: var(--button-radius);
transition-duration: 0.5s;
transition: background-color 0.2s ease-out;
}
</style>

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

View file

@ -3,8 +3,9 @@ import { isAudioFile, isImageFile, isKnownFileType, isTextFile, isVideoFile } fr
import { MimeTypeToFileTypes } from "Enums/FileTypeMimeTypeMapping";
import { isImageMimeType, isTextMimeType, MimeType, toMimeType } from "Enums/MimeType";
import { StringTools } from "Helpers/StringTools";
import type { IBinaryFile } from "Conversations/IBinaryFile";
export class Attachment {
export class Attachment implements IBinaryFile {
public fileName: string;
public mimeType: string;
@ -41,6 +42,14 @@ export class Attachment {
return this.base64;
}
public getStoragePath(): string | undefined {
return this.filePath;
}
public setStoragePath(path: string): void {
this.filePath = path;
}
public getFileID(provider: AIProvider): string | undefined {
return this.fileID[provider];
}

View file

@ -3,6 +3,7 @@ import { ApiErrorType } from "Types/ApiError";
import type { Attachment } from "./Attachment";
import type { Reference } from "./Reference";
import { Copy } from "Enums/Copy";
import type { Artifact } from "./Artifact";
type ConversationContentInit = {
role: Role;
@ -11,6 +12,7 @@ type ConversationContentInit = {
displayContent?: string;
toolCall?: string;
functionResponse?: string;
artifacts?: Artifact[];
attachments?: Attachment[];
references?: Reference[];
shouldDisplayContent?: boolean;
@ -20,12 +22,17 @@ type ConversationContentInit = {
};
export class ConversationContent {
// Runtime-only counter for Svelte {#each} keying
private static nextId: number = 0;
public readonly id: number;
public role: Role;
public timestamp: Date;
public content: string | undefined;
public displayContent: string | undefined;
public toolCall: string | undefined;
public functionResponse: string | undefined;
public artifacts: Artifact[];
public attachments: Attachment[];
public references: Reference[];
public shouldDisplayContent: boolean;
@ -43,6 +50,7 @@ export class ConversationContent {
* @param init.displayContent - Display content is used when content needs to be formatted differently when displayed versus as a prompt
* @param init.toolCall - JSON string of the function call data (only set for function/tool calls)
* @param init.functionResponse - JSON string of the function call response data (only set for function/tool responses)
* @param init.artifacts - Array of artefacts that track edits to files made by the agent during the turn
* @param init.attachments - Array of file attachments associated with this message (defaults to empty array)
* @param init.references - Array of file references, used to display attachment's to the user associated with attachments
* @param init.shouldDisplayContent - Whether this content should be displayed in the UI (defaults to true, false for system-generated messages)
@ -51,12 +59,14 @@ export class ConversationContent {
* @param init.errorType - Indicates that this contains an error of the given type
*/
constructor(init: ConversationContentInit) {
this.id = ConversationContent.nextId++;
this.role = init.role;
this.timestamp = init.timestamp ?? new Date();
this.content = init.content;
this.displayContent = init.displayContent;
this.toolCall = init.toolCall;
this.functionResponse = init.functionResponse;
this.artifacts = init.artifacts ?? [];
this.attachments = init.attachments ?? [];
this.references = init.references ?? [];
this.shouldDisplayContent = init.shouldDisplayContent ?? true;
@ -79,6 +89,7 @@ export class ConversationContent {
displayContent?: string;
toolCall?: string;
functionResponse?: string;
artifacts?: unknown[];
attachments?: unknown[];
references?: unknown[];
shouldDisplayContent?: boolean;
@ -98,6 +109,7 @@ export class ConversationContent {
(!("displayContent" in data) || typeof data.displayContent === "string") &&
(!("toolCall" in data) || typeof data.toolCall === "string") &&
(!("functionResponse" in data) || typeof data.functionResponse === "string") &&
(!("artifacts" in data) || Array.isArray(data.artifacts)) &&
(!("attachments" in data) || Array.isArray(data.attachments)) &&
(!("references" in data) || Array.isArray(data.references)) &&
(!("shouldDisplayContent" in data) || typeof data.shouldDisplayContent === "boolean") &&

View file

@ -0,0 +1,5 @@
export interface IBinaryFile {
base64: string | undefined;
getStoragePath(): string | undefined;
setStoragePath(path: string): void;
}

View file

@ -78,10 +78,9 @@ export enum AIProviderModel {
GeminiPro_3_1_Preview = "gemini-3.1-pro-preview",
// OpenAI models
GPT_5_5_Pro = "gpt-5.5-pro-2026-04-23",
GPT_5_5 = "gpt-5.5-2026-04-23",
GPT_5_4_Mini = "gpt-5.4-mini-2026-03-17",
GPT_5_4_Nano = "gpt-5.4-nano-2026-03-17",
GPT_5_6_Sol = "gpt-5.6-sol",
GPT_5_6_Terra = "gpt-5.6-terra",
GPT_5_6_Luna = "gpt-5.6-luna",
// Mistral models
MistralMedium = "mistral-medium-3-5",
@ -90,7 +89,7 @@ export enum AIProviderModel {
// Conversation naming models (aliases to existing models)
ClaudeNamer = ClaudeHaiku_4_5,
GeminiNamer = GeminiFlash_3_1_Lite,
OpenAINamer = GPT_5_4_Nano,
OpenAINamer = GPT_5_6_Luna,
MistralNamer = MistralSmall,
// Local models are freely typed so no default is given
@ -120,7 +119,7 @@ export enum MistralAgentEndpoint {
export const DEFAULT_QUICK_MODEL_BY_PROVIDER: Record<AIProvider, AIProviderModel> = {
[AIProvider.Claude]: AIProviderModel.ClaudeHaiku_4_5,
[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.Local]: AIProviderModel.None
}
@ -128,7 +127,7 @@ export const DEFAULT_QUICK_MODEL_BY_PROVIDER: Record<AIProvider, AIProviderModel
export const DEFAULT_MODEL_BY_PROVIDER: Record<AIProvider, AIProviderModel> = {
[AIProvider.Claude]: AIProviderModel.ClaudeSonnet_5,
[AIProvider.Gemini]: AIProviderModel.GeminiFlash_3_5_Flash,
[AIProvider.OpenAI]: AIProviderModel.GPT_5_5,
[AIProvider.OpenAI]: AIProviderModel.GPT_5_6_Terra,
[AIProvider.Mistral]: AIProviderModel.MistralMedium,
[AIProvider.Local]: AIProviderModel.None
}
@ -136,7 +135,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Record<AIProvider, AIProviderModel> = {
export const DEFAULT_PLANNING_MODEL_BY_PROVIDER: Record<AIProvider, AIProviderModel> = {
[AIProvider.Claude]: AIProviderModel.ClaudeOpus_4_8,
[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.Local]: AIProviderModel.None
}

28
Enums/ArtifactAction.ts Normal file
View 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;
}
}

View file

@ -16,10 +16,9 @@ export enum Copy {
GeminiFlash_3_5_Flash = "Gemini 3.5 Flash",
GeminiPro_3_1_Preview = "Gemini 3.1 Pro Preview",
GPT_5_5_Pro = "GPT-5.5 Pro",
GPT_5_5 = "GPT-5.5",
GPT_5_4_Mini = "GPT-5.4 Mini",
GPT_5_4_Nano = "GPT-5.4 Nano",
GPT_5_6_Sol = "GPT-5.6 Sol",
GPT_5_6_Terra = "GPT-5.6 Terra",
GPT_5_6_Luna = "GPT-5.6 Luna",
MistralMedium = "Mistral Medium 3.5",
MistralSmall = "Mistral Small 4",
@ -144,13 +143,14 @@ export enum Copy {
ButtonWebSearchUnavailable = "Web search unavailable",
ButtonUserInstruction = "User Instruction",
ButtonAttachFiles = "Attach Files",
// Plan Approval View
PlanApprovalViewTitle = "Vaultkeeper AI plan",
ButtonApprove = "Approve",
ButtonReject = "Reject",
ButtonSuggest = "Suggest",
ButtonDiscuss = "Discuss",
ButtonRestore = "Restore this version",
ButtonRestorePrevious = "Restore previous version",
ButtonConfirm = "Confirm?",
// 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.`,
@ -470,6 +470,11 @@ Each AI provider has their own data policies:
// Conversation Modal Copy
NoConversationsFound = "No conversations match your search.",
// Artifact Copy
ArtifactActionCreated = "CREATED",
ArtifactActionModified = "MODIFIED",
ArtifactActionDeleted = "DELETED",
// Help Modal Additional Copy
HelpModalCloseAriaLabel = "Close Help Modal",
PluginVersionPrefix = "Plugin version: ",

View file

@ -151,8 +151,9 @@ export enum FileType {
}
export function toFileType(fileType: string): FileType {
if (isKnownFileType(fileType)) {
return fileType;
const normalized = fileType.startsWith('.') ? fileType.slice(1) : fileType;
if (isKnownFileType(normalized)) {
return normalized;
}
return FileType.UNKNOWN;
}

View file

@ -3,6 +3,7 @@ export enum Path {
VaultkeeperAIDir = "Vaultkeeper AI",
Conversations = `${Path.VaultkeeperAIDir}/Conversations`,
Attachments = `${Path.Conversations}/Attachments`,
Artifacts = `${Path.Conversations}/Artifacts`,
UserInstructions = `${Path.VaultkeeperAIDir}/User Instructions`,
ExampleUserInstructions = `${Path.UserInstructions}/EXAMPLE_INSTRUCTIONS.md`,
Memories = `${Path.VaultkeeperAIDir}/Memories.md`

View file

@ -9,7 +9,7 @@
<img width="1280" height="640" alt="vaultkeeper-social-1280x640" src="https://github.com/user-attachments/assets/47a5ba6c-e59a-4f95-895a-8abc988369dd" />
</p>
> **New!** Local model support - Point Vaultkeeper AI at a self-hosted server (LM Studio, Ollama, vLLM, etc.) and chat with your own models, no API key or cloud provider required. See [Local Models](#local-models) below.
> **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
@ -19,6 +19,7 @@
- ✏️ **Edit Mode**: AI can create, edit, delete, and move notes and folders (when you need it)
- 📋 **Planning Mode**: A three-agent workflow where a planning agent analyzes your vault and creates a step-by-step strategy before execution
- **Interactive Diff Viewer** - Review and approve AI-proposed changes before they're applied with side-by-side diff view, or let the AI make changes without asking"
- **Restore File Changes** - Revert any note the AI created, edited, or deleted (including binary files) back to its original or previous version
- **Plan Approval Workflow** - In Planning Mode, review the AI's proposed plan and approve it, reject it, or suggest changes before execution begins
- **Smart Reference System** - Mention tags (`#`), files (`@`), and folders (`/`) with autocomplete
- **Custom System Instructions** - Create and switch between personalized AI behaviors

View file

@ -6,7 +6,7 @@ import { AIToolResponse } from "AIClasses/ToolDefinitions/AIToolResponse";
import { AIToolCall } from "AIClasses/AIToolCall";
import type { ISearchMatch } from "../../Types/SearchTypes";
import { AbortService } from "../AbortService";
import { normalizePath, TAbstractFile, TFile } from "obsidian";
import { arrayBufferToBase64, normalizePath, TAbstractFile, TFile } from "obsidian";
import { Exception } from "Helpers/Exception";
import { Copy } from "Enums/Copy";
import { pathExtname, replaceCopy } from "Helpers/Helpers";
@ -16,7 +16,7 @@ import type { WebViewerService } from "Services/WebViewerService";
import { AIToolResponsePayload } from "AIClasses/ToolDefinitions/AIToolResponsePayload";
import { Attachment } from "Conversations/Attachment";
import { isDocumentMimeType, MimeType } from "Enums/MimeType";
import { isTextFile, toFileType } from "Enums/FileType";
import { isBinaryFile, isTextFile, toFileType } from "Enums/FileType";
import { FileTypeToMimeType } from "Enums/FileTypeMimeTypeMapping";
import { StringTools } from "Helpers/StringTools";
import { AIToolDefinitions } from "AIClasses/ToolDefinitions/AIToolDefinitions";
@ -36,6 +36,9 @@ import {
DeleteVaultFolderArgsSchema,
MoveVaultFolderArgsSchema
} from "AIClasses/Schemas/AIToolSchemas";
import { Artifact } from "Conversations/Artifact";
import { extname } from "path-browserify";
import { ArtifactAction } from "Enums/ArtifactAction";
export class AIToolService {
@ -337,23 +340,19 @@ export class AIToolService {
count: binaryResults.length
};
return new AIToolResponsePayload(response, attachments);
return new AIToolResponsePayload(response, [], attachments);
}
private async writeVaultFile(filePath: string, content: string): Promise<AIToolResponsePayload> {
const result = await this.fileSystemService.writeToFilePath(normalizePath(filePath), content);
if (result instanceof Error) {
return new AIToolResponsePayload({ success: false, error: result.message });
}
return new AIToolResponsePayload({ success: true });
return await this.asTrackedAction(filePath, async () => {
return await this.fileSystemService.writeToFilePath(normalizePath(filePath), content);
});
}
private async patchVaultFile(filePath: string, oldContent: string[], newContent: string[]): Promise<AIToolResponsePayload> {
const result = await this.fileSystemService.patchFileAtPath(normalizePath(filePath), oldContent, newContent);
if (result instanceof Error) {
return new AIToolResponsePayload({ success: false, error: result.message });
}
return new AIToolResponsePayload({ success: true });
return await this.asTrackedAction(filePath, async () => {
return await this.fileSystemService.patchFileAtPath(normalizePath(filePath), oldContent, newContent);
});
}
private async deleteVaultFiles(filePaths: string[], confirmation: boolean): Promise<AIToolResponsePayload> {
@ -361,15 +360,19 @@ export class AIToolService {
return new AIToolResponsePayload({ error: "Confirmation was false, no action taken" });
}
const results = await Promise.all(filePaths.map(async filePath => {
const result = await this.fileSystemService.deleteFile(filePath);
if (result instanceof Error) {
return { path: filePath, success: false, error: result.message };
}
return { path: filePath, success: true };
}));
const results: object[] = [];
const artifacts = await this.collectDeletionCandidatesArtifacts(filePaths);
return new AIToolResponsePayload({ results });
for (const filePath of filePaths) {
const deleteResult = await this.fileSystemService.deleteFile(filePath);
if (deleteResult instanceof Error) {
results.push({ path: filePath, success: false, error: deleteResult.message });
continue;
}
results.push({ path: filePath, success: true });
}
return new AIToolResponsePayload({ results }, artifacts);
}
private async moveVaultFiles(sourcePaths: string[], destinationPaths: string[]): Promise<AIToolResponsePayload> {
@ -401,10 +404,16 @@ export class AIToolService {
if (!confirmation) {
return new AIToolResponsePayload({ error: "Confirmation was false, no action taken" });
}
const contents = await this.fileSystemService.listDirectoryContents(path, true);
const filePaths = contents.filter(content => content instanceof TFile).map(file => file.path);
const artifacts = await this.collectDeletionCandidatesArtifacts(filePaths);
const result = await this.fileSystemService.deleteFolder(path);
return result instanceof Error
? new AIToolResponsePayload({ path: path, success: false, error: result.message })
: new AIToolResponsePayload({ path: path, success: true });
? new AIToolResponsePayload({ path: path, success: false, error: result.message }, artifacts)
: new AIToolResponsePayload({ path: path, success: true }, artifacts);
}
private async moveVaultFolder(sourcePath: string, destinationPath: string): Promise<AIToolResponsePayload> {
@ -437,7 +446,7 @@ export class AIToolService {
return format === "text"
? new AIToolResponsePayload({ content: result })
: new AIToolResponsePayload({ success: true }, [new Attachment("screenshot.png", MimeType.IMAGE_PNG, result)]);
: new AIToolResponsePayload({ success: true }, [], [new Attachment("screenshot.png", MimeType.IMAGE_PNG, result)]);
}
private async readMemories(): Promise<AIToolResponsePayload> {
@ -460,4 +469,56 @@ export class AIToolService {
return new AIToolResponsePayload({ result: await this.memoriesService.updateMemories(content) });
}
/** Helpers **/
private async collectDeletionCandidatesArtifacts(filePaths: string[]): Promise<Artifact[]> {
const artifacts: Artifact[] = [];
for (const filePath of filePaths) {
const fileType = toFileType(extname(filePath));
// Anything that isn't a note we will just store as a binary artifact
if (isBinaryFile(fileType) || isDocumentMimeType(FileTypeToMimeType[fileType])) {
const result = await this.fileSystemService.readBinaryFile(filePath);
if (result instanceof ArrayBuffer) {
artifacts.push(new Artifact(filePath, FileTypeToMimeType[fileType], ArtifactAction.Delete, filePath, "", arrayBufferToBase64(result)));
}
} else {
const result = await this.fileSystemService.readFilePath(filePath);
if (typeof result === "string") {
artifacts.push(new Artifact(filePath, FileTypeToMimeType[fileType], ArtifactAction.Delete, result, ""));
}
}
}
return artifacts;
}
private async asTrackedAction(filePath: string, action: () => Promise<TFile|Error|void>): Promise<AIToolResponsePayload> {
let artifactAction: ArtifactAction | undefined;
let preActionResult = await this.fileSystemService.readFilePath(filePath);
if (preActionResult instanceof Error) {
preActionResult = ""; // The file does not exist yet
artifactAction = ArtifactAction.Create;
}
const actionResult = await action();
if (actionResult instanceof Error) {
return new AIToolResponsePayload({ success: false, error: actionResult.message });
}
let postActionResult = actionResult ? await this.fileSystemService.readFile(actionResult) : "";
if (postActionResult instanceof Error) {
postActionResult = ""; // The file has been deleted
artifactAction = ArtifactAction.Delete;
}
if (artifactAction === undefined) {
artifactAction = ArtifactAction.Modify;
}
const fileType = toFileType(extname(filePath));
return new AIToolResponsePayload({ success: true },
[new Artifact(filePath, FileTypeToMimeType[fileType], artifactAction, preActionResult, postActionResult)]);
}
}

View file

@ -208,12 +208,18 @@ export abstract class BaseAgent {
return { toolCall: capturedToolCall, shouldContinue: capturedShouldContinue };
}
protected async performAITool(toolCall: AIToolCall): Promise<AIToolResponse> {
protected async performAITool(toolCall: AIToolCall, callbacks: IChatServiceCallbacks): Promise<AIToolResponse> {
const providerResult = await this.ai?.resolveToolCall?.(toolCall) ?? null;
if (providerResult !== null) {
return providerResult;
}
return this.aiToolService.performAITool(toolCall);
const result = await this.aiToolService.performAITool(toolCall);
for (const artifact of result.payload.artifacts) {
callbacks.onArtifactProduced(artifact);
}
return result;
}
private async withToolCallingDisabled<T>(callback: () => Promise<T>): Promise<T> {

View file

@ -73,7 +73,7 @@ export class ExecutionAgent extends BaseAgent {
this.debugService?.log("ExecutionAgent", `Executing function: ${toolCall.name}`);
this.updateThought(toolCall, callbacks);
const functionResponse = await this.performAITool(toolCall);
const functionResponse = await this.performAITool(toolCall, callbacks);
this.conversation.addFunctionResponse(functionResponse);
return { shouldExit: false };
});

View file

@ -79,7 +79,7 @@ export class MainAgent extends BaseAgent {
this.debugService?.log("MainAgent", `Executing function: ${toolCall.name}`);
this.updateThought(toolCall, callbacks);
const functionResponse = await this.performAITool(toolCall);
const functionResponse = await this.performAITool(toolCall, callbacks);
conversation.addFunctionResponse(functionResponse);
return { shouldExit: false };
});

View file

@ -220,7 +220,7 @@ export class OrchestrationAgent extends BaseAgent {
isAITool(toolCallName, AITool.ListVaultFiles)) {
this.debugService?.log("Orchestration", `Vault tool called for recovery: ${toolCallName}`);
this.updateThought(toolCall, callbacks);
const toolResponse = await this.performAITool(toolCall);
const toolResponse = await this.performAITool(toolCall, callbacks);
planningConversation.addFunctionResponse(toolResponse);
return Promise.resolve({ shouldExit: false });
}

View file

@ -90,7 +90,7 @@ export class PlanningAgent extends BaseAgent {
}
this.updateThought(toolCall, callbacks);
const functionResponse = await this.performAITool(toolCall);
const functionResponse = await this.performAITool(toolCall, callbacks);
conversation.addFunctionResponse(functionResponse);
return { shouldExit: false };
});

View file

@ -44,6 +44,7 @@ export class QuickAgent extends BaseAgent {
onStreamingUpdate: () => {},
onThoughtUpdate: () => {},
onToolCallStarted: () => {},
onArtifactProduced: () => {},
onPlanningStarted: () => {},
onPlanningFinished: () => {},
onUserQuestion: async () => new Promise<string>(() => {}),

View file

@ -18,12 +18,14 @@ import type { ExecutionPlan } from "Types/ExecutionPlan";
import type { MainAgent } from "./AIServices/MainAgent";
import type { ChatMode } from "Enums/ChatMode";
import type { PlanApprovalResponse } from "Types/PlanApprovalResponse";
import type { Artifact } from "Conversations/Artifact";
export interface IChatServiceCallbacks {
onSubmit: () => void;
onStreamingUpdate: () => void;
onThoughtUpdate: (thought: string | null) => void;
onToolCallStarted: (toolName: string) => void;
onArtifactProduced: (artifact: Artifact) => void;
onPlanningStarted: () => void;
onPlanningFinished: () => void;
onUserQuestion: (question: string) => Promise<string>;
@ -125,13 +127,12 @@ export class ChatService {
}
} finally {
this.eventService.trigger(Event.DiffClosed);
await this.saveConversation(conversation);
callbacks.onThoughtUpdate(null);
callbacks.onComplete();
if (this.semaphoreHeld) {
this.semaphoreHeld = false;
this.semaphore.release();
}
callbacks.onThoughtUpdate(null);
callbacks.onComplete();
}
}
@ -146,9 +147,6 @@ export class ChatService {
}
private async saveConversation(conversation: Conversation) {
const result = await this.conversationService.saveConversation(conversation);
if (result instanceof Error) {
new Notice(`Failed to save conversation data for '${conversation.title}'`);
}
await this.conversationService.saveConversation(conversation);
}
}

View file

@ -8,7 +8,9 @@ import { Attachment } from "Conversations/Attachment";
import { Exception } from "Helpers/Exception";
import type { IAIFileService } from "AIClasses/IAIFileService";
import { Reference } from "Conversations/Reference";
import { arrayBufferToBase64 } from "obsidian";
import { Artifact } from "Conversations/Artifact";
import type { IBinaryFile } from "Conversations/IBinaryFile";
import { arrayBufferToBase64, Notice } from "obsidian";
import { StringTools } from "Helpers/StringTools";
export class ConversationFileSystemService {
@ -32,9 +34,9 @@ export class ConversationFileSystemService {
return `${Path.Conversations}/${conversation.title}.json`;
}
public async saveConversation(conversation: Conversation): Promise<string | Error> {
public async saveConversation(conversation: Conversation): Promise<void> {
if (this.isDeleted) {
return ""; // Return empty string to indicate silent skip (not an error)
return;
}
if (!this.currentConversationPath) {
@ -43,21 +45,19 @@ export class ConversationFileSystemService {
// can happen if the conversation is deleted during an active request
const fileExists = await this.fileSystemService.exists(this.currentConversationPath, true);
if (!fileExists) {
return this.currentConversationPath;
return;
}
}
conversation.updated = new Date();
// Save attachment files and update filePaths
// Save binary files (attachments and artifacts) and update their storage paths
for (const content of conversation.contents) {
for (const attachment of content.attachments) {
if (!attachment.filePath && attachment.base64) {
const filePath = await this.saveAttachmentFile(attachment);
if (!(filePath instanceof Error)) {
attachment.filePath = filePath.replace(`${Path.Conversations}/`, '');
}
}
await this.saveBinaryFile(attachment, Path.Attachments);
}
for (const artifact of content.artifacts) {
await this.saveBinaryFile(artifact, Path.Artifacts);
}
}
@ -73,6 +73,14 @@ export class ConversationFileSystemService {
displayContent: content.displayContent,
toolCall: content.toolCall,
functionResponse: content.functionResponse,
artifacts: content.artifacts.map(artifact => ({
filePath: artifact.filePath,
mimeType: artifact.mimeType,
action: artifact.action,
originalContent: artifact.originalContent,
updatedContent: artifact.updatedContent,
artifactPath: artifact.artifactPath
})),
attachments: content.attachments.map(att => ({
fileName: att.fileName,
mimeType: att.mimeType,
@ -90,10 +98,8 @@ export class ConversationFileSystemService {
const result = await this.fileSystemService.writeObjectToFile(this.currentConversationPath, conversationData, true, false);
if (result instanceof Error) {
return result;
new Notice(`Failed to save conversation data for '${conversation.title}'`);
}
return this.currentConversationPath;
}
public resetCurrentConversation() {
@ -137,6 +143,7 @@ export class ConversationFileSystemService {
// Queue garbage collection after AI file deletion
this.deletionQueue = this.deletionQueue.then(async () => {
await this.garbageCollectAttachments();
await this.garbageCollectArtifacts();
});
}
@ -205,6 +212,57 @@ export class ConversationFileSystemService {
}
}
public async garbageCollectArtifacts(): Promise<void | Error> {
try {
// 1. Get all artifact files
const artifactFiles = await this.fileSystemService.listFilesInDirectory(
Path.Artifacts,
false,
true
);
if (artifactFiles.length === 0) {
return;
}
// 2. Build reference count map
const referenceCount = new Map<string, number>();
const conversations = await this.getAllConversations();
for (const conversation of conversations) {
for (const content of conversation.contents) {
for (const artifact of content.artifacts) {
if (artifact.artifactPath) {
const count = referenceCount.get(artifact.artifactPath) || 0;
referenceCount.set(artifact.artifactPath, count + 1);
}
}
}
}
// 3. Delete unreferenced files
for (const file of artifactFiles) {
const relativePath = file.path.replace(`${Path.Conversations}/`, '');
const refCount = referenceCount.get(relativePath) || 0;
if (refCount === 0) {
const deleteResult = await this.fileSystemService.deleteFile(
file.path,
true,
false
);
if (deleteResult instanceof Error) {
Exception.log(deleteResult);
}
}
}
} catch (error) {
Exception.log(error);
return Exception.new(error);
}
}
public async updateConversationTitle(oldPath: string, newTitle: string): Promise<void | Error> {
const newPath = `${Path.Conversations}/${newTitle}.json`;
@ -219,30 +277,31 @@ export class ConversationFileSystemService {
}
}
private async saveAttachmentFile(attachment: Attachment): Promise<string | Error> {
const hash = await StringTools.computeSHA256Hash(attachment.base64);
private async saveBinaryFile(file: IBinaryFile, storageFolder: Path): Promise<void> {
if (file.getStoragePath() || !file.base64) {
return;
}
const hash = await StringTools.computeSHA256Hash(file.base64);
const fileName = `${hash}.bin`;
const filePath = `${Path.Attachments}/${fileName}`;
const filePath = `${storageFolder}/${fileName}`;
const exists = await this.fileSystemService.exists(filePath, true);
if (exists) {
return filePath;
if (!exists) {
const arrayBuffer = StringTools.toBuffer(file.base64);
const result = await this.fileSystemService.writeBinaryFile(filePath, arrayBuffer, true);
if (result instanceof Error) {
Exception.log(result);
return;
}
}
const arrayBuffer = StringTools.toBuffer(attachment.base64);
const result = await this.fileSystemService.writeBinaryFile(filePath, arrayBuffer, true);
if (result instanceof Error) {
Exception.log(result);
return filePath;
}
return filePath;
file.setStoragePath(filePath.replace(`${Path.Conversations}/`, ""));
}
private async loadAttachmentFile(filePath: string): Promise<string> {
const fullPath = `${Path.Conversations}/${filePath}`;
private async loadBinaryFile(storagePath: string): Promise<string> {
const fullPath = `${Path.Conversations}/${storagePath}`;
const arrayBuffer = await this.fileSystemService.readBinaryFile(fullPath, true);
if (arrayBuffer instanceof Error) {
@ -271,6 +330,7 @@ export class ConversationFileSystemService {
const contentPromises = result.contents.map(async content => {
const attachments = await this.deserializeAttachments(content.attachments);
const references = this.deserializeReferences(content.references);
const artifacts = await this.deserializeArtifacts(content.artifacts);
return new ConversationContent({
role: content.role,
@ -279,6 +339,7 @@ export class ConversationFileSystemService {
displayContent: content.displayContent,
toolCall: content.toolCall,
functionResponse: content.functionResponse,
artifacts: artifacts,
attachments: attachments,
references: references,
shouldDisplayContent: content.shouldDisplayContent,
@ -306,7 +367,7 @@ export class ConversationFileSystemService {
continue;
}
const base64 = await this.loadAttachmentFile(attachmentData.filePath);
const base64 = await this.loadBinaryFile(attachmentData.filePath);
if (!base64) {
Exception.warn(`Skipping attachment with missing file: ${attachmentData.fileName} (${attachmentData.filePath})`);
@ -327,6 +388,36 @@ export class ConversationFileSystemService {
return attachments;
}
private async deserializeArtifacts(artifactsData: unknown): Promise<Artifact[]> {
if (!Array.isArray(artifactsData)) {
return [];
}
const artifacts: Artifact[] = [];
for (const artifactData of artifactsData) {
if (!Artifact.isArtifactData(artifactData)) {
continue;
}
const base64 = artifactData.artifactPath
? await this.loadBinaryFile(artifactData.artifactPath)
: undefined;
artifacts.push(new Artifact(
artifactData.filePath,
artifactData.mimeType,
artifactData.action,
artifactData.originalContent,
artifactData.updatedContent,
base64,
artifactData.artifactPath
));
}
return artifacts;
}
private deserializeReferences(referencesData: unknown): Reference[] {
if (!Array.isArray(referencesData)) {
return [];

View file

@ -55,12 +55,8 @@ export class ConversationNamingService {
}
conversation.title = validatedName;
const saveResult = await this.conversationService.saveConversation(conversation);
await this.conversationService.saveConversation(conversation);
if (saveResult instanceof Error) {
Exception.throw(saveResult);
}
onNameChanged?.(conversation.title);
} catch (error) {
if (!AbortService.isAbortError(error)) {

View file

@ -8,6 +8,7 @@ import type { Diff2HtmlUIConfig } from 'diff2html/lib/ui/js/diff2html-ui-base';
import { ColorSchemeType, OutputFormatType } from 'diff2html/lib/types';
import { Component } from 'obsidian';
import { AbortService } from './AbortService';
import type { Artifact } from 'Conversations/Artifact';
interface DiffResult {
accepted: boolean;
@ -35,6 +36,26 @@ export class DiffService extends Component {
}));
}
public showArtifactDiff(artifact: Artifact): void {
const diffString = this.createDiffString(artifact.filePath,
artifact.filePath, artifact.originalContent, artifact.updatedContent);
const outputFormat: OutputFormatType = "line-by-line";
const config: Diff2HtmlUIConfig = {
drawFileList: false,
matching: "words",
outputFormat: outputFormat,
highlight: false,
fileListToggle: false,
fileContentToggle: false,
synchronisedScroll: true,
colorScheme: ColorSchemeType.AUTO
};
void this.plugin.activateArtifactView(artifact, diffString, config);
}
public async requestDiff(oldFileName: string, newFileName: string, oldContent: string, newContent: string): Promise<DiffResult> {
const diffString = this.createDiffString(oldFileName, newFileName, oldContent, newContent);

View file

@ -48,7 +48,7 @@ export class FileSystemService {
if (file instanceof TFile) {
const arrayBuffer = await this.vaultService.readBinaryData(file, allowAccessToPluginRoot);
if (!arrayBuffer) {
return Exception.new(`Failed to read binary dta for: ${filePath}`);
return Exception.new(`Failed to read binary data for: ${filePath}`);
}
return arrayBuffer;
}

View file

@ -150,8 +150,11 @@ export class SettingsService {
private settingsSnapshot: string;
public constructor(loadedSettings: Partial<IVaultkeeperAISettings>) {
public constructor(loadedSettings: Partial<IVaultkeeperAISettings> | null) {
this.plugin = Resolve<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin);
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)

View file

@ -16,6 +16,8 @@ export class StreamingMarkdownService {
private readonly states = new WeakMap<HTMLElement, RenderState>();
public async render(markdown: string, container: HTMLElement, isFinal: boolean = false): Promise<void> {
markdown = this.neutraliseFrontmatter(markdown);
if (isFinal) {
const existing = this.states.get(container);
if (existing) {
@ -52,6 +54,14 @@ 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 {
if (this.states.has(container)) {
return this.states.get(container)!;

View file

@ -42,12 +42,15 @@ export class VaultCacheService {
this.metaDataCache = this.plugin.app.metadataCache;
this.registerFileEvents();
this.plugin.app.metadataCache.on("resolved", async () => {
const tryInitialise = async () => {
if (!this.initialised) {
await this.setupCaches();
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 }> {

View file

@ -116,13 +116,15 @@ export class VaultService {
public async create(filePath: string, content: string, allowAccessToPluginRoot: boolean = false, requiresConfirmation: boolean = true): Promise<TFile | Error> {
filePath = this.sanitiserService.sanitize(filePath);
const fileExtension = pathExtname(filePath);
if (this.isExclusion(filePath, allowAccessToPluginRoot)) {
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}`);
}
if (isFileType(pathExtname(filePath), FileType.PDF)) {
return Exception.new("Creating PDF files is not supported");
if (isBinaryFile(pathExtname(fileExtension)) || isDocumentFile(pathExtname(fileExtension))) {
return Exception.new(`Creating ${pathExtname(filePath)} files is not supported`);
}
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> {
const filePath = this.sanitiserService.sanitize(file.path);
const fileExtension = pathExtname(filePath);
if (this.isExclusion(file.path, allowAccessToPluginRoot)) {
Exception.log(`Plugin attempted to modify a file that is in the exclusion list: ${filePath}`);
return Exception.new(`File does not exist: ${filePath}`);
}
if (isFileType(file.extension.toLocaleLowerCase(), FileType.PDF)) {
return Exception.new("Modifying PDF files is not supported");
if (isBinaryFile(pathExtname(filePath)) || isDocumentFile(pathExtname(fileExtension))) {
return Exception.new(`Modifying ${pathExtname(filePath)} files is not supported`);
}
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> {
const filePath = this.sanitiserService.sanitize(file.path);
const fileExtension = pathExtname(filePath);
if (this.isExclusion(file.path, allowAccessToPluginRoot)) {
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}`);
}
if (isFileType(file.extension.toLocaleLowerCase(), FileType.PDF)) {
return Exception.new("Modifying PDF files is not supported");
if (isBinaryFile(pathExtname(filePath)) || isDocumentFile(pathExtname(fileExtension))) {
return Exception.new(`Modifying ${pathExtname(filePath)} files is not supported`);
}
try {
@ -183,8 +189,8 @@ export class VaultService {
return Exception.new(`File does not exist: ${filePath}`);
}
if (isFileType(pathExtname(filePath), FileType.PDF)) {
return Exception.new("Patching PDF files is not supported");
if (isBinaryFile(pathExtname(filePath))) {
return Exception.new(`Patching ${pathExtname(filePath)} files is not supported`);
}
if (oldContent.length !== newContent.length) {
@ -236,10 +242,6 @@ export class VaultService {
return currentContent;
}
if (isFileType(pathExtname(filePath), FileType.PDF)) {
await this.fileManager.trashFile(file);
}
return this.proposeChange(file.name, file.name, currentContent, "", requiresConfirmation, async () => {
await this.fileManager.trashFile(file);
});

View file

@ -93,6 +93,16 @@ body:not(.is-tablet) .workspace-drawer.mod-right {
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 */
/* ============================== */

View file

@ -131,43 +131,35 @@
.diff-mobile-controls .diff-mobile-button {
min-height: 44px;
font-size: var(--font-ui-medium);
font-weight: var(--font-semibold);
border-radius: var(--button-radius);
cursor: pointer;
border: none;
color: var(--text-on-accent);
transition: background-color 0.2s ease-out;
}
.diff-mobile-controls .diff-mobile-accept {
background-color: color-mix(
in srgb,
var(--color-green) 75%,
var(--background-primary) 25%
);
color: white;
background-color: #38533a;
}
.diff-mobile-controls .diff-mobile-accept:hover,
.diff-mobile-controls .diff-mobile-accept:focus,
.diff-mobile-controls .diff-mobile-accept:active {
background-color: var(--color-green);
background-color: #537555;
}
.diff-mobile-controls .diff-mobile-suggest {
background-color: var(--interactive-accent);
}
.diff-mobile-controls .diff-mobile-suggest:active {
background-color: var(--interactive-accent-hover);
.diff-mobile-controls .diff-mobile-discuss {
color: white;
}
.diff-mobile-controls .diff-mobile-reject {
background-color: color-mix(
in srgb,
var(--color-red) 75%,
var(--background-primary) 25%
);
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: var(--color-red);
background-color: #774545;
}
/* Adjust diff height on mobile to account for buttons */
@ -180,4 +172,74 @@
.diff-view-mobile .d2h-file-diff {
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);
}
}

View file

@ -40,43 +40,35 @@
.plan-approval-mobile-controls .plan-approval-mobile-button {
min-height: 44px;
font-size: var(--font-ui-medium);
font-weight: var(--font-semibold);
border-radius: var(--button-radius);
cursor: pointer;
border: none;
color: var(--text-on-accent);
transition: background-color 0.2s ease-out;
}
.plan-approval-mobile-controls .plan-approval-mobile-approve {
background-color: color-mix(
in srgb,
var(--color-green) 75%,
var(--background-primary) 25%
);
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: var(--color-green);
background-color: #537555;
}
.plan-approval-mobile-controls .plan-approval-mobile-suggest {
background-color: var(--interactive-accent);
}
.plan-approval-mobile-controls .plan-approval-mobile-suggest:active {
background-color: var(--interactive-accent-hover);
.plan-approval-mobile-controls .plan-approval-mobile-discuss {
color: white;
}
.plan-approval-mobile-controls .plan-approval-mobile-reject {
background-color: color-mix(
in srgb,
var(--color-red) 75%,
var(--background-primary) 25%
);
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: var(--color-red);
background-color: #774545;
}
/* Adjust plan height on mobile to account for buttons */

209
Views/ArtifactView.ts Normal file
View 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();
}
}

View file

@ -49,7 +49,7 @@ export class DiffView extends ItemView {
}
public getDisplayText(): string {
return "Vaultkeeper AI diff";
return "Vaultkeeper AI diff viewer";
}
public async setState(state: DiffViewState, result: ViewStateResult): Promise<void> {
@ -116,11 +116,11 @@ export class DiffView extends ItemView {
});
acceptButton.setAttribute('aria-label', Copy.ButtonApprove);
const suggestButton = container.createEl('button', {
cls: 'diff-mobile-button diff-mobile-suggest',
text: Copy.ButtonSuggest
const discussButton = container.createEl('button', {
cls: 'diff-mobile-button diff-mobile-discuss',
text: Copy.ButtonDiscuss
});
suggestButton.setAttribute('aria-label', Copy.ButtonSuggest);
discussButton.setAttribute('aria-label', Copy.ButtonDiscuss);
const rejectButton = container.createEl('button', {
cls: 'diff-mobile-button diff-mobile-reject',
@ -133,7 +133,7 @@ export class DiffView extends ItemView {
await this.refocusMainView();
});
this.registerDomEvent(suggestButton, 'click', async () => {
this.registerDomEvent(discussButton, 'click', async () => {
await this.refocusMainView(true);
});

View file

@ -56,7 +56,7 @@ export class PlanApprovalView extends ItemView {
}
public getDisplayText(): string {
return Copy.PlanApprovalViewTitle;
return "Vaultkeeper AI plan";
}
public async setState(state: PlanApprovalViewState, result: ViewStateResult): Promise<void> {
@ -129,11 +129,11 @@ export class PlanApprovalView extends ItemView {
});
approveButton.setAttribute('aria-label', Copy.ButtonApprove);
const suggestButton = container.createEl('button', {
cls: 'plan-approval-mobile-button plan-approval-mobile-suggest',
text: Copy.ButtonSuggest
const discussButton = container.createEl('button', {
cls: 'plan-approval-mobile-button plan-approval-mobile-discuss',
text: Copy.ButtonDiscuss
});
suggestButton.setAttribute('aria-label', Copy.ButtonSuggest);
discussButton.setAttribute('aria-label', Copy.ButtonDiscuss);
const rejectButton = container.createEl('button', {
cls: 'plan-approval-mobile-button plan-approval-mobile-reject',
@ -146,7 +146,7 @@ export class PlanApprovalView extends ItemView {
await this.refocusMainView();
});
this.registerDomEvent(suggestButton, 'click', async () => {
this.registerDomEvent(discussButton, 'click', async () => {
await this.refocusMainView(true);
});

View file

@ -495,10 +495,9 @@ export class VaultkeeperAISettingTab extends PluginSettingTab {
break;
case AIProvider.OpenAI:
dropdown.addOptions({
[AIProviderModel.GPT_5_5_Pro]: Copy.GPT_5_5_Pro,
[AIProviderModel.GPT_5_5]: Copy.GPT_5_5,
[AIProviderModel.GPT_5_4_Mini]: Copy.GPT_5_4_Mini,
[AIProviderModel.GPT_5_4_Nano]: Copy.GPT_5_4_Nano
[AIProviderModel.GPT_5_6_Sol]: Copy.GPT_5_6_Sol,
[AIProviderModel.GPT_5_6_Terra]: Copy.GPT_5_6_Terra,
[AIProviderModel.GPT_5_6_Luna]: Copy.GPT_5_6_Luna
});
break;
case AIProvider.Gemini:

View file

@ -19,7 +19,7 @@ describe('OpenAIConversationNamingAgent', () => {
// Mock SettingsService
mockSettingsService = {
settings: {
model: AIProviderModel.GPT_5_4_Nano,
model: AIProviderModel.GPT_5_6_Luna,
apiKeys: {
claude: 'test-claude-key',
openai: 'test-openai-key',

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

View file

@ -479,6 +479,7 @@ describe('Conversation', () => {
AITool.ReadVaultFiles,
new AIToolResponsePayload(
{ results: [{ path: 'image.png', error: 'File does not exist: image.png' }] },
[],
[validAttachment]
),
'tool-123'
@ -527,6 +528,7 @@ describe('Conversation', () => {
AITool.ReadVaultFiles,
new AIToolResponsePayload(
{ message: 'Files retrieved successfully. The contents of the files are included below.', count: 1 },
[],
[attachment]
),
'tool-123'
@ -550,6 +552,7 @@ describe('Conversation', () => {
AITool.ReadVaultFiles,
new AIToolResponsePayload(
{ message: 'Files retrieved successfully. The contents of the files are included below.', count: 1 },
[],
[docAttachment]
),
'tool-123'
@ -577,6 +580,7 @@ describe('Conversation', () => {
AITool.ReadVaultFiles,
new AIToolResponsePayload(
{ results: [{ type: 'md', path: 'notes.md', contents: '# Notes' }], message: 'The contents of the files are included below.' },
[],
[xlsxAttachment, imgAttachment]
),
'tool-123'

View file

@ -33,6 +33,7 @@ describe('AIControllerService - Integration Tests', () => {
onStreamingUpdate: vi.fn(),
onThoughtUpdate: vi.fn(),
onToolCallStarted: vi.fn(),
onArtifactProduced: vi.fn(),
onPlanningStarted: vi.fn(),
onPlanningFinished: vi.fn(),
onUserQuestion: vi.fn(),

View file

@ -7,6 +7,7 @@ import { TFile } from 'obsidian';
import { Exception } from '../../Helpers/Exception';
import { AbortService } from '../../Services/AbortService';
import { ChatMode } from '../../Enums/ChatMode';
import { Artifact } from '../../Conversations/Artifact';
/**
* INTEGRATION TESTS - AIToolService
@ -35,6 +36,8 @@ describe('AIToolService - Integration Tests', () => {
searchVaultFiles: vi.fn(),
listFilesInDirectory: vi.fn(),
readFilePath: vi.fn(),
readFile: vi.fn(),
readBinaryFile: vi.fn(),
writeToFilePath: vi.fn(),
patchFileAtPath: vi.fn(),
deleteFile: vi.fn(),
@ -319,6 +322,64 @@ describe('AIToolService - Integration Tests', () => {
expect((result.payload.response as any).error).toBeDefined();
});
it('should produce an artifact tracking the before/after content on success', async () => {
mockFileSystemService.readFilePath.mockResolvedValue('Old content');
mockFileSystemService.writeToFilePath.mockResolvedValue(createMockFile('notes/new-note.md', 'new-note'));
mockFileSystemService.readFile.mockResolvedValue('# New Note\n\nContent here');
const result = await service.performAITool({
name: AITool.WriteVaultFile,
arguments: {
file_path: 'notes/new-note.md',
content: '# New Note\n\nContent here',
user_message: 'test search'
},
toolId: 'tool_11b'
} as any);
expect(result.payload.artifacts).toHaveLength(1);
const artifact = result.payload.artifacts[0];
expect(artifact).toBeInstanceOf(Artifact);
expect(artifact.filePath).toBe('notes/new-note.md');
expect(artifact.originalContent).toBe('Old content');
expect(artifact.updatedContent).toBe('# New Note\n\nContent here');
});
it('should track empty originalContent when the file did not previously exist', async () => {
mockFileSystemService.readFilePath.mockResolvedValue(new Error('File does not exist'));
mockFileSystemService.writeToFilePath.mockResolvedValue(createMockFile('brand-new.md', 'brand-new'));
mockFileSystemService.readFile.mockResolvedValue('Brand new content');
const result = await service.performAITool({
name: AITool.WriteVaultFile,
arguments: {
file_path: 'brand-new.md',
content: 'Brand new content',
user_message: 'test search'
},
toolId: 'tool_11c'
} as any);
expect(result.payload.artifacts[0].originalContent).toBe('');
expect(result.payload.artifacts[0].updatedContent).toBe('Brand new content');
});
it('should not produce an artifact when the write fails', async () => {
mockFileSystemService.writeToFilePath.mockResolvedValue(new Error('Permission denied'));
const result = await service.performAITool({
name: AITool.WriteVaultFile,
arguments: {
file_path: 'protected.md',
content: 'Content',
user_message: 'test search'
},
toolId: 'tool_11d'
} as any);
expect(result.payload.artifacts).toEqual([]);
});
it('should normalize file path', async () => {
mockFileSystemService.writeToFilePath.mockResolvedValue(undefined);
@ -379,6 +440,48 @@ describe('AIToolService - Integration Tests', () => {
expect(result.payload.response).toEqual({ success: true });
});
it('should produce an artifact tracking the before/after content on success', async () => {
mockFileSystemService.readFilePath.mockResolvedValue('# Title\nold content');
mockFileSystemService.patchFileAtPath.mockResolvedValue(createMockFile('notes/test.md', 'test'));
mockFileSystemService.readFile.mockResolvedValue('# Title\nnew content');
const result = await service.performAITool({
name: AITool.PatchVaultFile,
arguments: {
file_path: 'notes/test.md',
oldContent: ['old content'],
newContent: ['new content'],
user_message: 'Updating test note'
},
toolId: 'tool_patch_1b'
} as any);
expect(result.payload.artifacts).toHaveLength(1);
const artifact = result.payload.artifacts[0];
expect(artifact).toBeInstanceOf(Artifact);
expect(artifact.filePath).toBe('notes/test.md');
expect(artifact.originalContent).toBe('# Title\nold content');
expect(artifact.updatedContent).toBe('# Title\nnew content');
});
it('should not produce an artifact when the patch fails', async () => {
mockFileSystemService.readFilePath.mockResolvedValue('# Title\nold content');
mockFileSystemService.patchFileAtPath.mockResolvedValue(new Error('Content to replace was not found in the file'));
const result = await service.performAITool({
name: AITool.PatchVaultFile,
arguments: {
file_path: 'notes/test.md',
oldContent: ['missing content'],
newContent: ['new content'],
user_message: 'Updating test note'
},
toolId: 'tool_patch_1c'
} as any);
expect(result.payload.artifacts).toEqual([]);
});
it('should handle patch failure', async () => {
const error = new Error('Content to replace was not found in the file');
mockFileSystemService.patchFileAtPath.mockResolvedValue(error);
@ -669,6 +772,133 @@ describe('AIToolService - Integration Tests', () => {
expect((result.payload.response as any).results).toEqual([]);
});
it('should produce artifacts capturing content of deleted text files', async () => {
mockFileSystemService.readFilePath.mockResolvedValue('Content before deletion');
mockFileSystemService.deleteFile.mockResolvedValue(undefined);
const result = await service.performAITool({
name: AITool.DeleteVaultFiles,
arguments: {
file_paths: ['notes/gone.md'],
confirm_deletion: true,
user_message: 'test search'
},
toolId: 'tool_19b'
} as any);
expect(result.payload.artifacts).toHaveLength(1);
const artifact = result.payload.artifacts[0];
expect(artifact).toBeInstanceOf(Artifact);
expect(artifact.filePath).toBe('notes/gone.md');
expect(artifact.originalContent).toBe('Content before deletion');
expect(artifact.updatedContent).toBe('');
});
it('should produce base64 artifacts capturing content of deleted binary files', async () => {
const buffer = new TextEncoder().encode('binary-bytes').buffer;
mockFileSystemService.readBinaryFile.mockResolvedValue(buffer);
mockFileSystemService.deleteFile.mockResolvedValue(undefined);
const result = await service.performAITool({
name: AITool.DeleteVaultFiles,
arguments: {
file_paths: ['images/gone.png'],
confirm_deletion: true,
user_message: 'test search'
},
toolId: 'tool_19c'
} as any);
expect(result.payload.artifacts).toHaveLength(1);
const artifact = result.payload.artifacts[0];
expect(artifact.filePath).toBe('images/gone.png');
expect(artifact.base64).toBeDefined();
expect(mockFileSystemService.readFilePath).not.toHaveBeenCalled();
});
it('should not collect artifacts when confirmation is false', async () => {
const result = await service.performAITool({
name: AITool.DeleteVaultFiles,
arguments: {
file_paths: ['notes/gone.md'],
confirm_deletion: false,
user_message: 'test search'
},
toolId: 'tool_19d'
} as any);
expect(result.payload.artifacts).toEqual([]);
expect(mockFileSystemService.readFilePath).not.toHaveBeenCalled();
});
});
describe('performAITool - DeleteVaultFolder', () => {
it('should delete folder successfully and produce artifacts for its contents', async () => {
mockFileSystemService.listDirectoryContents = vi.fn().mockResolvedValue([
createMockFile('folder/a.md', 'a'),
createMockFile('folder/b.md', 'b')
]);
mockFileSystemService.readFilePath
.mockResolvedValueOnce('Content A')
.mockResolvedValueOnce('Content B');
mockFileSystemService.deleteFolder = vi.fn().mockResolvedValue(undefined);
const result = await service.performAITool({
name: AITool.DeleteVaultFolder,
arguments: {
path: 'folder',
confirm_deletion: true,
user_message: 'test search'
},
toolId: 'tool_folder_1'
} as any);
expect(mockFileSystemService.deleteFolder).toHaveBeenCalledWith('folder');
expect(result.payload.response).toEqual({ path: 'folder', success: true });
expect(result.payload.artifacts).toHaveLength(2);
expect(result.payload.artifacts.map((a: Artifact) => a.filePath)).toEqual(['folder/a.md', 'folder/b.md']);
expect(result.payload.artifacts[0].originalContent).toBe('Content A');
});
it('should reject deletion when confirmation is false', async () => {
mockFileSystemService.listDirectoryContents = vi.fn();
mockFileSystemService.deleteFolder = vi.fn();
const result = await service.performAITool({
name: AITool.DeleteVaultFolder,
arguments: {
path: 'folder',
confirm_deletion: false,
user_message: 'test search'
},
toolId: 'tool_folder_2'
} as any);
expect(result.payload.response).toEqual({ error: 'Confirmation was false, no action taken' });
expect(mockFileSystemService.deleteFolder).not.toHaveBeenCalled();
});
it('should still return collected artifacts when the delete fails', async () => {
mockFileSystemService.listDirectoryContents = vi.fn().mockResolvedValue([
createMockFile('folder/a.md', 'a')
]);
mockFileSystemService.readFilePath.mockResolvedValue('Content A');
mockFileSystemService.deleteFolder = vi.fn().mockResolvedValue(new Error('Permission denied'));
const result = await service.performAITool({
name: AITool.DeleteVaultFolder,
arguments: {
path: 'folder',
confirm_deletion: true,
user_message: 'test search'
},
toolId: 'tool_folder_3'
} as any);
expect((result.payload.response as any).success).toBe(false);
expect(result.payload.artifacts).toHaveLength(1);
});
});
describe('performAITool - MoveVaultFiles', () => {

View file

@ -7,6 +7,8 @@ import { ConversationContent } from '../../Conversations/ConversationContent';
import { Role } from '../../Enums/Role';
import { TFile } from 'obsidian';
import { Exception } from '../../Helpers/Exception';
import { Artifact } from '../../Conversations/Artifact';
import { ArtifactAction } from '../../Enums/ArtifactAction';
/**
* INTEGRATION TESTS - ConversationFileSystemService
@ -33,7 +35,9 @@ describe('ConversationFileSystemService - Integration Tests', () => {
deleteFile: vi.fn(),
moveFile: vi.fn(),
listFilesInDirectory: vi.fn(),
exists: vi.fn().mockResolvedValue(true)
exists: vi.fn().mockResolvedValue(true),
writeBinaryFile: vi.fn().mockResolvedValue(new TFile()),
readBinaryFile: vi.fn()
};
// Register the mock
@ -105,9 +109,9 @@ describe('ConversationFileSystemService - Integration Tests', () => {
it('should save conversation with correct structure', async () => {
const conversation = createTestConversation('Test Save');
const path = await service.saveConversation(conversation);
await service.saveConversation(conversation);
expect(path).toBe('Vaultkeeper AI/Conversations/Test Save.json');
expect(service.getCurrentConversationPath()).toBe('Vaultkeeper AI/Conversations/Test Save.json');
expect(mockFileSystemService.writeObjectToFile).toHaveBeenCalledWith(
'Vaultkeeper AI/Conversations/Test Save.json',
expect.objectContaining({
@ -341,10 +345,9 @@ describe('ConversationFileSystemService - Integration Tests', () => {
await service.deleteCurrentConversation();
// Try to save the same conversation again (simulates what happens in finally block)
const result = await service.saveConversation(conversation);
await service.saveConversation(conversation);
// Should return empty string (silent skip), not save the file
expect(result).toBe('');
// Should silently skip, not save the file again
expect(mockFileSystemService.writeObjectToFile).toHaveBeenCalledTimes(1); // Only the initial save
});
});
@ -528,6 +531,296 @@ describe('ConversationFileSystemService - Integration Tests', () => {
});
});
describe('Artifacts', () => {
it('should save an artifact with base64 content as a binary file and store its path', async () => {
const conversation = createTestConversation('With Artifact');
const artifact = new Artifact('notes/test.md', 'text/markdown', ArtifactAction.Modify, 'old', 'new', 'YWJjZGVm');
conversation.contents.push(
new ConversationContent({ role: Role.Assistant, content: 'Edited', artifacts: [artifact] })
);
mockFileSystemService.exists.mockResolvedValue(false);
await service.saveConversation(conversation);
expect(mockFileSystemService.writeBinaryFile).toHaveBeenCalledTimes(1);
const [writtenPath] = mockFileSystemService.writeBinaryFile.mock.calls[0];
expect(writtenPath).toContain('Vaultkeeper AI/Conversations/Artifacts/');
expect(artifact.artifactPath).toBeDefined();
expect(artifact.artifactPath).not.toContain('Vaultkeeper AI/Conversations/');
const savedData = mockFileSystemService.writeObjectToFile.mock.calls[0][1];
const savedArtifact = savedData.contents[2].artifacts[0];
expect(savedArtifact).toMatchObject({
filePath: 'notes/test.md',
mimeType: 'text/markdown',
action: ArtifactAction.Modify,
originalContent: 'old',
updatedContent: 'new',
artifactPath: artifact.artifactPath
});
});
it('should not write a binary file for artifacts without base64 content (text-only edits)', async () => {
const conversation = createTestConversation('Text Only Artifact');
const artifact = new Artifact('notes/test.md', 'text/markdown', ArtifactAction.Modify, 'old', 'new');
conversation.contents.push(
new ConversationContent({ role: Role.Assistant, content: 'Edited', artifacts: [artifact] })
);
await service.saveConversation(conversation);
expect(mockFileSystemService.writeBinaryFile).not.toHaveBeenCalled();
expect(artifact.artifactPath).toBeUndefined();
});
it('should not re-write a binary file for an artifact that already has a storage path', async () => {
const conversation = createTestConversation('Existing Artifact');
const artifact = new Artifact('notes/test.md', 'text/markdown', ArtifactAction.Modify, 'old', 'new', 'YWJjZGVm', 'Artifacts/existing-hash.bin');
conversation.contents.push(
new ConversationContent({ role: Role.Assistant, content: 'Edited', artifacts: [artifact] })
);
await service.saveConversation(conversation);
expect(mockFileSystemService.writeBinaryFile).not.toHaveBeenCalled();
expect(artifact.artifactPath).toBe('Artifacts/existing-hash.bin');
});
it('should deserialize artifacts and load their binary content on getAllConversations', async () => {
const mockFiles = [createMockFile('Vaultkeeper AI/Conversations/with-artifact.json')];
mockFileSystemService.listFilesInDirectory.mockResolvedValue(mockFiles);
mockFileSystemService.readObjectFromFile.mockResolvedValue({
title: 'With Artifact',
created: '2024-01-01T10:00:00.000Z',
updated: '2024-01-01T10:30:00.000Z',
contents: [
{
role: Role.Assistant,
content: 'Edited a file',
timestamp: '2024-01-01T10:00:00.000Z',
artifacts: [
{
filePath: 'notes/test.md',
mimeType: 'text/markdown',
action: ArtifactAction.Modify,
originalContent: 'old',
updatedContent: 'new',
artifactPath: 'Artifacts/hash123.bin'
}
]
}
]
});
mockFileSystemService.readBinaryFile.mockResolvedValue(new TextEncoder().encode('abcdef').buffer);
const conversations = await service.getAllConversations();
expect(mockFileSystemService.readBinaryFile).toHaveBeenCalledWith(
'Vaultkeeper AI/Conversations/Artifacts/hash123.bin',
true
);
const artifact = conversations[0].contents[0].artifacts[0];
expect(artifact).toBeInstanceOf(Artifact);
expect(artifact.filePath).toBe('notes/test.md');
expect(artifact.originalContent).toBe('old');
expect(artifact.updatedContent).toBe('new');
expect(artifact.base64).toBeDefined();
});
it('should deserialize text-only artifacts without attempting to load binary content', async () => {
const mockFiles = [createMockFile('Vaultkeeper AI/Conversations/text-artifact.json')];
mockFileSystemService.listFilesInDirectory.mockResolvedValue(mockFiles);
mockFileSystemService.readObjectFromFile.mockResolvedValue({
title: 'Text Artifact',
created: '2024-01-01T10:00:00.000Z',
updated: '2024-01-01T10:30:00.000Z',
contents: [
{
role: Role.Assistant,
content: 'Edited a file',
timestamp: '2024-01-01T10:00:00.000Z',
artifacts: [
{
filePath: 'notes/test.md',
mimeType: 'text/markdown',
action: ArtifactAction.Modify,
originalContent: 'old',
updatedContent: 'new'
}
]
}
]
});
const conversations = await service.getAllConversations();
expect(mockFileSystemService.readBinaryFile).not.toHaveBeenCalled();
expect(conversations[0].contents[0].artifacts[0].base64).toBeUndefined();
});
it('should skip invalid artifact data during deserialization', async () => {
const mockFiles = [createMockFile('Vaultkeeper AI/Conversations/invalid-artifact.json')];
mockFileSystemService.listFilesInDirectory.mockResolvedValue(mockFiles);
mockFileSystemService.readObjectFromFile.mockResolvedValue({
title: 'Invalid Artifact',
created: '2024-01-01T10:00:00.000Z',
updated: '2024-01-01T10:30:00.000Z',
contents: [
{
role: Role.Assistant,
content: 'Edited a file',
timestamp: '2024-01-01T10:00:00.000Z',
artifacts: [
{ filePath: 'notes/test.md' } // missing required fields
]
}
]
});
const conversations = await service.getAllConversations();
expect(conversations[0].contents[0].artifacts).toEqual([]);
});
describe('garbageCollectArtifacts', () => {
it('should delete artifact files with no remaining references', async () => {
mockFileSystemService.listFilesInDirectory.mockImplementation((path: string) => {
if (path === 'Vaultkeeper AI/Conversations/Artifacts') {
return Promise.resolve([
createMockFile('Vaultkeeper AI/Conversations/Artifacts/referenced.bin'),
createMockFile('Vaultkeeper AI/Conversations/Artifacts/orphaned.bin')
]);
}
return Promise.resolve([createMockFile('Vaultkeeper AI/Conversations/conv1.json')]);
});
mockFileSystemService.readObjectFromFile.mockResolvedValue({
title: 'Conv',
created: '2024-01-01T10:00:00.000Z',
updated: '2024-01-01T10:00:00.000Z',
contents: [
{
role: Role.Assistant,
content: 'Edit',
timestamp: '2024-01-01T10:00:00.000Z',
artifacts: [
{
filePath: 'notes/test.md',
mimeType: 'text/markdown',
action: ArtifactAction.Modify,
originalContent: 'old',
updatedContent: 'new',
artifactPath: 'Artifacts/referenced.bin'
}
]
}
]
});
mockFileSystemService.readBinaryFile.mockResolvedValue(new ArrayBuffer(0));
mockFileSystemService.deleteFile.mockResolvedValue(undefined);
await service.garbageCollectArtifacts();
expect(mockFileSystemService.deleteFile).toHaveBeenCalledTimes(1);
expect(mockFileSystemService.deleteFile).toHaveBeenCalledWith(
'Vaultkeeper AI/Conversations/Artifacts/orphaned.bin',
true,
false
);
});
it('should do nothing when there are no artifact files', async () => {
mockFileSystemService.listFilesInDirectory.mockResolvedValue([]);
const result = await service.garbageCollectArtifacts();
expect(result).toBeUndefined();
expect(mockFileSystemService.deleteFile).not.toHaveBeenCalled();
});
it('should not delete any artifact files still referenced by a conversation', async () => {
mockFileSystemService.listFilesInDirectory.mockImplementation((path: string) => {
if (path === 'Vaultkeeper AI/Conversations/Artifacts') {
return Promise.resolve([createMockFile('Vaultkeeper AI/Conversations/Artifacts/kept.bin')]);
}
return Promise.resolve([createMockFile('Vaultkeeper AI/Conversations/conv1.json')]);
});
mockFileSystemService.readObjectFromFile.mockResolvedValue({
title: 'Conv',
created: '2024-01-01T10:00:00.000Z',
updated: '2024-01-01T10:00:00.000Z',
contents: [
{
role: Role.Assistant,
content: 'Edit',
timestamp: '2024-01-01T10:00:00.000Z',
artifacts: [
{
filePath: 'notes/test.md',
mimeType: 'text/markdown',
action: ArtifactAction.Modify,
originalContent: 'old',
updatedContent: 'new',
artifactPath: 'Artifacts/kept.bin'
}
]
}
]
});
mockFileSystemService.readBinaryFile.mockResolvedValue(new ArrayBuffer(0));
await service.garbageCollectArtifacts();
expect(mockFileSystemService.deleteFile).not.toHaveBeenCalled();
});
it('should log and return an Error when listing artifact files fails', async () => {
mockFileSystemService.listFilesInDirectory.mockRejectedValue(new Error('Disk error'));
const result = await service.garbageCollectArtifacts();
expect(result).toBeInstanceOf(Error);
expect(Exception.log).toHaveBeenCalled();
});
});
it('should run garbage collection for both attachments and artifacts on conversation deletion', async () => {
const conversation = createTestConversation('To Delete With Artifact');
await service.saveConversation(conversation);
mockFileSystemService.readObjectFromFile.mockResolvedValue({
title: 'To Delete With Artifact',
created: '2024-01-01T10:00:00.000Z',
updated: '2024-01-01T10:30:00.000Z',
contents: []
});
mockFileSystemService.deleteFile.mockResolvedValue(undefined);
mockFileSystemService.listFilesInDirectory.mockResolvedValue([]);
await service.deleteCurrentConversation();
// Drain the internal deletion queue (garbage collection is queued in the background)
await new Promise(resolve => setTimeout(resolve, 0));
await new Promise(resolve => setTimeout(resolve, 0));
expect(mockFileSystemService.listFilesInDirectory).toHaveBeenCalledWith(
'Vaultkeeper AI/Conversations/Attachments',
false,
true
);
expect(mockFileSystemService.listFilesInDirectory).toHaveBeenCalledWith(
'Vaultkeeper AI/Conversations/Artifacts',
false,
true
);
});
});
describe('updateConversationTitle', () => {
it('should move file to new path with new title', async () => {
mockFileSystemService.moveFile.mockResolvedValue(undefined); // void = success

View file

@ -7,6 +7,8 @@ import { AIToolCall } from '../../AIClasses/AIToolCall';
import { AIToolResponse } from '../../AIClasses/ToolDefinitions/AIToolResponse';
import { AIToolResponsePayload } from '../../AIClasses/ToolDefinitions/AIToolResponsePayload';
import type { ExecutionStep } from '../../Types/ExecutionStep';
import { Artifact } from '../../Conversations/Artifact';
import { ArtifactAction } from 'Enums/ArtifactAction';
/**
* UNIT TESTS - ExecutionAgent
@ -30,6 +32,7 @@ describe('ExecutionAgent - Unit Tests', () => {
onStreamingUpdate: vi.fn(),
onThoughtUpdate: vi.fn(),
onToolCallStarted: vi.fn(),
onArtifactProduced: vi.fn(),
onPlanningStarted: vi.fn(),
onPlanningFinished: vi.fn(),
onUserQuestion: vi.fn().mockResolvedValue('User answer'),
@ -397,6 +400,111 @@ describe('ExecutionAgent - Unit Tests', () => {
expect(result?.success).toBe(true);
});
it('should invoke onArtifactProduced for each artifact returned by a tool call', async () => {
const callbacks = createMockCallbacks();
const step: ExecutionStep = {
description: 'Write file',
instruction: 'Create new file with content'
};
const artifact = new Artifact('new-note.md', 'text/markdown', ArtifactAction.Create, '', '# New Note\n\nContent here');
mockAIToolService.performAITool.mockResolvedValueOnce(
new AIToolResponse(
AITool.WriteVaultFile,
new AIToolResponsePayload({ success: true }, [artifact]),
'tool-1'
)
).mockResolvedValueOnce(
new AIToolResponse(AITool.CompleteTask, new AIToolResponsePayload({ success: true }), 'tool-2')
);
let toolCallCount = 0;
mockAI.streamRequest.mockImplementation(async function* () {
toolCallCount++;
if (toolCallCount === 1) {
yield {
content: 'Writing',
toolCall: new AIToolCall(
AITool.WriteVaultFile,
{
file_path: 'new-note.md',
content: '# New Note\n\nContent here',
user_message: 'Creating file'
},
'tool-1'
),
isComplete: true
};
} else {
yield {
content: 'Done',
toolCall: new AIToolCall(
AITool.CompleteTask,
{
success: true,
description: 'File created successfully'
},
'tool-2'
),
isComplete: true
};
}
});
service.resolveAIProvider();
await service.runExecutionAgent(step, callbacks);
expect(callbacks.onArtifactProduced).toHaveBeenCalledTimes(1);
expect(callbacks.onArtifactProduced).toHaveBeenCalledWith(artifact);
});
it('should not invoke onArtifactProduced when a tool call returns no artifacts', async () => {
const callbacks = createMockCallbacks();
const step: ExecutionStep = {
description: 'Search for files',
instruction: 'Search for files with tag #important'
};
let toolCallCount = 0;
mockAI.streamRequest.mockImplementation(async function* () {
toolCallCount++;
if (toolCallCount === 1) {
yield {
content: 'Searching',
toolCall: new AIToolCall(
AITool.SearchVaultFiles,
{
search_terms: ['#important'],
user_message: 'Searching for tagged files'
},
'tool-1'
),
isComplete: true
};
} else {
yield {
content: 'Done',
toolCall: new AIToolCall(
AITool.CompleteTask,
{
success: true,
description: 'Found 3 files with #important tag'
},
'tool-2'
),
isComplete: true
};
}
});
service.resolveAIProvider();
await service.runExecutionAgent(step, callbacks);
expect(callbacks.onArtifactProduced).not.toHaveBeenCalled();
});
it('should call multiple functions in sequence', async () => {
const callbacks = createMockCallbacks();
const step: ExecutionStep = {

View file

@ -39,6 +39,7 @@ describe('Multi-Agent Integration Tests', () => {
onStreamingUpdate: vi.fn(),
onThoughtUpdate: vi.fn(),
onToolCallStarted: vi.fn(),
onArtifactProduced: vi.fn(),
onPlanningStarted: vi.fn(),
onPlanningFinished: vi.fn(),
onUserQuestion: vi.fn().mockResolvedValue('User answer'),

View file

@ -32,6 +32,7 @@ describe('PlanningAgent - Unit Tests', () => {
onStreamingUpdate: vi.fn(),
onThoughtUpdate: vi.fn(),
onToolCallStarted: vi.fn(),
onArtifactProduced: vi.fn(),
onPlanningStarted: vi.fn(),
onPlanningFinished: vi.fn(),
onUserQuestion: vi.fn().mockResolvedValue('User answer'),

View file

@ -3,7 +3,13 @@ import { SettingsService, type IVaultkeeperAISettings } from '../../Services/Set
import { makeTestSettings } from '../Helpers/makeTestSettings';
import { RegisterSingleton, DeregisterAllServices } from '../../Services/DependencyService';
import { Services } from '../../Services/Services';
import { AIProvider, AIProviderModel } from '../../Enums/ApiProvider';
import {
AIProvider,
AIProviderModel,
DEFAULT_MODEL_BY_PROVIDER,
DEFAULT_PLANNING_MODEL_BY_PROVIDER,
DEFAULT_QUICK_MODEL_BY_PROVIDER
} from '../../Enums/ApiProvider';
describe('SettingsService', () => {
let settingsService: SettingsService;
@ -68,7 +74,7 @@ describe('SettingsService', () => {
it('should handle partially loaded settings and fill missing properties with defaults', () => {
const loadedSettings: Partial<IVaultkeeperAISettings> = {
provider: AIProvider.OpenAI,
model: AIProviderModel.GPT_5_5,
model: AIProviderModel.GPT_5_6_Terra,
apiKeys: {
claude: '',
openai: 'partial-key',
@ -79,7 +85,7 @@ describe('SettingsService', () => {
settingsService = new SettingsService(loadedSettings as IVaultkeeperAISettings);
expect(settingsService.settings.firstTimeStart).toBe(true); // Default
expect(settingsService.settings.model).toBe(AIProviderModel.GPT_5_5); // Loaded
expect(settingsService.settings.model).toBe(AIProviderModel.GPT_5_6_Terra); // Loaded
expect(settingsService.settings.apiKeys.openai).toBe('partial-key'); // Loaded
expect(settingsService.settings.exclusions).toEqual([]); // Default
expect(settingsService.settings.userInstruction).toBe(''); // Default
@ -236,7 +242,7 @@ describe('SettingsService', () => {
it('should return OpenAI key when current model is GPT', () => {
const loadedSettings = makeTestSettings({
provider: AIProvider.OpenAI,
model: AIProviderModel.GPT_5_4_Mini,
model: AIProviderModel.GPT_5_6_Luna,
apiKeys: {
claude: 'claude-key',
openai: 'openai-key',
@ -285,7 +291,7 @@ describe('SettingsService', () => {
// Test with various GPT models
settingsService = new SettingsService({
provider: AIProvider.OpenAI,
model: AIProviderModel.GPT_5_5,
model: AIProviderModel.GPT_5_6_Terra,
apiKeys: { claude: '', openai: 'gpt5-key', gemini: '', mistral: '', local: '' }
});
expect(settingsService.getApiKeyForCurrentProvider()).toBe('gpt5-key');
@ -478,10 +484,9 @@ describe('SettingsService', () => {
it('should correctly identify OpenAI models', () => {
const openaiModels = [
AIProviderModel.GPT_5_4_Nano,
AIProviderModel.GPT_5_4_Mini,
AIProviderModel.GPT_5_5,
AIProviderModel.GPT_5_4_Mini
AIProviderModel.GPT_5_6_Sol,
AIProviderModel.GPT_5_6_Terra,
AIProviderModel.GPT_5_6_Luna
];
openaiModels.forEach(model => {
@ -523,6 +528,93 @@ describe('SettingsService', () => {
});
});
describe('ensureValidModels (migration of stale/renamed model strings)', () => {
it('should fall back to the provider default when the loaded model string is no longer a valid enum member', () => {
// Simulates a plugin data file persisted by an older version that referenced a
// model id which has since been renamed/removed (e.g. the GPT-5.5 -> GPT-5.6 rename).
settingsService = new SettingsService({
provider: AIProvider.OpenAI,
model: 'gpt-5.5-2026-04-23' as AIProviderModel,
planningModel: 'gpt-5.5-pro-2026-04-23' as AIProviderModel,
quickActionModel: 'gpt-5.4-nano-2026-03-17' as AIProviderModel,
apiKeys: { claude: '', openai: 'test-openai', gemini: '', mistral: '', local: '' }
} as Partial<IVaultkeeperAISettings>);
expect(settingsService.settings.model).toBe(DEFAULT_MODEL_BY_PROVIDER[AIProvider.OpenAI]);
expect(settingsService.settings.planningModel).toBe(DEFAULT_PLANNING_MODEL_BY_PROVIDER[AIProvider.OpenAI]);
expect(settingsService.settings.quickActionModel).toBe(DEFAULT_QUICK_MODEL_BY_PROVIDER[AIProvider.OpenAI]);
});
it('should fall back to the provider default when the loaded model no longer matches the provider', () => {
// e.g. provider stayed OpenAI but the persisted model string now belongs to another provider's namespace
settingsService = new SettingsService({
provider: AIProvider.OpenAI,
model: AIProviderModel.ClaudeSonnet_5,
apiKeys: { claude: '', openai: 'test-openai', gemini: '', mistral: '', local: '' }
} as Partial<IVaultkeeperAISettings>);
expect(settingsService.settings.model).toBe(DEFAULT_MODEL_BY_PROVIDER[AIProvider.OpenAI]);
});
it('should reset an invalid persisted provider to the default provider', () => {
settingsService = new SettingsService({
provider: 'not-a-real-provider' as AIProvider
} as Partial<IVaultkeeperAISettings>);
expect(settingsService.settings.provider).toBe(AIProvider.Local);
});
it('should fall back to the provider default when a cached model for a non-active provider is stale', () => {
settingsService = new SettingsService({
provider: AIProvider.Claude,
cachedModelSettings: {
[AIProvider.Claude]: {
model: AIProviderModel.ClaudeSonnet_5,
planningModel: AIProviderModel.ClaudeOpus_4_8,
quickActionModel: AIProviderModel.ClaudeHaiku_4_5
},
[AIProvider.OpenAI]: {
model: 'gpt-5.5-2026-04-23' as AIProviderModel,
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]
}
}
} as Partial<IVaultkeeperAISettings>);
// The stale cache entry only gets repaired once OpenAI becomes the active provider
// (ensureValidModels only touches cachedModelSettings[settings.provider]).
expect(settingsService.settings.cachedModelSettings[AIProvider.Claude].model).toBe(AIProviderModel.ClaudeSonnet_5);
});
it('should not touch a model string that is still a valid enum member for the provider', () => {
settingsService = new SettingsService({
provider: AIProvider.Claude,
model: AIProviderModel.ClaudeOpus_4_8,
planningModel: AIProviderModel.ClaudeOpus_4_8,
quickActionModel: AIProviderModel.ClaudeHaiku_4_5
} as Partial<IVaultkeeperAISettings>);
expect(settingsService.settings.model).toBe(AIProviderModel.ClaudeOpus_4_8);
expect(settingsService.settings.planningModel).toBe(AIProviderModel.ClaudeOpus_4_8);
expect(settingsService.settings.quickActionModel).toBe(AIProviderModel.ClaudeHaiku_4_5);
});
});
describe('Settings Immutability and Reference', () => {
it('should maintain reference to settings object', () => {
settingsService = new SettingsService({

View file

@ -136,6 +136,43 @@ describe('StreamingMarkdownService', () => {
expect(renderSpy.mock.calls.length).toBeGreaterThan(callsWithOpenFence);
});
// --- Leading frontmatter neutralisation ---
it('rewrites a leading --- line so it cannot open frontmatter', async () => {
const { MarkdownRenderer } = await import('obsidian');
const renderSpy = vi.mocked(MarkdownRenderer.render);
const container = document.createElement('div');
await service.render('---\n\n**Title**\n\n---\n\nBody text', container, true);
const rendered = renderSpy.mock.calls[renderSpy.mock.calls.length - 1][1];
expect(rendered).toBe('***\n\n**Title**\n\n---\n\nBody text');
});
it('applies the same rewrite on incremental renders so offsets stay aligned', async () => {
const container = document.createElement('div');
await service.render('---\n\n**Title**\n\nLive', container);
await service.render('---\n\n**Title**\n\n---\n\nLive tail grows', container);
expect(container.textContent).toContain('**Title**');
expect(container.textContent).toContain('Live tail grows');
// The opener was rewritten before any slicing, so the frozen
// content starts with the neutralised rule
expect(container.textContent?.startsWith('***')).toBe(true);
});
it('leaves non-frontmatter leading lines untouched', async () => {
const { MarkdownRenderer } = await import('obsidian');
const renderSpy = vi.mocked(MarkdownRenderer.render);
const container = document.createElement('div');
await service.render('----\n\n--- not frontmatter', container, true);
const rendered = renderSpy.mock.calls[renderSpy.mock.calls.length - 1][1];
expect(rendered).toBe('----\n\n--- not frontmatter');
});
it('isFinal=true resets state and does a clean full render', async () => {
const container = document.createElement('div');

31
main.ts
View file

@ -17,6 +17,8 @@ import "katex/dist/katex.min.css";
import 'highlight.js/styles/monokai.min.css';
import 'diff2html/bundles/css/diff2html.min.css';
import type { AssetsService } from "Services/AssetsService";
import type { Artifact } from "Conversations/Artifact";
import { ArtifactView, VIEW_TYPE_ARTIFACT } from "Views/ArtifactView";
export default class VaultkeeperAIPlugin extends Plugin {
@ -32,6 +34,10 @@ export default class VaultkeeperAIPlugin extends Plugin {
VIEW_TYPE_DIFF,
(leaf) => new DiffView(leaf)
);
this.registerView(
VIEW_TYPE_ARTIFACT,
(leaf) => new ArtifactView(leaf)
);
this.registerView(
VIEW_TYPE_PLAN_APPROVAL,
(leaf) => new PlanApprovalView(leaf)
@ -53,6 +59,7 @@ export default class VaultkeeperAIPlugin extends Plugin {
this.addSettingTab(new VaultkeeperAISettingTab());
this.app.workspace.onLayoutReady(async () => {
this.closeStaleViews();
await this.setup();
});
}
@ -113,6 +120,30 @@ export default class VaultkeeperAIPlugin extends Plugin {
}
}
public async activateArtifactView(artifact: Artifact, diffString: string, config: Diff2HtmlUIConfig) {
const { workspace } = this.app;
const leaves = workspace.getLeavesOfType(VIEW_TYPE_ARTIFACT);
const leaf = leaves.length > 0 ? leaves[0] : workspace.getLeaf("tab");
await leaf?.setViewState({
type: VIEW_TYPE_ARTIFACT,
active: true,
state: { artifact, diffString, config }
});
if (leaf != null) {
await workspace.revealLeaf(leaf);
}
}
private closeStaleViews() {
const { workspace } = this.app;
workspace.getLeavesOfType(VIEW_TYPE_DIFF).forEach(leaf => leaf.detach());
workspace.getLeavesOfType(VIEW_TYPE_PLAN_APPROVAL).forEach(leaf => leaf.detach());
}
// create example user instruction (on first launch only)
private async setup() {
const settingsService = Resolve<SettingsService>(Services.SettingsService);

View file

@ -1,7 +1,7 @@
{
"id": "vaultkeeper-ai",
"name": "Vaultkeeper AI",
"version": "1.4.7",
"version": "1.4.9",
"minAppVersion": "1.9.14",
"description": "Multi-AI assistant. Read, search, create, and edit notes with multiple AI providers.",
"author": "Andy-Stack",

306
package-lock.json generated
View file

@ -9,8 +9,8 @@
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "^0.110.0",
"@google/genai": "^2.10.0",
"@anthropic-ai/sdk": "^0.111.0",
"@google/genai": "^2.11.0",
"@shikijs/rehype": "^4.3.1",
"core-js": "^3.49.0",
"diff": "^9.0.0",
@ -21,7 +21,7 @@
"highlight.js": "^11.11.1",
"katex": "^0.17.0",
"lowlight": "^3.3.0",
"openai": "^6.45.0",
"openai": "^6.46.0",
"path-browserify": "^1.0.1",
"regex-parser": "^2.3.1",
"uuid": "^14.0.1",
@ -32,20 +32,20 @@
"@eslint/js": "^10.0.1",
"@testing-library/svelte": "^5.4.2",
"@types/express": "^5.0.6",
"@types/node": "^26.1.0",
"@types/node": "^26.1.1",
"@types/path-browserify": "^1.0.3",
"@typescript-eslint/eslint-plugin": "8.63.0",
"@typescript-eslint/parser": "8.63.0",
"@vitest/ui": "^4.1.10",
"esbuild": "^0.28.1",
"esbuild-svelte": "^0.9.5",
"eslint": "^10.6.0",
"eslint": "^10.7.0",
"eslint-plugin-obsidianmd": "^0.4.1",
"globals": "^17.7.0",
"happy-dom": "^20.10.6",
"obsidian": "latest",
"svelte": "^5.56.4",
"svelte-check": "^4.7.1",
"svelte-check": "^4.7.2",
"svelte-preprocess": "^6.0.5",
"tslib": "2.8.1",
"typescript": "^6.0.3",
@ -54,9 +54,9 @@
}
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.110.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.110.0.tgz",
"integrity": "sha512-hOP4bNYXDFHDxxiEgzlILXrxZIYCDnhe8sry0RDRKD/QnsEpvZcQpablCdm9X/WuD/YgOiSIkkqsL1mLLlTqJw==",
"version": "0.111.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.111.0.tgz",
"integrity": "sha512-1hUqKi+uJQoS5X90+InwHbFAXMvgq0DnsC5hVLEeSRaODiU5WvmqDAcVCmGS2wC0pN9Z8jtWCbWw7JLzeDdm/Q==",
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "^3.1.1",
@ -700,9 +700,9 @@
}
},
"node_modules/@eslint/eslintrc": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz",
"integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==",
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz",
"integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -712,7 +712,7 @@
"globals": "^14.0.0",
"ignore": "^5.2.0",
"import-fresh": "^3.2.1",
"js-yaml": "^4.1.1",
"js-yaml": "^4.3.0",
"minimatch": "^3.1.5",
"strip-json-comments": "^3.1.1"
},
@ -731,9 +731,9 @@
"license": "MIT"
},
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
"version": "1.1.16",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -897,9 +897,9 @@
}
},
"node_modules/@google/genai": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.10.0.tgz",
"integrity": "sha512-e4cFxj3tiuMtsgOT4G9c1hXyGJhg7/Buj7VVeBacRY3fRtkRZZ59Q3nuVp2xbq8BGQXLXCDB253qMhklMOeUDg==",
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.11.0.tgz",
"integrity": "sha512-d2Csf29vS0GfHc52H0MG25ccY4FKvvbDgqDlEovLrPLF8sPegWr/GGO+LMOy85/1SnX0iV0zDAW7R8SsvWg8Vg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@ -1074,9 +1074,9 @@
}
},
"node_modules/@oxc-project/types": {
"version": "0.138.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz",
"integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==",
"version": "0.139.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
"integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
"dev": true,
"license": "MIT",
"funding": {
@ -1173,9 +1173,9 @@
"license": "BSD-3-Clause"
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
"integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
"integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
"cpu": [
"arm64"
],
@ -1190,9 +1190,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz",
"integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
"integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
"cpu": [
"arm64"
],
@ -1207,9 +1207,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz",
"integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
"integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
"cpu": [
"x64"
],
@ -1224,9 +1224,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz",
"integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
"integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
"cpu": [
"x64"
],
@ -1241,9 +1241,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz",
"integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
"integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
"cpu": [
"arm"
],
@ -1258,9 +1258,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz",
"integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
"integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
"cpu": [
"arm64"
],
@ -1278,9 +1278,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz",
"integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
"integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
"cpu": [
"arm64"
],
@ -1298,9 +1298,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz",
"integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
"integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
"cpu": [
"ppc64"
],
@ -1318,9 +1318,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz",
"integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
"integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
"cpu": [
"s390x"
],
@ -1338,9 +1338,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz",
"integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
"integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
"cpu": [
"x64"
],
@ -1358,9 +1358,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz",
"integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
"integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
"cpu": [
"x64"
],
@ -1378,9 +1378,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz",
"integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
"integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
"cpu": [
"arm64"
],
@ -1395,9 +1395,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz",
"integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
"integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
"cpu": [
"wasm32"
],
@ -1414,9 +1414,9 @@
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz",
"integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
"integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
"cpu": [
"arm64"
],
@ -1431,9 +1431,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz",
"integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
"integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
"cpu": [
"x64"
],
@ -1592,9 +1592,9 @@
"license": "MIT"
},
"node_modules/@sveltejs/acorn-typescript": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz",
"integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==",
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz",
"integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==",
"dev": true,
"license": "MIT",
"peerDependencies": {
@ -1776,9 +1776,9 @@
}
},
"node_modules/@types/express-serve-static-core": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
"integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.2.tgz",
"integrity": "sha512-d3KvEXBSo/lOAMc2u6fkyDHBvetBHeqD7wm/AcXfLpSOQwlmG9D/aQ0SFswVjv05p7ullQS7Mjohj6/VdbZuTg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -1789,9 +1789,9 @@
}
},
"node_modules/@types/hast": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
"integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz",
"integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==",
"license": "MIT",
"dependencies": {
"@types/unist": "*"
@ -1828,9 +1828,9 @@
}
},
"node_modules/@types/node": {
"version": "26.1.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz",
"integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==",
"version": "26.1.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
@ -2158,9 +2158,9 @@
}
},
"node_modules/@ungap/structured-clone": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.2.tgz",
"integrity": "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==",
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz",
"integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==",
"license": "ISC"
},
"node_modules/@vitest/expect": {
@ -3430,9 +3430,9 @@
}
},
"node_modules/es-iterator-helpers": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.3.tgz",
"integrity": "sha512-0PuBxFi+4uPanB97iDxCLWuHeYud2FALrw5HFZGtAF38UpJDbDC8frwp2cnDyae692CQ0dou60UwWfhgsa4U/g==",
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.4.0.tgz",
"integrity": "sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -3458,9 +3458,9 @@
}
},
"node_modules/es-module-lexer": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz",
"integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==",
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
"integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
"dev": true,
"license": "MIT"
},
@ -3605,9 +3605,9 @@
}
},
"node_modules/eslint": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.6.0.tgz",
"integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==",
"version": "10.7.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.7.0.tgz",
"integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==",
"dev": true,
"license": "MIT",
"workspaces": [
@ -3817,9 +3817,9 @@
"license": "MIT"
},
"node_modules/eslint-plugin-json-schema-validator/node_modules/brace-expansion": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -3883,9 +3883,9 @@
"license": "MIT"
},
"node_modules/eslint-plugin-n/node_modules/brace-expansion": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -4025,9 +4025,9 @@
}
},
"node_modules/eslint-plugin-obsidianmd/node_modules/@eslint/js": {
"version": "9.39.4",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz",
"integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==",
"version": "9.39.5",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz",
"integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==",
"dev": true,
"license": "MIT",
"engines": {
@ -4107,9 +4107,9 @@
"license": "MIT"
},
"node_modules/eslint-plugin-obsidianmd/node_modules/brace-expansion": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
"version": "1.1.16",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -4118,9 +4118,9 @@
}
},
"node_modules/eslint-plugin-obsidianmd/node_modules/eslint": {
"version": "9.39.4",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz",
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"version": "9.39.5",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz",
"integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -4129,8 +4129,8 @@
"@eslint/config-array": "^0.21.2",
"@eslint/config-helpers": "^0.4.2",
"@eslint/core": "^0.17.0",
"@eslint/eslintrc": "^3.3.5",
"@eslint/js": "9.39.4",
"@eslint/eslintrc": "^3.3.6",
"@eslint/js": "9.39.5",
"@eslint/plugin-kit": "^0.4.1",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
@ -4923,9 +4923,9 @@
"license": "MIT"
},
"node_modules/gaxios": {
"version": "7.1.6",
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.6.tgz",
"integrity": "sha512-aIQ0QL8Or8vsUhHyXGA6AohOFRrAAiHhrvsAG6myzcSlfhxSXtnwXA/pRuQTilFgjhLe30swK5rg1d7E1f8Izw==",
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.2.0.tgz",
"integrity": "sha512-CUVb4wcYe+771XevyH6HtGmXFAGGKkIC3kswAP8Z1JCe0j80JMaTPZH930DWFrvo0atjh18Arc0pEyUCWa5bfg==",
"license": "Apache-2.0",
"dependencies": {
"extend": "^3.0.2",
@ -5345,9 +5345,9 @@
}
},
"node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz",
"integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==",
"dev": true,
"license": "MIT",
"engines": {
@ -6695,9 +6695,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"dev": true,
"funding": [
{
@ -7001,9 +7001,9 @@
}
},
"node_modules/openai": {
"version": "6.45.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.45.0.tgz",
"integrity": "sha512-5DQVNErssk0afNpTTHUm/qZPU4iKR9OYdNid8Ib4puq4gHNNvGWZht2zY4h9a8JMF949Ik6m8gQutllVPbjdnw==",
"version": "6.46.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.46.0.tgz",
"integrity": "sha512-DFg6jEPT2RO+oAyXtddeUJU8zkGy1OQ1AjGzNIJUMQG03TTqvCpy9tBpQ+2VVVnvrl3E56F8GEin2JYtWpITtA==",
"license": "Apache-2.0",
"peerDependencies": {
"@aws-sdk/credential-provider-node": ">=3.972.0 <4",
@ -7214,9 +7214,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.16",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
"version": "8.5.17",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.17.tgz",
"integrity": "sha512-J7EF+8X+CzRPaJPOv9Ck2wNWJvGnnl3PcNPAdGg6GTLjyVpyQ0yATMSXRFRV01BviT/9Gwuc3rjEyJbDJG9a4w==",
"dev": true,
"funding": [
{
@ -7565,13 +7565,13 @@
}
},
"node_modules/rolldown": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz",
"integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
"integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.138.0",
"@oxc-project/types": "=0.139.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
@ -7581,21 +7581,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.1.4",
"@rolldown/binding-darwin-arm64": "1.1.4",
"@rolldown/binding-darwin-x64": "1.1.4",
"@rolldown/binding-freebsd-x64": "1.1.4",
"@rolldown/binding-linux-arm-gnueabihf": "1.1.4",
"@rolldown/binding-linux-arm64-gnu": "1.1.4",
"@rolldown/binding-linux-arm64-musl": "1.1.4",
"@rolldown/binding-linux-ppc64-gnu": "1.1.4",
"@rolldown/binding-linux-s390x-gnu": "1.1.4",
"@rolldown/binding-linux-x64-gnu": "1.1.4",
"@rolldown/binding-linux-x64-musl": "1.1.4",
"@rolldown/binding-openharmony-arm64": "1.1.4",
"@rolldown/binding-wasm32-wasi": "1.1.4",
"@rolldown/binding-win32-arm64-msvc": "1.1.4",
"@rolldown/binding-win32-x64-msvc": "1.1.4"
"@rolldown/binding-android-arm64": "1.1.5",
"@rolldown/binding-darwin-arm64": "1.1.5",
"@rolldown/binding-darwin-x64": "1.1.5",
"@rolldown/binding-freebsd-x64": "1.1.5",
"@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
"@rolldown/binding-linux-arm64-gnu": "1.1.5",
"@rolldown/binding-linux-arm64-musl": "1.1.5",
"@rolldown/binding-linux-ppc64-gnu": "1.1.5",
"@rolldown/binding-linux-s390x-gnu": "1.1.5",
"@rolldown/binding-linux-x64-gnu": "1.1.5",
"@rolldown/binding-linux-x64-musl": "1.1.5",
"@rolldown/binding-openharmony-arm64": "1.1.5",
"@rolldown/binding-wasm32-wasi": "1.1.5",
"@rolldown/binding-win32-arm64-msvc": "1.1.5",
"@rolldown/binding-win32-x64-msvc": "1.1.5"
}
},
"node_modules/router": {
@ -8014,9 +8014,9 @@
}
},
"node_modules/std-env": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
"dev": true,
"license": "MIT"
},
@ -8233,9 +8233,9 @@
}
},
"node_modules/svelte-check": {
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.1.tgz",
"integrity": "sha512-FGUOmAqxXdN/H9Zm8slrqO7SLtFisXRB7rfOsHNJ3MLTD2po/+Stg8XyErkpumPHbuUiYTcqrEIzxpVWKTLqtg==",
"version": "4.7.2",
"resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.2.tgz",
"integrity": "sha512-GoS4XJdGswlq0rIT1vtFLzJY1bvHtY37McY9H9Gkm1Ggw/ICdZYn8J/Z8Yi0BEL0i3R4+jtaWVePjyppMlij/A==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -8847,16 +8847,16 @@
}
},
"node_modules/vite": {
"version": "8.1.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
"integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
"version": "8.1.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz",
"integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"picomatch": "^4.0.5",
"postcss": "^8.5.16",
"rolldown": "~1.1.3",
"rolldown": "~1.1.4",
"tinyglobby": "^0.2.17"
},
"bin": {

View file

@ -24,20 +24,20 @@
"@eslint/js": "^10.0.1",
"@testing-library/svelte": "^5.4.2",
"@types/express": "^5.0.6",
"@types/node": "^26.1.0",
"@types/node": "^26.1.1",
"@types/path-browserify": "^1.0.3",
"@typescript-eslint/eslint-plugin": "8.63.0",
"@typescript-eslint/parser": "8.63.0",
"@vitest/ui": "^4.1.10",
"esbuild": "^0.28.1",
"esbuild-svelte": "^0.9.5",
"eslint": "^10.6.0",
"eslint": "^10.7.0",
"eslint-plugin-obsidianmd": "^0.4.1",
"globals": "^17.7.0",
"happy-dom": "^20.10.6",
"obsidian": "latest",
"svelte": "^5.56.4",
"svelte-check": "^4.7.1",
"svelte-check": "^4.7.2",
"svelte-preprocess": "^6.0.5",
"tslib": "2.8.1",
"typescript": "^6.0.3",
@ -45,8 +45,8 @@
"yaml": "^2.9.0"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.110.0",
"@google/genai": "^2.10.0",
"@anthropic-ai/sdk": "^0.111.0",
"@google/genai": "^2.11.0",
"@shikijs/rehype": "^4.3.1",
"core-js": "^3.49.0",
"diff": "^9.0.0",
@ -57,7 +57,7 @@
"highlight.js": "^11.11.1",
"katex": "^0.17.0",
"lowlight": "^3.3.0",
"openai": "^6.45.0",
"openai": "^6.46.0",
"path-browserify": "^1.0.1",
"regex-parser": "^2.3.1",
"uuid": "^14.0.1",