🐛 fix(operation): isolate loading state to current active topic (#10360)

* fix(operation): isolate loading state to current active topic

- Modified isMainWindowAgentRuntimeRunning to only check operations in current active topic
- Prevents loading state from other topics affecting the send button
- Added comprehensive test case to verify topic isolation
- Fixes issue where switching topics would still show loading state from previous topic

* test: fix isMainWindowAgentRuntimeRunning tests to set active context

- Added activeId and activeTopicId setup in test cases
- Ensured operation context matches active context for proper filtering
- Fixed tests to align with new getCurrentContextOperations-based implementation

* fix: change activeTopicId from null to undefined in tests

- Fixed TypeScript type error where null is not assignable to string | undefined
- Changed all activeTopicId: null to activeTopicId: undefined

* fix: check if operation's message is in current displayed messages

- Changed from using getCurrentContextOperations to checking message presence
- Prevents loading state from showing when switching back to default topic
- Operation's context topicId is captured at creation time and doesn't update
- Now checks if operation's message is in activeDisplayMessages instead

* refactor

* refactor to fix

* try to fix stylelint ci issue

* fix tests

* fix tests
This commit is contained in:
Arvin Xu
2025-11-23 19:24:40 +08:00
committed by GitHub
parent 19f7d74652
commit c568369c69
24 changed files with 341 additions and 300 deletions

View File

@@ -396,9 +396,15 @@
"access": "public",
"registry": "https://registry.npmjs.org"
},
"overrides": {
"stylelint-config-clean-order": "7.0.0"
},
"pnpm": {
"onlyBuiltDependencies": [
"@vercel/speed-insights"
]
],
"overrides": {
"stylelint-config-clean-order": "7.0.0"
}
}
}

View File

