Skip to content

Commit ada7623

Browse files
feat: [issue ZhuLinsen#1199/PR2] expand settings help coverage for core config (ZhuLinsen#1328)
* feat: expand settings help coverage Add structured help metadata and bilingual in-app help copy for the PR2 configuration coverage slice, including LLM runtime fields, channel editor fields, data sources, notifications, WebUI, auth, schedule, trading-day, and proxy settings. Clarify restart semantics for startup-only and bind-time settings. WEBUI_HOST and WEBUI_PORT now warn that the running process must be restarted before host or port changes rebind, while RUN_IMMEDIATELY, SCHEDULE_ENABLED, and SCHEDULE_RUN_IMMEDIATELY are marked as startup behavior. Treat SCHEDULE_TIME separately from startup-only schedule switches: an already-running schedule mode can reload the time and rebuild the daily job, but saving the value will not create a scheduler in a non-schedule process. Document settings-help maintenance boundaries and add regression coverage for help metadata, restart warning codes, API warnings, service warnings, and the LLM channel editor help affordance. * feat: enhance settings help documentation and improve test coverage --------- Co-authored-by: mumu <42829555+ZhuLinsen@users.noreply.github.com>
1 parent 81bfe06 commit ada7623

12 files changed

Lines changed: 1611 additions & 53 deletions

apps/dsa-web/src/components/settings/LLMChannelEditor.tsx

Lines changed: 153 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
getProviderTemplate,
1414
isKnownProviderTemplate,
1515
} from './llmProviderTemplates';
16+
import { SettingsHelpButton } from './SettingsHelpButton';
1617

