fix: isolate concurrent OpenCode chat runs

This commit is contained in:
2026-08-17 22:03:37 +08:00
parent 7e8d9e3811
commit 65040730ec
29 changed files with 5207 additions and 427 deletions

View File

@@ -824,9 +824,10 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
const sessions = useOpencodeStore((state) => state.sessions);
const sessionMessagesBySessionId = useOpencodeStore((state) => state.sessionMessagesBySessionId);
const selectedSessionId = useOpencodeStore((state) => state.selectedSessionId);
const selectedSessionRunError = useOpencodeStore((state) => (
selectedSessionId ? state.sessionRunStates[selectedSessionId]?.error ?? null : null
));
const sessionRunStates = useOpencodeStore((state) => state.sessionRunStates);
const selectedSessionRunError = selectedSessionId
? sessionRunStates[selectedSessionId]?.error ?? null
: null;
const sessionStatuses = useOpencodeStore((state) => state.sessionStatuses);
const sessionTranscript = useOpencodeStore((state) => (
selectedSessionId ? state.sessionTranscriptBySessionId[selectedSessionId] : undefined
@@ -950,7 +951,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
const lastCompactionAnnouncementSessionRef = useRef<string | null>(null);
const composerAttachmentInputRef = useRef<HTMLInputElement | null>(null);
const composerStopArmTimerRef = useRef<number | null>(null);
const userModelConfigSyncRef = useRef<Promise<void> | null>(null);
const userModelConfigSyncRef = useRef<Promise<boolean> | null>(null);
const localProxyModelConfigSyncedRef = useRef(false);
const voiceRecorderRef = useRef<MediaRecorder | null>(null);
const voiceStreamRef = useRef<MediaStream | null>(null);
@@ -1055,8 +1056,12 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
&& !loading;
const quotaExhaustedRetryActive = selectedSessionStatus?.type === 'retry'
&& isQuotaExhaustedRetryMessage(selectedSessionStatus.message);
const quotaExhaustedError = getOpencodeErrorKind(selectedSessionRunError) === 'quota_exhausted'
? selectedSessionRunError
const projectedError = error ?? selectedSessionRunError;
const projectedErrorKind = error
? errorKind ?? getOpencodeErrorKind(error)
: getOpencodeErrorKind(selectedSessionRunError);
const quotaExhaustedError = projectedErrorKind === 'quota_exhausted'
? projectedError
: null;
const quotaExhaustedRetryError = quotaExhaustedRetryActive
? selectedSessionStatus.message ?? 'quota_exhausted'
@@ -1067,24 +1072,23 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
const tokenBalanceExhaustedActive = isOpencodeTokenBalanceExhausted(activeQuotaExhaustedError);
const authenticationInvalidRetryActive = selectedSessionStatus?.type === 'retry'
&& getOpencodeErrorKind(selectedSessionStatus.message) === 'authentication_invalid';
const authenticationInvalidActive = (errorKind === 'authentication_invalid' && Boolean(error))
const authenticationInvalidActive = (projectedErrorKind === 'authentication_invalid' && Boolean(projectedError))
|| authenticationInvalidRetryActive;
const authenticationFailureKey = authenticationInvalidActive
? `${selectedSessionId ?? 'global'}:${error ?? selectedSessionStatus?.message ?? 'authentication_invalid'}`
? `${error ? 'global' : selectedSessionId ?? 'global'}:${projectedError ?? selectedSessionStatus?.message ?? 'authentication_invalid'}`
: null;
const quotaFailureKey = rawQuotaExhaustedActive
? `${selectedSessionId ?? 'global'}:${activeQuotaExhaustedError}`
? `${error ? 'global' : selectedSessionId ?? 'global'}:${activeQuotaExhaustedError}`
: null;
const quotaExhaustedErrorActive = quotaFailureKey !== null
&& confirmedQuotaFailureKey === quotaFailureKey;
const quotaUpgradePromptOpen = quotaFailureKey !== null
&& quotaUpgradePromptKey === quotaFailureKey;
const displayErrorKind = errorKind ?? getOpencodeErrorKind(error);
const displayError = error
&& displayErrorKind !== 'authentication_invalid'
&& displayErrorKind !== 'quota_exhausted'
&& !isUpstreamSaturatedError(error)
? error
const displayError = projectedError
&& projectedErrorKind !== 'authentication_invalid'
&& projectedErrorKind !== 'quota_exhausted'
&& !isUpstreamSaturatedError(projectedError)
? projectedError
: null;
const visibleTranscriptMessages = useMemo(() => {
return buildVisibleTranscriptMessages(
@@ -1521,21 +1525,27 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
const userModelAccount = providerAccounts.find((account) => account.id === NIANCODE_USER_MODEL_ACCOUNT_ID);
const shouldSyncLocalProxy = userModelAccount?.metadata?.worksSquareCredentialMode === WORKS_SQUARE_AI_GATEWAY_PROXY_CREDENTIAL_MODE
&& !localProxyModelConfigSyncedRef.current;
if (!shouldRefreshWorksSquareUserModelConfig(providerAccounts) && !shouldSyncLocalProxy) return;
if (!shouldRefreshWorksSquareUserModelConfig(providerAccounts) && !shouldSyncLocalProxy) return true;
if (userModelConfigSyncRef.current) {
await userModelConfigSyncRef.current;
return;
return await userModelConfigSyncRef.current;
}
const syncTask = (async () => {
const validAccessToken = await getValidAccessToken();
if (!validAccessToken) return;
if (!validAccessToken) return true;
try {
await importUserModelConfig(validAccessToken);
const result = await importUserModelConfig(validAccessToken, {
runtimeRefresh: 'defer',
});
if (result.runtimeRefreshRequired) {
toast.error('模型配置已更新,请手动重启运行时后重试。');
return false;
}
if (shouldSyncLocalProxy) {
localProxyModelConfigSyncedRef.current = true;
}
return true;
} catch (error) {
if (!isUnauthorizedWorksError(error)) {
throw error;
@@ -1544,25 +1554,34 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
if (!refreshedAccessToken) {
throw error;
}
await importUserModelConfig(refreshedAccessToken);
const result = await importUserModelConfig(refreshedAccessToken, {
runtimeRefresh: 'defer',
});
if (result.runtimeRefreshRequired) {
toast.error('模型配置已更新,请手动重启运行时后重试。');
return false;
}
if (shouldSyncLocalProxy) {
localProxyModelConfigSyncedRef.current = true;
}
return true;
}
})()
.catch((error) => {
console.warn('[chat] Failed to refresh Works Square user model config', error);
toast.error('模型配置同步失败,请稍后重试。');
return false;
})
.finally(() => {
userModelConfigSyncRef.current = null;
});
userModelConfigSyncRef.current = syncTask;
await syncTask;
return await syncTask;
}, [getValidAccessToken, importUserModelConfig, providerAccounts, refreshSession]);
const ensureChatModelConfigured = useCallback(async () => {
await refreshWorksSquareUserModelConfigIfNeeded();
if (!(await refreshWorksSquareUserModelConfigIfNeeded())) return false;
if (selectedProjectAgent?.model?.trim()) return true;
setModelConfigPromptOpen(true);
return false;
@@ -1611,7 +1630,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
return;
}
const configuredAgentModel = submissionAgent.model?.trim();
await refreshWorksSquareUserModelConfigIfNeeded();
if (!(await refreshWorksSquareUserModelConfigIfNeeded())) return;
if (!sessionStillMatches()) return;
const latestProviderState = useProviderStore.getState();
const latestConfiguredChatModelOptions = buildConfiguredModelOptions(
@@ -2053,6 +2072,9 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
}, []);
const runCompact = useCallback(async () => {
if (!(await ensureChatModelConfigured())) {
throw new Error('模型未就绪,请完成配置或手动重启运行时后重试。');
}
const owner = submissionSessionRef.current;
const sessionId = owner.id;
if (!sessionId) throw new Error('请先选择会话');
@@ -2061,7 +2083,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
owner,
submissionSessionRef.current,
);
}, [compactSession, selectedProjectAgent?.model]);
}, [compactSession, ensureChatModelConfigured, selectedProjectAgent?.model]);
const runShare = useCallback(async () => {
const owner = submissionSessionRef.current;
@@ -2230,6 +2252,9 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
rawName: string;
arguments: string;
}): Promise<{ status: 'executed' | 'cancelled' }> => {
if (!(await ensureChatModelConfigured())) {
throw new Error('模型未就绪,请完成配置或手动重启运行时后重试。');
}
const owner = submissionSessionRef.current;
const submissionSessionId = owner.id;
if (!submissionSessionId) throw new Error('请先选择会话');
@@ -2261,6 +2286,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
}, [
composer,
currentSession?.agent,
ensureChatModelConfigured,
executeProjectCommand,
selectedProjectAgent?.id,
selectedProjectAgent?.model,