Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/pr-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,11 @@ jobs:
- name: Test native control scripts
run: npx vitest run tests/nativeControlScripts.test.mjs

- name: Validate native packaging script syntax
if: runner.os != 'Windows'
shell: bash
run: bash -n bin/release/package-native.sh

docker-build:
name: Docker Build
runs-on: ubuntu-latest
Expand All @@ -185,6 +190,9 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Validate Docker Compose configuration
run: docker compose -f docker-compose.manager.yml config

- name: Build Manager Server image
uses: docker/build-push-action@v6
with:
Expand Down
100 changes: 43 additions & 57 deletions apps/web/src/features/aiProviders/AiProvidersPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import type {
OpenAIProviderConfig,
ProviderKeyConfig,
} from '@/types';
import { createConfigMutationLock } from './model/configMutationLock';
import styles from './AiProvidersPage.module.scss';

const PROVIDER_TABLE_DEFAULT_PAGE_SIZE = 10;
Expand Down Expand Up @@ -87,8 +88,16 @@ export function AiProvidersPage() {
);

const [configSwitchingKey, setConfigSwitchingKey] = useState<string | null>(null);
/** Synchronous lock so double-clicks in the same tick cannot start two enable/disable writes. */
const configSwitchingLockRef = useRef(false);
const configMutationLockRef = useRef(createConfigMutationLock());
const beginConfigMutation = useCallback((switchingKey: string) => {
if (!configMutationLockRef.current.tryAcquire()) return false;
setConfigSwitchingKey(switchingKey);
return true;
}, []);
const finishConfigMutation = useCallback(() => {
configMutationLockRef.current.release();
setConfigSwitchingKey(null);
}, []);

// 表格筛选 / 排序 / 详情状态
const [kindFilter, setKindFilter] = useState<ProviderKindFilter>('all');
Expand Down Expand Up @@ -321,7 +330,7 @@ export function AiProvidersPage() {
actions: Map<string, ProviderHealthCheckApplyAction>
) => {
if (actions.size === 0) return;
if (configSwitchingLockRef.current || configSwitchingKey) return;
if (configMutationLockRef.current.isLocked()) return;

const rowByKey = new Map(rows.map((row) => [row.key, row]));
const previous = {
Expand Down Expand Up @@ -431,8 +440,7 @@ export function AiProvidersPage() {
return;
}

configSwitchingLockRef.current = true;
setConfigSwitchingKey('health-check');
if (!beginConfigMutation('health-check')) return;

const applyLocalState = (
gemini: GeminiKeyConfig[],
Expand Down Expand Up @@ -556,9 +564,8 @@ export function AiProvidersPage() {
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
throw err;
} finally {
configSwitchingLockRef.current = false;
setConfigSwitchingKey(null);
}
finishConfigMutation();
}
};

const setHealthCheckProviderEnabled = async (providerKey: string, enabled: boolean) => {
Expand All @@ -571,16 +578,13 @@ export function AiProvidersPage() {
index: number,
enabled: boolean
) => {
if (configSwitchingLockRef.current || configSwitchingKey) return;

if (provider === 'gemini' || provider === 'interactions') {
const source = provider === 'gemini' ? geminiKeys : interactionsKeys;
const current = source[index];
if (!current) return;

const switchingKey = `${provider}:${current.apiKey}`;
configSwitchingLockRef.current = true;
setConfigSwitchingKey(switchingKey);
if (!beginConfigMutation(switchingKey)) return;

const previousList = source;
const nextExcluded = enabled
Expand Down Expand Up @@ -623,8 +627,7 @@ export function AiProvidersPage() {
}
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
} finally {
configSwitchingLockRef.current = false;
setConfigSwitchingKey(null);
finishConfigMutation();
}
return;
}
Expand All @@ -641,8 +644,7 @@ export function AiProvidersPage() {
if (!current) return;

const switchingKey = `${provider}:${current.apiKey}`;
configSwitchingLockRef.current = true;
setConfigSwitchingKey(switchingKey);
if (!beginConfigMutation(switchingKey)) return;

const previousList = source;
const nextExcluded = enabled
Expand Down Expand Up @@ -705,19 +707,16 @@ export function AiProvidersPage() {
}
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
} finally {
configSwitchingLockRef.current = false;
setConfigSwitchingKey(null);
}
finishConfigMutation();
}
};