1718
const PROTOCOL_OPTIONS: Array<{ value: ChannelProtocol; label: string }> = [
1819
{ value: 'openai', label: 'OpenAI Compatible' },
@@ -128,6 +129,51 @@ interface ChannelRowProps {
128129
onCheckCapabilities: (channel: ChannelConfig) => void;
129130
}
130131

132+
const LLM_CHANNEL_HELP_DOCS = [
133+
{
134+
label: 'LLM 配置指南',
135+
href: 'https://github.com/ZhuLinsen/daily_stock_analysis/blob/main/docs/LLM_CONFIG_GUIDE.md',
136+
},
137+
{
138+
label: 'LLM 服务商配置速查',
139+
href: 'https://github.com/ZhuLinsen/daily_stock_analysis/blob/main/docs/llm-providers.md',
140+
},
141+
];
142+
143+
function HelpLabel({
144+
htmlFor,
145+
label,
146+
fieldKey,
147+
helpKey,
148+
examples,
149+
compact = false,
150+
}: {
151+
htmlFor?: string;
152+
label: string;
153+
fieldKey: string;
154+
helpKey: string;
155+
examples?: string[];
156+
compact?: boolean;
157+
}) {
158+
return (
159+
<div className={compact ? 'mb-1 flex items-center gap-1.5' : 'mb-2 flex items-center gap-1.5'}>
160+
<label
161+
htmlFor={htmlFor}
162+
className={compact ? 'text-xs text-muted-text' : 'text-sm font-medium text-foreground'}
163+
>
164+
{label}
165+
</label>
166+
<SettingsHelpButton
167+
fieldKey={fieldKey}
168+
title={label}
169+
helpKey={helpKey}
170+
examples={examples}
171+
docs={LLM_CHANNEL_HELP_DOCS}
172+
/>
173+
</div>
174+
);
175+
}
176+
131177
const ChannelRow: React.FC<ChannelRowProps> = ({
132178
channel,
133179
index,
@@ -169,6 +215,11 @@ const ChannelRow: React.FC<ChannelRowProps> = ({
169215
const selectedCapabilities = capabilityState?.selected || [];
170216
const capabilityResults = capabilityState?.results || {};
171217
const capabilityBusy = capabilityState?.status === 'loading';
218+
const channelNameInputId = `llm-channel-${channel.id}-name`;
219+
const protocolInputId = `llm-channel-${channel.id}-protocol`;
220+
const baseUrlInputId = `llm-channel-${channel.id}-base-url`;
221+
const apiKeyInputId = `llm-channel-${channel.id}-api-key`;
222+
const modelsInputId = `llm-channel-${channel.id}-models`;
172223

173224
return (
174225
<div className="mb-2 overflow-hidden rounded-xl border border-[var(--settings-border)] bg-[var(--settings-surface)] shadow-soft-card transition-[background-color,border-color,box-shadow] duration-200 hover:border-[var(--settings-border-strong)] hover:bg-[var(--settings-surface-hover)]">
@@ -259,16 +310,32 @@ const ChannelRow: React.FC<ChannelRowProps> = ({
259310
{expanded ? (
260311
<div className="settings-surface-overlay-soft space-y-4 px-4 py-4">
261312
<div className="grid gap-2 sm:grid-cols-2">
313+
<div>
314+
<HelpLabel
315+
htmlFor={channelNameInputId}
316+
label="渠道名称"
317+
fieldKey="LLM_CHANNEL_NAME"
318+
helpKey="settings.llm_channel.channel_name"
319+
examples={['LLM_CHANNELS=deepseek,aihubmix', 'LLM_DEEPSEEK_MODELS=deepseek-v4-flash,deepseek-v4-pro']}
320+
/>
262321
<Input
263-
label="渠道名称"
322+
id={channelNameInputId}
264323
value={channel.name}
265324
disabled={busy}
266325
onChange={(e) => onUpdate(index, 'name', e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, ''))}
267326
placeholder="primary"
268327
/>
328+
</div>
269329
<div className="space-y-2">
270-
<label className="block text-sm font-medium text-foreground">协议</label>
330+
<HelpLabel
331+
htmlFor={protocolInputId}
332+
label="协议"
333+
fieldKey="LLM_CHANNEL_PROTOCOL"
334+
helpKey="settings.llm_channel.protocol"
335+
examples={['LLM_DEEPSEEK_PROTOCOL=deepseek', 'LLM_OPENROUTER_PROTOCOL=openai']}
336+
/>
271337
<Select
338+
id={protocolInputId}
272339
value={channel.protocol}
273340
onChange={(v) => onUpdate(index, 'protocol', normalizeProtocol(v))}
274341
options={PROTOCOL_OPTIONS}
@@ -278,8 +345,16 @@ const ChannelRow: React.FC<ChannelRowProps> = ({
278345
</div>
279346
</div>
280347

348+
<div>
349+
<HelpLabel
350+
htmlFor={baseUrlInputId}
351+
label="Base URL"
352+
fieldKey="LLM_CHANNEL_BASE_URL"
353+
helpKey="settings.llm_channel.base_url"
354+
examples={['LLM_DEEPSEEK_BASE_URL=https://api.deepseek.com', 'LLM_OPENROUTER_BASE_URL=https://openrouter.ai/api/v1']}
355+
/>
281356
<Input
282-
label="Base URL"
357+
id={baseUrlInputId}
283358
value={channel.baseUrl}
284359
disabled={busy}
285360
onChange={(e) => onUpdate(index, 'baseUrl', e.target.value)}
@@ -289,6 +364,7 @@ const ChannelRow: React.FC<ChannelRowProps> = ({
289364
: preset?.baseUrl || 'https://api.example.com/v1'
290365
}
291366
/>
367+
</div>
292368

293369
{showProviderTemplateDetails ? (
294370
<div className="space-y-2 rounded-xl border border-[var(--settings-border)] bg-[var(--settings-surface-hover)] p-3">
@@ -332,8 +408,16 @@ const ChannelRow: React.FC<ChannelRowProps> = ({
332408
</div>
333409
) : null}
334410

411+
<div>
412+
<HelpLabel
413+
htmlFor={apiKeyInputId}
414+
label="API Key"
415+
fieldKey="LLM_CHANNEL_API_KEY"
416+
helpKey="settings.llm_channel.api_key"
417+
examples={['LLM_DEEPSEEK_API_KEY=sk-xxxx', 'LLM_OPENAI_API_KEYS=sk-key-1,sk-key-2']}
418+
/>
335419
<Input
336-
label="API Key"
420+
id={apiKeyInputId}
337421
type="password"
338422
allowTogglePassword
339423
iconType="key"
@@ -344,6 +428,7 @@ const ChannelRow: React.FC<ChannelRowProps> = ({
344428
onChange={(e) => onUpdate(index, 'apiKey', e.target.value)}
345429
placeholder={channel.protocol === 'ollama' ? '本地 Ollama 可留空' : '支持多个 Key 逗号分隔'}
346430
/>
431+
</div>
347432

348433
<div className="space-y-3 rounded-xl border border-[var(--settings-border)] bg-[var(--settings-surface-hover)] p-3">
349434
<div className="flex flex-wrap items-center gap-2">
@@ -376,7 +461,12 @@ const ChannelRow: React.FC<ChannelRowProps> = ({
376461

377462
{discoveredModels.length > 0 ? (
378463
<div>
379-
<label className="mb-2 block text-sm font-medium text-foreground">可选模型(可多选)</label>
464+
<HelpLabel
465+
label="可选模型(可多选)"
466+
fieldKey="LLM_CHANNEL_DISCOVERED_MODELS"
467+
helpKey="settings.llm_channel.models"
468+
examples={['LLM_DEEPSEEK_MODELS=deepseek-v4-flash,deepseek-v4-pro']}
469+
/>
380470
<div className="max-h-48 space-y-2 overflow-y-auto rounded-xl border border-[var(--settings-border)] bg-[var(--settings-surface)] p-3">
381471
{discoveredModels.map((model) => (
382472
<label key={model} className="flex items-center gap-2 text-sm text-secondary-text">
@@ -396,8 +486,16 @@ const ChannelRow: React.FC<ChannelRowProps> = ({
396486
</div>
397487
) : null}
398488

489+
<div>
490+
<HelpLabel
491+
htmlFor={modelsInputId}
492+
label={discoveredModels.length > 0 ? '手动模型(逗号分隔)' : '模型(逗号分隔)'}
493+
fieldKey="LLM_CHANNEL_MODELS"
494+
helpKey="settings.llm_channel.models"
495+
examples={['LLM_DEEPSEEK_MODELS=deepseek-v4-flash,deepseek-v4-pro', 'LLM_OLLAMA_MODELS=qwen3:8b,llama3.1:8b']}
496+
/>
399497
<Input
400-
label={discoveredModels.length > 0 ? '手动模型(逗号分隔)' : '模型(逗号分隔)'}
498+
id={modelsInputId}
401499
value={channel.models}
402500
disabled={busy}
403501
onChange={(e) => onUpdate(index, 'models', e.target.value)}
@@ -408,6 +506,7 @@ const ChannelRow: React.FC<ChannelRowProps> = ({
408506
: '若渠道不支持自动发现或请求失败,可直接手动填写模型列表。'
409507
}
410508
/>
509+
</div>
411510

412511
{manualOnlyModels.length > 0 ? (
413512
<p className="text-[11px] text-secondary-text">
@@ -456,7 +555,16 @@ const ChannelRow: React.FC<ChannelRowProps> = ({
456555
<div className="space-y-3 rounded-xl border border-[var(--settings-border)] bg-[var(--settings-surface-hover)] p-3">
457556
<div className="flex flex-wrap items-center justify-between gap-2">
458557
<div>
459-
<p className="text-[11px] font-medium text-muted-text">运行时能力检测(可选)</p>
558+
<div className="flex items-center gap-1.5">
559+
<p className="text-[11px] font-medium text-muted-text">运行时能力检测(可选)</p>
560+
<SettingsHelpButton
561+
fieldKey="LLM_CHANNEL_CAPABILITY_CHECKS"
562+
title="运行时能力检测"
563+
helpKey="settings.llm_channel.capability_checks"
564+
examples={['JSON / Tools / Stream / Vision']}
565+
docs={LLM_CHANNEL_HELP_DOCS}
566+
/>
567+
</div>
460568
<p className="mt-0.5 text-[11px] text-secondary-text">
461569
仅在手动触发时发起真实 LLM 请求;多选可能需要 20-40 秒。
462570
</p>
@@ -1649,7 +1757,13 @@ export const LLMChannelEditor: React.FC<LLMChannelEditorProps> = ({
16491757
<Badge variant="default" className="border-[var(--settings-border)] bg-[var(--settings-surface-hover)] text-muted-text">Runtime</Badge>
16501758
</div>
16511759
<div className="mb-4">
1652-
<label className="mb-1 block text-xs text-muted-text">Temperature</label>
1760+
<HelpLabel
1761+
label="Temperature"
1762+
fieldKey="LLM_TEMPERATURE"
1763+
helpKey="settings.llm_channel.temperature"
1764+
examples={['LLM_TEMPERATURE=0.2', 'LLM_TEMPERATURE=0.7']}
1765+
compact
1766+
/>
16531767
<div className="flex items-center gap-3">
16541768
<input
16551769
type="range"
@@ -1675,7 +1789,14 @@ export const LLMChannelEditor: React.FC<LLMChannelEditorProps> = ({
16751789
) : (
16761790
<div className="space-y-4">
16771791
<div>
1678-
<label htmlFor="runtime-primary-model" className="mb-1 block text-xs text-muted-text">主模型</label>
1792+
<HelpLabel
1793+
htmlFor="runtime-primary-model"
1794+
label="主模型"
1795+
fieldKey="LITELLM_MODEL"
1796+
helpKey="settings.llm_channel.primary_model"
1797+
examples={['LITELLM_MODEL=deepseek/deepseek-v4-flash']}
1798+
compact
1799+
/>
16791800
<Select
16801801
id="runtime-primary-model"
16811802
value={runtimeConfig.primaryModel}
@@ -1687,7 +1808,14 @@ export const LLMChannelEditor: React.FC<LLMChannelEditorProps> = ({
16871808
</div>
16881809

16891810
<div>
1690-
<label htmlFor="runtime-agent-primary-model" className="mb-1 block text-xs text-muted-text">Agent 主模型</label>
1811+
<HelpLabel
1812+
htmlFor="runtime-agent-primary-model"
1813+
label="Agent 主模型"
1814+
fieldKey="AGENT_LITELLM_MODEL"
1815+
helpKey="settings.llm_channel.agent_primary_model"
1816+
examples={['AGENT_LITELLM_MODEL=deepseek/deepseek-v4-pro']}
1817+
compact
1818+
/>
16911819
<Select
16921820
id="runtime-agent-primary-model"
16931821
value={runtimeConfig.agentPrimaryModel}
@@ -1702,7 +1830,13 @@ export const LLMChannelEditor: React.FC<LLMChannelEditorProps> = ({
17021830
</div>
17031831

17041832
<div>
1705-
<label className="mb-2 block text-xs text-muted-text">备选模型</label>
1833+
<HelpLabel
1834+
label="备选模型"
1835+
fieldKey="LITELLM_FALLBACK_MODELS"
1836+
helpKey="settings.llm_channel.fallback_models"
1837+
examples={['LITELLM_FALLBACK_MODELS=deepseek/deepseek-v4-pro,gemini/gemini-3-flash-preview']}
1838+
compact
1839+
/>
17061840
<div className="space-y-2 rounded-xl border settings-border-strong settings-surface-overlay-soft p-3">
17071841
{availableModels.map((model) => (
17081842
<label key={model} className="flex items-center gap-2 text-sm text-secondary-text">
@@ -1723,7 +1857,14 @@ export const LLMChannelEditor: React.FC<LLMChannelEditorProps> = ({
17231857
</div>
17241858

17251859
<div>
1726-
<label htmlFor="runtime-vision-model" className="mb-1 block text-xs text-muted-text">Vision 模型</label>
1860+
<HelpLabel
1861+
htmlFor="runtime-vision-model"
1862+
label="Vision 模型"
1863+
fieldKey="VISION_MODEL"
1864+
helpKey="settings.llm_channel.vision_model"
1865+
examples={['VISION_MODEL=gemini/gemini-3.1-pro-preview']}
1866+
compact
1867+
/>
17271868
<Select
17281869
id="runtime-vision-model"
17291870
value={runtimeConfig.visionModel}

apps/dsa-web/src/components/settings/SettingsHelpButton.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ interface SettingsHelpButtonProps {
1111
fieldKey: string;
1212
title: string;
1313
schema?: SystemConfigFieldSchema;
14+
helpKey?: string;
15+
examples?: string[];
16+
docs?: SystemConfigFieldSchema['docs'];
1417
description?: string;
1518
}
1619

@@ -90,16 +93,19 @@ export const SettingsHelpButton: React.FC<SettingsHelpButtonProps> = ({
9093
fieldKey,
9194
title,
9295
schema,
96+
helpKey,
97+
examples: providedExamples,
98+
docs: providedDocs,
9399
description,
94100
}) => {
95-
const help = getSettingsHelpContent(schema?.helpKey, description);
101+
const help = getSettingsHelpContent(helpKey ?? schema?.helpKey, description);
96102
const [open, setOpen] = useState(false);
97103
const buttonRef = useRef<HTMLButtonElement | null>(null);
98104
const dialogRef = useRef<HTMLDivElement | null>(null);
99105
const closeButtonRef = useRef<HTMLButtonElement | null>(null);
100106
const titleId = useId();
101-
const examples = schema?.examples ?? [];
102-
const docs = schema?.docs?.length ? schema.docs : help?.docs ?? [];
107+
const examples = providedExamples ?? schema?.examples ?? [];
108+
const docs = providedDocs?.length ? providedDocs : schema?.docs?.length ? schema.docs : help?.docs ?? [];
103109

104110
useEffect(() => {
105111
if (!open) {

apps/dsa-web/src/components/settings/__tests__/LLMChannelEditor.test.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,43 @@ describe('LLMChannelEditor', () => {
5454
expect(input).toHaveAttribute('type', 'text');
5555
});
5656

57+
it('shows help dialogs for channel editor fields', async () => {
58+
render(
59+
<LLMChannelEditor
60+
items={[
61+
{ key: 'LLM_CHANNELS', value: 'deepseek' },
62+
{ key: 'LLM_DEEPSEEK_PROTOCOL', value: 'deepseek' },
63+
{ key: 'LLM_DEEPSEEK_BASE_URL', value: 'https://api.deepseek.com' },
64+
{ key: 'LLM_DEEPSEEK_ENABLED', value: 'true' },
65+
{ key: 'LLM_DEEPSEEK_API_KEY', value: 'sk-test' },
66+
{ key: 'LLM_DEEPSEEK_MODELS', value: 'deepseek-v4-flash' },
67+
]}
68+
configVersion="v1"
69+
maskToken="******"
70+
onSaved={() => {}}
71+
/>
72+
);
73+
74+
fireEvent.click(screen.getByRole('button', { name: /DeepSeek /i }));
75+
fireEvent.click(await screen.findByRole('button', { name: '查看 Base URL 配置说明' }));
76+
77+
expect(screen.getByRole('dialog', { name: 'Base URL' })).toBeInTheDocument();
78+
expect(screen.getByText('该渠道的接口根地址。')).toBeInTheDocument();
79+
expect(screen.getByText('LLM_DEEPSEEK_BASE_URL=https://api.deepseek.com')).toBeInTheDocument();
80+
81+
fireEvent.keyDown(document, { key: 'Escape' });
82+
fireEvent.click(await screen.findByRole('button', { name: '查看 Temperature 配置说明' }));
83+
84+
expect(screen.getByRole('dialog', { name: 'Temperature' })).toBeInTheDocument();
85+
expect(screen.getByText('运行时统一采样温度。')).toBeInTheDocument();
86+
87+
fireEvent.keyDown(document, { key: 'Escape' });
88+
fireEvent.click(await screen.findByRole('button', { name: '查看 运行时能力检测 配置说明' }));
89+
90+
expect(screen.getByRole('dialog', { name: '运行时能力检测' })).toBeInTheDocument();
91+
expect(screen.getByText('选择能力后点击检测;检测会发起真实 LLM 请求。')).toBeInTheDocument();
92+
});
93+
5794
it('hides LiteLLM wording when advanced YAML routing is enabled', () => {
5895
render(
5996
<LLMChannelEditor

0 commit comments

Comments
 (0)