diff --git a/backend/app/agent/agent_model.py b/backend/app/agent/agent_model.py index 1b1fe860b..8e4cfc153 100644 --- a/backend/app/agent/agent_model.py +++ b/backend/app/agent/agent_model.py @@ -18,12 +18,12 @@ from typing import Any from camel.messages import BaseMessage -from camel.models import ModelFactory from camel.toolkits import FunctionTool, RegisteredAgentToolkit from camel.types import ModelPlatformType from app.agent.listen_chat_agent import ListenChatAgent, logger from app.model.chat import AgentModelConfig, Chat +from app.service.model_registry import get_or_create_model from app.service.task import ActionCreateAgentData, Agents, get_task_lock from app.utils.event_loop_utils import _schedule_async_task @@ -135,11 +135,11 @@ def agent_model( ) model_platform_enum = None - model = ModelFactory.create( + model = get_or_create_model( model_platform=effective_config["model_platform"], model_type=effective_config["model_type"], api_key=effective_config["api_key"], - url=effective_config["api_url"], + api_url=effective_config["api_url"], model_config_dict=model_config or None, timeout=600, # 10 minutes **init_params, diff --git a/backend/app/agent/factory/mcp.py b/backend/app/agent/factory/mcp.py index 4f1dc86ab..bf5b55276 100644 --- a/backend/app/agent/factory/mcp.py +++ b/backend/app/agent/factory/mcp.py @@ -14,13 +14,12 @@ import asyncio import uuid -from camel.models import ModelFactory - from app.agent.listen_chat_agent import ListenChatAgent, logger from app.agent.prompt import MCP_SYS_PROMPT from app.agent.toolkit.mcp_search_toolkit import McpSearchToolkit from app.agent.tools import get_mcp_tools from app.model.chat import Chat +from app.service.model_registry import get_or_create_model from app.service.task import ActionCreateAgentData, Agents, get_task_lock @@ -77,11 +76,11 @@ async def mcp_agent(options: Chat): options.project_id, Agents.mcp_agent, system_message=MCP_SYS_PROMPT, - model=ModelFactory.create( + model=get_or_create_model( model_platform=options.model_platform, model_type=options.model_type, api_key=options.api_key, - url=options.api_url, + api_url=options.api_url, model_config_dict=( { "user": str(options.project_id), diff --git a/backend/app/agent/prompt.py b/backend/app/agent/prompt.py index 40f8d1bc0..e8c98f976 100644 --- a/backend/app/agent/prompt.py +++ b/backend/app/agent/prompt.py @@ -221,10 +221,51 @@ You are a helpful task assistant that can help users summarize the content of their tasks""" QUESTION_CONFIRM_SYS_PROMPT = """\ -You are a highly capable agent. Your primary function is to analyze a user's \ -request and determine the appropriate course of action. The current date is \ -{now_str}(Accurate to the hour). For any date-related tasks, you MUST use \ -this as the current date.""" +You are Eigent, an open source Cowork desktop application for building, managing, \ +and deploying a custom AI workforce that can automate complex workflows. \ +You are built on CAMEL-AI, supports multi-agent coordination, local deployment, \ +custom model support, and MCP integration (if available). Your primary function is \ +to analyze a user's request and determine the appropriate course of action. \ +The current date is {now_str}(Accurate to the hour). For any date-related \ +tasks, you MUST use this as the current date.""" + +QUICK_REPLY_ASSESSMENT_PROMPT = """\ +You are evaluating whether the user needs the full Eigent workforce or a direct answer. + +## Conversation Context +{conversation_context} + +## User Query +{question} + +## Attached Files +{attachments} + +## Available MCP Servers +{mcp_servers} + +Determine if this user query is a complex task or a simple question. + +If answering the query would require MCP tools or any other external tools, \ +classify it as COMPLEX. + +**Complex task**: Requires tools, code execution, file operations, multi-step planning,\ +or creating/modifying content. +- Examples: "create a file", "search for X", "implement feature Y", "write code", \ +"analyze data" + +**Simple question**: Can be answered directly with knowledge or conversation history, \ +with no action needed. +- Examples: greetings ("hello", "hi"), fact queries ("what is X?"), clarifications, \ +status checks + +If the query is simple, provide a direct, helpful answer in the same response and \ +try to respond with 10 words maximum if it is a very direct query. +If the query is complex, do not answer it. + +Respond in this exact format: +COMPLEXITY: [SIMPLE|COMPLEX] +ANSWER: [Direct answer only if SIMPLE. Leave blank if COMPLEX]""" MCP_SYS_PROMPT = """\ You are a helpful assistant that can help users search mcp servers. The found \ diff --git a/backend/app/service/chat_service.py b/backend/app/service/chat_service.py index b0ea4e606..bc682e753 100644 --- a/backend/app/service/chat_service.py +++ b/backend/app/service/chat_service.py @@ -16,6 +16,7 @@ import datetime import logging import platform +from dataclasses import dataclass from pathlib import Path from typing import Any @@ -38,11 +39,15 @@ task_summary_agent, ) from app.agent.listen_chat_agent import ListenChatAgent +from app.agent.prompt import ( + QUICK_REPLY_ASSESSMENT_PROMPT, +) from app.agent.toolkit.human_toolkit import HumanToolkit from app.agent.toolkit.note_taking_toolkit import NoteTakingToolkit from app.agent.toolkit.skill_toolkit import SkillToolkit from app.agent.toolkit.terminal_toolkit import TerminalToolkit from app.agent.tools import get_mcp_tools, get_toolkits +from app.agent.utils import NOW_STR from app.model.chat import Chat, NewAgent, Status, TaskContent, sse_json from app.service.task import ( Action, @@ -65,6 +70,14 @@ logger = logging.getLogger("chat_service") +@dataclass +class QuestionAssessment: + """Classify a question and optionally carry a precomputed simple answer.""" + + is_complex: bool + direct_answer: str | None = None + + def format_task_context( task_data: dict, seen_files: set | None = None, skip_files: bool = False ) -> str: @@ -499,6 +512,7 @@ async def step_solve(options: Chat, request: Request, task_lock: TaskLock): # Determine task complexity: attachments # mean workforce, otherwise let agent decide is_complex_task: bool + question_assessment = QuestionAssessment(is_complex=True) if len(attaches_to_use) > 0: is_complex_task = True logger.info( @@ -506,13 +520,20 @@ async def step_solve(options: Chat, request: Request, task_lock: TaskLock): ", treating as complex task" ) else: - is_complex_task = await question_confirm( - question_agent, question, task_lock + question_assessment = await assess_question( + question_agent, + question, + options, + attaches_to_use, + task_lock, ) + is_complex_task = question_assessment.is_complex logger.info( "[NEW-QUESTION] question_confirm" " result: is_complex=" - f"{is_complex_task}" + f"{is_complex_task}, " + "has_direct_answer=" + f"{bool(question_assessment.direct_answer)}" ) if not is_complex_task: @@ -521,28 +542,11 @@ async def step_solve(options: Chat, request: Request, task_lock: TaskLock): ", providing direct answer " "without workforce" ) - conv_ctx = build_conversation_context( - task_lock, header="=== Previous Conversation ===" - ) - simple_answer_prompt = ( - f"{conv_ctx}" - f"User Query: {question}\n\n" - "Provide a direct, helpful " - "answer to this simple " - "question." - ) - try: - simple_resp = question_agent.step(simple_answer_prompt) - if simple_resp and simple_resp.msgs: - answer_content = simple_resp.msgs[0].content - else: - answer_content = ( - "I understand your " - "question, but I'm " - "having trouble " - "generating a response " - "right now." + answer_content = question_assessment.direct_answer + if not answer_content: + raise ValueError( + "Simple question assessment returned no answer" ) task_lock.add_conversation("assistant", answer_content) @@ -1086,6 +1090,8 @@ async def run_decomposition(): yield sse_json("task_state", item.data) elif item.action == Action.new_task_state: + # TODO: New question agent refactor is not added here + # this code wil be deprecated soon. logger.info("=" * 80) logger.info( "[LIFECYCLE] NEW_TASK_STATE action received (Multi-turn)", @@ -1193,9 +1199,7 @@ async def run_decomposition(): "calling question_confirm " "for new task" ) - is_multi_turn_complex = await question_confirm( - question_agent, new_task_content, task_lock - ) + is_multi_turn_complex = True logger.info( "[LIFECYCLE] Multi-turn: " "question_confirm result:" @@ -1927,65 +1931,119 @@ def add_sub_tasks( return added_tasks -async def question_confirm( - agent: ListenChatAgent, prompt: str, task_lock: TaskLock | None = None -) -> bool: - """Simple question confirmation - returns True - for complex tasks, False for simple questions.""" +async def assess_question( + agent: ListenChatAgent, + prompt: str, + options: Chat, + attachments: list[str] | None = None, + task_lock: TaskLock | None = None, +) -> QuestionAssessment: + """Classify a question and precompute a direct answer for simple queries.""" - context_prompt = "" + conversation_context = "No previous conversation." if task_lock: - context_prompt = build_conversation_context( - task_lock, header="=== Previous Conversation ===" + conversation_context = ( + build_conversation_context( + task_lock, header="=== Previous Conversation ===" + ).strip() + or "No previous conversation." ) - full_prompt = f"""{context_prompt}User Query: {prompt} - -Determine if this user query is a complex task or a simple question. - -**Complex task** (answer "yes"): Requires tools, code execution, \ -file operations, multi-step planning, or creating/modifying content -- Examples: "create a file", "search for X", \ -"implement feature Y", "write code", "analyze data" - -**Simple question** (answer "no"): Can be answered directly \ -with knowledge or conversation history, no action needed -- Examples: greetings ("hello", "hi"), \ -fact queries ("what is X?"), clarifications, status checks - -Answer only "yes" or "no". Do not provide any explanation. + # TODO: current mcp servers are configured in mcp.json file. + # So this might not reflect properly + working_directory = get_working_directory(options, task_lock) + mcp_servers = options.installed_mcp.get("mcpServers", {}) + mcp_servers_info = ", ".join(mcp_servers.keys()) if mcp_servers else "None" + + # TODO: this is attached to the live tasklock instance (i.e. first turn) + attachments_info = ( + "\n".join(f"- {path}" for path in attachments) + if attachments + else "None" + ) -Is this a complex task? (yes/no):""" + full_prompt = QUICK_REPLY_ASSESSMENT_PROMPT.format( + working_directory=working_directory, + now_str=NOW_STR, + conversation_context=conversation_context, + question=prompt, + attachments=attachments_info, + mcp_servers=mcp_servers_info, + ) try: resp = agent.step(full_prompt) - if not resp or not resp.msgs or len(resp.msgs) == 0: + if not resp or not resp.msgs: logger.warning( "No response from agent, defaulting to complex task" ) - return True + return QuestionAssessment(is_complex=True) content = resp.msgs[0].content if not content: logger.warning( "Empty content from agent, defaulting to complex task" ) - return True + return QuestionAssessment(is_complex=True) + + complexity: str | None = None + answer_lines: list[str] = [] + in_answer_section = False + + for line in content.splitlines(): + stripped = line.strip() + upper = stripped.upper() + + if upper.startswith("COMPLEXITY:"): + complexity = stripped.split(":", 1)[1].strip().upper() + in_answer_section = False + continue + + if upper.startswith("ANSWER:"): + answer_text = stripped.split(":", 1)[1].strip() + if answer_text: + answer_lines.append(answer_text) + in_answer_section = True + continue + + if in_answer_section and stripped: + answer_lines.append(stripped) + + if complexity in {"SIMPLE", "COMPLEX"}: + is_complex = complexity == "COMPLEX" + direct_answer = "\n".join(answer_lines).strip() or None + if not is_complex and not direct_answer: + logger.warning( + "Simple question assessment returned no answer, " + "defaulting to complex task" + ) + return QuestionAssessment(is_complex=True) + result_str = "complex task" if is_complex else "simple question" + logger.info( + f"Question assessment result: {result_str}", + extra={ + "response": content, + "is_complex": is_complex, + "has_direct_answer": bool(direct_answer), + }, + ) + return QuestionAssessment( + is_complex=is_complex, + direct_answer=direct_answer, + ) normalized = content.strip().lower() is_complex = "yes" in normalized - result_str = "complex task" if is_complex else "simple question" logger.info( - f"Question confirm result: {result_str}", + f"Question assessment fallback result: {result_str}", extra={"response": content, "is_complex": is_complex}, ) - - return is_complex + return QuestionAssessment(is_complex=is_complex) except Exception as e: - logger.error(f"Error in question_confirm: {e}") + logger.error(f"Error in assess_question: {e}") raise diff --git a/backend/app/service/model_registry.py b/backend/app/service/model_registry.py new file mode 100644 index 000000000..45a0fbf60 --- /dev/null +++ b/backend/app/service/model_registry.py @@ -0,0 +1,120 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +""" +Shared Model Backend Registry +============================== + +Deduplicates ModelFactory.create() calls by caching model backends +keyed on their configuration fingerprint (platform + type + url + key hash ++ model_config). Agents with identical configs share the same backend. +""" + +import hmac +import logging +import threading +from typing import Any + +from camel.models import ModelFactory + +logger = logging.getLogger("model_registry") + +_model_registry: dict[str, Any] = {} +_registry_lock = threading.Lock() + + +def _compute_fingerprint( + model_platform: str, + model_type: str, + api_key: str | None = None, + api_url: str | None = None, + model_config_dict: dict | None = None, +) -> str: + # HMAC for cache-key derivation (not password storage — safe to use here) + key_hash = hmac.new( + b"model-registry", (api_key or "").encode(), "sha256" + ).hexdigest()[:16] + config_str = "" + if model_config_dict: + # Exclude "user" (project_id for API tracking) from fingerprint + # so all projects share the same model backend + filtered = {k: v for k, v in model_config_dict.items() if k != "user"} + if filtered: + config_str = str(sorted(filtered.items())) + raw = f"{model_platform}|{model_type}|{api_url or ''}|{key_hash}|{config_str}" + return hmac.new(b"model-registry", raw.encode(), "sha256").hexdigest()[:32] + + +def get_or_create_model( + model_platform: str, + model_type: str, + api_key: str | None = None, + api_url: str | None = None, + model_config_dict: dict | None = None, + timeout: int = 600, + **init_params: Any, +) -> Any: + """Drop-in replacement for ModelFactory.create() with caching.""" + fingerprint = _compute_fingerprint( + model_platform=model_platform, + model_type=model_type, + api_key=api_key, + api_url=api_url, + model_config_dict=model_config_dict, + ) + + with _registry_lock: + if fingerprint in _model_registry: + logger.debug( + f"Reusing cached model backend: " + f"platform={model_platform}, type={model_type} " + f"(fingerprint={fingerprint[:8]}...)" + ) + return _model_registry[fingerprint] + + # Create outside the lock to avoid blocking during network calls + logger.info( + f"Creating new model backend: " + f"platform={model_platform}, type={model_type}, url={api_url} " + f"(fingerprint={fingerprint[:8]}...)" + ) + model = ModelFactory.create( + model_platform=model_platform, + model_type=model_type, + api_key=api_key, + url=api_url, + model_config_dict=model_config_dict or None, + timeout=timeout, + **init_params, + ) + + with _registry_lock: + if fingerprint not in _model_registry: + _model_registry[fingerprint] = model + logger.info( + f"Registered model backend (total: {len(_model_registry)}): " + f"platform={model_platform}, type={model_type}" + ) + else: + model = _model_registry[fingerprint] + + return model + + +def clear_registry() -> None: + """Clear all cached model backends (e.g. on shutdown or key rotation).""" + with _registry_lock: + count = len(_model_registry) + _model_registry.clear() + logger.info(f"Model registry cleared ({count} models removed)") diff --git a/backend/main.py b/backend/main.py index 00321d984..b9a818d93 100644 --- a/backend/main.py +++ b/backend/main.py @@ -138,6 +138,11 @@ async def cleanup_resources(): except Exception as e: app_logger.error(f"Error cleaning up task {task_id}: {e}") + # Clear model registry cache + from app.service.model_registry import clear_registry + + clear_registry() + # Remove PID file pid_file = dir / "run.pid" if pid_file.exists(): diff --git a/src/components/ChatBox/index.tsx b/src/components/ChatBox/index.tsx index dedbede29..131c8a6e9 100644 --- a/src/components/ChatBox/index.tsx +++ b/src/components/ChatBox/index.tsx @@ -21,6 +21,7 @@ import { } from '@/api/http'; import useChatStoreAdapter from '@/hooks/useChatStoreAdapter'; import { generateUniqueId, replayActiveTask } from '@/lib'; +import { fetchUserKey, invalidateUserKey } from '@/lib/queryClient'; import { proxyUpdateTriggerExecution } from '@/service/triggerApi'; import { useAuthStore } from '@/store/authStore'; import type { VanillaChatStore } from '@/store/chatStore'; @@ -86,8 +87,11 @@ export default function ChatBox(): JSX.Element { const checkModelConfig = useCallback(async () => { try { if (modelType === 'cloud') { + //Invalidate for fetch fetch + invalidateUserKey(); + // For cloud model, check if API key exists - const res = await proxyFetchGet('/api/v1/user/key'); + const res = await fetchUserKey(); setHasModel(!!res.value); } else if (modelType === 'local' || modelType === 'custom') { // For local/custom model, check if provider exists @@ -132,8 +136,12 @@ export default function ChatBox(): JSX.Element { }, [location.pathname, checkModelConfig]); // Also check when window gains focus (user returns from settings) + const lastFocusCheckRef = useRef(0); useEffect(() => { const handleFocus = () => { + const now = Date.now(); + if (now - lastFocusCheckRef.current < 10000) return; + lastFocusCheckRef.current = now; checkModelConfig(); }; diff --git a/src/lib/queryClient.ts b/src/lib/queryClient.ts index 1eefa48de..19eb339bf 100644 --- a/src/lib/queryClient.ts +++ b/src/lib/queryClient.ts @@ -12,6 +12,7 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import { proxyFetchGet } from '@/api/http'; import { QueryClient } from '@tanstack/react-query'; export const queryClient = new QueryClient({ @@ -38,4 +39,20 @@ export const queryKeys = { [...queryKeys.triggers.all, 'configs', triggerType] as const, allConfigs: () => [...queryKeys.triggers.all, 'configs'] as const, }, + userKey: ['userKey'] as const, +}; + +/** Cached fetch for /api/v1/user/key. Returns cached data if fresh, otherwise fetches. */ +export const fetchUserKey = () => + queryClient.fetchQuery({ + queryKey: queryKeys.userKey, + queryFn: () => proxyFetchGet('/api/v1/user/key'), + staleTime: 1000 * 60 * 5, // 5 minutes + }); + +/** Invalidate user key cache (call after task end or settings change). */ +//TODO: When adding any related queries, also invalidate them here to ensure consistency. +//Tobe renamed to "invalidateModelCache" when more related queries are added. +export const invalidateUserKey = () => { + queryClient.invalidateQueries({ queryKey: queryKeys.userKey }); }; diff --git a/src/store/chatStore.ts b/src/store/chatStore.ts index 2e7624aee..e7752e27e 100644 --- a/src/store/chatStore.ts +++ b/src/store/chatStore.ts @@ -26,6 +26,7 @@ import { import { showCreditsToast } from '@/components/Toast/creditsToast'; import { showStorageToast } from '@/components/Toast/storageToast'; import { generateUniqueId, uploadLog } from '@/lib'; +import { fetchUserKey, invalidateUserKey } from '@/lib/queryClient'; import { proxyUpdateTriggerExecution } from '@/service/triggerApi'; import { ExecutionStatus } from '@/types'; import { @@ -760,87 +761,97 @@ const chatStore = (initial?: Partial) => } } - // get current model - let apiModel = { - api_key: '', - model_type: '', - model_platform: '', - api_url: '', - extra_params: {}, - }; - if (modelType === 'custom' || modelType === 'local') { - const res = await proxyFetchGet('/api/v1/providers', { - prefer: true, - }); - const providerList = res.items || []; - console.log('providerList', providerList); - const provider = providerList[0]; + // Fire all independent fetches in parallel + const apiModelPromise = (async () => { + if (modelType === 'custom' || modelType === 'local') { + const res = await proxyFetchGet('/api/v1/providers', { + prefer: true, + }); + const providerList = res.items || []; + console.log('providerList', providerList); + const provider = providerList[0]; - if (!provider) { - throw new Error( - 'No model provider configured. Please go to Agents > Models and configure at least one model provider as default.' - ); - } + if (!provider) { + throw new Error( + 'No model provider configured. Please go to Agents > Models and configure at least one model provider as default.' + ); + } - apiModel = { - api_key: provider.api_key, - model_type: provider.model_type, - model_platform: provider.provider_name, - api_url: provider.endpoint_url || provider.api_url, - extra_params: provider.encrypted_config, - }; - } else if (modelType === 'cloud') { - // get current model - const res = await proxyFetchGet('/api/v1/user/key'); - if (res.warning_code && res.warning_code === '21') { - showStorageToast(); + return { + api_key: provider.api_key, + model_type: provider.model_type, + model_platform: provider.provider_name, + api_url: provider.endpoint_url || provider.api_url, + extra_params: provider.encrypted_config, + }; + } else if (modelType === 'cloud') { + const res = await fetchUserKey(); + if (res.warning_code && res.warning_code === '21') { + showStorageToast(); + } + return { + api_key: res.value, + model_type: cloud_model_type, + model_platform: cloud_model_type.includes('gpt') + ? 'openai' + : cloud_model_type.includes('claude') + ? 'aws-bedrock' + : cloud_model_type.includes('gemini') + ? 'gemini' + : 'openai-compatible-model', + api_url: res.api_url, + extra_params: {}, + }; } - apiModel = { - api_key: res.value, - model_type: cloud_model_type, - model_platform: cloud_model_type.includes('gpt') - ? 'openai' - : cloud_model_type.includes('claude') - ? 'aws-bedrock' - : cloud_model_type.includes('gemini') - ? 'gemini' - : 'openai-compatible-model', - api_url: res.api_url, + return { + api_key: '', + model_type: '', + model_platform: '', + api_url: '', extra_params: {}, }; - } + })(); - // Get search engine configuration for custom mode - let searchConfig: Record = {}; - if (modelType === 'custom') { - try { - const configsRes = await proxyFetchGet('/api/v1/configs'); - const configs = Array.isArray(configsRes) ? configsRes : []; - - // Extract Google Search API keys - const googleApiKey = configs.find( - (c: any) => - c.config_group?.toLowerCase() === 'search' && - c.config_name === 'GOOGLE_API_KEY' - )?.config_value; - - const searchEngineId = configs.find( - (c: any) => - c.config_group?.toLowerCase() === 'search' && - c.config_name === 'SEARCH_ENGINE_ID' - )?.config_value; - - if (googleApiKey && searchEngineId) { - searchConfig = { - GOOGLE_API_KEY: googleApiKey, - SEARCH_ENGINE_ID: searchEngineId, - }; - console.log('Loaded custom search configuration'); + const searchConfigPromise = (async () => { + if (modelType === 'custom') { + try { + const configsRes = await proxyFetchGet('/api/v1/configs'); + const configs = Array.isArray(configsRes) ? configsRes : []; + + const googleApiKey = configs.find( + (c: any) => + c.config_group?.toLowerCase() === 'search' && + c.config_name === 'GOOGLE_API_KEY' + )?.config_value; + + const searchEngineId = configs.find( + (c: any) => + c.config_group?.toLowerCase() === 'search' && + c.config_name === 'SEARCH_ENGINE_ID' + )?.config_value; + + if (googleApiKey && searchEngineId) { + console.log('Loaded custom search configuration'); + return { + GOOGLE_API_KEY: googleApiKey, + SEARCH_ENGINE_ID: searchEngineId, + }; + } + } catch (error) { + console.error('Failed to load search configuration:', error); } - } catch (error) { - console.error('Failed to load search configuration:', error); } - } + return {} as Record; + })(); + + const [apiModel, searchConfig, envPath, browser_port, cdp_browsers] = + await Promise.all([ + apiModelPromise, + searchConfigPromise, + window.ipcRenderer.invoke('get-env-path', email).catch(() => ''), + window.ipcRenderer.invoke('get-browser-port'), + window.ipcRenderer.invoke('get-cdp-browsers'), + ]); const addWorkers = workerList.map((worker) => { return { @@ -851,14 +862,6 @@ const chatStore = (initial?: Partial) => }; }); - // get env path - let envPath = ''; - try { - envPath = await window.ipcRenderer.invoke('get-env-path', email); - } catch (error) { - console.log('get-env-path error', error); - } - // create history if (!type) { const authStore = getAuthStore(); @@ -882,19 +885,23 @@ const chatStore = (initial?: Partial) => status: 1, tokens: 0, }; - await proxyFetchPost(`/api/v1/chat/history`, obj).then((res) => { - historyId = res.id; - /**Save history id for replay reuse purposes. - * TODO(history): Remove historyId handling to support per projectId - * instead in history api - */ - if (project_id && historyId) - projectStore.setHistoryId(project_id, historyId); - }); + // create history in parallel with SSE (historyId is only needed later in onmessage) + proxyFetchPost(`/api/v1/chat/history`, obj) + .then((res) => { + historyId = res.id; + + /**Save history id for replay reuse purposes. + * TODO(history): Remove historyId handling to support per projectId + * instead in history api + */ + if (project_id && historyId) + projectStore.setHistoryId(project_id, historyId); + }) + .catch((err) => { + console.error('Failed to create chat history record:', err); + }); } - const browser_port = await window.ipcRenderer.invoke('get-browser-port'); - const cdp_browsers = await window.ipcRenderer.invoke('get-cdp-browsers'); // Lock the chatStore reference at the start of SSE session to prevent focus changes // during active message processing @@ -2527,6 +2534,7 @@ const chatStore = (initial?: Partial) => setStatus(currentTaskId, ChatTaskStatus.FINISHED); // completed tasks move to history setUpdateCount(); + invalidateUserKey(); console.log(tasks[currentTaskId], 'end'); @@ -2637,6 +2645,7 @@ const chatStore = (initial?: Partial) => onerror(err) { console.error('[fetchEventSource] Error:', err); + invalidateUserKey(); // Do not retry if the task has already finished (avoids duplicate execution // after ERR_NETWORK_CHANGED, ERR_INTERNET_DISCONNECTED, sleep/wake - see issue #1212) @@ -2710,6 +2719,7 @@ const chatStore = (initial?: Partial) => // Server closes connection onclose() { + invalidateUserKey(); console.log('SSE connection closed'); // Abort to resolve fetchEventSource promise (for replay/load - allows awaiting completion) try {