fix: isolate concurrent OpenCode chat runs
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -812,6 +812,7 @@ export function CharacterScene() {
|
||||
const [providerSnapshotLoaded, setProviderSnapshotLoaded] = useState(false);
|
||||
const [autoUserModelSyncAttempted, setAutoUserModelSyncAttempted] = useState(false);
|
||||
const [syncingUserModels, setSyncingUserModels] = useState(false);
|
||||
const [runtimeRefreshRequired, setRuntimeRefreshRequired] = useState(false);
|
||||
const [knowledgeItems, setKnowledgeItems] = useState<KnowledgeFile[]>(knowledgeFiles);
|
||||
const [knowledgeNotice, setKnowledgeNotice] = useState<string | null>(null);
|
||||
const [knowledgeDragActive, setKnowledgeDragActive] = useState(false);
|
||||
@@ -909,7 +910,10 @@ export function CharacterScene() {
|
||||
|
||||
setAutoUserModelSyncAttempted(true);
|
||||
setSyncingUserModels(true);
|
||||
void importUserModelConfig(accessToken)
|
||||
void importUserModelConfig(accessToken, { runtimeRefresh: 'defer' })
|
||||
.then((result) => {
|
||||
setRuntimeRefreshRequired(result.runtimeRefreshRequired);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[makelore] Failed to auto-sync current user model config', error);
|
||||
})
|
||||
@@ -940,7 +944,7 @@ export function CharacterScene() {
|
||||
|
||||
let result: Awaited<ReturnType<typeof importUserModelConfig>>;
|
||||
try {
|
||||
result = await importUserModelConfig(validAccessToken);
|
||||
result = await importUserModelConfig(validAccessToken, { runtimeRefresh: 'apply' });
|
||||
} catch (error) {
|
||||
if (!isUnauthorizedModelConfigError(error)) {
|
||||
throw error;
|
||||
@@ -949,7 +953,7 @@ export function CharacterScene() {
|
||||
if (!refreshedAccessToken) {
|
||||
throw error;
|
||||
}
|
||||
result = await importUserModelConfig(refreshedAccessToken);
|
||||
result = await importUserModelConfig(refreshedAccessToken, { runtimeRefresh: 'apply' });
|
||||
}
|
||||
|
||||
const importedModelCount = new Set([
|
||||
@@ -958,6 +962,7 @@ export function CharacterScene() {
|
||||
...result.importedModels,
|
||||
].filter(Boolean)).size;
|
||||
setProviderSnapshotLoaded(true);
|
||||
setRuntimeRefreshRequired(false);
|
||||
toast.success('已重新拉取远端模型配置', {
|
||||
description: importedModelCount > 0 ? `同步 ${importedModelCount} 个模型` : '模型列表已更新',
|
||||
});
|
||||
@@ -1253,9 +1258,17 @@ export function CharacterScene() {
|
||||
onClick={handleRefreshUserModelConfig}
|
||||
>
|
||||
<RefreshCw className={cn('h-4 w-4', syncingUserModels && 'animate-spin')} />
|
||||
{syncingUserModels ? '重新拉取中...' : '重新拉取远端模型配置'}
|
||||
{syncingUserModels ? '应用中...' : '重新拉取并应用模型配置'}
|
||||
</Button>
|
||||
</div>
|
||||
{runtimeRefreshRequired ? (
|
||||
<p
|
||||
data-testid="makelore-runtime-refresh-pending"
|
||||
className="mb-4 rounded-lg border border-brand/20 bg-brand-soft px-3 py-2 text-sm text-foreground"
|
||||
>
|
||||
模型列表已更新。准备好重启运行时后,点击“重新拉取并应用模型配置”。
|
||||
</p>
|
||||
) : null}
|
||||
<h3 className="mb-2 font-semibold">已通过的模型</h3>
|
||||
<div className="space-y-3">
|
||||
{approvedModels.map((model) => (
|
||||
|
||||
Reference in New Issue
Block a user