mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
feat: add early tool call feedback for improved UI responsiveness
Emit tool call start events immediately when tools begin execution, allowing UI to display contextual "thinking" messages (e.g., "Generating note contents..." for WriteVaultFile) before tool completion. Add Spinner to ThoughtIndicator component for visual feedback during tool operations.
This commit is contained in:
parent
89fa8b809c
commit
2bc12691d3
12 changed files with 78 additions and 8 deletions
|
|
@ -115,6 +115,13 @@ export class Claude extends BaseAIClass {
|
|||
this.accumulatedFunctionName = toolBlock.name || null;
|
||||
this.accumulatedFunctionArgs = "";
|
||||
this.accumulatedFunctionId = toolBlock.id || null;
|
||||
|
||||
// Return immediately to signal tool call has started
|
||||
return {
|
||||
content: "",
|
||||
isComplete: false,
|
||||
toolCallStarted: toolBlock.name
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { AIProvider, AIProviderURL } from "Enums/ApiProvider";
|
|||
import { AIToolCall } from "AIClasses/AIToolCall";
|
||||
import { fromString as aiToolFromString } from "Enums/AITool";
|
||||
import type { IAIToolDefinition } from "AIClasses/ToolDefinitions/IAIToolDefinition";
|
||||
import type { ResponseEvent, ResponseOutputTextDelta, ResponseOutputItemDone, ResponseErrorEvent, ResponseFailedEvent, OpenAIToolTool, ResponsesAPIInput } from "./OpenAITypes";
|
||||
import type { ResponseEvent, ResponseOutputTextDelta, ResponseOutputItemAdded, ResponseOutputItemDone, ResponseErrorEvent, ResponseFailedEvent, OpenAIToolTool, ResponsesAPIInput } from "./OpenAITypes";
|
||||
import { Exception } from "Helpers/Exception";
|
||||
import { ApiError, ApiErrorType } from "Types/ApiError";
|
||||
import { MimeType, toMimeType } from "Enums/MimeType";
|
||||
|
|
@ -138,6 +138,21 @@ export class OpenAI extends BaseAIClass {
|
|||
break;
|
||||
}
|
||||
|
||||
case "response.output_item.added": {
|
||||
// Function call starting - get name immediately for early UI feedback
|
||||
const itemAddedEvent = event as ResponseOutputItemAdded;
|
||||
|
||||
// Check if this is a function call and return tool name immediately
|
||||
if (itemAddedEvent.item.type === "function_call" && itemAddedEvent.item.name) {
|
||||
return {
|
||||
content: "",
|
||||
isComplete: false,
|
||||
toolCallStarted: itemAddedEvent.item.name
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "response.output_item.done": {
|
||||
// Complete output item received - this includes function calls with name
|
||||
const itemDoneEvent = event as ResponseOutputItemDone;
|
||||
|
|
@ -167,7 +182,6 @@ export class OpenAI extends BaseAIClass {
|
|||
case "response.in_progress":
|
||||
case "response.content_part.added":
|
||||
case "response.content_part.done":
|
||||
case "response.output_item.added":
|
||||
case "response.output_text.done":
|
||||
case "response.web_search_call.in_progress":
|
||||
case "response.web_search_call.searching":
|
||||
|
|
|
|||
|
|
@ -21,6 +21,19 @@ export interface ResponseToolCallArgumentsDone extends ResponseEvent {
|
|||
sequence_number: number;
|
||||
}
|
||||
|
||||
export interface ResponseOutputItemAdded extends ResponseEvent {
|
||||
type: "response.output_item.added";
|
||||
response_id: string;
|
||||
output_index: number;
|
||||
item: {
|
||||
type: string;
|
||||
id?: string;
|
||||
call_id?: string;
|
||||
name?: string;
|
||||
arguments?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ResponseOutputItemDone extends ResponseEvent {
|
||||
type: "response.output_item.done";
|
||||
item_id: string;
|
||||
|
|
|
|||
|
|
@ -289,7 +289,7 @@
|
|||
{/if}
|
||||
{/each}
|
||||
|
||||
<ThoughtIndicator thought={currentThought} bind:thoughtIndicatorElement={thoughtIndicatorElement}/>
|
||||
<ThoughtIndicator thought={currentThought} {editModeActive} bind:thoughtIndicatorElement={thoughtIndicatorElement}/>
|
||||
{#if isSubmitting}
|
||||
<StreamingIndicator editModeActive={editModeActive} bind:streamingIndicatorElement={streamingIndicatorElement}/>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@
|
|||
|
||||
{#if busyPlanning}
|
||||
<div id="chat-planning-in-progress" transition:slide>
|
||||
<Spinner {editModeActive}/>
|
||||
<Spinner {editModeActive} alternateBackground={true}/>
|
||||
<span id="chat-planning-in-progress-text">{$executionPlanState.plan?.isReplan ? "Replanning in progress..." : "Planning in progress..."}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
import type { ExecutionPlanStore } from "Stores/ExecutionPlanStore";
|
||||
import type { StreamingMarkdownService } from "Services/StreamingMarkdownService";
|
||||
import { HTMLService } from "Services/HTMLService";
|
||||
import { AITool, fromString } from "Enums/AITool";
|
||||
|
||||
const plugin: VaultkeeperAIPlugin = Resolve<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin);
|
||||
const executionPlanStore: ExecutionPlanStore = Resolve<ExecutionPlanStore>(Services.ExecutionPlanStore);
|
||||
|
|
@ -121,6 +122,19 @@
|
|||
currentThought = thought;
|
||||
}
|
||||
},
|
||||
onToolCallStarted: (toolName: string) => {
|
||||
const tool = fromString(toolName);
|
||||
switch(tool) {
|
||||
case AITool.WriteVaultFile:
|
||||
case AITool.PatchVaultFile:
|
||||
currentThought = Copy.AIThoughtGeneratingNote;
|
||||
break;
|
||||
case AITool.AskUserQuestionPlanning:
|
||||
case AITool.AskUserQuestionExecution:
|
||||
currentThought = Copy.AIThoughtPreparingQuery;
|
||||
break;
|
||||
}
|
||||
},
|
||||
onPlanningStarted: () => {
|
||||
busyPlanning = true;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
export let width: string = "18px";
|
||||
export let height: string = "18px";
|
||||
export let editModeActive: boolean = false;
|
||||
export let alternateBackground: boolean = false;
|
||||
export let spinnerElement: HTMLDivElement | undefined = undefined;
|
||||
</script>
|
||||
|
||||
|
|
@ -11,7 +12,7 @@
|
|||
style="width: {width}; height: {height};"
|
||||
bind:this={spinnerElement}
|
||||
>
|
||||
<div class="circle-core"></div>
|
||||
<div class="circle-core" class:alternate-background={alternateBackground}></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
|
@ -47,7 +48,11 @@
|
|||
.circle-core {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--background-secondary-alt);
|
||||
background-color: var(--background-secondary);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.circle-core.alternate-background {
|
||||
background-color: var(--background-secondary-alt);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,19 @@
|
|||
<script lang="ts">
|
||||
import { fade } from "svelte/transition";
|
||||
import Spinner from "./Spinner.svelte";
|
||||
|
||||
export let thought: string | null = null;
|
||||
export let thoughtIndicatorElement: HTMLElement | undefined = undefined;
|
||||
export let editModeActive : boolean = false;
|
||||
|
||||
$: isVisible = thought !== null && thought.trim().length > 0;
|
||||
|
||||
</script>
|
||||
|
||||
{#if isVisible}
|
||||
<div class="ai-thought-container" in:fade={{ duration: 200 }} out:fade={{ duration: 200 }} bind:this={thoughtIndicatorElement}>
|
||||
<div class="ai-thought-bubble">
|
||||
<span>{thought}</span>
|
||||
<span><Spinner {editModeActive}/> {thought}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -33,12 +38,15 @@
|
|||
z-index: 1;
|
||||
background: var(--background-primary-alt);
|
||||
border-radius: 10px;
|
||||
padding: 0.75rem 1rem;
|
||||
padding: 0.55rem 0.7rem;
|
||||
font-size: var(--font-smallest);
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
word-wrap: break-word;
|
||||
width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.ai-thought-bubble::after {
|
||||
|
|
|
|||
|
|
@ -73,6 +73,8 @@ export enum Copy {
|
|||
TooltipLearnMoreFileMonitoring = "Learn more in Plugin Guide",
|
||||
|
||||
AIThoughtMessage = "Thinking...",
|
||||
AIThoughtGeneratingNote = "Generating note contents...",
|
||||
AIThoughtPreparingQuery = "Preparing user query...",
|
||||
|
||||
// Rate Limit Countdown
|
||||
RateLimitCountdown = "Rate limit exceeded retrying in {seconds}...",
|
||||
|
|
|
|||
|
|
@ -138,6 +138,11 @@ export abstract class BaseAgent {
|
|||
break;
|
||||
}
|
||||
|
||||
if (chunk.toolCallStarted) {
|
||||
this.debugService?.log("ToolCallStarted", `Tool call started: ${chunk.toolCallStarted}`);
|
||||
callbacks.onToolCallStarted(chunk.toolCallStarted);
|
||||
}
|
||||
|
||||
if (chunk.toolCall) {
|
||||
this.debugService?.log("ToolCall", `Function call captured: ${chunk.toolCall.name}`);
|
||||
capturedToolCall = chunk.toolCall;
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export interface IChatServiceCallbacks {
|
|||
onSubmit: () => void;
|
||||
onStreamingUpdate: (streamingMessageId: string | null) => void;
|
||||
onThoughtUpdate: (thought: string | null) => void;
|
||||
onToolCallStarted: (toolName: string) => void;
|
||||
onPlanningStarted: () => void;
|
||||
onPlanningFinished: () => void;
|
||||
onUserQuestion: (question: string) => Promise<string>;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export interface IStreamChunk {
|
|||
error?: string;
|
||||
errorType?: ApiErrorType;
|
||||
toolCall?: AIToolCall;
|
||||
toolCallStarted?: string;
|
||||
shouldContinue?: boolean;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue