fix: 按需生成分享图并恢复基础设置中的选股开关

This commit is contained in:
zhulinsen
2026-08-02 20:59:13 +08:00
committed by GitHub
parent e430fcfe48
commit 3c849dec19
13 changed files with 103 additions and 107 deletions

View File

@@ -41,7 +41,7 @@ TUSHARE_TOKEN=
# TICKFLOW_BATCH_DAILY_ENABLED=true # 有权限时通过 TickFlow 批量预取日 K
# TICKFLOW_BATCH_SIZE=100 # TickFlow 单次批量请求的最大标的数
# 选股(参考 AlphaSift 实现,默认关闭;通常由 Web“开启选股”按钮维护)
# 选股(参考 AlphaSift 实现,默认关闭;通常由 Web“基础设置”中的选股开关维护)
SCREENING_ENABLED=false
# 全市场快照源优先级;未配置时 DSA 会按是否配置 TUSHARE_TOKEN 自动注入:
# - 有 TUSHARE_TOKENtushare,sina,efinance,akshare_em,em_datacenter

View File

@@ -37,7 +37,7 @@ vi.mock('../../theme/ThemeToggle', () => ({
describe('SidebarNav', () => {
it('hides the screening navigation item while Screening is disabled', () => {
mockGetScreeningStatus.mockResolvedValueOnce({ enabled: false, available: false });
mockGetScreeningStatus.mockResolvedValueOnce({ enabled: false, available: true });
render(
<MemoryRouter initialEntries={['/']}>
@@ -48,8 +48,8 @@ describe('SidebarNav', () => {
expect(screen.queryByRole('link', { name: '选股' })).not.toBeInTheDocument();
});
it('shows the screening navigation item when Screening is enabled', async () => {
mockGetScreeningStatus.mockResolvedValueOnce({ enabled: true, available: false });
it('shows screening directly after chat when Screening is enabled', async () => {
mockGetScreeningStatus.mockResolvedValueOnce({ enabled: true, available: true });
render(
<MemoryRouter initialEntries={['/']}>
@@ -58,26 +58,14 @@ describe('SidebarNav', () => {
);
expect(await screen.findByRole('link', { name: '选股' })).toHaveAttribute('href', '/screening');
});
it('places screening directly after chat when Screening is enabled', async () => {
mockGetScreeningStatus.mockResolvedValueOnce({ enabled: true, available: false });
render(
<MemoryRouter initialEntries={['/']}>
<SidebarNav />
</MemoryRouter>,
);
await screen.findByRole('link', { name: '选股' });
const hrefs = screen.getAllByRole('link').map((link) => link.getAttribute('href'));
expect(hrefs.slice(0, 5)).toEqual(['/', '/chat', '/screening', '/portfolio', '/decision-signals']);
});
it('refreshes the screening navigation item after any config save event', async () => {
it('refreshes the controlled screening entry after config changes', async () => {
mockGetScreeningStatus
.mockResolvedValueOnce({ enabled: false, available: false })
.mockResolvedValueOnce({ enabled: true, available: false });
.mockResolvedValueOnce({ enabled: false, available: true })
.mockResolvedValueOnce({ enabled: true, available: true });
render(
<MemoryRouter initialEntries={['/']}>
@@ -86,7 +74,7 @@ describe('SidebarNav', () => {
);
expect(screen.queryByRole('link', { name: '选股' })).not.toBeInTheDocument();
window.dispatchEvent(new Event('dsa-system-config-changed'));
window.dispatchEvent(new Event('screening-config-changed'));
expect(await screen.findByRole('link', { name: '选股' })).toHaveAttribute('href', '/screening');
await waitFor(() => expect(mockGetScreeningStatus.mock.calls.length).toBeGreaterThanOrEqual(2));

View File

@@ -47,12 +47,12 @@ export const ShareImageButton: React.FC<ShareImageButtonProps> = ({
state: ShareState;
}>(() => ({
recordId: activeRecordId,
state: activeRecordId === undefined ? 'idle' : 'loading',
state: 'idle',
}));
const resetTimerRef = useRef<number | null>(null);
const loadTokenRef = useRef(0);
const cachedImageRef = useRef<{ recordId: number; blob: Blob } | null>(null);
const state = stateSnapshot.recordId === activeRecordId ? stateSnapshot.state : 'loading';
const state = stateSnapshot.recordId === activeRecordId ? stateSnapshot.state : 'idle';
const setState = useCallback((nextState: ShareState) => {
setStateSnapshot({ recordId: activeRecordId, state: nextState });
}, [activeRecordId]);
@@ -63,11 +63,6 @@ export const ShareImageButton: React.FC<ShareImageButtonProps> = ({
}
}, []);
useEffect(() => () => {
loadTokenRef.current += 1;
clearResetTimer();
}, [clearResetTimer]);
const scheduleReset = useCallback(() => {
clearResetTimer();
const scheduledRecordId = activeRecordId;
@@ -80,57 +75,41 @@ export const ShareImageButton: React.FC<ShareImageButtonProps> = ({
}, 2200);
}, [activeRecordId, clearResetTimer]);
const prepareShareImage = useCallback(() => {
if (activeRecordId === undefined) return;
const loadToken = loadTokenRef.current + 1;
loadTokenRef.current = loadToken;
cachedImageRef.current = null;
setState('loading');
void historyApi.getShareImage(activeRecordId).then((blob) => {
if (loadTokenRef.current !== loadToken) return;
cachedImageRef.current = { recordId: activeRecordId, blob };
setState('idle');
}).catch((error: unknown) => {
if (loadTokenRef.current !== loadToken) return;
console.error('Generate share image failed:', error);
setState('error');
});
}, [activeRecordId, setState]);
useEffect(() => {
clearResetTimer();
if (activeRecordId === undefined) return undefined;
const loadToken = loadTokenRef.current + 1;
loadTokenRef.current = loadToken;
loadTokenRef.current += 1;
cachedImageRef.current = null;
void historyApi.getShareImage(activeRecordId).then((blob) => {
if (loadTokenRef.current !== loadToken) return;
cachedImageRef.current = { recordId: activeRecordId, blob };
setState('idle');
}).catch((error: unknown) => {
if (loadTokenRef.current !== loadToken) return;
console.error('Generate share image failed:', error);
setState('error');
});
return () => {
clearResetTimer();
loadTokenRef.current += 1;
};
}, [activeRecordId, clearResetTimer, setState]);
}, [activeRecordId, clearResetTimer]);
const handleShare = useCallback(async () => {
if (activeRecordId === undefined || state === 'loading') return;
const cachedImage = cachedImageRef.current;
if (!cachedImage || cachedImage.recordId !== activeRecordId) {
prepareShareImage();
return;
clearResetTimer();
let blob = cachedImageRef.current?.recordId === activeRecordId
? cachedImageRef.current.blob
: null;
if (!blob) {
const loadToken = loadTokenRef.current + 1;
loadTokenRef.current = loadToken;
setState('loading');
try {
blob = await historyApi.getShareImage(activeRecordId);
} catch (error) {
if (loadTokenRef.current !== loadToken) return;
console.error('Generate share image failed:', error);
setState('error');
return;
}
if (loadTokenRef.current !== loadToken) return;
cachedImageRef.current = { recordId: activeRecordId, blob };
}
const blob = cachedImage.blob;
const filename = `${safeFilenamePart(reportTitle)}-${activeRecordId}.png`;
const file = new File([blob], filename, { type: 'image/png' });
const canShareFile = typeof navigator.share === 'function'
@@ -141,8 +120,6 @@ export const ShareImageButton: React.FC<ShareImageButtonProps> = ({
try {
if (canShareFile) {
try {
// The image is preloaded before the click, so navigator.share() is
// invoked while this user-activation event is still active.
await navigator.share({
files: [file],
title: reportTitle,
@@ -165,7 +142,7 @@ export const ShareImageButton: React.FC<ShareImageButtonProps> = ({
console.error('Generate share image failed:', error);
setState('error');
}
}, [activeRecordId, prepareShareImage, reportTitle, scheduleReset, setState, state]);
}, [activeRecordId, clearResetTimer, reportTitle, scheduleReset, setState, state]);
if (activeRecordId === undefined) return null;

View File

@@ -38,13 +38,14 @@ describe('ShareImageButton', () => {
/>,
);
expect(mockedGetShareImage).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '分享' }));
await waitFor(() => expect(mockedGetShareImage).toHaveBeenCalledWith(17));
fireEvent.click(await screen.findByRole('button', { name: '分享' }));
await waitFor(() => expect(HTMLAnchorElement.prototype.click).toHaveBeenCalled());
expect(screen.getByRole('button', { name: '已生成' })).toBeInTheDocument();
});
it('preloads the PNG and invokes native sharing synchronously from the click', async () => {
it('generates the PNG after the click and then invokes native sharing', async () => {
const nativeShare = vi.fn().mockResolvedValue(undefined);
let resolveImage: ((blob: Blob) => void) | undefined;
mockedGetShareImage.mockReturnValue(new Promise((resolve) => {
@@ -61,6 +62,12 @@ describe('ShareImageButton', () => {
/>,
);
expect(screen.getByRole('button', { name: '分享' })).toBeEnabled();
expect(mockedGetShareImage).not.toHaveBeenCalled();
expect(nativeShare).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '分享' }));
expect(mockedGetShareImage).toHaveBeenCalledWith(18);
expect(screen.getByRole('button', { name: '生成中...' })).toBeDisabled();
expect(nativeShare).not.toHaveBeenCalled();
@@ -68,7 +75,6 @@ describe('ShareImageButton', () => {
resolveImage?.(new Blob(['png'], { type: 'image/png' }));
});
fireEvent.click(screen.getByRole('button', { name: '分享' }));
expect(nativeShare).toHaveBeenCalledTimes(1);
const sharePayload = nativeShare.mock.calls[0][0];
expect(sharePayload.title).toBe('A股市场复盘');
@@ -89,6 +95,7 @@ describe('ShareImageButton', () => {
/>,
);
expect(mockedGetShareImage).not.toHaveBeenCalled();
fireEvent.click(await screen.findByRole('button', { name: '分享' }));
await waitFor(() => expect(nativeShare).toHaveBeenCalledTimes(1));
@@ -107,7 +114,10 @@ describe('ShareImageButton', () => {
/>,
);
expect(mockedGetShareImage).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '分享' }));
expect(await screen.findByRole('button', { name: '重试' })).toBeInTheDocument();
expect(mockedGetShareImage).toHaveBeenCalledWith(19);
});
it('does not render or prefetch share images during desktop runtime', () => {
@@ -150,11 +160,7 @@ describe('ShareImageButton', () => {
/>,
);
await act(async () => {
await Promise.resolve();
});
expect(mockedGetShareImage).toHaveBeenCalledWith(21);
expect(mockedGetShareImage).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: '分享' })).toBeInTheDocument();
await act(async () => {
@@ -162,6 +168,7 @@ describe('ShareImageButton', () => {
await Promise.resolve();
});
expect(mockedGetShareImage).toHaveBeenCalledWith(21);
expect(nativeShare).toHaveBeenCalledTimes(1);
expect(screen.getByRole('button', { name: '已生成' })).toBeInTheDocument();
@@ -173,6 +180,9 @@ describe('ShareImageButton', () => {
/>,
);
expect(screen.getByRole('button', { name: '分享' })).toBeEnabled();
expect(mockedGetShareImage).not.toHaveBeenCalledWith(22);
fireEvent.click(screen.getByRole('button', { name: '分享' }));
expect(screen.getByRole('button', { name: '生成中...' })).toBeDisabled();
await act(async () => {
resolveSecondImage?.(new Blob(['b'], { type: 'image/png' }));
@@ -180,7 +190,7 @@ describe('ShareImageButton', () => {
});
expect(mockedGetShareImage).toHaveBeenCalledWith(22);
expect(screen.getByRole('button', { name: '分享' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '已生成' })).toBeInTheDocument();
expect(clearTimeoutSpy).toHaveBeenCalled();
await act(async () => {

View File

@@ -637,7 +637,7 @@ const zh = {
'settings.activePanelTitle': '当前分类配置项',
'settings.agentSettings': 'Agent 设置',
'settings.screening': '选股',
'settings.screeningDescription': '启用实验性质的选股功能,实现参考 AlphaSift。',
'settings.screeningDescription': '控制 Web 选股入口及内置选股功能。',
'settings.screeningDisabled': '选股未开启',
'settings.screeningEnabled': '选股已开启',
'settings.screeningRisk': '实验功能与风险提示:选股结果仅用于研究和辅助判断,不构成投资建议;市场有风险,交易决策和损益由使用者自行承担。',
@@ -1574,7 +1574,7 @@ const en: Record<UiTextKey, string> = {
'settings.activePanelTitle': 'Current category fields',
'settings.agentSettings': 'Agent settings',
'settings.screening': 'Screening',
'settings.screeningDescription': 'Enable experimental screening, implemented with reference to AlphaSift.',
'settings.screeningDescription': 'Control the Web navigation entry and built-in stock screening feature.',
'settings.screeningDisabled': 'Screening disabled',
'settings.screeningEnabled': 'Screening enabled',
'settings.screeningRisk': 'Experimental risk notice: screening results are for research and decision support only and are not investment advice. Markets involve risk; trading decisions and results are your responsibility.',

View File

@@ -324,7 +324,7 @@ const settingsHelpZhCN: SettingsHelpMap = {
impact: ['影响 Web 自动补全和后端股票名称解析使用的股票简称新鲜度。'],
notes: ['远程下载失败时会继续使用已有缓存或随应用打包的内置索引。'],
},
'settings.data_source.SCREENING_ENABLED': {
'settings.base.SCREENING_ENABLED': {
title: '选股',
summary: '控制是否启用选股页;实现参考 AlphaSift。',
usage: '默认关闭。设为 true 后显示选股入口并启用选股策略。',
@@ -1522,7 +1522,7 @@ const settingsHelpEnUS: SettingsHelpMap = {
impact: ['Affects stock-name freshness for Web autocomplete and backend stock-name resolution.'],
notes: ['When remote download fails, the app keeps using an existing cache or the bundled index.'],
},
'settings.data_source.SCREENING_ENABLED': {
'settings.base.SCREENING_ENABLED': {
title: 'Screening',
summary: 'Controls the Screening page, implemented with reference to AlphaSift.',
usage: 'Disabled by default. Set it to true to show Screening and enable screening strategies.',

View File

@@ -1045,10 +1045,10 @@ const SettingsPage: React.FC = () => {
const rawActiveItems = itemsByCategory[activeCategory] || [];
const rawActiveItemMap = new Map(rawActiveItems.map((item) => [item.key, String(item.value ?? '')]));
const firstSetupStockCode = parseSetupStockList(getConfigItem(itemsByCategory.base || [], 'STOCK_LIST')?.value)[0] || '';
const screeningItem = (itemsByCategory.data_source || []).find((item) => item.key === 'SCREENING_ENABLED');
const screeningItem = (itemsByCategory.base || []).find((item) => item.key === 'SCREENING_ENABLED');
const screeningEnabled = String(screeningItem?.value ?? '').trim().toLowerCase() === 'true';
const shouldShowFirstRunSetup = activeCategory === 'base';
const shouldShowScreeningSettings = activeCategory === 'data_source' && Boolean(screeningItem);
const shouldShowScreeningSettings = activeCategory === 'base' && Boolean(screeningItem);
const hasConfiguredChannels = Boolean((rawActiveItemMap.get('LLM_CHANNELS') || '').trim());
const hasLitellmConfig = Boolean((rawActiveItemMap.get('LITELLM_CONFIG') || '').trim());
const hasRuntimeSchedulerMismatch =
@@ -1103,12 +1103,14 @@ const SettingsPage: React.FC = () => {
'ADMIN_AUTH_ENABLED',
...SCHEDULER_SETTING_KEYS,
]);
const DATA_SOURCE_HIDDEN_KEYS = new Set([
const BASE_HIDDEN_KEYS = new Set([
'SCREENING_ENABLED',
]);
const AGENT_HIDDEN_KEYS = new Set(['AGENT_GENERATION_BACKEND']);
const activeItems =
activeCategory === 'ai_model'
activeCategory === 'base'
? rawActiveItems.filter((item) => !BASE_HIDDEN_KEYS.has(item.key))
: activeCategory === 'ai_model'
? rawActiveItems.filter((item) => {
if (hasConfiguredChannels && LLM_CHANNEL_KEY_RE.test(item.key)) {
return false;
@@ -1120,8 +1122,6 @@ const SettingsPage: React.FC = () => {
})
: activeCategory === 'system'
? rawActiveItems.filter((item) => !SYSTEM_HIDDEN_KEYS.has(item.key))
: activeCategory === 'data_source'
? rawActiveItems.filter((item) => !DATA_SOURCE_HIDDEN_KEYS.has(item.key))
: activeCategory === 'agent'
? rawActiveItems.filter((item) => !AGENT_HIDDEN_KEYS.has(item.key))
: rawActiveItems;

View File

@@ -1392,10 +1392,11 @@ describe('SettingsPage', () => {
it('runs Screening enable flow from the settings card', async () => {
const configState = buildSystemConfigState();
useSystemConfigMock.mockReturnValue(buildSystemConfigState({
activeCategory: 'data_source',
activeCategory: 'base',
itemsByCategory: {
...configState.itemsByCategory,
data_source: [
base: [
...configState.itemsByCategory.base,
{
key: 'SCREENING_ENABLED',
value: 'false',
@@ -1403,7 +1404,7 @@ describe('SettingsPage', () => {
isMasked: false,
schema: {
key: 'SCREENING_ENABLED',
category: 'data_source',
category: 'base',
dataType: 'boolean',
uiControl: 'switch',
isSensitive: false,
@@ -1430,10 +1431,11 @@ describe('SettingsPage', () => {
it('maps SCREENING_ENABLED to the built-in screening card instead of a generic field', () => {
const configState = buildSystemConfigState();
useSystemConfigMock.mockReturnValue(buildSystemConfigState({
activeCategory: 'data_source',
activeCategory: 'base',
itemsByCategory: {
...configState.itemsByCategory,
data_source: [
base: [
...configState.itemsByCategory.base,
{
key: 'SCREENING_ENABLED',
value: 'false',
@@ -1441,7 +1443,7 @@ describe('SettingsPage', () => {
isMasked: false,
schema: {
key: 'SCREENING_ENABLED',
category: 'data_source',
category: 'base',
dataType: 'boolean',
uiControl: 'switch',
isSensitive: false,
@@ -1462,9 +1464,10 @@ describe('SettingsPage', () => {
expect(screen.queryByTestId('settings-field-SCREENING_ENABLED')).not.toBeInTheDocument();
});
it('scopes setup and Screening helper cards to their related categories', async () => {
it('shows the Screening control only on the base settings page', async () => {
const configState = buildSystemConfigState();
const dataSourceItems = [
const baseItems = [
...configState.itemsByCategory.base,
{
key: 'SCREENING_ENABLED',
value: 'false',
@@ -1472,7 +1475,7 @@ describe('SettingsPage', () => {
isMasked: false,
schema: {
key: 'SCREENING_ENABLED',
category: 'data_source',
category: 'base',
dataType: 'boolean',
uiControl: 'switch',
isSensitive: false,
@@ -1489,20 +1492,20 @@ describe('SettingsPage', () => {
activeCategory: 'base',
itemsByCategory: {
...configState.itemsByCategory,
data_source: dataSourceItems,
base: baseItems,
},
}));
const { rerender } = render(<SettingsPage />);
expect(await screen.findByRole('heading', { name: '首次启动配置检查' })).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: '选股' })).not.toBeInTheDocument();
expect(screen.getByRole('heading', { name: '选股' })).toBeInTheDocument();
useSystemConfigMock.mockReturnValue(buildSystemConfigState({
activeCategory: 'ai_model',
itemsByCategory: {
...configState.itemsByCategory,
data_source: dataSourceItems,
base: baseItems,
},
}));
rerender(<SettingsPage />);
@@ -1514,12 +1517,12 @@ describe('SettingsPage', () => {
activeCategory: 'data_source',
itemsByCategory: {
...configState.itemsByCategory,
data_source: dataSourceItems,
base: baseItems,
},
}));
rerender(<SettingsPage />);
expect(await screen.findByRole('heading', { name: '选股' })).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: '选股' })).not.toBeInTheDocument();
expect(screen.queryByRole('heading', { name: '首次启动配置检查' })).not.toBeInTheDocument();
});
@@ -2135,10 +2138,11 @@ describe('SettingsPage', () => {
const configState = buildSystemConfigState();
screeningEnable.mockRejectedValueOnce(new Error('config update failed'));
useSystemConfigMock.mockReturnValue(buildSystemConfigState({
activeCategory: 'data_source',
activeCategory: 'base',
itemsByCategory: {
...configState.itemsByCategory,
data_source: [
base: [
...configState.itemsByCategory.base,
{
key: 'SCREENING_ENABLED',
value: 'false',
@@ -2146,7 +2150,7 @@ describe('SettingsPage', () => {
isMasked: false,
schema: {
key: 'SCREENING_ENABLED',
category: 'data_source',
category: 'base',
dataType: 'boolean',
uiControl: 'switch',
isSensitive: false,

View File

@@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
> For user-friendly release highlights, see the [GitHub Releases](https://github.com/ZhuLinsen/daily_stock_analysis/releases) page.
## [Unreleased]
- [修复] Web 分享图改为用户点击“分享”后才按需生成,不再在报告加载时自动请求
- [修复] 将 `SCREENING_ENABLED` 及 Web 选股功能开关归入“基础设置”,选股导航入口继续由该开关控制
- [修复] `scripts/ci_gate.sh``offline_test_suite``pytest -m "not network"``--timeout=120 -o timeout_method=thread``-o faulthandler_timeout=300`:单个测试(含其 teardown超过 2 分钟直接 fail单个测试含其 teardown超过 5 分钟时 dump 全部线程栈到 stderr。配合 `.github/requirements-ci.txt` 新增 `pytest-timeout>=2.3.0` 依赖。issue #2131 报告过 backend-gate 在 AlphaSift hotspot 用例附近间歇性无 traceback 卡住直到被 GitHub Actions 取消,此次修复让任何未来 CI hang 都会留下可定位的失败信息或 post-mortem 栈,而不是静默消亡。同步修正 `.github/workflows/docker-publish.yml``Install backend gate dependencies``setup-python cache-dependency-path` 对齐 `ci.yml` 的 backend-gate 依赖安装方式,避免发布流程跑同一个 `./scripts/ci_gate.sh` 时因缺少 `pytest-timeout` 而直接 fail。
- [修复] 选股策略栏稳定展示完整中文策略列表,并保留自定义策略 ID 入口

View File

@@ -22,6 +22,8 @@ DSA 将选股能力作为主项目的一部分维护。实现参考 [AlphaSift](
SCREENING_ENABLED=false
```
Web“基础设置”页展示“选股”开关。开启后左侧显示“选股”入口并允许执行策略、热点和选股任务关闭后入口隐藏选股 API 继续拒绝业务请求。
常用可选项:
```dotenv

View File

@@ -35,7 +35,7 @@ SHARE_IMAGE_XIAOHONGSHU_QR_PATH=assets/my-xiaohongshu-qr.png
## Web 一键分享
浏览器版历史个股报告、市场复盘和完整报告抽屉右上角都会显示“分享”按钮。页面加载报告时会先调用 `GET /api/v1/history/{record_id}/share-image` 预生成并缓存 PNG准备完成后按钮才可点击因此 `navigator.share()` 能在点击的瞬时用户激活窗口内同步发起。支持文件分享的浏览器会打开系统分享面板;其他浏览器会直接下载 PNG。如果浏览器已声明支持文件分享、但系统分享面板实际打开失败,除用户主动取消外也会自动回退下载已经生成的 PNG。
浏览器版历史个股报告、市场复盘和完整报告抽屉右上角都会显示“分享”按钮。页面加载报告时不会生成图片;只有用户点击“分享”后,页面才调用 `GET /api/v1/history/{record_id}/share-image` 按需生成或读取缓存 PNG。支持文件分享的浏览器会尝试打开系统分享面板;其他浏览器会直接下载 PNG。如果系统分享面板打开失败除用户主动取消外也会自动回退下载已经生成的 PNG。
Electron 桌面运行时默认不展示该按钮。当前 Windows/macOS 打包版不会随包分发 `wkhtmltoimage``markdown-to-file` 或 Playwright/Chromium renderer避免桌面用户在页面加载时就命中 `share_image_unavailable` 失败态。

View File

@@ -877,7 +877,7 @@ _FIELD_DEFINITIONS: Dict[str, Dict[str, Any]] = {
"SCREENING_ENABLED": {
"title": "Built-in Stock Screening",
"description": "Enable DSA's built-in stock screening tab. The implementation is based on AlphaSift and maintained as part of DSA. Disabled by default.",
"category": "data_source",
"category": "base",
"data_type": "boolean",
"ui_control": "switch",
"is_sensitive": False,
@@ -887,7 +887,7 @@ _FIELD_DEFINITIONS: Dict[str, Dict[str, Any]] = {
"options": [],
"validation": {},
"display_order": 17,
"help_key": "settings.data_source.SCREENING_ENABLED",
"help_key": "settings.base.SCREENING_ENABLED",
"examples": [
"SCREENING_ENABLED=false",
"SCREENING_ENABLED=true",

View File

@@ -154,10 +154,22 @@ class TestScreeningFieldsRegistered(unittest.TestCase):
def test_builtin_screening_toggle_is_registered(self):
field = get_field_definition("SCREENING_ENABLED")
self.assertEqual(field["category"], "base")
self.assertFalse(field["is_sensitive"])
self.assertEqual(field["ui_control"], "switch")
self.assertEqual(field["help_key"], "settings.base.SCREENING_ENABLED")
self.assertIn("built-in", field["description"].lower())
def test_builtin_screening_toggle_is_grouped_under_base_settings(self):
schema = build_schema_response()
categories = {
category["category"]: {field["key"] for field in category["fields"]}
for category in schema["categories"]
}
self.assertIn("SCREENING_ENABLED", categories["base"])
self.assertNotIn("SCREENING_ENABLED", categories["data_source"])
class TestLLMUsageHMACFieldsRegistered(unittest.TestCase):
def test_secret_is_sensitive_password_field(self):