const setOpenAIProviderEnabled = async (index: number, enabled: boolean) => {
if (configSwitchingLockRef.current || configSwitchingKey) return;
const current = openaiProviders[index];
if (!current) return;

const switchingKey = `openai:${current.name}:${index}`;
configSwitchingLockRef.current = true;
setConfigSwitchingKey(switchingKey);
if (!beginConfigMutation(switchingKey)) return;

const previousList = openaiProviders;
const nextItem: OpenAIProviderConfig = { ...current, disabled: !enabled };
Expand All @@ -741,9 +740,8 @@ export function AiProvidersPage() {
clearCache('openai-compatibility');
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
} finally {
configSwitchingLockRef.current = false;
setConfigSwitchingKey(null);
}
finishConfigMutation();
}
};

const setProviderWebsocketsEnabled = async (
Expand All @@ -756,10 +754,8 @@ export function AiProvidersPage() {
const current = source[index];
if (!current) return;

if (configSwitchingLockRef.current || configSwitchingKey) return;
const switchingKey = `${provider}:${current.apiKey}:websockets`;
configSwitchingLockRef.current = true;
setConfigSwitchingKey(switchingKey);
if (!beginConfigMutation(switchingKey)) return;

const previousList = source;
const nextItem: ProviderKeyConfig = { ...current, websockets: enabled };
Expand Down Expand Up @@ -810,9 +806,8 @@ export function AiProvidersPage() {
}
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
} finally {
configSwitchingLockRef.current = false;
setConfigSwitchingKey(null);
}
finishConfigMutation();
}
};

const setProviderCloakEnabled = async (
Expand All @@ -824,10 +819,8 @@ export function AiProvidersPage() {
const current = source[index];
if (!current) return;

if (configSwitchingLockRef.current || configSwitchingKey) return;
const switchingKey = `${provider}:${current.apiKey}:cloak`;
configSwitchingLockRef.current = true;
setConfigSwitchingKey(switchingKey);
if (!beginConfigMutation(switchingKey)) return;

const previousList = source;
const nextItem: ProviderKeyConfig = enabled
Expand Down Expand Up @@ -871,9 +864,8 @@ export function AiProvidersPage() {
}
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
} finally {
configSwitchingLockRef.current = false;
setConfigSwitchingKey(null);
}
finishConfigMutation();
}
};

const setProviderDisableCoolingEnabled = async (
Expand All @@ -887,7 +879,7 @@ export function AiProvidersPage() {
if (!current) return;

const switchingKey = `${provider}:${current.apiKey}:disable-cooling`;
setConfigSwitchingKey(switchingKey);
if (!beginConfigMutation(switchingKey)) return;

const previousList = source;
const nextItem: GeminiKeyConfig = { ...current, disableCooling: enabled };
Expand Down Expand Up @@ -931,8 +923,7 @@ export function AiProvidersPage() {
}
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
} finally {
configSwitchingLockRef.current = false;
setConfigSwitchingKey(null);
finishConfigMutation();
}
return;
}
Expand All @@ -942,7 +933,7 @@ export function AiProvidersPage() {
if (!current) return;

const switchingKey = `${provider}:${current.name}:${index}:disable-cooling`;
setConfigSwitchingKey(switchingKey);
if (!beginConfigMutation(switchingKey)) return;

const previousList = openaiProviders;
const nextItem: OpenAIProviderConfig = { ...current, disableCooling: enabled };
Expand All @@ -963,8 +954,7 @@ export function AiProvidersPage() {
clearCache('openai-compatibility');
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
} finally {
configSwitchingLockRef.current = false;
setConfigSwitchingKey(null);
finishConfigMutation();
}
return;
}
Expand All @@ -975,7 +965,7 @@ export function AiProvidersPage() {
if (!current) return;

const switchingKey = `${provider}:${current.apiKey}:disable-cooling`;
setConfigSwitchingKey(switchingKey);
if (!beginConfigMutation(switchingKey)) return;

const previousList = source;
const nextItem: ProviderKeyConfig = { ...current, disableCooling: enabled };
Expand Down Expand Up @@ -1026,9 +1016,8 @@ export function AiProvidersPage() {
}
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
} finally {
configSwitchingLockRef.current = false;
setConfigSwitchingKey(null);
}
finishConfigMutation();
}
};

