mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
refactor(editor): Add Instance AI thread provider (no-changelog) (#30090)
This commit is contained in:
parent
6f9b99a3cf
commit
0d571c05e4
|
|
@ -12,7 +12,6 @@ import { INSTANCE_AI_EMPTY_STATE_SUGGESTIONS } from './emptyStateSuggestions';
|
||||||
import { useCreditWarningBanner } from './composables/useCreditWarningBanner';
|
import { useCreditWarningBanner } from './composables/useCreditWarningBanner';
|
||||||
import InstanceAiInput from './components/InstanceAiInput.vue';
|
import InstanceAiInput from './components/InstanceAiInput.vue';
|
||||||
import InstanceAiEmptyState from './components/InstanceAiEmptyState.vue';
|
import InstanceAiEmptyState from './components/InstanceAiEmptyState.vue';
|
||||||
import InstanceAiStatusBar from './components/InstanceAiStatusBar.vue';
|
|
||||||
import InstanceAiViewHeader from './components/InstanceAiViewHeader.vue';
|
import InstanceAiViewHeader from './components/InstanceAiViewHeader.vue';
|
||||||
import CreditWarningBanner from '@/features/ai/assistant/components/Agent/CreditWarningBanner.vue';
|
import CreditWarningBanner from '@/features/ai/assistant/components/Agent/CreditWarningBanner.vue';
|
||||||
|
|
||||||
|
|
@ -69,7 +68,6 @@ function handleStop() {
|
||||||
<div :class="$style.emptyLayout">
|
<div :class="$style.emptyLayout">
|
||||||
<InstanceAiEmptyState />
|
<InstanceAiEmptyState />
|
||||||
<div :class="$style.centeredInput">
|
<div :class="$style.centeredInput">
|
||||||
<InstanceAiStatusBar />
|
|
||||||
<CreditWarningBanner
|
<CreditWarningBanner
|
||||||
v-if="creditBanner.visible.value"
|
v-if="creditBanner.visible.value"
|
||||||
:credits-remaining="store.creditsRemaining"
|
:credits-remaining="store.creditsRemaining"
|
||||||
|
|
@ -80,9 +78,16 @@ function handleStop() {
|
||||||
<InstanceAiInput
|
<InstanceAiInput
|
||||||
ref="chatInputRef"
|
ref="chatInputRef"
|
||||||
:is-streaming="store.isStreaming"
|
:is-streaming="store.isStreaming"
|
||||||
|
:is-sending-message="store.isSendingMessage"
|
||||||
|
:is-awaiting-confirmation="store.isAwaitingConfirmation"
|
||||||
|
:current-thread-id="store.currentThreadId"
|
||||||
|
:amend-context="store.amendContext"
|
||||||
|
:contextual-suggestion="store.contextualSuggestion"
|
||||||
|
:research-mode="store.researchMode"
|
||||||
:suggestions="INSTANCE_AI_EMPTY_STATE_SUGGESTIONS"
|
:suggestions="INSTANCE_AI_EMPTY_STATE_SUGGESTIONS"
|
||||||
@submit="handleSubmit"
|
@submit="handleSubmit"
|
||||||
@stop="handleStop"
|
@stop="handleStop"
|
||||||
|
@toggle-research-mode="store.toggleResearchMode()"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import {
|
||||||
watch,
|
watch,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import {
|
import {
|
||||||
N8nHeading,
|
N8nHeading,
|
||||||
N8nIconButton,
|
N8nIconButton,
|
||||||
|
|
@ -23,7 +23,7 @@ import { useI18n } from '@n8n/i18n';
|
||||||
import type { InstanceAiAttachment } from '@n8n/api-types';
|
import type { InstanceAiAttachment } from '@n8n/api-types';
|
||||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||||
import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHelper';
|
import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHelper';
|
||||||
import { useInstanceAiStore } from './instanceAi.store';
|
import { provideThread, useInstanceAiStore } from './instanceAi.store';
|
||||||
import { useCanvasPreview } from './useCanvasPreview';
|
import { useCanvasPreview } from './useCanvasPreview';
|
||||||
import { useEventRelay } from './useEventRelay';
|
import { useEventRelay } from './useEventRelay';
|
||||||
import { useExecutionPushEvents } from './useExecutionPushEvents';
|
import { useExecutionPushEvents } from './useExecutionPushEvents';
|
||||||
|
|
@ -49,10 +49,10 @@ const props = defineProps<{
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const store = useInstanceAiStore();
|
const store = useInstanceAiStore();
|
||||||
|
const thread = provideThread(store);
|
||||||
const { isLowCredits } = storeToRefs(store);
|
const { isLowCredits } = storeToRefs(store);
|
||||||
const rootStore = useRootStore();
|
const rootStore = useRootStore();
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const route = useRoute();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { goToUpgrade } = usePageRedirectionHelper();
|
const { goToUpgrade } = usePageRedirectionHelper();
|
||||||
const creditBanner = useCreditWarningBanner(isLowCredits);
|
const creditBanner = useCreditWarningBanner(isLowCredits);
|
||||||
|
|
@ -60,12 +60,12 @@ const creditBanner = useCreditWarningBanner(isLowCredits);
|
||||||
// Running builders render in a dedicated bottom section of the conversation.
|
// Running builders render in a dedicated bottom section of the conversation.
|
||||||
// Once a builder finishes it falls out of this list and AgentTimeline renders
|
// Once a builder finishes it falls out of this list and AgentTimeline renders
|
||||||
// it in its natural chronological slot.
|
// it in its natural chronological slot.
|
||||||
const builderAgents = computed(() => collectActiveBuilderAgents(store.messages));
|
const builderAgents = computed(() => collectActiveBuilderAgents(thread.messages));
|
||||||
|
|
||||||
// Assistant messages whose only content has been extracted to the bottom
|
// Assistant messages whose only content has been extracted to the bottom
|
||||||
// builder section (or which haven't produced anything renderable yet) would
|
// builder section (or which haven't produced anything renderable yet) would
|
||||||
// otherwise leave an empty wrapper in the list — filter them out.
|
// otherwise leave an empty wrapper in the list — filter them out.
|
||||||
const displayedMessages = computed(() => store.messages.filter(messageHasVisibleContent));
|
const displayedMessages = computed(() => thread.messages.filter(messageHasVisibleContent));
|
||||||
|
|
||||||
// --- Execution tracking via push events ---
|
// --- Execution tracking via push events ---
|
||||||
const executionTracking = useExecutionPushEvents();
|
const executionTracking = useExecutionPushEvents();
|
||||||
|
|
@ -75,11 +75,11 @@ const executionTracking = useExecutionPushEvents();
|
||||||
// figuring out which thread to show. Rendering only on a defined value avoids
|
// figuring out which thread to show. Rendering only on a defined value avoids
|
||||||
// the "New conversation" → real title flash when resuming a recent thread.
|
// the "New conversation" → real title flash when resuming a recent thread.
|
||||||
const currentThreadTitle = computed<string | undefined>(() => {
|
const currentThreadTitle = computed<string | undefined>(() => {
|
||||||
const thread = store.threads.find((t) => t.id === store.currentThreadId);
|
const threadSummary = store.threads.find((t) => t.id === store.currentThreadId);
|
||||||
if (thread && thread.title && thread.title !== NEW_CONVERSATION_TITLE) {
|
if (threadSummary && threadSummary.title && threadSummary.title !== NEW_CONVERSATION_TITLE) {
|
||||||
return thread.title;
|
return threadSummary.title;
|
||||||
}
|
}
|
||||||
const firstUserMsg = store.messages.find((m) => m.role === 'user');
|
const firstUserMsg = thread.messages.find((m) => m.role === 'user');
|
||||||
if (firstUserMsg?.content) {
|
if (firstUserMsg?.content) {
|
||||||
const text = firstUserMsg.content.trim();
|
const text = firstUserMsg.content.trim();
|
||||||
return text.length > 60 ? text.slice(0, 60) + '…' : text;
|
return text.length > 60 ? text.slice(0, 60) + '…' : text;
|
||||||
|
|
@ -89,8 +89,8 @@ const currentThreadTitle = computed<string | undefined>(() => {
|
||||||
|
|
||||||
// --- Canvas / data table preview ---
|
// --- Canvas / data table preview ---
|
||||||
const preview = useCanvasPreview({
|
const preview = useCanvasPreview({
|
||||||
store,
|
thread,
|
||||||
route,
|
threadId: () => props.threadId,
|
||||||
});
|
});
|
||||||
|
|
||||||
provide('openWorkflowPreview', preview.openWorkflowPreview);
|
provide('openWorkflowPreview', preview.openWorkflowPreview);
|
||||||
|
|
@ -233,10 +233,10 @@ watch(
|
||||||
);
|
);
|
||||||
|
|
||||||
function reconnectThreadIfHydrationApplied(threadId: string): void {
|
function reconnectThreadIfHydrationApplied(threadId: string): void {
|
||||||
void store.loadHistoricalMessages(threadId).then((hydrationStatus) => {
|
void thread.loadHistoricalMessages(threadId).then((hydrationStatus) => {
|
||||||
if (hydrationStatus === 'stale') return;
|
if (hydrationStatus === 'stale') return;
|
||||||
void store.loadThreadStatus(threadId);
|
void thread.loadThreadStatus(threadId);
|
||||||
store.connectSSE(threadId);
|
thread.connectSSE(threadId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -249,7 +249,7 @@ function reconnectThreadIfHydrationApplied(threadId: string): void {
|
||||||
async function syncRouteToStore() {
|
async function syncRouteToStore() {
|
||||||
const requestedThreadId = props.threadId;
|
const requestedThreadId = props.threadId;
|
||||||
if (requestedThreadId === store.currentThreadId) {
|
if (requestedThreadId === store.currentThreadId) {
|
||||||
if (store.sseState === 'disconnected') {
|
if (thread.sseState === 'disconnected') {
|
||||||
reconnectThreadIfHydrationApplied(requestedThreadId);
|
reconnectThreadIfHydrationApplied(requestedThreadId);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|
@ -309,11 +309,11 @@ const eventRelay = useEventRelay({
|
||||||
function handleSubmit(message: string, attachments?: InstanceAiAttachment[]) {
|
function handleSubmit(message: string, attachments?: InstanceAiAttachment[]) {
|
||||||
// Reset scroll on new user message
|
// Reset scroll on new user message
|
||||||
userScrolledUp.value = false;
|
userScrolledUp.value = false;
|
||||||
void store.sendMessage(message, attachments, rootStore.pushRef);
|
void thread.sendMessage(message, attachments, rootStore.pushRef);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleStop() {
|
function handleStop() {
|
||||||
void store.cancelRun();
|
void thread.cancelRun();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -327,7 +327,7 @@ function handleStop() {
|
||||||
{{ currentThreadTitle }}
|
{{ currentThreadTitle }}
|
||||||
</N8nHeading>
|
</N8nHeading>
|
||||||
<N8nText
|
<N8nText
|
||||||
v-if="store.sseState === 'reconnecting'"
|
v-if="thread.sseState === 'reconnecting'"
|
||||||
size="small"
|
size="small"
|
||||||
color="text-light"
|
color="text-light"
|
||||||
:class="$style.reconnecting"
|
:class="$style.reconnecting"
|
||||||
|
|
@ -396,7 +396,7 @@ function handleStop() {
|
||||||
>
|
>
|
||||||
<Transition name="fade">
|
<Transition name="fade">
|
||||||
<N8nIconButton
|
<N8nIconButton
|
||||||
v-if="userScrolledUp && store.hasMessages"
|
v-if="userScrolledUp && thread.hasMessages"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
icon="arrow-down"
|
icon="arrow-down"
|
||||||
:class="$style.scrollToBottomButton"
|
:class="$style.scrollToBottomButton"
|
||||||
|
|
@ -421,9 +421,16 @@ function handleStop() {
|
||||||
/>
|
/>
|
||||||
<InstanceAiInput
|
<InstanceAiInput
|
||||||
ref="chatInputRef"
|
ref="chatInputRef"
|
||||||
:is-streaming="store.isStreaming"
|
:is-streaming="thread.isStreaming"
|
||||||
|
:is-sending-message="thread.isSendingMessage"
|
||||||
|
:is-awaiting-confirmation="thread.isAwaitingConfirmation"
|
||||||
|
:current-thread-id="thread.currentThreadId"
|
||||||
|
:amend-context="thread.amendContext"
|
||||||
|
:contextual-suggestion="thread.contextualSuggestion"
|
||||||
|
:research-mode="store.researchMode"
|
||||||
@submit="handleSubmit"
|
@submit="handleSubmit"
|
||||||
@stop="handleStop"
|
@stop="handleStop"
|
||||||
|
@toggle-research-mode="store.toggleResearchMode()"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { createTestingPinia } from '@pinia/testing';
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
import { setActivePinia } from 'pinia';
|
import { setActivePinia } from 'pinia';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { createComponentRenderer } from '@/__tests__/render';
|
import { createThreadComponentRenderer } from './createThreadComponentRenderer';
|
||||||
import type {
|
import type {
|
||||||
InstanceAiConfirmation,
|
InstanceAiConfirmation,
|
||||||
InstanceAiToolCallState,
|
InstanceAiToolCallState,
|
||||||
|
|
@ -89,7 +89,7 @@ vi.mock('../components/PlanReviewPanel.vue', () => ({
|
||||||
default: { template: '<div />', props: ['plannedTasks', 'message', 'readOnly'] },
|
default: { template: '<div />', props: ['plannedTasks', 'message', 'readOnly'] },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const renderComponent = createComponentRenderer(InstanceAiConfirmationPanel);
|
const renderComponent = createThreadComponentRenderer(InstanceAiConfirmationPanel);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { createTestingPinia } from '@pinia/testing';
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
import { setActivePinia } from 'pinia';
|
import { setActivePinia } from 'pinia';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { createComponentRenderer } from '@/__tests__/render';
|
import { createThreadComponentRenderer } from './createThreadComponentRenderer';
|
||||||
import type { InstanceAiCredentialRequest } from '@n8n/api-types';
|
import type { InstanceAiCredentialRequest } from '@n8n/api-types';
|
||||||
import InstanceAiCredentialSetup from '../components/InstanceAiCredentialSetup.vue';
|
import InstanceAiCredentialSetup from '../components/InstanceAiCredentialSetup.vue';
|
||||||
import { useInstanceAiStore } from '../instanceAi.store';
|
import { useInstanceAiStore } from '../instanceAi.store';
|
||||||
|
|
@ -49,7 +49,7 @@ vi.mock('@/features/credentials/components/NodeCredentials.vue', () => ({
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const renderComponent = createComponentRenderer(InstanceAiCredentialSetup);
|
const renderComponent = createThreadComponentRenderer(InstanceAiCredentialSetup);
|
||||||
|
|
||||||
/** Creates requests with no existing credentials (shows setup button) */
|
/** Creates requests with no existing credentials (shows setup button) */
|
||||||
function makeCredentialRequests(count: number): InstanceAiCredentialRequest[] {
|
function makeCredentialRequests(count: number): InstanceAiCredentialRequest[] {
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,52 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { fireEvent, waitFor, within } from '@testing-library/vue';
|
import { fireEvent, waitFor, within } from '@testing-library/vue';
|
||||||
import { reactive } from 'vue';
|
|
||||||
import { createComponentRenderer } from '@/__tests__/render';
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
import InstanceAiInput from '../components/InstanceAiInput.vue';
|
import InstanceAiInput from '../components/InstanceAiInput.vue';
|
||||||
import { INSTANCE_AI_EMPTY_STATE_SUGGESTIONS as suggestions } from '../emptyStateSuggestions';
|
import { INSTANCE_AI_EMPTY_STATE_SUGGESTIONS as suggestions } from '../emptyStateSuggestions';
|
||||||
|
|
||||||
const toggleResearchMode = vi.fn();
|
|
||||||
const telemetryTrack = vi.fn();
|
const telemetryTrack = vi.fn();
|
||||||
const storeState = reactive({
|
|
||||||
amendContext: null as { agentId: string; role: string } | null,
|
type InputTestProps = {
|
||||||
contextualSuggestion: null as string | null,
|
isStreaming: boolean;
|
||||||
currentThreadId: 'thread-1',
|
isSendingMessage: boolean;
|
||||||
researchMode: false,
|
isAwaitingConfirmation: boolean;
|
||||||
|
currentThreadId: string;
|
||||||
|
amendContext: { agentId: string; role: string } | null;
|
||||||
|
contextualSuggestion: string | null;
|
||||||
|
researchMode: boolean;
|
||||||
|
suggestions?: typeof suggestions;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultProps = (): InputTestProps => ({
|
||||||
|
isStreaming: false,
|
||||||
isSendingMessage: false,
|
isSendingMessage: false,
|
||||||
toggleResearchMode,
|
isAwaitingConfirmation: false,
|
||||||
|
currentThreadId: 'thread-1',
|
||||||
|
amendContext: null,
|
||||||
|
contextualSuggestion: null,
|
||||||
|
researchMode: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function inputProps(overrides: Partial<InputTestProps> = {}): InputTestProps {
|
||||||
|
return {
|
||||||
|
...defaultProps(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
vi.mock('@/app/composables/useTelemetry', () => ({
|
vi.mock('@/app/composables/useTelemetry', () => ({
|
||||||
useTelemetry: vi.fn(() => ({ track: telemetryTrack })),
|
useTelemetry: vi.fn(() => ({ track: telemetryTrack })),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../instanceAi.store', () => ({
|
const renderComponent = createComponentRenderer(InstanceAiInput, {
|
||||||
useInstanceAiStore: vi.fn(() => storeState),
|
props: defaultProps(),
|
||||||
}));
|
});
|
||||||
|
|
||||||
const renderComponent = createComponentRenderer(InstanceAiInput);
|
|
||||||
|
|
||||||
describe('InstanceAiInput', () => {
|
describe('InstanceAiInput', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
telemetryTrack.mockReset();
|
telemetryTrack.mockReset();
|
||||||
storeState.amendContext = null;
|
|
||||||
storeState.contextualSuggestion = null;
|
|
||||||
storeState.currentThreadId = 'thread-1';
|
|
||||||
storeState.researchMode = false;
|
|
||||||
storeState.isSendingMessage = false;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the shared suggestions fixture with the expected top-level contract', () => {
|
it('uses the shared suggestions fixture with the expected top-level contract', () => {
|
||||||
|
|
@ -93,11 +104,11 @@ describe('InstanceAiInput', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fills the textarea from the contextual suggestion when Tab is pressed on an empty input', async () => {
|
it('fills the textarea from the contextual suggestion when Tab is pressed on an empty input', async () => {
|
||||||
storeState.contextualSuggestion = 'Summarize the last workflow error for me';
|
|
||||||
const { getByRole } = renderComponent({
|
const { getByRole } = renderComponent({
|
||||||
props: {
|
props: {
|
||||||
isStreaming: false,
|
isStreaming: false,
|
||||||
suggestions,
|
suggestions,
|
||||||
|
contextualSuggestion: 'Summarize the last workflow error for me',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -336,7 +347,7 @@ describe('InstanceAiInput', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides suggestions while a send is pending', async () => {
|
it('hides suggestions while a send is pending', async () => {
|
||||||
const { queryByTestId } = renderComponent({
|
const { queryByTestId, rerender } = renderComponent({
|
||||||
props: {
|
props: {
|
||||||
isStreaming: false,
|
isStreaming: false,
|
||||||
suggestions,
|
suggestions,
|
||||||
|
|
@ -345,7 +356,7 @@ describe('InstanceAiInput', () => {
|
||||||
|
|
||||||
expect(queryByTestId('instance-ai-suggestion-build-workflow')).toBeInTheDocument();
|
expect(queryByTestId('instance-ai-suggestion-build-workflow')).toBeInTheDocument();
|
||||||
|
|
||||||
storeState.isSendingMessage = true;
|
await rerender(inputProps({ suggestions, isSendingMessage: true }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(queryByTestId('instance-ai-suggestion-build-workflow')).not.toBeInTheDocument();
|
expect(queryByTestId('instance-ai-suggestion-build-workflow')).not.toBeInTheDocument();
|
||||||
|
|
@ -353,7 +364,7 @@ describe('InstanceAiInput', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('toggles research mode from the composer footer button', async () => {
|
it('toggles research mode from the composer footer button', async () => {
|
||||||
const { getByTestId } = renderComponent({
|
const { emitted, getByTestId } = renderComponent({
|
||||||
props: {
|
props: {
|
||||||
isStreaming: false,
|
isStreaming: false,
|
||||||
suggestions,
|
suggestions,
|
||||||
|
|
@ -362,7 +373,7 @@ describe('InstanceAiInput', () => {
|
||||||
|
|
||||||
await userEvent.click(getByTestId('instance-ai-research-toggle'));
|
await userEvent.click(getByTestId('instance-ai-research-toggle'));
|
||||||
|
|
||||||
expect(toggleResearchMode).toHaveBeenCalledTimes(1);
|
expect(emitted()['toggle-research-mode']).toEqual([[]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('emits stop when the streaming stop button is clicked', async () => {
|
it('emits stop when the streaming stop button is clicked', async () => {
|
||||||
|
|
@ -379,7 +390,7 @@ describe('InstanceAiInput', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clears the ghost prompt when suggestions become hidden', async () => {
|
it('clears the ghost prompt when suggestions become hidden', async () => {
|
||||||
const { getByRole, getByTestId, queryByTestId } = renderComponent({
|
const { getByRole, getByTestId, queryByTestId, rerender } = renderComponent({
|
||||||
props: {
|
props: {
|
||||||
isStreaming: false,
|
isStreaming: false,
|
||||||
suggestions,
|
suggestions,
|
||||||
|
|
@ -391,7 +402,7 @@ describe('InstanceAiInput', () => {
|
||||||
await userEvent.hover(getByTestId('instance-ai-suggestion-build-workflow'));
|
await userEvent.hover(getByTestId('instance-ai-suggestion-build-workflow'));
|
||||||
expect(textbox.getAttribute('placeholder')).not.toBe(initialPlaceholder);
|
expect(textbox.getAttribute('placeholder')).not.toBe(initialPlaceholder);
|
||||||
|
|
||||||
storeState.isSendingMessage = true;
|
await rerender(inputProps({ suggestions, isSendingMessage: true }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(queryByTestId('instance-ai-suggestion-build-workflow')).not.toBeInTheDocument();
|
expect(queryByTestId('instance-ai-suggestion-build-workflow')).not.toBeInTheDocument();
|
||||||
|
|
@ -400,12 +411,11 @@ describe('InstanceAiInput', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('tracks suggestions shown once when suggestions hide and reappear in the same thread', async () => {
|
it('tracks suggestions shown once when suggestions hide and reappear in the same thread', async () => {
|
||||||
storeState.currentThreadId = 'thread-shown';
|
const { rerender } = renderComponent({
|
||||||
|
|
||||||
renderComponent({
|
|
||||||
props: {
|
props: {
|
||||||
isStreaming: false,
|
isStreaming: false,
|
||||||
suggestions,
|
suggestions,
|
||||||
|
currentThreadId: 'thread-shown',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -417,24 +427,29 @@ describe('InstanceAiInput', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
storeState.isSendingMessage = true;
|
await rerender(
|
||||||
|
inputProps({
|
||||||
|
suggestions,
|
||||||
|
currentThreadId: 'thread-shown',
|
||||||
|
isSendingMessage: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(telemetryTrack).toHaveBeenCalledTimes(1);
|
expect(telemetryTrack).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
storeState.isSendingMessage = false;
|
await rerender(inputProps({ suggestions, currentThreadId: 'thread-shown' }));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(telemetryTrack).toHaveBeenCalledTimes(1);
|
expect(telemetryTrack).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('tracks suggestions shown again when the empty-state thread changes', async () => {
|
it('tracks suggestions shown again when the empty-state thread changes', async () => {
|
||||||
storeState.currentThreadId = 'thread-a';
|
const { rerender } = renderComponent({
|
||||||
|
|
||||||
renderComponent({
|
|
||||||
props: {
|
props: {
|
||||||
isStreaming: false,
|
isStreaming: false,
|
||||||
suggestions,
|
suggestions,
|
||||||
|
currentThreadId: 'thread-a',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -446,7 +461,7 @@ describe('InstanceAiInput', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
storeState.currentThreadId = 'thread-b';
|
await rerender(inputProps({ suggestions, currentThreadId: 'thread-b' }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(telemetryTrack).toHaveBeenCalledWith('Instance AI prompt suggestions shown', {
|
expect(telemetryTrack).toHaveBeenCalledWith('Instance AI prompt suggestions shown', {
|
||||||
|
|
@ -483,11 +498,11 @@ describe('InstanceAiInput', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('includes research mode in the quick examples telemetry payload when enabled', async () => {
|
it('includes research mode in the quick examples telemetry payload when enabled', async () => {
|
||||||
storeState.researchMode = true;
|
|
||||||
const { getByTestId } = renderComponent({
|
const { getByTestId } = renderComponent({
|
||||||
props: {
|
props: {
|
||||||
isStreaming: false,
|
isStreaming: false,
|
||||||
suggestions,
|
suggestions,
|
||||||
|
researchMode: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -538,11 +553,11 @@ describe('InstanceAiInput', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('includes the research-mode flag when tracking top-level suggestion selection telemetry', async () => {
|
it('includes the research-mode flag when tracking top-level suggestion selection telemetry', async () => {
|
||||||
storeState.researchMode = true;
|
|
||||||
const { getByTestId } = renderComponent({
|
const { getByTestId } = renderComponent({
|
||||||
props: {
|
props: {
|
||||||
isStreaming: false,
|
isStreaming: false,
|
||||||
suggestions,
|
suggestions,
|
||||||
|
researchMode: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
import { createComponentRenderer } from '@/__tests__/render';
|
import { createThreadComponentRenderer } from './createThreadComponentRenderer';
|
||||||
import { createTestingPinia } from '@pinia/testing';
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
import { mockedStore } from '@/__tests__/utils';
|
import { mockedStore } from '@/__tests__/utils';
|
||||||
import InstanceAiMarkdown from '../components/InstanceAiMarkdown.vue';
|
import InstanceAiMarkdown from '../components/InstanceAiMarkdown.vue';
|
||||||
|
|
@ -14,7 +14,7 @@ vi.mock('@/features/ai/chatHub/components/ChatMarkdownChunk.vue', () => ({
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const renderComponent = createComponentRenderer(InstanceAiMarkdown);
|
const renderComponent = createThreadComponentRenderer(InstanceAiMarkdown);
|
||||||
|
|
||||||
function makeRegistry(
|
function makeRegistry(
|
||||||
entries: Array<{ type: string; id: string; name: string; projectId?: string }>,
|
entries: Array<{ type: string; id: string; name: string; projectId?: string }>,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
import { createComponentRenderer } from '@/__tests__/render';
|
import { createThreadComponentRenderer } from './createThreadComponentRenderer';
|
||||||
import { createTestingPinia } from '@pinia/testing';
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
import InstanceAiMessageComponent from '../components/InstanceAiMessage.vue';
|
import InstanceAiMessageComponent from '../components/InstanceAiMessage.vue';
|
||||||
import type { InstanceAiMessage, InstanceAiAgentNode } from '@n8n/api-types';
|
import type { InstanceAiMessage, InstanceAiAgentNode } from '@n8n/api-types';
|
||||||
|
|
@ -11,7 +11,7 @@ vi.mock('@/features/ai/chatHub/components/ChatMarkdownChunk.vue', () => ({
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const renderComponent = createComponentRenderer(InstanceAiMessageComponent, {
|
const renderComponent = createThreadComponentRenderer(InstanceAiMessageComponent, {
|
||||||
global: {
|
global: {
|
||||||
stubs: {
|
stubs: {
|
||||||
AgentActivityTree: {
|
AgentActivityTree: {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { createTestingPinia } from '@pinia/testing';
|
||||||
import { setActivePinia } from 'pinia';
|
import { setActivePinia } from 'pinia';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { waitFor } from '@testing-library/vue';
|
import { waitFor } from '@testing-library/vue';
|
||||||
import { createComponentRenderer } from '@/__tests__/render';
|
import { createThreadComponentRenderer } from './createThreadComponentRenderer';
|
||||||
import type { InstanceAiWorkflowSetupNode } from '@n8n/api-types';
|
import type { InstanceAiWorkflowSetupNode } from '@n8n/api-types';
|
||||||
import InstanceAiWorkflowSetup from '../components/InstanceAiWorkflowSetup.vue';
|
import InstanceAiWorkflowSetup from '../components/InstanceAiWorkflowSetup.vue';
|
||||||
import { useInstanceAiStore } from '../instanceAi.store';
|
import { useInstanceAiStore } from '../instanceAi.store';
|
||||||
|
|
@ -78,7 +78,7 @@ vi.mock('@/features/workflows/canvas/experimental/composables/useExpressionResol
|
||||||
useExpressionResolveCtx: () => ({}),
|
useExpressionResolveCtx: () => ({}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const renderComponent = createComponentRenderer(InstanceAiWorkflowSetup);
|
const renderComponent = createThreadComponentRenderer(InstanceAiWorkflowSetup);
|
||||||
|
|
||||||
/** Render the component and wait for the async onMounted to complete (isStoreReady = true). */
|
/** Render the component and wait for the async onMounted to complete (isStoreReady = true). */
|
||||||
async function renderAndWait(
|
async function renderAndWait(
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
import { createComponentRenderer } from '@/__tests__/render';
|
import { createThreadComponentRenderer } from './createThreadComponentRenderer';
|
||||||
import { createTestingPinia } from '@pinia/testing';
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
import SubagentStepTimeline from '../components/SubagentStepTimeline.vue';
|
import SubagentStepTimeline from '../components/SubagentStepTimeline.vue';
|
||||||
import type { InstanceAiAgentNode, InstanceAiToolCallState } from '@n8n/api-types';
|
import type { InstanceAiAgentNode, InstanceAiToolCallState } from '@n8n/api-types';
|
||||||
|
|
||||||
const renderComponent = createComponentRenderer(SubagentStepTimeline, {
|
const renderComponent = createThreadComponentRenderer(SubagentStepTimeline, {
|
||||||
global: {
|
global: {
|
||||||
stubs: {
|
stubs: {
|
||||||
// ToolCallStep is stubbed so we can verify which toolCall was passed
|
// ToolCallStep is stubbed so we can verify which toolCall was passed
|
||||||
|
|
|
||||||
|
|
@ -191,8 +191,8 @@ export async function createInstanceAiHarness(): Promise<InstanceAiHarness> {
|
||||||
const executionTracking = useExecutionPushEvents();
|
const executionTracking = useExecutionPushEvents();
|
||||||
|
|
||||||
const preview = useCanvasPreview({
|
const preview = useCanvasPreview({
|
||||||
store: store as unknown as ReturnType<typeof useInstanceAiStore>,
|
thread: store as unknown as ReturnType<typeof useInstanceAiStore>,
|
||||||
route: route as Parameters<typeof useCanvasPreview>[0]['route'],
|
threadId: () => route.params.threadId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const relayedEvents: PushMessage[] = [];
|
const relayedEvents: PushMessage[] = [];
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { defineComponent, h, type Component } from 'vue';
|
||||||
|
import { createComponentRenderer, type RenderOptions } from '@/__tests__/render';
|
||||||
|
import { provideThread, useInstanceAiStore } from '../instanceAi.store';
|
||||||
|
|
||||||
|
type RendererOptions = { merge?: boolean };
|
||||||
|
|
||||||
|
export function createThreadComponentRenderer<T extends Component>(
|
||||||
|
component: T,
|
||||||
|
defaultOptions: RenderOptions<T> = {},
|
||||||
|
) {
|
||||||
|
const ThreadProvider = defineComponent({
|
||||||
|
name: 'InstanceAiThreadTestProvider',
|
||||||
|
inheritAttrs: false,
|
||||||
|
setup(_, { attrs, slots }) {
|
||||||
|
provideThread(useInstanceAiStore());
|
||||||
|
return () => h(component as Component, attrs, slots);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderProvider = createComponentRenderer(
|
||||||
|
ThreadProvider,
|
||||||
|
defaultOptions as unknown as RenderOptions<typeof ThreadProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (options: RenderOptions<T> = {}, rendererOptions: RendererOptions = {}) =>
|
||||||
|
renderProvider(options as unknown as RenderOptions<typeof ThreadProvider>, rendererOptions);
|
||||||
|
}
|
||||||
|
|
@ -47,10 +47,10 @@ function makeMessage(overrides: Partial<InstanceAiMessage> = {}): InstanceAiMess
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Store mock
|
// Thread mock
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function createMockStore() {
|
function createMockThread() {
|
||||||
const messages = ref<InstanceAiMessage[]>([]) as Ref<InstanceAiMessage[]>;
|
const messages = ref<InstanceAiMessage[]>([]) as Ref<InstanceAiMessage[]>;
|
||||||
const isStreaming = ref(false);
|
const isStreaming = ref(false);
|
||||||
const isHydratingThread = ref(false);
|
const isHydratingThread = ref(false);
|
||||||
|
|
@ -67,31 +67,36 @@ function createMockStore() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
type MockStore = ReturnType<typeof createMockStore>;
|
type MockThread = ReturnType<typeof createMockThread>;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Registry helpers — populate the store's producedArtifacts so computed
|
// Registry helpers — populate the thread's producedArtifacts so computed
|
||||||
// activeWorkflowId / activeDataTableId can derive values from tabs.
|
// activeWorkflowId / activeDataTableId can derive values from tabs.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function registerWorkflow(store: MockStore, id: string, name = `Workflow ${id}`) {
|
function registerWorkflow(thread: MockThread, id: string, name = `Workflow ${id}`) {
|
||||||
const entry: ResourceEntry = { type: 'workflow', id, name };
|
const entry: ResourceEntry = { type: 'workflow', id, name };
|
||||||
const nextProduced = new Map(store.producedArtifacts);
|
const nextProduced = new Map(thread.producedArtifacts);
|
||||||
nextProduced.set(id, entry);
|
nextProduced.set(id, entry);
|
||||||
store.producedArtifacts = nextProduced;
|
thread.producedArtifacts = nextProduced;
|
||||||
const nextByName = new Map(store.resourceNameIndex);
|
const nextByName = new Map(thread.resourceNameIndex);
|
||||||
nextByName.set(name.toLowerCase(), entry);
|
nextByName.set(name.toLowerCase(), entry);
|
||||||
store.resourceNameIndex = nextByName;
|
thread.resourceNameIndex = nextByName;
|
||||||
}
|
}
|
||||||
|
|
||||||
function registerDataTable(store: MockStore, id: string, name = `Table ${id}`, projectId?: string) {
|
function registerDataTable(
|
||||||
|
thread: MockThread,
|
||||||
|
id: string,
|
||||||
|
name = `Table ${id}`,
|
||||||
|
projectId?: string,
|
||||||
|
) {
|
||||||
const entry: ResourceEntry = { type: 'data-table', id, name, projectId };
|
const entry: ResourceEntry = { type: 'data-table', id, name, projectId };
|
||||||
const nextProduced = new Map(store.producedArtifacts);
|
const nextProduced = new Map(thread.producedArtifacts);
|
||||||
nextProduced.set(id, entry);
|
nextProduced.set(id, entry);
|
||||||
store.producedArtifacts = nextProduced;
|
thread.producedArtifacts = nextProduced;
|
||||||
const nextByName = new Map(store.resourceNameIndex);
|
const nextByName = new Map(thread.resourceNameIndex);
|
||||||
nextByName.set(name.toLowerCase(), entry);
|
nextByName.set(name.toLowerCase(), entry);
|
||||||
store.resourceNameIndex = nextByName;
|
thread.resourceNameIndex = nextByName;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -116,19 +121,18 @@ function createMockRoute(threadId = 'thread-1') {
|
||||||
// Test helper — create composable + flush
|
// Test helper — create composable + flush
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function setup(options?: { storeOverrides?: Partial<MockStore> }) {
|
function setup(options?: { threadOverrides?: Partial<MockThread> }) {
|
||||||
const store = createMockStore();
|
const thread = createMockThread();
|
||||||
if (options?.storeOverrides) Object.assign(store, options.storeOverrides);
|
if (options?.threadOverrides) Object.assign(thread, options.threadOverrides);
|
||||||
const route = createMockRoute();
|
const route = createMockRoute();
|
||||||
|
|
||||||
const result = useCanvasPreview({
|
const result = useCanvasPreview({
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
store: store as any,
|
thread: thread as any,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
threadId: () => route.params.threadId,
|
||||||
route: route as any,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return { ...result, store, route };
|
return { ...result, thread, route };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -143,8 +147,8 @@ describe('useCanvasPreview', () => {
|
||||||
describe('allArtifactTabs', () => {
|
describe('allArtifactTabs', () => {
|
||||||
test('derives tabs from resource registry', () => {
|
test('derives tabs from resource registry', () => {
|
||||||
const ctx = setup();
|
const ctx = setup();
|
||||||
registerWorkflow(ctx.store, 'wf-1', 'My Workflow');
|
registerWorkflow(ctx.thread, 'wf-1', 'My Workflow');
|
||||||
registerDataTable(ctx.store, 'dt-1', 'My Table', 'proj-1');
|
registerDataTable(ctx.thread, 'dt-1', 'My Table', 'proj-1');
|
||||||
|
|
||||||
expect(ctx.allArtifactTabs.value).toEqual([
|
expect(ctx.allArtifactTabs.value).toEqual([
|
||||||
{
|
{
|
||||||
|
|
@ -163,7 +167,7 @@ describe('useCanvasPreview', () => {
|
||||||
const registry = new Map<string, ResourceEntry>();
|
const registry = new Map<string, ResourceEntry>();
|
||||||
registry.set('wf-1', { type: 'workflow', id: 'wf-1', name: 'WF' });
|
registry.set('wf-1', { type: 'workflow', id: 'wf-1', name: 'WF' });
|
||||||
registry.set('cred-1', { type: 'credential', id: 'cred-1', name: 'Cred' });
|
registry.set('cred-1', { type: 'credential', id: 'cred-1', name: 'Cred' });
|
||||||
ctx.store.producedArtifacts = registry;
|
ctx.thread.producedArtifacts = registry;
|
||||||
|
|
||||||
expect(ctx.allArtifactTabs.value).toHaveLength(1);
|
expect(ctx.allArtifactTabs.value).toHaveLength(1);
|
||||||
expect(ctx.allArtifactTabs.value[0].type).toBe('workflow');
|
expect(ctx.allArtifactTabs.value[0].type).toBe('workflow');
|
||||||
|
|
@ -173,7 +177,7 @@ describe('useCanvasPreview', () => {
|
||||||
describe('selectTab / closePreview', () => {
|
describe('selectTab / closePreview', () => {
|
||||||
test('selectTab sets activeTabId', () => {
|
test('selectTab sets activeTabId', () => {
|
||||||
const ctx = setup();
|
const ctx = setup();
|
||||||
registerWorkflow(ctx.store, 'wf-1');
|
registerWorkflow(ctx.thread, 'wf-1');
|
||||||
|
|
||||||
ctx.selectTab('wf-1');
|
ctx.selectTab('wf-1');
|
||||||
|
|
||||||
|
|
@ -184,7 +188,7 @@ describe('useCanvasPreview', () => {
|
||||||
|
|
||||||
test('closePreview clears activeTabId', () => {
|
test('closePreview clears activeTabId', () => {
|
||||||
const ctx = setup();
|
const ctx = setup();
|
||||||
registerWorkflow(ctx.store, 'wf-1');
|
registerWorkflow(ctx.thread, 'wf-1');
|
||||||
ctx.selectTab('wf-1');
|
ctx.selectTab('wf-1');
|
||||||
|
|
||||||
ctx.closePreview();
|
ctx.closePreview();
|
||||||
|
|
@ -197,8 +201,8 @@ describe('useCanvasPreview', () => {
|
||||||
describe('openWorkflowPreview', () => {
|
describe('openWorkflowPreview', () => {
|
||||||
test('sets activeWorkflowId and clears data table state', () => {
|
test('sets activeWorkflowId and clears data table state', () => {
|
||||||
const ctx = setup();
|
const ctx = setup();
|
||||||
registerDataTable(ctx.store, 'dt-1', 'Table', 'proj-1');
|
registerDataTable(ctx.thread, 'dt-1', 'Table', 'proj-1');
|
||||||
registerWorkflow(ctx.store, 'wf-1');
|
registerWorkflow(ctx.thread, 'wf-1');
|
||||||
ctx.openDataTablePreview('dt-1', 'proj-1');
|
ctx.openDataTablePreview('dt-1', 'proj-1');
|
||||||
|
|
||||||
ctx.openWorkflowPreview('wf-1');
|
ctx.openWorkflowPreview('wf-1');
|
||||||
|
|
@ -210,7 +214,7 @@ describe('useCanvasPreview', () => {
|
||||||
|
|
||||||
test('makes isPreviewVisible true', () => {
|
test('makes isPreviewVisible true', () => {
|
||||||
const ctx = setup();
|
const ctx = setup();
|
||||||
registerWorkflow(ctx.store, 'wf-1');
|
registerWorkflow(ctx.thread, 'wf-1');
|
||||||
expect(ctx.isPreviewVisible.value).toBe(false);
|
expect(ctx.isPreviewVisible.value).toBe(false);
|
||||||
|
|
||||||
ctx.openWorkflowPreview('wf-1');
|
ctx.openWorkflowPreview('wf-1');
|
||||||
|
|
@ -221,8 +225,8 @@ describe('useCanvasPreview', () => {
|
||||||
describe('openDataTablePreview', () => {
|
describe('openDataTablePreview', () => {
|
||||||
test('sets data table state and clears workflow state', () => {
|
test('sets data table state and clears workflow state', () => {
|
||||||
const ctx = setup();
|
const ctx = setup();
|
||||||
registerWorkflow(ctx.store, 'wf-1');
|
registerWorkflow(ctx.thread, 'wf-1');
|
||||||
registerDataTable(ctx.store, 'dt-1', 'Table', 'proj-1');
|
registerDataTable(ctx.thread, 'dt-1', 'Table', 'proj-1');
|
||||||
ctx.openWorkflowPreview('wf-1');
|
ctx.openWorkflowPreview('wf-1');
|
||||||
|
|
||||||
ctx.openDataTablePreview('dt-1', 'proj-1');
|
ctx.openDataTablePreview('dt-1', 'proj-1');
|
||||||
|
|
@ -234,7 +238,7 @@ describe('useCanvasPreview', () => {
|
||||||
|
|
||||||
test('makes isPreviewVisible true', () => {
|
test('makes isPreviewVisible true', () => {
|
||||||
const ctx = setup();
|
const ctx = setup();
|
||||||
registerDataTable(ctx.store, 'dt-1', 'Table', 'proj-1');
|
registerDataTable(ctx.thread, 'dt-1', 'Table', 'proj-1');
|
||||||
expect(ctx.isPreviewVisible.value).toBe(false);
|
expect(ctx.isPreviewVisible.value).toBe(false);
|
||||||
|
|
||||||
ctx.openDataTablePreview('dt-1', 'proj-1');
|
ctx.openDataTablePreview('dt-1', 'proj-1');
|
||||||
|
|
@ -245,7 +249,7 @@ describe('useCanvasPreview', () => {
|
||||||
describe('thread switch (route.params.threadId change)', () => {
|
describe('thread switch (route.params.threadId change)', () => {
|
||||||
test('resets all preview state on thread switch', async () => {
|
test('resets all preview state on thread switch', async () => {
|
||||||
const ctx = setup();
|
const ctx = setup();
|
||||||
registerWorkflow(ctx.store, 'wf-1');
|
registerWorkflow(ctx.thread, 'wf-1');
|
||||||
ctx.openWorkflowPreview('wf-1');
|
ctx.openWorkflowPreview('wf-1');
|
||||||
|
|
||||||
ctx.route.params.threadId = 'thread-2';
|
ctx.route.params.threadId = 'thread-2';
|
||||||
|
|
@ -259,7 +263,7 @@ describe('useCanvasPreview', () => {
|
||||||
|
|
||||||
test('clears the preview on thread switch, then stays closed while the new thread hydrates', async () => {
|
test('clears the preview on thread switch, then stays closed while the new thread hydrates', async () => {
|
||||||
const ctx = setup();
|
const ctx = setup();
|
||||||
registerWorkflow(ctx.store, 'wf-1');
|
registerWorkflow(ctx.thread, 'wf-1');
|
||||||
ctx.openWorkflowPreview('wf-1');
|
ctx.openWorkflowPreview('wf-1');
|
||||||
expect(ctx.isPreviewVisible.value).toBe(true);
|
expect(ctx.isPreviewVisible.value).toBe(true);
|
||||||
|
|
||||||
|
|
@ -271,9 +275,9 @@ describe('useCanvasPreview', () => {
|
||||||
|
|
||||||
// Past artifacts surfacing during the new thread's hydration shouldn't
|
// Past artifacts surfacing during the new thread's hydration shouldn't
|
||||||
// pop the panel — historical data, not a live build.
|
// pop the panel — historical data, not a live build.
|
||||||
ctx.store.isHydratingThread = true;
|
ctx.thread.isHydratingThread = true;
|
||||||
registerWorkflow(ctx.store, 'wf-historical');
|
registerWorkflow(ctx.thread, 'wf-historical');
|
||||||
ctx.store.messages = [
|
ctx.thread.messages = [
|
||||||
makeMessage({
|
makeMessage({
|
||||||
agentTree: makeAgentNode({
|
agentTree: makeAgentNode({
|
||||||
toolCalls: [
|
toolCalls: [
|
||||||
|
|
@ -295,10 +299,10 @@ describe('useCanvasPreview', () => {
|
||||||
describe('auto-open on build result', () => {
|
describe('auto-open on build result', () => {
|
||||||
test('auto-opens canvas when streaming and build result appears', async () => {
|
test('auto-opens canvas when streaming and build result appears', async () => {
|
||||||
const ctx = setup();
|
const ctx = setup();
|
||||||
ctx.store.isStreaming = true;
|
ctx.thread.isStreaming = true;
|
||||||
registerWorkflow(ctx.store, 'wf-new');
|
registerWorkflow(ctx.thread, 'wf-new');
|
||||||
|
|
||||||
ctx.store.messages = [
|
ctx.thread.messages = [
|
||||||
makeMessage({
|
makeMessage({
|
||||||
agentTree: makeAgentNode({
|
agentTree: makeAgentNode({
|
||||||
toolCalls: [
|
toolCalls: [
|
||||||
|
|
@ -321,9 +325,9 @@ describe('useCanvasPreview', () => {
|
||||||
const ctx = setup();
|
const ctx = setup();
|
||||||
// Simulate the loadHistoricalMessages window: artifacts that surface
|
// Simulate the loadHistoricalMessages window: artifacts that surface
|
||||||
// as part of past data shouldn't pop the preview panel.
|
// as part of past data shouldn't pop the preview panel.
|
||||||
ctx.store.isHydratingThread = true;
|
ctx.thread.isHydratingThread = true;
|
||||||
|
|
||||||
ctx.store.messages = [
|
ctx.thread.messages = [
|
||||||
makeMessage({
|
makeMessage({
|
||||||
agentTree: makeAgentNode({
|
agentTree: makeAgentNode({
|
||||||
toolCalls: [
|
toolCalls: [
|
||||||
|
|
@ -344,12 +348,12 @@ describe('useCanvasPreview', () => {
|
||||||
|
|
||||||
test('switches to latest artifact when a new workflow is built while viewing different artifact', async () => {
|
test('switches to latest artifact when a new workflow is built while viewing different artifact', async () => {
|
||||||
const ctx = setup();
|
const ctx = setup();
|
||||||
registerDataTable(ctx.store, 'dt-1', 'Table', 'proj-1');
|
registerDataTable(ctx.thread, 'dt-1', 'Table', 'proj-1');
|
||||||
registerWorkflow(ctx.store, 'wf-1');
|
registerWorkflow(ctx.thread, 'wf-1');
|
||||||
ctx.openDataTablePreview('dt-1', 'proj-1');
|
ctx.openDataTablePreview('dt-1', 'proj-1');
|
||||||
ctx.store.isStreaming = true;
|
ctx.thread.isStreaming = true;
|
||||||
|
|
||||||
ctx.store.messages = [
|
ctx.thread.messages = [
|
||||||
makeMessage({
|
makeMessage({
|
||||||
agentTree: makeAgentNode({
|
agentTree: makeAgentNode({
|
||||||
toolCalls: [
|
toolCalls: [
|
||||||
|
|
@ -371,11 +375,11 @@ describe('useCanvasPreview', () => {
|
||||||
|
|
||||||
test('increments workflowRefreshKey on each build', async () => {
|
test('increments workflowRefreshKey on each build', async () => {
|
||||||
const ctx = setup();
|
const ctx = setup();
|
||||||
ctx.store.isStreaming = true;
|
ctx.thread.isStreaming = true;
|
||||||
registerWorkflow(ctx.store, 'wf-1');
|
registerWorkflow(ctx.thread, 'wf-1');
|
||||||
const initialKey = ctx.workflowRefreshKey.value;
|
const initialKey = ctx.workflowRefreshKey.value;
|
||||||
|
|
||||||
ctx.store.messages = [
|
ctx.thread.messages = [
|
||||||
makeMessage({
|
makeMessage({
|
||||||
agentTree: makeAgentNode({
|
agentTree: makeAgentNode({
|
||||||
toolCalls: [
|
toolCalls: [
|
||||||
|
|
@ -397,10 +401,10 @@ describe('useCanvasPreview', () => {
|
||||||
describe('auto-open data table preview', () => {
|
describe('auto-open data table preview', () => {
|
||||||
test('auto-opens data table preview when streaming', async () => {
|
test('auto-opens data table preview when streaming', async () => {
|
||||||
const ctx = setup();
|
const ctx = setup();
|
||||||
ctx.store.isStreaming = true;
|
ctx.thread.isStreaming = true;
|
||||||
registerDataTable(ctx.store, 'dt-1', 'Test Table');
|
registerDataTable(ctx.thread, 'dt-1', 'Test Table');
|
||||||
|
|
||||||
ctx.store.messages = [
|
ctx.thread.messages = [
|
||||||
makeMessage({
|
makeMessage({
|
||||||
agentTree: makeAgentNode({
|
agentTree: makeAgentNode({
|
||||||
toolCalls: [
|
toolCalls: [
|
||||||
|
|
@ -422,9 +426,9 @@ describe('useCanvasPreview', () => {
|
||||||
|
|
||||||
test('does not auto-open data table preview while hydrating', async () => {
|
test('does not auto-open data table preview while hydrating', async () => {
|
||||||
const ctx = setup();
|
const ctx = setup();
|
||||||
ctx.store.isHydratingThread = true;
|
ctx.thread.isHydratingThread = true;
|
||||||
|
|
||||||
ctx.store.messages = [
|
ctx.thread.messages = [
|
||||||
makeMessage({
|
makeMessage({
|
||||||
agentTree: makeAgentNode({
|
agentTree: makeAgentNode({
|
||||||
toolCalls: [
|
toolCalls: [
|
||||||
|
|
@ -445,10 +449,10 @@ describe('useCanvasPreview', () => {
|
||||||
|
|
||||||
test('looks up projectId from producedArtifacts', async () => {
|
test('looks up projectId from producedArtifacts', async () => {
|
||||||
const ctx = setup();
|
const ctx = setup();
|
||||||
ctx.store.isStreaming = true;
|
ctx.thread.isStreaming = true;
|
||||||
registerDataTable(ctx.store, 'dt-1', 'Test Table', 'proj-42');
|
registerDataTable(ctx.thread, 'dt-1', 'Test Table', 'proj-42');
|
||||||
|
|
||||||
ctx.store.messages = [
|
ctx.thread.messages = [
|
||||||
makeMessage({
|
makeMessage({
|
||||||
agentTree: makeAgentNode({
|
agentTree: makeAgentNode({
|
||||||
toolCalls: [
|
toolCalls: [
|
||||||
|
|
@ -469,10 +473,10 @@ describe('useCanvasPreview', () => {
|
||||||
|
|
||||||
test('increments dataTableRefreshKey on each data table update', async () => {
|
test('increments dataTableRefreshKey on each data table update', async () => {
|
||||||
const ctx = setup();
|
const ctx = setup();
|
||||||
ctx.store.isStreaming = true;
|
ctx.thread.isStreaming = true;
|
||||||
const initialKey = ctx.dataTableRefreshKey.value;
|
const initialKey = ctx.dataTableRefreshKey.value;
|
||||||
|
|
||||||
ctx.store.messages = [
|
ctx.thread.messages = [
|
||||||
makeMessage({
|
makeMessage({
|
||||||
agentTree: makeAgentNode({
|
agentTree: makeAgentNode({
|
||||||
toolCalls: [
|
toolCalls: [
|
||||||
|
|
@ -495,11 +499,11 @@ describe('useCanvasPreview', () => {
|
||||||
describe('close data table on delete', () => {
|
describe('close data table on delete', () => {
|
||||||
test('closes data table preview when active table is deleted', async () => {
|
test('closes data table preview when active table is deleted', async () => {
|
||||||
const ctx = setup();
|
const ctx = setup();
|
||||||
registerDataTable(ctx.store, 'dt-1', 'Table', 'proj-1');
|
registerDataTable(ctx.thread, 'dt-1', 'Table', 'proj-1');
|
||||||
ctx.openDataTablePreview('dt-1', 'proj-1');
|
ctx.openDataTablePreview('dt-1', 'proj-1');
|
||||||
expect(ctx.activeDataTableId.value).toBe('dt-1');
|
expect(ctx.activeDataTableId.value).toBe('dt-1');
|
||||||
|
|
||||||
ctx.store.messages = [
|
ctx.thread.messages = [
|
||||||
makeMessage({
|
makeMessage({
|
||||||
agentTree: makeAgentNode({
|
agentTree: makeAgentNode({
|
||||||
toolCalls: [
|
toolCalls: [
|
||||||
|
|
@ -519,10 +523,10 @@ describe('useCanvasPreview', () => {
|
||||||
|
|
||||||
test('does not close preview when a different table is deleted', async () => {
|
test('does not close preview when a different table is deleted', async () => {
|
||||||
const ctx = setup();
|
const ctx = setup();
|
||||||
registerDataTable(ctx.store, 'dt-1', 'Table 1', 'proj-1');
|
registerDataTable(ctx.thread, 'dt-1', 'Table 1', 'proj-1');
|
||||||
ctx.openDataTablePreview('dt-1', 'proj-1');
|
ctx.openDataTablePreview('dt-1', 'proj-1');
|
||||||
|
|
||||||
ctx.store.messages = [
|
ctx.thread.messages = [
|
||||||
makeMessage({
|
makeMessage({
|
||||||
agentTree: makeAgentNode({
|
agentTree: makeAgentNode({
|
||||||
toolCalls: [
|
toolCalls: [
|
||||||
|
|
@ -542,11 +546,11 @@ describe('useCanvasPreview', () => {
|
||||||
|
|
||||||
test('falls back to first remaining tab when active table is deleted', async () => {
|
test('falls back to first remaining tab when active table is deleted', async () => {
|
||||||
const ctx = setup();
|
const ctx = setup();
|
||||||
registerWorkflow(ctx.store, 'wf-1');
|
registerWorkflow(ctx.thread, 'wf-1');
|
||||||
registerDataTable(ctx.store, 'dt-1', 'Table', 'proj-1');
|
registerDataTable(ctx.thread, 'dt-1', 'Table', 'proj-1');
|
||||||
ctx.openDataTablePreview('dt-1', 'proj-1');
|
ctx.openDataTablePreview('dt-1', 'proj-1');
|
||||||
|
|
||||||
ctx.store.messages = [
|
ctx.thread.messages = [
|
||||||
makeMessage({
|
makeMessage({
|
||||||
agentTree: makeAgentNode({
|
agentTree: makeAgentNode({
|
||||||
toolCalls: [
|
toolCalls: [
|
||||||
|
|
@ -570,14 +574,14 @@ describe('useCanvasPreview', () => {
|
||||||
describe('isPreviewVisible', () => {
|
describe('isPreviewVisible', () => {
|
||||||
test('is true when workflow is active', () => {
|
test('is true when workflow is active', () => {
|
||||||
const ctx = setup();
|
const ctx = setup();
|
||||||
registerWorkflow(ctx.store, 'wf-1');
|
registerWorkflow(ctx.thread, 'wf-1');
|
||||||
ctx.openWorkflowPreview('wf-1');
|
ctx.openWorkflowPreview('wf-1');
|
||||||
expect(ctx.isPreviewVisible.value).toBe(true);
|
expect(ctx.isPreviewVisible.value).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('is true when data table is active', () => {
|
test('is true when data table is active', () => {
|
||||||
const ctx = setup();
|
const ctx = setup();
|
||||||
registerDataTable(ctx.store, 'dt-1', 'Table', 'proj-1');
|
registerDataTable(ctx.thread, 'dt-1', 'Table', 'proj-1');
|
||||||
ctx.openDataTablePreview('dt-1', 'proj-1');
|
ctx.openDataTablePreview('dt-1', 'proj-1');
|
||||||
expect(ctx.isPreviewVisible.value).toBe(true);
|
expect(ctx.isPreviewVisible.value).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
@ -591,8 +595,8 @@ describe('useCanvasPreview', () => {
|
||||||
describe('tab guard', () => {
|
describe('tab guard', () => {
|
||||||
test('falls back to first tab when active tab is removed from registry', async () => {
|
test('falls back to first tab when active tab is removed from registry', async () => {
|
||||||
const ctx = setup();
|
const ctx = setup();
|
||||||
registerWorkflow(ctx.store, 'wf-1');
|
registerWorkflow(ctx.thread, 'wf-1');
|
||||||
registerWorkflow(ctx.store, 'wf-2', 'Second Workflow');
|
registerWorkflow(ctx.thread, 'wf-2', 'Second Workflow');
|
||||||
ctx.selectTab('wf-2');
|
ctx.selectTab('wf-2');
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
|
|
@ -601,7 +605,7 @@ describe('useCanvasPreview', () => {
|
||||||
// Remove wf-2 from registry, keeping wf-1
|
// Remove wf-2 from registry, keeping wf-1
|
||||||
const next = new Map<string, ResourceEntry>();
|
const next = new Map<string, ResourceEntry>();
|
||||||
next.set('wf-1', { type: 'workflow', id: 'wf-1', name: 'Workflow wf-1' });
|
next.set('wf-1', { type: 'workflow', id: 'wf-1', name: 'Workflow wf-1' });
|
||||||
ctx.store.producedArtifacts = next;
|
ctx.thread.producedArtifacts = next;
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
expect(ctx.activeTabId.value).toBe('wf-1');
|
expect(ctx.activeTabId.value).toBe('wf-1');
|
||||||
|
|
@ -609,12 +613,12 @@ describe('useCanvasPreview', () => {
|
||||||
|
|
||||||
test('does not clear activeTabId when registry is empty (race condition)', async () => {
|
test('does not clear activeTabId when registry is empty (race condition)', async () => {
|
||||||
const ctx = setup();
|
const ctx = setup();
|
||||||
registerWorkflow(ctx.store, 'wf-1');
|
registerWorkflow(ctx.thread, 'wf-1');
|
||||||
ctx.selectTab('wf-1');
|
ctx.selectTab('wf-1');
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
// Temporarily empty registry (simulates race where registry hasn't been populated yet)
|
// Temporarily empty registry (simulates race where registry hasn't been populated yet)
|
||||||
ctx.store.producedArtifacts = new Map();
|
ctx.thread.producedArtifacts = new Map();
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
// Tab should remain set — guard skips when tabs are empty
|
// Tab should remain set — guard skips when tabs are empty
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { CollapsibleRoot, CollapsibleTrigger } from 'reka-ui';
|
||||||
import AnimatedCollapsibleContent from './AnimatedCollapsibleContent.vue';
|
import AnimatedCollapsibleContent from './AnimatedCollapsibleContent.vue';
|
||||||
import { computed, toRef, useTemplateRef } from 'vue';
|
import { computed, toRef, useTemplateRef } from 'vue';
|
||||||
import type { ArtifactInfo } from '../agentTimeline.utils';
|
import type { ArtifactInfo } from '../agentTimeline.utils';
|
||||||
import { useInstanceAiStore } from '../instanceAi.store';
|
import { useThread } from '../instanceAi.store';
|
||||||
import { useTimelineGrouping } from '../useTimelineGrouping';
|
import { useTimelineGrouping } from '../useTimelineGrouping';
|
||||||
import AgentTimeline from './AgentTimeline.vue';
|
import AgentTimeline from './AgentTimeline.vue';
|
||||||
import ArtifactCard from './ArtifactCard.vue';
|
import ArtifactCard from './ArtifactCard.vue';
|
||||||
|
|
@ -25,7 +25,7 @@ const props = withDefaults(
|
||||||
);
|
);
|
||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const store = useInstanceAiStore();
|
const thread = useThread();
|
||||||
|
|
||||||
const hasReasoning = computed(() => props.agentNode.reasoning.length > 0);
|
const hasReasoning = computed(() => props.agentNode.reasoning.length > 0);
|
||||||
const triggerRef = useTemplateRef<HTMLElement>('triggerRef');
|
const triggerRef = useTemplateRef<HTMLElement>('triggerRef');
|
||||||
|
|
@ -46,7 +46,7 @@ const lastGroupIdx = computed(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
function resolveArtifactName(artifact: ArtifactInfo): string {
|
function resolveArtifactName(artifact: ArtifactInfo): string {
|
||||||
const entry = store.producedArtifacts.get(artifact.resourceId);
|
const entry = thread.producedArtifacts.get(artifact.resourceId);
|
||||||
return entry?.name ?? artifact.name;
|
return entry?.name ?? artifact.name;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -91,7 +91,7 @@ function resolveArtifactName(artifact: ArtifactInfo): string {
|
||||||
:name="resolveArtifactName(artifact)"
|
:name="resolveArtifactName(artifact)"
|
||||||
:resource-id="artifact.resourceId"
|
:resource-id="artifact.resourceId"
|
||||||
:project-id="artifact.projectId"
|
:project-id="artifact.projectId"
|
||||||
:archived="store.producedArtifacts.get(artifact.resourceId)?.archived"
|
:archived="thread.producedArtifacts.get(artifact.resourceId)?.archived"
|
||||||
:class="$style.artifactCard"
|
:class="$style.artifactCard"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import { computed } from 'vue';
|
||||||
import { extractArtifacts, HIDDEN_TOOLS, type ArtifactInfo } from '../agentTimeline.utils';
|
import { extractArtifacts, HIDDEN_TOOLS, type ArtifactInfo } from '../agentTimeline.utils';
|
||||||
import { useTelemetry } from '@/app/composables/useTelemetry';
|
import { useTelemetry } from '@/app/composables/useTelemetry';
|
||||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||||
import { useInstanceAiStore } from '../instanceAi.store';
|
import { useThread } from '../instanceAi.store';
|
||||||
import { isActiveBuilderAgent } from '../builderAgents';
|
import { isActiveBuilderAgent } from '../builderAgents';
|
||||||
import AgentSection from './AgentSection.vue';
|
import AgentSection from './AgentSection.vue';
|
||||||
import AnsweredQuestions from './AnsweredQuestions.vue';
|
import AnsweredQuestions from './AnsweredQuestions.vue';
|
||||||
|
|
@ -23,13 +23,13 @@ import TaskChecklist from './TaskChecklist.vue';
|
||||||
import ToolCallStep from './ToolCallStep.vue';
|
import ToolCallStep from './ToolCallStep.vue';
|
||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const store = useInstanceAiStore();
|
const thread = useThread();
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
const rootStore = useRootStore();
|
const rootStore = useRootStore();
|
||||||
|
|
||||||
/** Resolve artifact name from the enriched registry (falls back to extracted name). */
|
/** Resolve artifact name from the enriched registry (falls back to extracted name). */
|
||||||
function resolveArtifactName(artifact: ArtifactInfo): string {
|
function resolveArtifactName(artifact: ArtifactInfo): string {
|
||||||
const entry = store.producedArtifacts.get(artifact.resourceId);
|
const entry = thread.producedArtifacts.get(artifact.resourceId);
|
||||||
return entry?.name ?? artifact.name;
|
return entry?.name ?? artifact.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -120,7 +120,7 @@ function handlePlanConfirm(tc: InstanceAiToolCallState, approved: boolean, feedb
|
||||||
|
|
||||||
const numTasks = ((tc.args?.tasks as PlannedTaskArg[] | undefined) ?? []).length;
|
const numTasks = ((tc.args?.tasks as PlannedTaskArg[] | undefined) ?? []).length;
|
||||||
const eventProps = {
|
const eventProps = {
|
||||||
thread_id: store.currentThreadId,
|
thread_id: thread.currentThreadId,
|
||||||
input_thread_id: tc.confirmation?.inputThreadId ?? '',
|
input_thread_id: tc.confirmation?.inputThreadId ?? '',
|
||||||
instance_id: rootStore.instanceId,
|
instance_id: rootStore.instanceId,
|
||||||
type: 'plan-review',
|
type: 'plan-review',
|
||||||
|
|
@ -137,8 +137,8 @@ function handlePlanConfirm(tc: InstanceAiToolCallState, approved: boolean, feedb
|
||||||
};
|
};
|
||||||
telemetry.track('User finished providing input', eventProps);
|
telemetry.track('User finished providing input', eventProps);
|
||||||
|
|
||||||
store.resolveConfirmation(requestId, approved ? 'approved' : 'denied');
|
thread.resolveConfirmation(requestId, approved ? 'approved' : 'denied');
|
||||||
void store.confirmAction(requestId, {
|
void thread.confirmAction(requestId, {
|
||||||
kind: 'approval',
|
kind: 'approval',
|
||||||
approved,
|
approved,
|
||||||
...(feedback ? { userInput: feedback } : {}),
|
...(feedback ? { userInput: feedback } : {}),
|
||||||
|
|
@ -293,7 +293,7 @@ function mapTaskItemsToPlannedTasks(tasks?: TaskList): PlannedTaskArg[] | undefi
|
||||||
:name="resolveArtifactName(artifact)"
|
:name="resolveArtifactName(artifact)"
|
||||||
:resource-id="artifact.resourceId"
|
:resource-id="artifact.resourceId"
|
||||||
:project-id="artifact.projectId"
|
:project-id="artifact.projectId"
|
||||||
:archived="store.producedArtifacts.get(artifact.resourceId)?.archived"
|
:archived="thread.producedArtifacts.get(artifact.resourceId)?.archived"
|
||||||
:metadata="formatArtifactMetadata(artifact)"
|
:metadata="formatArtifactMetadata(artifact)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { N8nButton, N8nText } from '@n8n/design-system';
|
||||||
import type { ActionDropdownItem } from '@n8n/design-system/types';
|
import type { ActionDropdownItem } from '@n8n/design-system/types';
|
||||||
import { useI18n } from '@n8n/i18n';
|
import { useI18n } from '@n8n/i18n';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useInstanceAiStore } from '../instanceAi.store';
|
import { useThread } from '../instanceAi.store';
|
||||||
import ConfirmationFooter from './ConfirmationFooter.vue';
|
import ConfirmationFooter from './ConfirmationFooter.vue';
|
||||||
import ConfirmationPreview from './ConfirmationPreview.vue';
|
import ConfirmationPreview from './ConfirmationPreview.vue';
|
||||||
import SplitButton from './SplitButton.vue';
|
import SplitButton from './SplitButton.vue';
|
||||||
|
|
@ -29,7 +29,7 @@ interface WebSearchProps {
|
||||||
const props = defineProps<DomainProps | WebSearchProps>();
|
const props = defineProps<DomainProps | WebSearchProps>();
|
||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const store = useInstanceAiStore();
|
const thread = useThread();
|
||||||
const resolved = ref(false);
|
const resolved = ref(false);
|
||||||
|
|
||||||
const isWebSearch = computed(() => props.query !== undefined);
|
const isWebSearch = computed(() => props.query !== undefined);
|
||||||
|
|
@ -63,8 +63,8 @@ const dropdownItems = computed<Array<ActionDropdownItem<DomainAction>>>(() => [
|
||||||
|
|
||||||
function handleAction(approved: boolean, domainAccessAction?: DomainAction) {
|
function handleAction(approved: boolean, domainAccessAction?: DomainAction) {
|
||||||
resolved.value = true;
|
resolved.value = true;
|
||||||
store.resolveConfirmation(props.requestId, approved ? 'approved' : 'denied');
|
thread.resolveConfirmation(props.requestId, approved ? 'approved' : 'denied');
|
||||||
void store.confirmAction(
|
void thread.confirmAction(
|
||||||
props.requestId,
|
props.requestId,
|
||||||
approved && domainAccessAction
|
approved && domainAccessAction
|
||||||
? { kind: 'domainAccessApprove', domainAccessAction }
|
? { kind: 'domainAccessApprove', domainAccessAction }
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { computed } from 'vue';
|
||||||
|
|
||||||
import { useTelemetry } from '@/app/composables/useTelemetry';
|
import { useTelemetry } from '@/app/composables/useTelemetry';
|
||||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||||
import { useInstanceAiStore } from '../instanceAi.store';
|
import { useThread } from '../instanceAi.store';
|
||||||
import ConfirmationFooter from './ConfirmationFooter.vue';
|
import ConfirmationFooter from './ConfirmationFooter.vue';
|
||||||
import ConfirmationPreview from './ConfirmationPreview.vue';
|
import ConfirmationPreview from './ConfirmationPreview.vue';
|
||||||
import SplitButton from './SplitButton.vue';
|
import SplitButton from './SplitButton.vue';
|
||||||
|
|
@ -35,7 +35,7 @@ const props = defineProps<{
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
const rootStore = useRootStore();
|
const rootStore = useRootStore();
|
||||||
const store = useInstanceAiStore();
|
const thread = useThread();
|
||||||
|
|
||||||
interface OptionEntry {
|
interface OptionEntry {
|
||||||
decision: InstanceGatewayResourceDecision;
|
decision: InstanceGatewayResourceDecision;
|
||||||
|
|
@ -72,10 +72,10 @@ const approveDropdownItems = computed(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
async function confirm(decision: InstanceGatewayResourceDecision) {
|
async function confirm(decision: InstanceGatewayResourceDecision) {
|
||||||
const tc = store.findToolCallByRequestId(props.requestId);
|
const tc = thread.findToolCallByRequestId(props.requestId);
|
||||||
const inputThreadId = tc?.confirmation?.inputThreadId ?? '';
|
const inputThreadId = tc?.confirmation?.inputThreadId ?? '';
|
||||||
const eventProps = {
|
const eventProps = {
|
||||||
thread_id: store.currentThreadId,
|
thread_id: thread.currentThreadId,
|
||||||
input_thread_id: inputThreadId,
|
input_thread_id: inputThreadId,
|
||||||
instance_id: rootStore.instanceId,
|
instance_id: rootStore.instanceId,
|
||||||
type: 'resource-decision',
|
type: 'resource-decision',
|
||||||
|
|
@ -83,7 +83,7 @@ async function confirm(decision: InstanceGatewayResourceDecision) {
|
||||||
skipped_inputs: [],
|
skipped_inputs: [],
|
||||||
};
|
};
|
||||||
telemetry.track('User finished providing input', eventProps);
|
telemetry.track('User finished providing input', eventProps);
|
||||||
await store.confirmResourceDecision(props.requestId, decision);
|
await thread.confirmResourceDecision(props.requestId, decision);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,14 @@
|
||||||
import { computed, inject } from 'vue';
|
import { computed, inject } from 'vue';
|
||||||
import { N8nHeading, N8nIcon } from '@n8n/design-system';
|
import { N8nHeading, N8nIcon } from '@n8n/design-system';
|
||||||
import { useI18n } from '@n8n/i18n';
|
import { useI18n } from '@n8n/i18n';
|
||||||
import { useInstanceAiStore } from '../instanceAi.store';
|
import { useThread } from '../instanceAi.store';
|
||||||
import type { TaskItem } from '@n8n/api-types';
|
import type { TaskItem } from '@n8n/api-types';
|
||||||
import type { IconName } from '@n8n/design-system';
|
import type { IconName } from '@n8n/design-system';
|
||||||
import type { ResourceEntry } from '../useResourceRegistry';
|
import type { ResourceEntry } from '../useResourceRegistry';
|
||||||
import ConnectionsCard from './ConnectionsCard.vue';
|
import ConnectionsCard from './ConnectionsCard.vue';
|
||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const store = useInstanceAiStore();
|
const thread = useThread();
|
||||||
const openPreview = inject<((id: string) => void) | undefined>('openWorkflowPreview', undefined);
|
const openPreview = inject<((id: string) => void) | undefined>('openWorkflowPreview', undefined);
|
||||||
const openDataTablePreview = inject<((id: string, projectId: string) => void) | undefined>(
|
const openDataTablePreview = inject<((id: string, projectId: string) => void) | undefined>(
|
||||||
'openDataTablePreview',
|
'openDataTablePreview',
|
||||||
|
|
@ -35,7 +35,7 @@ function handleArtifactClick(artifact: ResourceEntry, e: MouseEvent) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Tasks ---
|
// --- Tasks ---
|
||||||
const tasks = computed(() => store.currentTasks);
|
const tasks = computed(() => thread.currentTasks);
|
||||||
|
|
||||||
const doneCount = computed(() => {
|
const doneCount = computed(() => {
|
||||||
if (!tasks.value) return 0;
|
if (!tasks.value) return 0;
|
||||||
|
|
@ -56,7 +56,7 @@ const statusIconMap: Record<
|
||||||
// --- Artifacts ---
|
// --- Artifacts ---
|
||||||
const artifacts = computed((): ResourceEntry[] => {
|
const artifacts = computed((): ResourceEntry[] => {
|
||||||
const result: ResourceEntry[] = [];
|
const result: ResourceEntry[] = [];
|
||||||
for (const entry of store.producedArtifacts.values()) {
|
for (const entry of thread.producedArtifacts.values()) {
|
||||||
if (entry.type === 'workflow' || entry.type === 'data-table') {
|
if (entry.type === 'workflow' || entry.type === 'data-table') {
|
||||||
result.push(entry);
|
result.push(entry);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import type { InstanceAiConfirmation } from '@n8n/api-types';
|
||||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useTelemetry } from '@/app/composables/useTelemetry';
|
import { useTelemetry } from '@/app/composables/useTelemetry';
|
||||||
import { useInstanceAiStore, type PendingConfirmationItem } from '../instanceAi.store';
|
import { useThread, type PendingConfirmationItem } from '../instanceAi.store';
|
||||||
import { useToolLabel } from '../toolLabels';
|
import { useToolLabel } from '../toolLabels';
|
||||||
import ConfirmationFooter from './ConfirmationFooter.vue';
|
import ConfirmationFooter from './ConfirmationFooter.vue';
|
||||||
import DomainAccessApproval from './DomainAccessApproval.vue';
|
import DomainAccessApproval from './DomainAccessApproval.vue';
|
||||||
|
|
@ -17,7 +17,7 @@ import InstanceAiWorkflowSetup from './InstanceAiWorkflowSetup.vue';
|
||||||
import ConfirmationPreview from './ConfirmationPreview.vue';
|
import ConfirmationPreview from './ConfirmationPreview.vue';
|
||||||
import PlanReviewPanel, { type PlannedTaskArg } from './PlanReviewPanel.vue';
|
import PlanReviewPanel, { type PlannedTaskArg } from './PlanReviewPanel.vue';
|
||||||
|
|
||||||
const store = useInstanceAiStore();
|
const thread = useThread();
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const rootStore = useRootStore();
|
const rootStore = useRootStore();
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
|
|
@ -48,7 +48,7 @@ function trackInputCompleted(
|
||||||
extra?: Record<string, unknown>,
|
extra?: Record<string, unknown>,
|
||||||
): void {
|
): void {
|
||||||
const eventProps = {
|
const eventProps = {
|
||||||
thread_id: store.currentThreadId,
|
thread_id: thread.currentThreadId,
|
||||||
input_thread_id: conf.inputThreadId ?? '',
|
input_thread_id: conf.inputThreadId ?? '',
|
||||||
instance_id: rootStore.instanceId,
|
instance_id: rootStore.instanceId,
|
||||||
type: getConfirmationType(conf),
|
type: getConfirmationType(conf),
|
||||||
|
|
@ -108,7 +108,7 @@ const chunks = computed((): ConfirmationChunk[] => {
|
||||||
const result: ConfirmationChunk[] = [];
|
const result: ConfirmationChunk[] = [];
|
||||||
const wrappedByAgent = new Map<string, ApprovalWrappedGroup>();
|
const wrappedByAgent = new Map<string, ApprovalWrappedGroup>();
|
||||||
|
|
||||||
for (const item of store.pendingConfirmations) {
|
for (const item of thread.pendingConfirmations) {
|
||||||
if (isApprovalWrapped(item)) {
|
if (isApprovalWrapped(item)) {
|
||||||
const key = item.agentNode.agentId;
|
const key = item.agentNode.agentId;
|
||||||
let group = wrappedByAgent.get(key);
|
let group = wrappedByAgent.get(key);
|
||||||
|
|
@ -134,7 +134,7 @@ const textInputValues = ref<Record<string, string>>({});
|
||||||
|
|
||||||
function handleConfirm(item: PendingConfirmationItem, approved: boolean) {
|
function handleConfirm(item: PendingConfirmationItem, approved: boolean) {
|
||||||
const conf = item.toolCall.confirmation;
|
const conf = item.toolCall.confirmation;
|
||||||
if (store.resolvedConfirmationIds.has(conf.requestId)) return;
|
if (thread.resolvedConfirmationIds.has(conf.requestId)) return;
|
||||||
trackInputCompleted(
|
trackInputCompleted(
|
||||||
conf,
|
conf,
|
||||||
[
|
[
|
||||||
|
|
@ -146,21 +146,21 @@ function handleConfirm(item: PendingConfirmationItem, approved: boolean) {
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
store.resolveConfirmation(conf.requestId, approved ? 'approved' : 'denied');
|
thread.resolveConfirmation(conf.requestId, approved ? 'approved' : 'denied');
|
||||||
void store.confirmAction(conf.requestId, { kind: 'approval', approved });
|
void thread.confirmAction(conf.requestId, { kind: 'approval', approved });
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleApproveAll(items: PendingConfirmationItem[]) {
|
function handleApproveAll(items: PendingConfirmationItem[]) {
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const conf = item.toolCall.confirmation;
|
const conf = item.toolCall.confirmation;
|
||||||
if (store.resolvedConfirmationIds.has(conf.requestId)) continue;
|
if (thread.resolvedConfirmationIds.has(conf.requestId)) continue;
|
||||||
trackInputCompleted(
|
trackInputCompleted(
|
||||||
conf,
|
conf,
|
||||||
[{ label: conf.message, options: ['approve', 'deny'], option_chosen: 'approve' }],
|
[{ label: conf.message, options: ['approve', 'deny'], option_chosen: 'approve' }],
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
store.resolveConfirmation(conf.requestId, 'approved');
|
thread.resolveConfirmation(conf.requestId, 'approved');
|
||||||
void store.confirmAction(conf.requestId, { kind: 'approval', approved: true });
|
void thread.confirmAction(conf.requestId, { kind: 'approval', approved: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -180,8 +180,8 @@ function handleTextSubmit(conf: InstanceAiConfirmation) {
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
store.resolveConfirmation(conf.requestId, 'approved');
|
thread.resolveConfirmation(conf.requestId, 'approved');
|
||||||
void store.confirmAction(conf.requestId, { kind: 'approval', approved: true, userInput: value });
|
void thread.confirmAction(conf.requestId, { kind: 'approval', approved: true, userInput: value });
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTextSkip(conf: InstanceAiConfirmation) {
|
function handleTextSkip(conf: InstanceAiConfirmation) {
|
||||||
|
|
@ -190,19 +190,19 @@ function handleTextSkip(conf: InstanceAiConfirmation) {
|
||||||
[],
|
[],
|
||||||
[{ label: conf.message, question: conf.message, input_type: 'text', options: [] }],
|
[{ label: conf.message, question: conf.message, input_type: 'text', options: [] }],
|
||||||
);
|
);
|
||||||
store.resolveConfirmation(conf.requestId, 'deferred');
|
thread.resolveConfirmation(conf.requestId, 'deferred');
|
||||||
void store.confirmAction(conf.requestId, { kind: 'approval', approved: false });
|
void thread.confirmAction(conf.requestId, { kind: 'approval', approved: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleContinue(conf: InstanceAiConfirmation) {
|
function handleContinue(conf: InstanceAiConfirmation) {
|
||||||
if (store.resolvedConfirmationIds.has(conf.requestId)) return;
|
if (thread.resolvedConfirmationIds.has(conf.requestId)) return;
|
||||||
trackInputCompleted(
|
trackInputCompleted(
|
||||||
conf,
|
conf,
|
||||||
[{ label: conf.message, options: ['continue'], option_chosen: 'continue' }],
|
[{ label: conf.message, options: ['continue'], option_chosen: 'continue' }],
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
store.resolveConfirmation(conf.requestId, 'approved');
|
thread.resolveConfirmation(conf.requestId, 'approved');
|
||||||
void store.confirmAction(conf.requestId, { kind: 'approval', approved: true });
|
void thread.confirmAction(conf.requestId, { kind: 'approval', approved: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleQuestionsSubmit(conf: InstanceAiConfirmation, answers: QuestionAnswer[]) {
|
function handleQuestionsSubmit(conf: InstanceAiConfirmation, answers: QuestionAnswer[]) {
|
||||||
|
|
@ -243,8 +243,8 @@ function handleQuestionsSubmit(conf: InstanceAiConfirmation, answers: QuestionAn
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
trackInputCompleted(conf, provided, skipped, { num_tasks: answers.length });
|
trackInputCompleted(conf, provided, skipped, { num_tasks: answers.length });
|
||||||
store.resolveConfirmation(conf.requestId, 'approved');
|
thread.resolveConfirmation(conf.requestId, 'approved');
|
||||||
void store.confirmAction(conf.requestId, { kind: 'questions', answers });
|
void thread.confirmAction(conf.requestId, { kind: 'questions', answers });
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePlanApprove(conf: InstanceAiConfirmation, numTasks: number) {
|
function handlePlanApprove(conf: InstanceAiConfirmation, numTasks: number) {
|
||||||
|
|
@ -254,8 +254,8 @@ function handlePlanApprove(conf: InstanceAiConfirmation, numTasks: number) {
|
||||||
[],
|
[],
|
||||||
{ num_tasks: numTasks },
|
{ num_tasks: numTasks },
|
||||||
);
|
);
|
||||||
store.resolveConfirmation(conf.requestId, 'approved');
|
thread.resolveConfirmation(conf.requestId, 'approved');
|
||||||
void store.confirmAction(conf.requestId, { kind: 'approval', approved: true });
|
void thread.confirmAction(conf.requestId, { kind: 'approval', approved: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePlanRequestChanges(
|
function handlePlanRequestChanges(
|
||||||
|
|
@ -269,8 +269,8 @@ function handlePlanRequestChanges(
|
||||||
[],
|
[],
|
||||||
{ num_tasks: numTasks, feedback },
|
{ num_tasks: numTasks, feedback },
|
||||||
);
|
);
|
||||||
store.resolveConfirmation(conf.requestId, 'denied');
|
thread.resolveConfirmation(conf.requestId, 'denied');
|
||||||
void store.confirmAction(conf.requestId, {
|
void thread.confirmAction(conf.requestId, {
|
||||||
kind: 'approval',
|
kind: 'approval',
|
||||||
approved: false,
|
approved: false,
|
||||||
userInput: feedback,
|
userInput: feedback,
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import { useI18n } from '@n8n/i18n';
|
||||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
import { useTelemetry } from '@/app/composables/useTelemetry';
|
import { useTelemetry } from '@/app/composables/useTelemetry';
|
||||||
import { useInstanceAiStore } from '../instanceAi.store';
|
import { useThread } from '../instanceAi.store';
|
||||||
import ConfirmationFooter from './ConfirmationFooter.vue';
|
import ConfirmationFooter from './ConfirmationFooter.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
|
|
@ -26,7 +26,7 @@ const props = defineProps<{
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
const rootStore = useRootStore();
|
const rootStore = useRootStore();
|
||||||
const store = useInstanceAiStore();
|
const thread = useThread();
|
||||||
const credentialsStore = useCredentialsStore();
|
const credentialsStore = useCredentialsStore();
|
||||||
const uiStore = useUIStore();
|
const uiStore = useUIStore();
|
||||||
|
|
||||||
|
|
@ -246,7 +246,7 @@ function onCredentialSelected(
|
||||||
}
|
}
|
||||||
|
|
||||||
function trackCredentialInput() {
|
function trackCredentialInput() {
|
||||||
const tc = store.findToolCallByRequestId(props.requestId);
|
const tc = thread.findToolCallByRequestId(props.requestId);
|
||||||
const inputThreadId = tc?.confirmation?.inputThreadId ?? '';
|
const inputThreadId = tc?.confirmation?.inputThreadId ?? '';
|
||||||
const provided: Array<{ label: string; options: string[]; option_chosen: string }> = [];
|
const provided: Array<{ label: string; options: string[]; option_chosen: string }> = [];
|
||||||
const skipped: Array<{ label: string; options: string[] }> = [];
|
const skipped: Array<{ label: string; options: string[] }> = [];
|
||||||
|
|
@ -259,7 +259,7 @@ function trackCredentialInput() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
telemetry.track('User finished providing input', {
|
telemetry.track('User finished providing input', {
|
||||||
thread_id: store.currentThreadId,
|
thread_id: thread.currentThreadId,
|
||||||
input_thread_id: inputThreadId,
|
input_thread_id: inputThreadId,
|
||||||
instance_id: rootStore.instanceId,
|
instance_id: rootStore.instanceId,
|
||||||
type: 'credential-setup',
|
type: 'credential-setup',
|
||||||
|
|
@ -279,12 +279,12 @@ async function handleContinue() {
|
||||||
|
|
||||||
isSubmitted.value = true;
|
isSubmitted.value = true;
|
||||||
|
|
||||||
const success = await store.confirmAction(props.requestId, {
|
const success = await thread.confirmAction(props.requestId, {
|
||||||
kind: 'credentialSelection',
|
kind: 'credentialSelection',
|
||||||
credentials,
|
credentials,
|
||||||
});
|
});
|
||||||
if (success) {
|
if (success) {
|
||||||
store.resolveConfirmation(props.requestId, 'approved');
|
thread.resolveConfirmation(props.requestId, 'approved');
|
||||||
} else {
|
} else {
|
||||||
isSubmitted.value = false;
|
isSubmitted.value = false;
|
||||||
}
|
}
|
||||||
|
|
@ -296,12 +296,12 @@ async function handleLater() {
|
||||||
isSubmitted.value = true;
|
isSubmitted.value = true;
|
||||||
isDeferred.value = true;
|
isDeferred.value = true;
|
||||||
|
|
||||||
const success = await store.confirmAction(props.requestId, {
|
const success = await thread.confirmAction(props.requestId, {
|
||||||
kind: 'approval',
|
kind: 'approval',
|
||||||
approved: false,
|
approved: false,
|
||||||
});
|
});
|
||||||
if (success) {
|
if (success) {
|
||||||
store.resolveConfirmation(props.requestId, 'deferred');
|
thread.resolveConfirmation(props.requestId, 'deferred');
|
||||||
} else {
|
} else {
|
||||||
isSubmitted.value = false;
|
isSubmitted.value = false;
|
||||||
isDeferred.value = false;
|
isDeferred.value = false;
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,13 @@
|
||||||
import { N8nIcon, N8nIconButton } from '@n8n/design-system';
|
import { N8nIcon, N8nIconButton } from '@n8n/design-system';
|
||||||
import { useI18n } from '@n8n/i18n';
|
import { useI18n } from '@n8n/i18n';
|
||||||
import { computed, nextTick, onMounted, ref, useTemplateRef, watch } from 'vue';
|
import { computed, nextTick, onMounted, ref, useTemplateRef, watch } from 'vue';
|
||||||
import { useInstanceAiStore } from '../instanceAi.store';
|
import { useInstanceAiStore, useThread } from '../instanceAi.store';
|
||||||
import { useInstanceAiDebugStore } from '../instanceAiDebug.store';
|
import { useInstanceAiDebugStore } from '../instanceAiDebug.store';
|
||||||
|
|
||||||
const emit = defineEmits<{ close: [] }>();
|
const emit = defineEmits<{ close: [] }>();
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const store = useInstanceAiStore();
|
const store = useInstanceAiStore();
|
||||||
|
const thread = useThread();
|
||||||
const debugStore = useInstanceAiDebugStore();
|
const debugStore = useInstanceAiDebugStore();
|
||||||
|
|
||||||
// --- Tab state ---
|
// --- Tab state ---
|
||||||
|
|
@ -17,7 +18,7 @@ const activeTab = ref<Tab>('events');
|
||||||
// --- Events tab state ---
|
// --- Events tab state ---
|
||||||
const expandedIndex = ref<number | null>(null);
|
const expandedIndex = ref<number | null>(null);
|
||||||
const eventListRef = useTemplateRef<HTMLElement>('eventList');
|
const eventListRef = useTemplateRef<HTMLElement>('eventList');
|
||||||
const events = computed(() => store.debugEvents);
|
const events = computed(() => thread.debugEvents);
|
||||||
|
|
||||||
// --- Threads tab state ---
|
// --- Threads tab state ---
|
||||||
const expandedMessageIndex = ref<number | null>(null);
|
const expandedMessageIndex = ref<number | null>(null);
|
||||||
|
|
@ -81,7 +82,7 @@ function contentPreview(content: unknown): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCopyTrace() {
|
async function handleCopyTrace() {
|
||||||
const trace = store.copyFullTrace();
|
const trace = thread.copyFullTrace();
|
||||||
await navigator.clipboard.writeText(trace);
|
await navigator.clipboard.writeText(trace);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -116,7 +117,7 @@ function handleRefreshThreads() {
|
||||||
// Tool call timing summary
|
// Tool call timing summary
|
||||||
const toolCallTimings = computed(() => {
|
const toolCallTimings = computed(() => {
|
||||||
const timings: Array<{ name: string; duration: string; toolCallId: string }> = [];
|
const timings: Array<{ name: string; duration: string; toolCallId: string }> = [];
|
||||||
for (const msg of store.messages) {
|
for (const msg of thread.messages) {
|
||||||
if (!msg.agentTree) continue;
|
if (!msg.agentTree) continue;
|
||||||
const nodes = [msg.agentTree, ...msg.agentTree.children];
|
const nodes = [msg.agentTree, ...msg.agentTree.children];
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
|
|
@ -177,8 +178,8 @@ onMounted(() => {
|
||||||
<template v-if="activeTab === 'events'">
|
<template v-if="activeTab === 'events'">
|
||||||
<!-- Connection status -->
|
<!-- Connection status -->
|
||||||
<div :class="$style.statusBar">
|
<div :class="$style.statusBar">
|
||||||
<span :class="$style.statusDot" :data-state="store.sseState" />
|
<span :class="$style.statusDot" :data-state="thread.sseState" />
|
||||||
<span>SSE: {{ store.sseState }}</span>
|
<span>SSE: {{ thread.sseState }}</span>
|
||||||
<span :class="$style.eventCount">{{ events.length }} events</span>
|
<span :class="$style.eventCount">{{ events.length }} events</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,23 +6,30 @@ import ChatInputBase from '@/features/ai/shared/components/ChatInputBase.vue';
|
||||||
import AttachmentPreview from './AttachmentPreview.vue';
|
import AttachmentPreview from './AttachmentPreview.vue';
|
||||||
import InstanceAiPromptSuggestions from './InstanceAiPromptSuggestions.vue';
|
import InstanceAiPromptSuggestions from './InstanceAiPromptSuggestions.vue';
|
||||||
import { convertFileToBinaryData } from '@/app/utils/fileUtils';
|
import { convertFileToBinaryData } from '@/app/utils/fileUtils';
|
||||||
import { useInstanceAiStore } from '../instanceAi.store';
|
|
||||||
import type { InstanceAiAttachment } from '@n8n/api-types';
|
import type { InstanceAiAttachment } from '@n8n/api-types';
|
||||||
import type { InstanceAiEmptyStateSuggestion } from '../emptyStateSuggestions';
|
import type { InstanceAiEmptyStateSuggestion } from '../emptyStateSuggestions';
|
||||||
import { useInstanceAiPromptSuggestionsTelemetry } from '../instanceAiPromptSuggestions.telemetry';
|
import { useInstanceAiPromptSuggestionsTelemetry } from '../instanceAiPromptSuggestions.telemetry';
|
||||||
|
|
||||||
|
type AmendContext = { agentId: string; role: string } | null;
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
|
isSendingMessage: boolean;
|
||||||
|
isAwaitingConfirmation: boolean;
|
||||||
|
currentThreadId: string;
|
||||||
|
amendContext: AmendContext;
|
||||||
|
contextualSuggestion: string | null;
|
||||||
|
researchMode: boolean;
|
||||||
suggestions?: readonly InstanceAiEmptyStateSuggestion[];
|
suggestions?: readonly InstanceAiEmptyStateSuggestion[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
submit: [message: string, attachments?: InstanceAiAttachment[]];
|
submit: [message: string, attachments?: InstanceAiAttachment[]];
|
||||||
stop: [];
|
stop: [];
|
||||||
|
'toggle-research-mode': [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const store = useInstanceAiStore();
|
|
||||||
const promptSuggestionsTelemetry = useInstanceAiPromptSuggestionsTelemetry();
|
const promptSuggestionsTelemetry = useInstanceAiPromptSuggestionsTelemetry();
|
||||||
const inputText = ref('');
|
const inputText = ref('');
|
||||||
const attachedFiles = ref<File[]>([]);
|
const attachedFiles = ref<File[]>([]);
|
||||||
|
|
@ -33,12 +40,12 @@ defineExpose({
|
||||||
focus: () => chatInputRef.value?.focus(),
|
focus: () => chatInputRef.value?.focus(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const isBusy = computed(() => props.isStreaming || store.isSendingMessage);
|
const isBusy = computed(() => props.isStreaming || props.isSendingMessage);
|
||||||
const hasNonWhitespaceDraftText = computed(() => inputText.value.trim().length > 0);
|
const hasNonWhitespaceDraftText = computed(() => inputText.value.trim().length > 0);
|
||||||
const isInputVisuallyEmpty = computed(() => inputText.value.length === 0);
|
const isInputVisuallyEmpty = computed(() => inputText.value.length === 0);
|
||||||
const hasAttachments = computed(() => attachedFiles.value.length > 0);
|
const hasAttachments = computed(() => attachedFiles.value.length > 0);
|
||||||
const isComposerDirty = computed(() => hasNonWhitespaceDraftText.value || hasAttachments.value);
|
const isComposerDirty = computed(() => hasNonWhitespaceDraftText.value || hasAttachments.value);
|
||||||
const isGatedBySetup = computed(() => store.isAwaitingConfirmation);
|
const isGatedBySetup = computed(() => props.isAwaitingConfirmation);
|
||||||
const canSubmit = computed(() => isComposerDirty.value && !isBusy.value && !isGatedBySetup.value);
|
const canSubmit = computed(() => isComposerDirty.value && !isBusy.value && !isGatedBySetup.value);
|
||||||
const canShowSuggestions = computed(
|
const canShowSuggestions = computed(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -48,7 +55,7 @@ const canShowSuggestions = computed(
|
||||||
!isGatedBySetup.value,
|
!isGatedBySetup.value,
|
||||||
);
|
);
|
||||||
const visibleSuggestionThreadId = computed(() =>
|
const visibleSuggestionThreadId = computed(() =>
|
||||||
canShowSuggestions.value ? store.currentThreadId : null,
|
canShowSuggestions.value ? props.currentThreadId : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
const placeholder = computed(() => {
|
const placeholder = computed(() => {
|
||||||
|
|
@ -58,13 +65,13 @@ const placeholder = computed(() => {
|
||||||
if (previewPromptKey.value && isInputVisuallyEmpty.value) {
|
if (previewPromptKey.value && isInputVisuallyEmpty.value) {
|
||||||
return i18n.baseText(previewPromptKey.value);
|
return i18n.baseText(previewPromptKey.value);
|
||||||
}
|
}
|
||||||
if (store.amendContext) {
|
if (props.amendContext) {
|
||||||
return i18n.baseText('instanceAi.input.amendPlaceholder', {
|
return i18n.baseText('instanceAi.input.amendPlaceholder', {
|
||||||
interpolate: { role: store.amendContext.role },
|
interpolate: { role: props.amendContext.role },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (store.contextualSuggestion) {
|
if (props.contextualSuggestion) {
|
||||||
return store.contextualSuggestion;
|
return props.contextualSuggestion;
|
||||||
}
|
}
|
||||||
return i18n.baseText('instanceAi.input.placeholder');
|
return i18n.baseText('instanceAi.input.placeholder');
|
||||||
});
|
});
|
||||||
|
|
@ -75,7 +82,7 @@ watch(
|
||||||
if (threadId) {
|
if (threadId) {
|
||||||
promptSuggestionsTelemetry.trackSuggestionsShown({
|
promptSuggestionsTelemetry.trackSuggestionsShown({
|
||||||
threadId,
|
threadId,
|
||||||
researchMode: store.researchMode,
|
researchMode: props.researchMode,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -132,8 +139,8 @@ function handleStop() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTabAutocomplete() {
|
function handleTabAutocomplete() {
|
||||||
if (!inputText.value && store.contextualSuggestion) {
|
if (!inputText.value && props.contextualSuggestion) {
|
||||||
inputText.value = store.contextualSuggestion;
|
inputText.value = props.contextualSuggestion;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -150,8 +157,8 @@ function handleFileRemove(file: File) {
|
||||||
|
|
||||||
function getTelemetryContext() {
|
function getTelemetryContext() {
|
||||||
return {
|
return {
|
||||||
threadId: store.currentThreadId,
|
threadId: props.currentThreadId,
|
||||||
researchMode: store.researchMode,
|
researchMode: props.researchMode,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -225,9 +232,9 @@ const resizable = computed(() => {
|
||||||
:show-after="300"
|
:show-after="300"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
:class="[$style.researchToggle, { [$style.active]: store.researchMode }]"
|
:class="[$style.researchToggle, { [$style.active]: props.researchMode }]"
|
||||||
data-test-id="instance-ai-research-toggle"
|
data-test-id="instance-ai-research-toggle"
|
||||||
@click="store.toggleResearchMode()"
|
@click="emit('toggle-research-mode')"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
:class="$style.researchIcon"
|
:class="$style.researchIcon"
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,13 @@
|
||||||
import ChatMarkdownChunk from '@/features/ai/chatHub/components/ChatMarkdownChunk.vue';
|
import ChatMarkdownChunk from '@/features/ai/chatHub/components/ChatMarkdownChunk.vue';
|
||||||
import type { ComponentPublicInstance } from 'vue';
|
import type { ComponentPublicInstance } from 'vue';
|
||||||
import { computed, inject, onBeforeUnmount, onMounted, onUpdated, ref, useCssModule } from 'vue';
|
import { computed, inject, onBeforeUnmount, onMounted, onUpdated, ref, useCssModule } from 'vue';
|
||||||
import { useInstanceAiStore } from '../instanceAi.store';
|
import { useThread } from '../instanceAi.store';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
content: string;
|
content: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const store = useInstanceAiStore();
|
const thread = useThread();
|
||||||
const styles = useCssModule();
|
const styles = useCssModule();
|
||||||
const wrapperRef = ref<ComponentPublicInstance | null>(null);
|
const wrapperRef = ref<ComponentPublicInstance | null>(null);
|
||||||
|
|
||||||
|
|
@ -55,7 +55,7 @@ const INTERNAL_BLOCK_PATTERN =
|
||||||
/<(?:planning-blueprint|planned-task-follow-up|background-task-completed|running-tasks)[\s\S]*?<\/(?:planning-blueprint|planned-task-follow-up|background-task-completed|running-tasks)>/g;
|
/<(?:planning-blueprint|planned-task-follow-up|background-task-completed|running-tasks)[\s\S]*?<\/(?:planning-blueprint|planned-task-follow-up|background-task-completed|running-tasks)>/g;
|
||||||
|
|
||||||
const processedContent = computed(() => {
|
const processedContent = computed(() => {
|
||||||
const registry = store.resourceNameIndex;
|
const registry = thread.resourceNameIndex;
|
||||||
|
|
||||||
// Strip internal protocol blocks the LLM may have echoed
|
// Strip internal protocol blocks the LLM may have echoed
|
||||||
let result = props.content.replace(INTERNAL_BLOCK_PATTERN, '').trim();
|
let result = props.content.replace(INTERNAL_BLOCK_PATTERN, '').trim();
|
||||||
|
|
@ -178,7 +178,7 @@ function enhanceResourceLinks(): void {
|
||||||
// Search the name index because it contains both produced and listed
|
// Search the name index because it contains both produced and listed
|
||||||
// resources — a user may click through to a resource the agent
|
// resources — a user may click through to a resource the agent
|
||||||
// only referenced via a list call.
|
// only referenced via a list call.
|
||||||
const registryEntry = [...store.resourceNameIndex.values()].find(
|
const registryEntry = [...thread.resourceNameIndex.values()].find(
|
||||||
(r) => r.type === type && r.id === id,
|
(r) => r.type === type && r.id === id,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import type { RatingFeedback } from '@n8n/design-system';
|
||||||
import { N8nCallout, N8nIconButton, N8nMessageRating, N8nText } from '@n8n/design-system';
|
import { N8nCallout, N8nIconButton, N8nMessageRating, N8nText } from '@n8n/design-system';
|
||||||
import { useI18n } from '@n8n/i18n';
|
import { useI18n } from '@n8n/i18n';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useInstanceAiStore } from '../instanceAi.store';
|
import { useInstanceAiStore, useThread } from '../instanceAi.store';
|
||||||
import AgentActivityTree from './AgentActivityTree.vue';
|
import AgentActivityTree from './AgentActivityTree.vue';
|
||||||
import AttachmentPreview from './AttachmentPreview.vue';
|
import AttachmentPreview from './AttachmentPreview.vue';
|
||||||
import InstanceAiMarkdown from './InstanceAiMarkdown.vue';
|
import InstanceAiMarkdown from './InstanceAiMarkdown.vue';
|
||||||
|
|
@ -15,6 +15,7 @@ const props = defineProps<{
|
||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const store = useInstanceAiStore();
|
const store = useInstanceAiStore();
|
||||||
|
const thread = useThread();
|
||||||
const showDebugInfo = ref(false);
|
const showDebugInfo = ref(false);
|
||||||
|
|
||||||
const isUser = computed(() => props.message.role === 'user');
|
const isUser = computed(() => props.message.role === 'user');
|
||||||
|
|
@ -66,16 +67,16 @@ const responseId = computed(() => props.message.messageGroupId ?? props.message.
|
||||||
const isRateable = computed(
|
const isRateable = computed(
|
||||||
() =>
|
() =>
|
||||||
!isUser.value &&
|
!isUser.value &&
|
||||||
store.rateableResponseId === responseId.value &&
|
thread.rateableResponseId === responseId.value &&
|
||||||
!(responseId.value in store.feedbackByResponseId),
|
!(responseId.value in thread.feedbackByResponseId),
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasSubmittedFeedback = computed(
|
const hasSubmittedFeedback = computed(
|
||||||
() => !isUser.value && responseId.value in store.feedbackByResponseId,
|
() => !isUser.value && responseId.value in thread.feedbackByResponseId,
|
||||||
);
|
);
|
||||||
|
|
||||||
function onFeedback(payload: RatingFeedback) {
|
function onFeedback(payload: RatingFeedback) {
|
||||||
store.submitFeedback(responseId.value, payload);
|
thread.submitFeedback(responseId.value, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatJson(value: unknown): string {
|
function formatJson(value: unknown): string {
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,10 @@ import { ref, computed, watch, onUnmounted } from 'vue';
|
||||||
import { useI18n } from '@n8n/i18n';
|
import { useI18n } from '@n8n/i18n';
|
||||||
import { N8nIcon } from '@n8n/design-system';
|
import { N8nIcon } from '@n8n/design-system';
|
||||||
import type { InstanceAiMessage } from '@n8n/api-types';
|
import type { InstanceAiMessage } from '@n8n/api-types';
|
||||||
import { useInstanceAiStore } from '../instanceAi.store';
|
import { useThread } from '../instanceAi.store';
|
||||||
import { useToolLabel } from '../toolLabels';
|
import { useToolLabel } from '../toolLabels';
|
||||||
|
|
||||||
const store = useInstanceAiStore();
|
const thread = useThread();
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const { getToolLabel } = useToolLabel();
|
const { getToolLabel } = useToolLabel();
|
||||||
|
|
||||||
|
|
@ -47,13 +47,13 @@ function deriveActivity(messages: InstanceAiMessage[]): { label: string; detail?
|
||||||
}
|
}
|
||||||
|
|
||||||
const activity = computed(() => {
|
const activity = computed(() => {
|
||||||
if (store.isAwaitingConfirmation) {
|
if (thread.isAwaitingConfirmation) {
|
||||||
return { label: i18n.baseText('instanceAi.statusBar.waitingForInput') };
|
return { label: i18n.baseText('instanceAi.statusBar.waitingForInput') };
|
||||||
}
|
}
|
||||||
return deriveActivity(store.messages);
|
return deriveActivity(thread.messages);
|
||||||
});
|
});
|
||||||
|
|
||||||
const isVisible = computed(() => store.isStreaming);
|
const isVisible = computed(() => thread.isStreaming);
|
||||||
|
|
||||||
const formattedElapsed = computed(() => {
|
const formattedElapsed = computed(() => {
|
||||||
const s = elapsed.value;
|
const s = elapsed.value;
|
||||||
|
|
@ -63,7 +63,7 @@ const formattedElapsed = computed(() => {
|
||||||
return `${String(m)}m ${String(remaining).padStart(2, '0')}s`;
|
return `${String(m)}m ${String(remaining).padStart(2, '0')}s`;
|
||||||
});
|
});
|
||||||
|
|
||||||
const isCountingElapsed = computed(() => isVisible.value && !store.isAwaitingConfirmation);
|
const isCountingElapsed = computed(() => isVisible.value && !thread.isAwaitingConfirmation);
|
||||||
|
|
||||||
watch(isVisible, (visible) => {
|
watch(isVisible, (visible) => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
|
|
@ -98,11 +98,11 @@ onUnmounted(() => {
|
||||||
<Transition name="status-bar">
|
<Transition name="status-bar">
|
||||||
<div
|
<div
|
||||||
v-if="isVisible && activity"
|
v-if="isVisible && activity"
|
||||||
:class="[$style.bar, { [$style.muted]: store.isAwaitingConfirmation }]"
|
:class="[$style.bar, { [$style.muted]: thread.isAwaitingConfirmation }]"
|
||||||
data-test-id="instance-ai-status-bar"
|
data-test-id="instance-ai-status-bar"
|
||||||
>
|
>
|
||||||
<N8nIcon
|
<N8nIcon
|
||||||
v-if="store.isAwaitingConfirmation"
|
v-if="thread.isAwaitingConfirmation"
|
||||||
:class="$style.glyph"
|
:class="$style.glyph"
|
||||||
icon="circle-pause"
|
icon="circle-pause"
|
||||||
size="xsmall"
|
size="xsmall"
|
||||||
|
|
@ -111,7 +111,7 @@ onUnmounted(() => {
|
||||||
<span :class="$style.label">{{ activity.label }}</span>
|
<span :class="$style.label">{{ activity.label }}</span>
|
||||||
<span v-if="activity.detail" :class="$style.separator">·</span>
|
<span v-if="activity.detail" :class="$style.separator">·</span>
|
||||||
<span v-if="activity.detail" :class="$style.detail">{{ activity.detail }}</span>
|
<span v-if="activity.detail" :class="$style.detail">{{ activity.detail }}</span>
|
||||||
<template v-if="!store.isAwaitingConfirmation">
|
<template v-if="!thread.isAwaitingConfirmation">
|
||||||
<span :class="$style.separator">·</span>
|
<span :class="$style.separator">·</span>
|
||||||
<span :class="$style.elapsed">{{ formattedElapsed }}</span>
|
<span :class="$style.elapsed">{{ formattedElapsed }}</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { useI18n } from '@n8n/i18n';
|
||||||
import { CollapsibleRoot, CollapsibleTrigger } from 'reka-ui';
|
import { CollapsibleRoot, CollapsibleTrigger } from 'reka-ui';
|
||||||
import AnimatedCollapsibleContent from './AnimatedCollapsibleContent.vue';
|
import AnimatedCollapsibleContent from './AnimatedCollapsibleContent.vue';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useInstanceAiStore } from '../instanceAi.store';
|
import { useThread } from '../instanceAi.store';
|
||||||
import { useToolLabel } from '../toolLabels';
|
import { useToolLabel } from '../toolLabels';
|
||||||
import ToolResultJson from './ToolResultJson.vue';
|
import ToolResultJson from './ToolResultJson.vue';
|
||||||
import ToolResultRenderer from './ToolResultRenderer.vue';
|
import ToolResultRenderer from './ToolResultRenderer.vue';
|
||||||
|
|
@ -15,7 +15,7 @@ const props = defineProps<{
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const store = useInstanceAiStore();
|
const thread = useThread();
|
||||||
const { getToolLabel } = useToolLabel();
|
const { getToolLabel } = useToolLabel();
|
||||||
const isOpen = ref(false);
|
const isOpen = ref(false);
|
||||||
|
|
||||||
|
|
@ -37,14 +37,14 @@ const showConfirmation = computed(
|
||||||
props.toolCall.isLoading &&
|
props.toolCall.isLoading &&
|
||||||
props.toolCall.confirmationStatus !== 'approved' &&
|
props.toolCall.confirmationStatus !== 'approved' &&
|
||||||
props.toolCall.confirmationStatus !== 'denied' &&
|
props.toolCall.confirmationStatus !== 'denied' &&
|
||||||
!store.resolvedConfirmationIds.has(props.toolCall.confirmation.requestId),
|
!thread.resolvedConfirmationIds.has(props.toolCall.confirmation.requestId),
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Resolved confirmation action — from backend or local optimistic state. */
|
/** Resolved confirmation action — from backend or local optimistic state. */
|
||||||
const resolvedAction = computed((): 'approved' | 'denied' | 'deferred' | null => {
|
const resolvedAction = computed((): 'approved' | 'denied' | 'deferred' | null => {
|
||||||
// Local optimistic state takes priority (has richer semantics like 'deferred')
|
// Local optimistic state takes priority (has richer semantics like 'deferred')
|
||||||
const rid = props.toolCall.confirmation?.requestId;
|
const rid = props.toolCall.confirmation?.requestId;
|
||||||
const local = rid ? store.resolvedConfirmationIds.get(rid) : undefined;
|
const local = rid ? thread.resolvedConfirmationIds.get(rid) : undefined;
|
||||||
if (local) return local;
|
if (local) return local;
|
||||||
const status = props.toolCall.confirmationStatus;
|
const status = props.toolCall.confirmationStatus;
|
||||||
if (status === 'approved' || status === 'denied') return status;
|
if (status === 'approved' || status === 'denied') return status;
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ import { useI18n, type BaseTextKey } from '@n8n/i18n';
|
||||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||||
import { NodeHelpers } from 'n8n-workflow';
|
import { NodeHelpers } from 'n8n-workflow';
|
||||||
import { computed, defineComponent, onMounted, onUnmounted, provide, ref, toRef, watch } from 'vue';
|
import { computed, defineComponent, onMounted, onUnmounted, provide, ref, toRef, watch } from 'vue';
|
||||||
import { useInstanceAiStore } from '../instanceAi.store';
|
import { useThread } from '../instanceAi.store';
|
||||||
import {
|
import {
|
||||||
credGroupKey,
|
credGroupKey,
|
||||||
isTriggerOnly as isTriggerOnlyUtil,
|
isTriggerOnly as isTriggerOnlyUtil,
|
||||||
|
|
@ -52,7 +52,7 @@ const props = defineProps<{
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const store = useInstanceAiStore();
|
const thread = useThread();
|
||||||
const credentialsStore = useCredentialsStore();
|
const credentialsStore = useCredentialsStore();
|
||||||
const nodeTypesStore = useNodeTypesStore();
|
const nodeTypesStore = useNodeTypesStore();
|
||||||
const rootStore = useRootStore();
|
const rootStore = useRootStore();
|
||||||
|
|
@ -171,7 +171,7 @@ const {
|
||||||
onCredentialSelected,
|
onCredentialSelected,
|
||||||
} = useSetupActions({
|
} = useSetupActions({
|
||||||
requestId: toRef(props, 'requestId'),
|
requestId: toRef(props, 'requestId'),
|
||||||
store,
|
thread,
|
||||||
cards,
|
cards,
|
||||||
currentDisplayCard,
|
currentDisplayCard,
|
||||||
displayCards,
|
displayCards,
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,13 @@ import { useNodeHelpers } from '@/app/composables/useNodeHelpers';
|
||||||
import { useTelemetry } from '@/app/composables/useTelemetry';
|
import { useTelemetry } from '@/app/composables/useTelemetry';
|
||||||
import type { INodeUi } from '@/Interface';
|
import type { INodeUi } from '@/Interface';
|
||||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||||
import type { useInstanceAiStore } from '../instanceAi.store';
|
import type { ThreadRuntime } from '../instanceAi.store';
|
||||||
import type { DisplayCard, SetupCard } from '../instanceAiWorkflowSetup.utils';
|
import type { DisplayCard, SetupCard } from '../instanceAiWorkflowSetup.utils';
|
||||||
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
|
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
|
||||||
|
|
||||||
export function useSetupActions(deps: {
|
export function useSetupActions(deps: {
|
||||||
requestId: Ref<string>;
|
requestId: Ref<string>;
|
||||||
store: ReturnType<typeof useInstanceAiStore>;
|
thread: ThreadRuntime;
|
||||||
cards: ComputedRef<SetupCard[]>;
|
cards: ComputedRef<SetupCard[]>;
|
||||||
currentDisplayCard: ComputedRef<DisplayCard | undefined>;
|
currentDisplayCard: ComputedRef<DisplayCard | undefined>;
|
||||||
displayCards: ComputedRef<DisplayCard[]>;
|
displayCards: ComputedRef<DisplayCard[]>;
|
||||||
|
|
@ -50,7 +50,7 @@ export function useSetupActions(deps: {
|
||||||
});
|
});
|
||||||
|
|
||||||
function trackSetupInput() {
|
function trackSetupInput() {
|
||||||
const tc = deps.store.findToolCallByRequestId(deps.requestId.value);
|
const tc = deps.thread.findToolCallByRequestId(deps.requestId.value);
|
||||||
const inputThreadId = tc?.confirmation?.inputThreadId ?? '';
|
const inputThreadId = tc?.confirmation?.inputThreadId ?? '';
|
||||||
const provided: Array<{ label: string; options: string[]; option_chosen: string }> = [];
|
const provided: Array<{ label: string; options: string[]; option_chosen: string }> = [];
|
||||||
const skipped: Array<{ label: string; options: string[] }> = [];
|
const skipped: Array<{ label: string; options: string[] }> = [];
|
||||||
|
|
@ -63,7 +63,7 @@ export function useSetupActions(deps: {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
telemetry.track('User finished providing input', {
|
telemetry.track('User finished providing input', {
|
||||||
thread_id: deps.store.currentThreadId,
|
thread_id: deps.thread.currentThreadId,
|
||||||
input_thread_id: inputThreadId,
|
input_thread_id: inputThreadId,
|
||||||
instance_id: useRootStore().instanceId,
|
instance_id: useRootStore().instanceId,
|
||||||
type: 'setup',
|
type: 'setup',
|
||||||
|
|
@ -82,7 +82,7 @@ export function useSetupActions(deps: {
|
||||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
const promise = new Promise<Record<string, unknown> | null>((resolve) => {
|
const promise = new Promise<Record<string, unknown> | null>((resolve) => {
|
||||||
const existing = deps.store.findToolCallByRequestId(requestId);
|
const existing = deps.thread.findToolCallByRequestId(requestId);
|
||||||
if (existing?.result !== undefined) {
|
if (existing?.result !== undefined) {
|
||||||
resolve(isToolResult(existing.result) ? existing.result : null);
|
resolve(isToolResult(existing.result) ? existing.result : null);
|
||||||
return;
|
return;
|
||||||
|
|
@ -91,7 +91,7 @@ export function useSetupActions(deps: {
|
||||||
stopWatch = watch(
|
stopWatch = watch(
|
||||||
() => {
|
() => {
|
||||||
const tc: InstanceAiToolCallState | undefined =
|
const tc: InstanceAiToolCallState | undefined =
|
||||||
deps.store.findToolCallByRequestId(requestId);
|
deps.thread.findToolCallByRequestId(requestId);
|
||||||
return tc?.result;
|
return tc?.result;
|
||||||
},
|
},
|
||||||
(result) => {
|
(result) => {
|
||||||
|
|
@ -167,7 +167,7 @@ export function useSetupActions(deps: {
|
||||||
isApplying.value = true;
|
isApplying.value = true;
|
||||||
applyError.value = null;
|
applyError.value = null;
|
||||||
|
|
||||||
const postSuccess = await deps.store.confirmAction(deps.requestId.value, {
|
const postSuccess = await deps.thread.confirmAction(deps.requestId.value, {
|
||||||
kind: 'setupWorkflowApply',
|
kind: 'setupWorkflowApply',
|
||||||
nodeCredentials,
|
nodeCredentials,
|
||||||
nodeParameters,
|
nodeParameters,
|
||||||
|
|
@ -191,7 +191,7 @@ export function useSetupActions(deps: {
|
||||||
isSubmitted.value = true;
|
isSubmitted.value = true;
|
||||||
isPartial.value = toolResult.partial === true;
|
isPartial.value = toolResult.partial === true;
|
||||||
deps.onApplySuccess?.();
|
deps.onApplySuccess?.();
|
||||||
deps.store.resolveConfirmation(deps.requestId.value, 'approved');
|
deps.thread.resolveConfirmation(deps.requestId.value, 'approved');
|
||||||
} else if (toolResult) {
|
} else if (toolResult) {
|
||||||
applyError.value = typeof toolResult.error === 'string' ? toolResult.error : 'Apply failed';
|
applyError.value = typeof toolResult.error === 'string' ? toolResult.error : 'Apply failed';
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -205,7 +205,7 @@ export function useSetupActions(deps: {
|
||||||
|
|
||||||
applyError.value = null;
|
applyError.value = null;
|
||||||
|
|
||||||
const postSuccess = await deps.store.confirmAction(deps.requestId.value, {
|
const postSuccess = await deps.thread.confirmAction(deps.requestId.value, {
|
||||||
kind: 'setupWorkflowTestTrigger',
|
kind: 'setupWorkflowTestTrigger',
|
||||||
testTriggerNode: nodeName,
|
testTriggerNode: nodeName,
|
||||||
nodeCredentials,
|
nodeCredentials,
|
||||||
|
|
@ -289,12 +289,12 @@ export function useSetupActions(deps: {
|
||||||
isSubmitted.value = true;
|
isSubmitted.value = true;
|
||||||
isDeferred.value = true;
|
isDeferred.value = true;
|
||||||
|
|
||||||
const success = await deps.store.confirmAction(deps.requestId.value, {
|
const success = await deps.thread.confirmAction(deps.requestId.value, {
|
||||||
kind: 'approval',
|
kind: 'approval',
|
||||||
approved: false,
|
approved: false,
|
||||||
});
|
});
|
||||||
if (success) {
|
if (success) {
|
||||||
deps.store.resolveConfirmation(deps.requestId.value, 'deferred');
|
deps.thread.resolveConfirmation(deps.requestId.value, 'deferred');
|
||||||
} else {
|
} else {
|
||||||
isSubmitted.value = false;
|
isSubmitted.value = false;
|
||||||
isDeferred.value = false;
|
isDeferred.value = false;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { ref, computed } from 'vue';
|
import { ref, computed, inject, provide, type InjectionKey } from 'vue';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||||
import { useToast } from '@/app/composables/useToast';
|
import { useToast } from '@/app/composables/useToast';
|
||||||
|
|
@ -345,3 +345,20 @@ export const useInstanceAiStore = defineStore('instanceAi', () => {
|
||||||
closeSSE: runtime.closeSSE,
|
closeSSE: runtime.closeSSE,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export type ThreadRuntime = ReturnType<typeof useInstanceAiStore>;
|
||||||
|
|
||||||
|
const ThreadKey: InjectionKey<ThreadRuntime> = Symbol('instanceAiThread');
|
||||||
|
|
||||||
|
export function provideThread(thread: ThreadRuntime): ThreadRuntime {
|
||||||
|
provide(ThreadKey, thread);
|
||||||
|
return thread;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useThread(): ThreadRuntime {
|
||||||
|
const thread = inject(ThreadKey, null);
|
||||||
|
if (!thread) {
|
||||||
|
throw new Error('useThread() requires a provideThread() ancestor.');
|
||||||
|
}
|
||||||
|
return thread;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import type { RouteLocationNormalizedLoadedGeneric } from 'vue-router';
|
|
||||||
import type { IconName } from '@n8n/design-system';
|
import type { IconName } from '@n8n/design-system';
|
||||||
import {
|
import {
|
||||||
getLatestBuildResult,
|
getLatestBuildResult,
|
||||||
|
|
@ -7,7 +6,7 @@ import {
|
||||||
getLatestDataTableResult,
|
getLatestDataTableResult,
|
||||||
getLatestDeletedDataTableId,
|
getLatestDeletedDataTableId,
|
||||||
} from './canvasPreview.utils';
|
} from './canvasPreview.utils';
|
||||||
import type { useInstanceAiStore } from './instanceAi.store';
|
import type { ThreadRuntime } from './instanceAi.store';
|
||||||
|
|
||||||
export interface ArtifactTab {
|
export interface ArtifactTab {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -23,18 +22,18 @@ const ARTIFACT_ICON_MAP: Record<string, IconName> = {
|
||||||
};
|
};
|
||||||
|
|
||||||
interface UseCanvasPreviewOptions {
|
interface UseCanvasPreviewOptions {
|
||||||
store: ReturnType<typeof useInstanceAiStore>;
|
thread: ThreadRuntime;
|
||||||
route: RouteLocationNormalizedLoadedGeneric;
|
threadId: () => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCanvasPreview({ store, route }: UseCanvasPreviewOptions) {
|
export function useCanvasPreview({ thread, threadId }: UseCanvasPreviewOptions) {
|
||||||
// --- Tab state ---
|
// --- Tab state ---
|
||||||
const activeTabId = ref<string>();
|
const activeTabId = ref<string>();
|
||||||
|
|
||||||
// All artifacts (workflows + data tables) in the current thread, derived from resource registry
|
// All artifacts (workflows + data tables) in the current thread, derived from resource registry
|
||||||
const allArtifactTabs = computed((): ArtifactTab[] => {
|
const allArtifactTabs = computed((): ArtifactTab[] => {
|
||||||
const result: ArtifactTab[] = [];
|
const result: ArtifactTab[] = [];
|
||||||
for (const entry of store.producedArtifacts.values()) {
|
for (const entry of thread.producedArtifacts.values()) {
|
||||||
if (entry.type === 'workflow' || entry.type === 'data-table') {
|
if (entry.type === 'workflow' || entry.type === 'data-table') {
|
||||||
result.push({
|
result.push({
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
|
|
@ -117,26 +116,23 @@ export function useCanvasPreview({ store, route }: UseCanvasPreviewOptions) {
|
||||||
// Each thread is stateless for the preview panel: switching threads
|
// Each thread is stateless for the preview panel: switching threads
|
||||||
// closes the panel. Past artifacts are reachable via their inline
|
// closes the panel. Past artifacts are reachable via their inline
|
||||||
// references in the message timeline.
|
// references in the message timeline.
|
||||||
watch(
|
watch(threadId, (nextThreadId, oldThreadId) => {
|
||||||
() => route.params.threadId,
|
// Skip if this is the initial route setup (e.g. URL updated from
|
||||||
(threadId, oldThreadId) => {
|
// /instance-ai to /instance-ai/:threadId after the first message)
|
||||||
// Skip if this is the initial route setup (e.g. URL updated from
|
if (!oldThreadId) return;
|
||||||
// /instance-ai to /instance-ai/:threadId after the first message)
|
// Skip if the thread ID hasn't actually changed
|
||||||
if (!oldThreadId) return;
|
if (nextThreadId === oldThreadId) return;
|
||||||
// Skip if the thread ID hasn't actually changed
|
|
||||||
if (threadId === oldThreadId) return;
|
|
||||||
|
|
||||||
activeTabId.value = undefined;
|
activeTabId.value = undefined;
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
// --- Auto-open canvas when AI creates/modifies a workflow ---
|
// --- Auto-open canvas when AI creates/modifies a workflow ---
|
||||||
|
|
||||||
const workflowRefreshKey = ref(0);
|
const workflowRefreshKey = ref(0);
|
||||||
|
|
||||||
const latestBuildResult = computed(() => {
|
const latestBuildResult = computed(() => {
|
||||||
for (let i = store.messages.length - 1; i >= 0; i--) {
|
for (let i = thread.messages.length - 1; i >= 0; i--) {
|
||||||
const msg = store.messages[i];
|
const msg = thread.messages[i];
|
||||||
if (msg.agentTree) {
|
if (msg.agentTree) {
|
||||||
const result = getLatestBuildResult(msg.agentTree);
|
const result = getLatestBuildResult(msg.agentTree);
|
||||||
if (result) return result;
|
if (result) return result;
|
||||||
|
|
@ -158,7 +154,7 @@ export function useCanvasPreview({ store, route }: UseCanvasPreviewOptions) {
|
||||||
() => latestBuildResult.value?.toolCallId,
|
() => latestBuildResult.value?.toolCallId,
|
||||||
(toolCallId) => {
|
(toolCallId) => {
|
||||||
if (!toolCallId || !latestBuildResult.value) return;
|
if (!toolCallId || !latestBuildResult.value) return;
|
||||||
if (store.isHydratingThread) return;
|
if (thread.isHydratingThread) return;
|
||||||
|
|
||||||
activeTabId.value = latestBuildResult.value.workflowId;
|
activeTabId.value = latestBuildResult.value.workflowId;
|
||||||
workflowRefreshKey.value++;
|
workflowRefreshKey.value++;
|
||||||
|
|
@ -171,8 +167,8 @@ export function useCanvasPreview({ store, route }: UseCanvasPreviewOptions) {
|
||||||
// by getLatestBuildResult. Refresh the preview so the iframe shows the latest state.
|
// by getLatestBuildResult. Refresh the preview so the iframe shows the latest state.
|
||||||
|
|
||||||
const latestSetupResult = computed(() => {
|
const latestSetupResult = computed(() => {
|
||||||
for (let i = store.messages.length - 1; i >= 0; i--) {
|
for (let i = thread.messages.length - 1; i >= 0; i--) {
|
||||||
const msg = store.messages[i];
|
const msg = thread.messages[i];
|
||||||
if (msg.agentTree) {
|
if (msg.agentTree) {
|
||||||
const result = getLatestWorkflowSetupResult(msg.agentTree);
|
const result = getLatestWorkflowSetupResult(msg.agentTree);
|
||||||
if (result) return result;
|
if (result) return result;
|
||||||
|
|
@ -198,8 +194,8 @@ export function useCanvasPreview({ store, route }: UseCanvasPreviewOptions) {
|
||||||
// --- Auto-open data table preview when AI creates/modifies a data table ---
|
// --- Auto-open data table preview when AI creates/modifies a data table ---
|
||||||
|
|
||||||
const latestDataTableResult = computed(() => {
|
const latestDataTableResult = computed(() => {
|
||||||
for (let i = store.messages.length - 1; i >= 0; i--) {
|
for (let i = thread.messages.length - 1; i >= 0; i--) {
|
||||||
const msg = store.messages[i];
|
const msg = thread.messages[i];
|
||||||
if (msg.agentTree) {
|
if (msg.agentTree) {
|
||||||
const result = getLatestDataTableResult(msg.agentTree);
|
const result = getLatestDataTableResult(msg.agentTree);
|
||||||
if (result) return result;
|
if (result) return result;
|
||||||
|
|
@ -212,7 +208,7 @@ export function useCanvasPreview({ store, route }: UseCanvasPreviewOptions) {
|
||||||
() => latestDataTableResult.value?.toolCallId,
|
() => latestDataTableResult.value?.toolCallId,
|
||||||
(toolCallId) => {
|
(toolCallId) => {
|
||||||
if (!toolCallId || !latestDataTableResult.value) return;
|
if (!toolCallId || !latestDataTableResult.value) return;
|
||||||
if (store.isHydratingThread) return;
|
if (thread.isHydratingThread) return;
|
||||||
|
|
||||||
activeTabId.value = latestDataTableResult.value.dataTableId;
|
activeTabId.value = latestDataTableResult.value.dataTableId;
|
||||||
dataTableRefreshKey.value++;
|
dataTableRefreshKey.value++;
|
||||||
|
|
@ -223,8 +219,8 @@ export function useCanvasPreview({ store, route }: UseCanvasPreviewOptions) {
|
||||||
// --- Close data table preview if the active table is deleted ---
|
// --- Close data table preview if the active table is deleted ---
|
||||||
|
|
||||||
const latestDeletedDataTableId = computed(() => {
|
const latestDeletedDataTableId = computed(() => {
|
||||||
for (let i = store.messages.length - 1; i >= 0; i--) {
|
for (let i = thread.messages.length - 1; i >= 0; i--) {
|
||||||
const msg = store.messages[i];
|
const msg = thread.messages[i];
|
||||||
if (msg.agentTree) {
|
if (msg.agentTree) {
|
||||||
const id = getLatestDeletedDataTableId(msg.agentTree);
|
const id = getLatestDeletedDataTableId(msg.agentTree);
|
||||||
if (id) return id;
|
if (id) return id;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user