@@ -129,12 +129,6 @@ export class KnowledgeRepo {
};
});
console.log('[KnowledgeRepo.query] Fetched items:', {
count: mappedResults.length,
documents: mappedResults.filter((item) => item.sourceType === 'document'),
sampleEditorData: mappedResults.find((item) => item.sourceType === 'document')?.editorData,
});
return mappedResults;
}
@@ -308,7 +302,10 @@ export class KnowledgeRepo {
// Exclude custom/document and source_type='file' from Documents category
if (category === FilesTabs.Documents) {
whereConditions.push(sql`${documents.fileType} != ${'custom/document'}`, sql`${documents.sourceType} != ${'file'}`);
whereConditions.push(
sql`${documents.fileType} != ${'custom/document'}`,
sql`${documents.sourceType} != ${'file'}`,
);
}
} else if (fileTypePrefix) {
whereConditions.push(sql`${documents.fileType} ILIKE ${`${fileTypePrefix}%`}`);
@@ -338,7 +335,7 @@ export class KnowledgeRepo {
// Documents don't have knowledge base association currently, so skip if knowledgeBaseId is set
if (knowledgeBaseId) {
return sql`
SELECT
SELECT
NULL::varchar(30) as id,
NULL::text as name,
NULL::varchar(255) as file_type,

View File

@@ -51,73 +51,5 @@ export interface SearchEngineKeyVaults {
}
export interface UserKeyVaults extends SearchEngineKeyVaults {
ai21?: OpenAICompatibleKeyVault;
ai302?: OpenAICompatibleKeyVault;
ai360?: OpenAICompatibleKeyVault;
aihubmix?: OpenAICompatibleKeyVault;
akashchat?: OpenAICompatibleKeyVault;
anthropic?: OpenAICompatibleKeyVault;
azure?: AzureOpenAIKeyVault;
azureai?: AzureOpenAIKeyVault;
baichuan?: OpenAICompatibleKeyVault;
bedrock?: AWSBedrockKeyVault;
bfl?: any;
cerebras?: OpenAICompatibleKeyVault;
cloudflare?: CloudflareKeyVault;
cohere?: OpenAICompatibleKeyVault;
cometapi?: OpenAICompatibleKeyVault;
comfyui?: ComfyUIKeyVault;
deepseek?: OpenAICompatibleKeyVault;
fal?: FalKeyVault;
fireworksai?: OpenAICompatibleKeyVault;
giteeai?: OpenAICompatibleKeyVault;
github?: OpenAICompatibleKeyVault;
google?: OpenAICompatibleKeyVault;
groq?: OpenAICompatibleKeyVault;
higress?: OpenAICompatibleKeyVault;
huggingface?: OpenAICompatibleKeyVault;
hunyuan?: OpenAICompatibleKeyVault;
infiniai?: OpenAICompatibleKeyVault;
internlm?: OpenAICompatibleKeyVault;
jina?: OpenAICompatibleKeyVault;
lmstudio?: OpenAICompatibleKeyVault;
lobehub?: any;
minimax?: OpenAICompatibleKeyVault;
mistral?: OpenAICompatibleKeyVault;
modelscope?: OpenAICompatibleKeyVault;
moonshot?: OpenAICompatibleKeyVault;
nebius?: OpenAICompatibleKeyVault;
newapi?: OpenAICompatibleKeyVault;
novita?: OpenAICompatibleKeyVault;
nvidia?: OpenAICompatibleKeyVault;
ollama?: OpenAICompatibleKeyVault;
ollamacloud?: OpenAICompatibleKeyVault;
openai?: OpenAICompatibleKeyVault;
openrouter?: OpenAICompatibleKeyVault;
password?: string;
perplexity?: OpenAICompatibleKeyVault;
ppio?: OpenAICompatibleKeyVault;
qiniu?: OpenAICompatibleKeyVault;
qwen?: OpenAICompatibleKeyVault;
sambanova?: OpenAICompatibleKeyVault;
search1api?: OpenAICompatibleKeyVault;
sensenova?: OpenAICompatibleKeyVault;
siliconcloud?: OpenAICompatibleKeyVault;
spark?: OpenAICompatibleKeyVault;
stepfun?: OpenAICompatibleKeyVault;
taichu?: OpenAICompatibleKeyVault;
tencentcloud?: OpenAICompatibleKeyVault;
togetherai?: OpenAICompatibleKeyVault;
upstage?: OpenAICompatibleKeyVault;
v0?: OpenAICompatibleKeyVault;
vercelaigateway?: OpenAICompatibleKeyVault;
vertexai?: VertexAIKeyVault;
vllm?: OpenAICompatibleKeyVault;
volcengine?: OpenAICompatibleKeyVault;
wenxin?: OpenAICompatibleKeyVault;
xai?: OpenAICompatibleKeyVault;
xinference?: OpenAICompatibleKeyVault;
zenmux?: OpenAICompatibleKeyVault;
zeroone?: OpenAICompatibleKeyVault;
zhipu?: OpenAICompatibleKeyVault;
}

View File

@@ -1,63 +0,0 @@
import { Button, InputPassword } from '@lobehub/ui';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { useChatStore } from '@/store/chat';
import { useUserStore } from '@/store/user';
import { keyVaultsConfigSelectors } from '@/store/user/selectors';
import { FormAction } from './style';
interface AccessCodeFormProps {
id: string;
}
const AccessCodeForm = memo<AccessCodeFormProps>(({ id }) => {
const { t } = useTranslation('error');
const [password, updateKeyVaults] = useUserStore((s) => [
keyVaultsConfigSelectors.password(s),
s.updateKeyVaults,
]);
const [resend, deleteMessage] = useChatStore((s) => [s.delAndRegenerateMessage, s.deleteMessage]);
return (
<>
<FormAction
avatar={'🗳'}
description={t('unlock.password.description')}
title={t('unlock.password.title')}
>
<InputPassword
autoComplete={'new-password'}
onChange={(e) => {
updateKeyVaults({ password: e.target.value });
}}
placeholder={t('unlock.password.placeholder')}
value={password}
variant={'filled'}
/>
</FormAction>
<Flexbox gap={12}>
<Button
onClick={() => {
resend(id);
deleteMessage(id);
}}
type={'primary'}
>
{t('unlock.confirm')}
</Button>
<Button
onClick={() => {
deleteMessage(id);
}}
>
{t('unlock.closeMessage')}
</Button>
</Flexbox>
</>
);
});
export default AccessCodeForm;

View File

@@ -23,10 +23,7 @@ const mockTogetherAIAPIKey = 'togetherai-api-key';
// mock the traditional zustand
vi.mock('zustand/traditional');
const setModelProviderConfig = <T extends GlobalLLMProviderKey>(
provider: T,
config: Partial<UserKeyVaults[T]>,
) => {
const setModelProviderConfig = (provider: string, config: any) => {
useUserStore.setState({
settings: { keyVaults: { [provider]: config } },
});

View File

@@ -1,61 +0,0 @@
import type { PartialDeep } from 'type-fest';
import { Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { LOBE_URL_IMPORT_NAME } from '@/const/url';
import { UserSettings } from '@/types/user/settings';
import { shareService } from '../share';
// Mock dependencies
vi.mock('@/utils/parseMarkdown', () => ({
parseMarkdown: vi.fn(),
}));
global.fetch = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
describe('ShareGPTService', () => {
describe('ShareViaUrl', () => {
describe('createShareSettingsUrl', () => {
it('should create a share settings URL with the provided settings', () => {
const settings: PartialDeep<UserSettings> = {
keyVaults: {
openai: {
apiKey: 'user-key',
},
},
};
const url = shareService.createShareSettingsUrl(settings);
expect(url).toBe(
`/?${LOBE_URL_IMPORT_NAME}=%7B%22keyVaults%22:%7B%22openai%22:%7B%22apiKey%22:%22user-key%22%7D%7D%7D`,
);
});
});
describe('decodeShareSettings', () => {
it('should decode share settings from search params', () => {
const settings = '{"languageModel":{"openai":{"apiKey":"user-key"}}}';
const decodedSettings = shareService.decodeShareSettings(settings);
expect(decodedSettings).toEqual({
data: {
languageModel: {
openai: {
apiKey: 'user-key',
},
},
},
});
});
it('should return an error message if decoding fails', () => {
const settings = '%7B%22theme%22%3A%22dark%22%2C%22fontSize%22%3A16%';
const decodedSettings = shareService.decodeShareSettings(settings);
expect(decodedSettings).toEqual({
message: expect.any(String),
});
});
});
});
});

View File

@@ -13,7 +13,7 @@ import { ModelProvider } from 'model-bank';
import { aiProviderSelectors, useAiInfraStore } from '@/store/aiInfra';
import { useUserStore } from '@/store/user';
import { keyVaultsConfigSelectors, userProfileSelectors } from '@/store/user/selectors';
import { userProfileSelectors } from '@/store/user/selectors';
import { obfuscatePayloadWithXOR } from '@/utils/client/xor-obfuscation';
import { resolveRuntimeProvider } from './chat/helper';
@@ -105,10 +105,9 @@ export const getProviderAuthPayload = (
};
const createAuthTokenWithPayload = (payload = {}) => {
const accessCode = keyVaultsConfigSelectors.password(useUserStore.getState());
const userId = userProfileSelectors.userId(useUserStore.getState());
return obfuscatePayloadWithXOR<ClientSecretPayload>({ accessCode, userId, ...payload });
return obfuscatePayloadWithXOR<ClientSecretPayload>({ userId, ...payload });
};
interface AuthParams {

View File

@@ -1,12 +1,6 @@
import {
LOBE_CHAT_ACCESS_CODE,
LOBE_USER_ID,
OPENAI_API_KEY_HEADER_KEY,
OPENAI_END_POINT,
} from '@/const/fetch';
import { LOBE_USER_ID, OPENAI_API_KEY_HEADER_KEY, OPENAI_END_POINT } from '@/const/fetch';
import { aiProviderSelectors, useAiInfraStore } from '@/store/aiInfra';
import { useUserStore } from '@/store/user';
import { keyVaultsConfigSelectors } from '@/store/user/selectors';
/**
* TODO: Need to be removed after tts refactor
@@ -22,7 +16,6 @@ export const createHeaderWithOpenAI = (header?: HeadersInit): HeadersInit => {
// eslint-disable-next-line no-undef
return {
...header,
[LOBE_CHAT_ACCESS_CODE]: keyVaultsConfigSelectors.password(state),
[LOBE_USER_ID]: state.user?.id || '',
[OPENAI_API_KEY_HEADER_KEY]: keyVaults.apiKey || '',
[OPENAI_END_POINT]: keyVaults.baseURL || '',

View File

@@ -58,6 +58,9 @@ describe('call_llm executor', () => {
sessionId: 'test-session',
topicId: 'test-topic',
}),
expect.objectContaining({
operationId: expect.any(String),
}),
);
});
@@ -229,6 +232,9 @@ describe('call_llm executor', () => {
expect.objectContaining({
parentId: 'msg_payload_parent',
}),
expect.objectContaining({
operationId: expect.any(String),
}),
);
});
@@ -262,6 +268,9 @@ describe('call_llm executor', () => {
expect.objectContaining({
parentId: 'msg_context_parent',
}),
expect.objectContaining({
operationId: expect.any(String),
}),
);
});
});
@@ -1061,6 +1070,9 @@ describe('call_llm executor', () => {
model: 'claude-3-opus',
provider: 'anthropic',
}),
expect.objectContaining({
operationId: expect.any(String),
}),
);
expect(mockStore.internal_fetchAIChatMessage).toHaveBeenCalledWith(
expect.objectContaining({
@@ -1180,6 +1192,9 @@ describe('call_llm executor', () => {
expect.objectContaining({
threadId,
}),
expect.objectContaining({
operationId: expect.any(String),
}),
);
});
@@ -1211,6 +1226,9 @@ describe('call_llm executor', () => {
expect.objectContaining({
threadId: undefined,
}),
expect.objectContaining({
operationId: expect.any(String),
}),
);
});
});

View File

@@ -156,17 +156,22 @@ describe('call_tool executor', () => {
});
// Then
expect(mockStore.optimisticCreateMessage).toHaveBeenCalledWith({
content: '',
groupId: 'group_789',
parentId: 'msg_parent_123',
plugin: toolCall,
role: 'tool',
sessionId: 'sess_123',
threadId: undefined,
tool_call_id: 'tool_call_xyz',
topicId: 'topic_456',
});
expect(mockStore.optimisticCreateMessage).toHaveBeenCalledWith(
expect.objectContaining({
content: '',
groupId: 'group_789',
parentId: 'msg_parent_123',
plugin: toolCall,
role: 'tool',
sessionId: 'sess_123',
threadId: undefined,
tool_call_id: 'tool_call_xyz',
topicId: 'topic_456',
}),
expect.objectContaining({
operationId: expect.any(String),
}),
);
});
it('should use assistant message groupId for tool message', async () => {
@@ -194,6 +199,9 @@ describe('call_tool executor', () => {
expect.objectContaining({
groupId: 'group_special',
}),
expect.objectContaining({
operationId: expect.any(String),
}),
);
});
@@ -222,6 +230,9 @@ describe('call_tool executor', () => {
expect.objectContaining({
parentId: 'msg_custom_parent',
}),
expect.objectContaining({
operationId: expect.any(String),
}),
);
});
@@ -262,6 +273,9 @@ describe('call_tool executor', () => {
expect.objectContaining({
plugin: toolCall,
}),
expect.objectContaining({
operationId: expect.any(String),
}),
);
});
});
@@ -1542,6 +1556,9 @@ describe('call_tool executor', () => {
expect.objectContaining({
groupId: undefined,
}),
expect.objectContaining({
operationId: expect.any(String),
}),
);
expect(result.events).toHaveLength(1);
});
@@ -1570,6 +1587,9 @@ describe('call_tool executor', () => {
expect.objectContaining({
groupId: undefined,
}),
expect.objectContaining({
operationId: expect.any(String),
}),
);
});
@@ -1641,6 +1661,9 @@ describe('call_tool executor', () => {
expect.objectContaining({
topicId: undefined,
}),
expect.objectContaining({
operationId: expect.any(String),
}),
);
});
@@ -1679,6 +1702,9 @@ describe('call_tool executor', () => {
type: 'builtin',
}),
}),
expect.objectContaining({
operationId: expect.any(String),
}),
);
expect(result.events).toHaveLength(1);
});
@@ -1768,6 +1794,9 @@ describe('call_tool executor', () => {
expect.objectContaining({
groupId: 'group_latest',
}),
expect.objectContaining({
operationId: expect.any(String),
}),
);
});
});

View File

@@ -22,6 +22,9 @@ export const expectMessageCreated = (mockStore: ChatStore, role: 'assistant' | '
expect.objectContaining({
role,
}),
expect.objectContaining({
operationId: expect.any(String),
}),
);
};

View File

@@ -51,6 +51,9 @@ describe('request_human_approve executor', () => {
parentId: 'msg_assistant',
groupId: assistantMessage.groupId,
}),
expect.objectContaining({
operationId: expect.any(String),
}),
);
});
@@ -205,6 +208,9 @@ describe('request_human_approve executor', () => {
expect.objectContaining({
groupId: 'group_123',
}),
expect.objectContaining({
operationId: expect.any(String),
}),
);
});
@@ -232,6 +238,9 @@ describe('request_human_approve executor', () => {
expect.objectContaining({
parentId: 'msg_assistant_456',
}),
expect.objectContaining({
operationId: expect.any(String),
}),
);
});
});
@@ -330,6 +339,9 @@ describe('request_human_approve executor', () => {
tool_call_id: toolCall.id,
pluginIntervention: { status: 'pending' },
}),
expect.objectContaining({
operationId: expect.any(String),
}),
);
});
});
@@ -539,6 +551,9 @@ describe('request_human_approve executor', () => {
expect.objectContaining({
parentId: 'msg_3_last',
}),
expect.objectContaining({
operationId: expect.any(String),
}),
);
});
});

View File

@@ -57,6 +57,9 @@ describe('resolve_aborted_tools executor', () => {
topicId: 'test-topic',
parentId: parentMessage.id,
}),
expect.objectContaining({
operationId: expect.any(String),
}),
);
});
@@ -114,6 +117,9 @@ describe('resolve_aborted_tools executor', () => {
pluginIntervention: { status: 'aborted' },
tool_call_id: toolCall.id,
}),
expect.objectContaining({
operationId: expect.any(String),
}),
);
});
});
@@ -193,17 +199,22 @@ describe('resolve_aborted_tools executor', () => {
});
// Then
expect(mockStore.optimisticCreateMessage).toHaveBeenCalledWith({
role: 'tool',
content: 'Tool execution was aborted by user.',
plugin: toolCall,
pluginIntervention: { status: 'aborted' },
tool_call_id: 'tool_abc',
parentId: 'msg_parent',
sessionId: 'sess_123',
topicId: 'topic_456',
threadId: undefined,
});
expect(mockStore.optimisticCreateMessage).toHaveBeenCalledWith(
expect.objectContaining({
role: 'tool',
content: 'Tool execution was aborted by user.',
plugin: toolCall,
pluginIntervention: { status: 'aborted' },
tool_call_id: 'tool_abc',
parentId: 'msg_parent',
sessionId: 'sess_123',
topicId: 'topic_456',
threadId: undefined,
}),
expect.objectContaining({
operationId: expect.any(String),
}),
);
});
it('should preserve tool payload details', async () => {
@@ -240,6 +251,9 @@ describe('resolve_aborted_tools executor', () => {
expect.objectContaining({
plugin: toolCall,
}),
expect.objectContaining({
operationId: expect.any(String),
}),
);
});
@@ -264,6 +278,9 @@ describe('resolve_aborted_tools executor', () => {
expect.objectContaining({
topicId: undefined,
}),
expect.objectContaining({
operationId: expect.any(String),
}),
);
});
});
@@ -481,6 +498,9 @@ describe('resolve_aborted_tools executor', () => {
expect.objectContaining({
plugin: toolCall,
}),
expect.objectContaining({
operationId: expect.any(String),
}),
);
});
@@ -571,6 +591,9 @@ describe('resolve_aborted_tools executor', () => {
type: 'builtin',
}),
}),
expect.objectContaining({
operationId: expect.any(String),
}),
);
});
@@ -606,6 +629,9 @@ describe('resolve_aborted_tools executor', () => {
type: 'default',
}),
}),
expect.objectContaining({
operationId: expect.any(String),
}),
);
});

View File

@@ -89,16 +89,19 @@ export const createAgentExecutors = (context: {
llmPayload.parentMessageId = context.parentId;
}
// Create assistant message (following server-side pattern)
const assistantMessageItem = await context.get().optimisticCreateMessage({
content: LOADING_FLAT,
model: llmPayload.model,
parentId: llmPayload.parentMessageId,
provider: llmPayload.provider,
role: 'assistant',
sessionId: opContext.sessionId!,
threadId: opContext.threadId,
topicId: opContext.topicId ?? undefined,
});
const assistantMessageItem = await context.get().optimisticCreateMessage(
{
content: LOADING_FLAT,
model: llmPayload.model,
parentId: llmPayload.parentMessageId,
provider: llmPayload.provider,
role: 'assistant',
sessionId: opContext.sessionId!,
threadId: opContext.threadId,
topicId: opContext.topicId ?? undefined,
},
{ operationId: context.operationId },
);
if (!assistantMessageItem) {
throw new Error('Failed to create assistant message');
@@ -371,7 +374,9 @@ export const createAgentExecutors = (context: {
topicId: opContext.topicId ?? undefined,
};
const createPromise = context.get().optimisticCreateMessage(toolMessageParams);
const createPromise = context
.get()
.optimisticCreateMessage(toolMessageParams, { operationId: createToolMsgOpId });
context.get().updateOperationMetadata(createToolMsgOpId, {
createMessagePromise: createPromise,
});
@@ -632,7 +637,9 @@ export const createAgentExecutors = (context: {
topicId: opContext.topicId ?? undefined,
};
const createResult = await context.get().optimisticCreateMessage(toolMessageParams);
const createResult = await context
.get()
.optimisticCreateMessage(toolMessageParams, { operationId: context.operationId });
if (!createResult) {
log(
@@ -709,7 +716,9 @@ export const createAgentExecutors = (context: {
topicId: opContext.topicId ?? undefined,
};
const createResult = await context.get().optimisticCreateMessage(toolMessageParams);
const createResult = await context
.get()
.optimisticCreateMessage(toolMessageParams, { operationId: context.operationId });
if (createResult) {
log(

View File

@@ -261,6 +261,10 @@ export const conversationLifecycle: StateCreator<
summaryTitle().catch(console.error);
// Complete sendMessage operation here - message creation is done
// execAgentRuntime is a separate operation (child) that handles AI response generation
get().completeOperation(operationId);
// Get the current messages to generate AI response
const displayMessages = displayMessageSelectors.activeDisplayMessages(get());
@@ -287,16 +291,8 @@ export const conversationLifecycle: StateCreator<
if (userFiles.length > 0) {
await getAgentStoreState().addFilesToAgent(userFiles, false);
}
// Complete operation on success
get().completeOperation(operationId);
} catch (e) {
console.error(e);
// Fail operation on error
get().failOperation(operationId, {
type: e instanceof Error ? e.name : 'unknown_error',
message: e instanceof Error ? e.message : 'AI generation failed',
});
} finally {
if (data.topicId) get().internal_updateTopicLoading(data.topicId, false);
}

View File

@@ -241,9 +241,18 @@ describe('search actions', () => {
it('should update arguments and perform search', async () => {
const { result } = renderHook(() => useChatStore());
const spy = vi.spyOn(result.current, 'search');
const { triggerSearchAgain } = result.current;
const messageId = 'test-message-id';
const operationId = 'op_test';
// Set up messageOperationMap so triggerSearchAgain can get operationId
useChatStore.setState({
messageOperationMap: {
[messageId]: operationId,
},
});
const { triggerSearchAgain } = result.current;
const query: SearchQuery = {
query: 'test query',
};
@@ -252,7 +261,12 @@ describe('search actions', () => {
await triggerSearchAgain(messageId, query, { aiSummary: true });
});
expect(result.current.optimisticUpdatePluginArguments).toHaveBeenCalledWith(messageId, query);
expect(result.current.optimisticUpdatePluginArguments).toHaveBeenCalledWith(
messageId,
query,
false,
{ operationId },
);
expect(spy).toHaveBeenCalledWith(messageId, query, true);
});
});

View File

@@ -108,7 +108,11 @@ export const localSystemSlice: StateCreator<
},
reSearchLocalFiles: async (id, params) => {
await get().optimisticUpdatePluginArguments(id, params);
// Get operationId from messageOperationMap to ensure proper context isolation
const operationId = get().messageOperationMap[id];
const context = operationId ? { operationId } : undefined;
await get().optimisticUpdatePluginArguments(id, params, false, context);
return get().searchLocalFiles(id, params);
},

View File

@@ -276,7 +276,11 @@ export const searchSlice: StateCreator<
},
triggerSearchAgain: async (id, data, options) => {
await get().optimisticUpdatePluginArguments(id, data);
// Get operationId from messageOperationMap to ensure proper context isolation
const operationId = get().messageOperationMap[id];
const context = operationId ? { operationId } : undefined;
await get().optimisticUpdatePluginArguments(id, data, false, context);
await get().search(id, data, options?.aiSummary);
},

View File

@@ -167,14 +167,22 @@ export const messagePublicApi: StateCreator<
const message = dbMessageSelectors.getDbMessageById(id)(get());
if (!message || message.role !== 'tool') return;
// Get operationId from messageOperationMap to ensure proper context isolation
const operationId = get().messageOperationMap[id];
const context = operationId ? { operationId } : undefined;
const removeToolInAssistantMessage = async () => {
if (!message.parentId) return;
await get().optimisticRemoveToolFromAssistantMessage(message.parentId, message.tool_call_id);
await get().optimisticRemoveToolFromAssistantMessage(
message.parentId,
message.tool_call_id,
context,
);
};
await Promise.all([
// 1. remove tool message
get().optimisticDeleteMessage(id),
get().optimisticDeleteMessage(id, context),
// 2. remove the tool item in the assistant tools
removeToolInAssistantMessage(),
]);

View File

@@ -32,6 +32,7 @@ export interface MessageQueryAction {
messages: UIChatMessage[],
params?: {
action?: any;
operationId?: string;
sessionId?: string;
topicId?: string | null;
},
@@ -66,10 +67,22 @@ export const messageQuery: StateCreator<
},
replaceMessages: (messages, params) => {
const messagesKey = messageMapKey(
params?.sessionId ?? get().activeId,
params?.topicId ?? get().activeTopicId,
);
let sessionId: string;
let topicId: string | null | undefined;
// Priority 1: Get context from operation if operationId is provided
if (params?.operationId) {
const { sessionId: opSessionId, topicId: opTopicId } =
get().internal_getSessionContext(params);
sessionId = opSessionId;
topicId = opTopicId;
} else {
// Priority 2: Use explicit sessionId/topicId or fallback to global state
sessionId = params?.sessionId ?? get().activeId;
topicId = params?.topicId ?? get().activeTopicId;
}
const messagesKey = messageMapKey(sessionId, topicId);
// Get raw messages from dbMessagesMap and apply reducer
const nextDbMap = { ...get().dbMessagesMap, [messagesKey]: messages };

View File

@@ -393,6 +393,11 @@ describe('Operation Selectors', () => {
it('isMainWindowAgentRuntimeRunning should only detect main window operations', () => {
const { result } = renderHook(() => useChatStore());
// Set active context
act(() => {
useChatStore.setState({ activeId: 'session1', activeTopicId: undefined });
});
expect(operationSelectors.isMainWindowAgentRuntimeRunning(result.current)).toBe(false);
// Start a main window operation (inThread: false)
@@ -400,7 +405,7 @@ describe('Operation Selectors', () => {
act(() => {
mainOpId = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId: 'session1' },
context: { sessionId: 'session1', topicId: null },
metadata: { inThread: false },
}).operationId;
});
@@ -420,12 +425,17 @@ describe('Operation Selectors', () => {
it('isMainWindowAgentRuntimeRunning should exclude thread operations', () => {
const { result } = renderHook(() => useChatStore());
// Set active context
act(() => {
useChatStore.setState({ activeId: 'session1', activeTopicId: undefined });
});
// Start a thread operation (inThread: true)
let threadOpId: string;
act(() => {
threadOpId = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId: 'session1', threadId: 'thread1' },
context: { sessionId: 'session1', topicId: null, threadId: 'thread1' },
metadata: { inThread: true },
}).operationId;
});
@@ -447,6 +457,11 @@ describe('Operation Selectors', () => {
it('isMainWindowAgentRuntimeRunning should distinguish between main and thread operations', () => {
const { result } = renderHook(() => useChatStore());
// Set active context
act(() => {
useChatStore.setState({ activeId: 'session1', activeTopicId: undefined });
});
let mainOpId: string;
let threadOpId: string;
@@ -454,13 +469,13 @@ describe('Operation Selectors', () => {
act(() => {
mainOpId = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId: 'session1' },
context: { sessionId: 'session1', topicId: null },
metadata: { inThread: false },
}).operationId;
threadOpId = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId: 'session1', threadId: 'thread1' },
context: { sessionId: 'session1', topicId: null, threadId: 'thread1' },
metadata: { inThread: true },
}).operationId;
});
@@ -489,11 +504,16 @@ describe('Operation Selectors', () => {
it('isMainWindowAgentRuntimeRunning should exclude aborting operations', () => {
const { result } = renderHook(() => useChatStore());
// Set active context
act(() => {
useChatStore.setState({ activeId: 'session1', activeTopicId: undefined });
});
let opId: string;
act(() => {
opId = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId: 'session1' },
context: { sessionId: 'session1', topicId: null },
metadata: { inThread: false },
}).operationId;
});
@@ -509,5 +529,73 @@ describe('Operation Selectors', () => {
expect(operationSelectors.isMainWindowAgentRuntimeRunning(result.current)).toBe(false);
expect(operationSelectors.isAgentRuntimeRunning(result.current)).toBe(false);
});
it('isMainWindowAgentRuntimeRunning should only detect operations in current active topic', () => {
const { result } = renderHook(() => useChatStore());
// Set active session and topic
act(() => {
useChatStore.setState({ activeId: 'session1', activeTopicId: 'topic1' });
});
let topic1OpId: string;
let topic2OpId: string;
// Start operation in topic1 (current active topic)
act(() => {
topic1OpId = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId: 'session1', topicId: 'topic1' },
metadata: { inThread: false },
}).operationId;
});
// Should detect operation in current topic
expect(operationSelectors.isMainWindowAgentRuntimeRunning(result.current)).toBe(true);
// Start operation in topic2 (different topic)
act(() => {
topic2OpId = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId: 'session1', topicId: 'topic2' },
metadata: { inThread: false },
}).operationId;
});
// Should still only detect topic1 operation (current active topic)
expect(operationSelectors.isMainWindowAgentRuntimeRunning(result.current)).toBe(true);
// Switch to topic2
act(() => {
useChatStore.setState({ activeTopicId: 'topic2' });
});
// Should now detect topic2 operation
expect(operationSelectors.isMainWindowAgentRuntimeRunning(result.current)).toBe(true);
// Complete topic2 operation
act(() => {
result.current.completeOperation(topic2OpId!);
});
// Should not detect any operation in topic2 now
expect(operationSelectors.isMainWindowAgentRuntimeRunning(result.current)).toBe(false);
// Switch back to topic1
act(() => {
useChatStore.setState({ activeTopicId: 'topic1' });
});
// Should detect topic1 operation again
expect(operationSelectors.isMainWindowAgentRuntimeRunning(result.current)).toBe(true);
// Complete topic1 operation
act(() => {
result.current.completeOperation(topic1OpId!);
});
// Should not detect any operation now
expect(operationSelectors.isMainWindowAgentRuntimeRunning(result.current)).toBe(false);
});
});
});

View File

@@ -180,14 +180,27 @@ const isAgentRuntimeRunning = (s: ChatStoreState): boolean => {
/**
* Check if agent runtime is running in main window only
* Used for main window UI state (e.g., send button loading)
* Excludes thread operations to prevent cross-contamination
* Excludes thread operations and operations from other topics to prevent cross-contamination
*/
const isMainWindowAgentRuntimeRunning = (s: ChatStoreState): boolean => {
const operationIds = s.operationsByType['execAgentRuntime'] || [];
return operationIds.some((id) => {
const op = s.operations[id];
// Only include main window operations (not thread)
return op && op.status === 'running' && !op.metadata.isAborting && !op.metadata.inThread;
if (!op || op.status !== 'running' || op.metadata.isAborting || op.metadata.inThread) {
return false;
}
// Session must match
if (s.activeId !== op.context.sessionId) return false;
// Topic comparison: normalize null/undefined (both mean "default topic")
// activeTopicId can be null (initial state) or undefined (after topic operations)
// Operation context topicId can also be null or undefined
const activeTopicId = s.activeTopicId ?? null;
const opTopicId = op.context.topicId ?? null;
return activeTopicId === opTopicId;
});
};

View File

@@ -32,6 +32,7 @@ export interface PluginOptimisticUpdateAction {
id: string,
value: T,
replace?: boolean,
context?: OptimisticUpdateContext,
) => Promise<void>;
/**
@@ -106,7 +107,7 @@ export const pluginOptimisticUpdate: StateCreator<
}
},
optimisticUpdatePluginArguments: async (id, value, replace = false) => {
optimisticUpdatePluginArguments: async (id, value, replace = false, context) => {
const { refreshMessages } = get();
const toolMessage = displayMessageSelectors.getDisplayMessageById(id)(get());
if (!toolMessage || !toolMessage?.tool_call_id) return;
@@ -121,20 +122,22 @@ export const pluginOptimisticUpdate: StateCreator<
if (isEqual(prevJson, nextValue)) return;
// optimistic update
get().internal_dispatchMessage({
id,
type: 'updateMessagePlugin',
value: { arguments: JSON.stringify(nextValue) },
});
get().internal_dispatchMessage(
{ id, type: 'updateMessagePlugin', value: { arguments: JSON.stringify(nextValue) } },
context,
);
// 同样需要更新 assistantMessage 的 pluginArguments
if (assistantMessage) {
get().internal_dispatchMessage({
id: assistantMessage.id,
type: 'updateMessageTools',
tool_call_id: toolMessage?.tool_call_id,
value: { arguments: JSON.stringify(nextValue) },
});
get().internal_dispatchMessage(
{
id: assistantMessage.id,
type: 'updateMessageTools',
tool_call_id: toolMessage?.tool_call_id,
value: { arguments: JSON.stringify(nextValue) },
},
context,
);
assistantMessage = displayMessageSelectors.getDisplayMessageById(assistantMessage?.id)(get());
}
@@ -183,11 +186,14 @@ export const pluginOptimisticUpdate: StateCreator<
if (!assistantMessage) return;
const { internal_dispatchMessage, internal_refreshToUpdateMessageTools } = get();
internal_dispatchMessage({
type: 'addMessageTool',
value: tool,
id: assistantMessage.id,
});
internal_dispatchMessage(
{
type: 'addMessageTool',
value: tool,
id: assistantMessage.id,
},
context,
);
await internal_refreshToUpdateMessageTools(id, context);
},
@@ -199,7 +205,7 @@ export const pluginOptimisticUpdate: StateCreator<
const { internal_dispatchMessage, internal_refreshToUpdateMessageTools } = get();
// optimistic update
internal_dispatchMessage({ type: 'deleteMessageTool', tool_call_id, id: message.id });
internal_dispatchMessage({ type: 'deleteMessageTool', tool_call_id, id: message.id }, context);
// update the message tools
await internal_refreshToUpdateMessageTools(id, context);

View File

@@ -10,12 +10,7 @@ const getVaultByProvider = (provider: string) => (s: UserStore) =>
// @ts-ignore
(keyVaultsSettings(s)[provider] || {}) as any;
const password = (s: UserStore) => keyVaultsSettings(s).password || '';
export const keyVaultsConfigSelectors = {
getVaultByProvider,
keyVaultsSettings,
password,
};