const setProviderPriority = async (row: ProviderRow, priority: number) => {
Expand All @@ -1041,7 +1030,7 @@ export function AiProvidersPage() {
const current = source[row.originalIndex];
if (!current || current.priority === nextPriority) return;

setConfigSwitchingKey(switchingKey);
if (!beginConfigMutation(switchingKey)) return;
const previousList = source;
const nextList = previousList.map((item, idx) =>
idx === row.originalIndex ? { ...item, priority: nextPriority } : item
Expand Down Expand Up @@ -1085,8 +1074,7 @@ export function AiProvidersPage() {
}
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
} finally {
configSwitchingLockRef.current = false;
setConfigSwitchingKey(null);
finishConfigMutation();
}
return;
}
Expand All @@ -1095,7 +1083,7 @@ export function AiProvidersPage() {
const current = openaiProviders[row.originalIndex];
if (!current || current.priority === nextPriority) return;

setConfigSwitchingKey(switchingKey);
if (!beginConfigMutation(switchingKey)) return;
const previousList = openaiProviders;
const nextList = previousList.map((item, idx) =>
idx === row.originalIndex ? { ...item, priority: nextPriority } : item
Expand All @@ -1119,8 +1107,7 @@ export function AiProvidersPage() {
clearCache('openai-compatibility');
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
} finally {
configSwitchingLockRef.current = false;
setConfigSwitchingKey(null);
finishConfigMutation();
}
return;
}
Expand All @@ -1136,7 +1123,7 @@ export function AiProvidersPage() {
const current = source[row.originalIndex];
if (!current || current.priority === nextPriority) return;

setConfigSwitchingKey(switchingKey);
if (!beginConfigMutation(switchingKey)) return;
const previousList = source;
const nextList = previousList.map((item, idx) =>
idx === row.originalIndex ? { ...item, priority: nextPriority } : item
Expand Down Expand Up @@ -1199,9 +1186,8 @@ export function AiProvidersPage() {
}
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
} finally {
configSwitchingLockRef.current = false;
setConfigSwitchingKey(null);
}
finishConfigMutation();
}
};

// 删除(按 provider 分派,沿用既有 API 契约)
Expand Down
17 changes: 17 additions & 0 deletions apps/web/src/features/aiProviders/model/configMutationLock.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { createConfigMutationLock } from './configMutationLock';

describe('createConfigMutationLock', () => {
it('rejects overlapping mutations until the active mutation releases the lock', () => {
const lock = createConfigMutationLock();

expect(lock.tryAcquire()).toBe(true);
expect(lock.isLocked()).toBe(true);
expect(lock.tryAcquire()).toBe(false);

lock.release();

expect(lock.isLocked()).toBe(false);
expect(lock.tryAcquire()).toBe(true);
});
});
21 changes: 21 additions & 0 deletions apps/web/src/features/aiProviders/model/configMutationLock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export type ConfigMutationLock = {
isLocked: () => boolean;
release: () => void;
tryAcquire: () => boolean;
};

export const createConfigMutationLock = (): ConfigMutationLock => {
let locked = false;

return {
isLocked: () => locked,
release: () => {
locked = false;
},
tryAcquire: () => {
if (locked) return false;
locked = true;
return true;
},
};
};
Loading
Loading