mirror of
https://github.com/ZhuLinsen/daily_stock_analysis.git
synced 2026-10-06 14:33:11 +08:00
* feat: add settings field help infrastructure * fix: avoid online fallback in bot name routing test Resolve natural-language stock-name candidates through deterministic local partial matches before invoking the broader name resolver. This keeps common aliases like 茅台 on the fast local path and prevents offline CI from waiting on AkShare network fallback. Guard the async dispatcher test with an assertion that AkShare fallback is not called for the local alias case. * test: isolate schedule time provider failure case The schedule-time provider failure test could fail when SCHEDULE_TIME was present in the process environment before importing main. In that case _INITIAL_PROCESS_ENV marks it as an explicit override, the provider returns the env value, and ConfigManager.read_config_map is never called, so the expected RuntimeError is not raised. Patch _INITIAL_PROCESS_ENV in the test to model the intended no-process-override scenario and keep the assertion independent of the shell environment used by scripts/ci_gate.sh. * fix: improve settings help dialog accessibility Trap keyboard focus inside the settings help dialog while it is open and return focus to the trigger on close. Keep the backdrop click target out of the tab order and cover the focus loop behavior in the settings field test. * feat: add help entry and multilingual support for system settings page * feat: add maintenance guidelines for settings help documentation * fix: clarify WebUI bind settings and toast visibility * chore: remove trailing blank line from settings help
This commit is contained in:
@@ -30,6 +30,13 @@ class SystemConfigOption(BaseModel):
|
||||
value: str
|
||||
|
||||
|
||||
class SystemConfigDocLink(BaseModel):
|
||||
"""Documentation link metadata for field help panels."""
|
||||
|
||||
label: str
|
||||
href: str
|
||||
|
||||
|
||||
class SystemConfigFieldSchema(BaseModel):
|
||||
"""Metadata schema for a single config field."""
|
||||
|
||||
@@ -46,6 +53,10 @@ class SystemConfigFieldSchema(BaseModel):
|
||||
options: List[str | SystemConfigOption] = Field(default_factory=list)
|
||||
validation: Dict[str, Any] = Field(default_factory=dict)
|
||||
display_order: int
|
||||
help_key: Optional[str] = Field(None, description="Stable localization key for detailed help content")
|
||||
examples: List[str] = Field(default_factory=list, description="Safe example values for help panels")
|
||||
docs: List[SystemConfigDocLink] = Field(default_factory=list, description="Related documentation links")
|
||||
warning_codes: List[str] = Field(default_factory=list, description="Stable warning identifiers for help panels")
|
||||
|
||||
|
||||
class SystemConfigCategorySchema(BaseModel):
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type React from 'react';
|
||||
import { Button, InlineAlert } from '../common';
|
||||
import { cn } from '../../utils/cn';
|
||||
|
||||
interface SettingsAlertProps {
|
||||
title: string;
|
||||
message: string;
|
||||
variant?: 'error' | 'success' | 'warning';
|
||||
presentation?: 'inline' | 'toast';
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
className?: string;
|
||||
@@ -16,20 +18,35 @@ const variantMap: Record<NonNullable<SettingsAlertProps['variant']>, 'danger' |
|
||||
warning: 'warning',
|
||||
};
|
||||
|
||||
const toastHighlightStyle = [
|
||||
'relative overflow-hidden bg-card/95 text-foreground shadow-soft-card-strong backdrop-blur-sm',
|
||||
'before:pointer-events-none before:absolute before:inset-x-0 before:top-0 before:h-1.5',
|
||||
'before:bg-gradient-to-r before:from-cyan/80 before:via-primary/70 before:to-purple/70',
|
||||
].join(' ');
|
||||
|
||||
const toastVariantStyles: Record<NonNullable<SettingsAlertProps['variant']>, string> = {
|
||||
error: toastHighlightStyle,
|
||||
success: toastHighlightStyle,
|
||||
warning: toastHighlightStyle,
|
||||
};
|
||||
|
||||
export const SettingsAlert: React.FC<SettingsAlertProps> = ({
|
||||
title,
|
||||
message,
|
||||
variant = 'error',
|
||||
presentation = 'inline',
|
||||
actionLabel,
|
||||
onAction,
|
||||
className = '',
|
||||
}) => {
|
||||
const presentationClassName = presentation === 'toast' ? toastVariantStyles[variant] : '';
|
||||
|
||||
return (
|
||||
<InlineAlert
|
||||
title={title}
|
||||
message={message}
|
||||
variant={variantMap[variant]}
|
||||
className={className}
|
||||
className={cn(presentationClassName, className)}
|
||||
action={actionLabel && onAction ? (
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import type React from 'react';
|
||||
import { Badge, Button, Select, Input, Tooltip } from '../common';
|
||||
import { Badge, Button, Select, Input } from '../common';
|
||||
import type { ConfigValidationIssue, SystemConfigFieldSchema, SystemConfigItem } from '../../types/systemConfig';
|
||||
import { getFieldDescriptionZh, getFieldTitleZh } from '../../utils/systemConfigI18n';
|
||||
import { cn } from '../../utils/cn';
|
||||
import { SettingsHelpButton } from './SettingsHelpButton';
|
||||
|
||||
function normalizeSelectOptions(options: SystemConfigFieldSchema['options'] = []) {
|
||||
return options.map((option) => {
|
||||
@@ -215,6 +216,12 @@ export const SettingsField: React.FC<SettingsFieldProps> = ({
|
||||
<label className="text-sm font-semibold text-foreground" htmlFor={controlId}>
|
||||
{title}
|
||||
</label>
|
||||
<SettingsHelpButton
|
||||
fieldKey={item.key}
|
||||
title={title}
|
||||
schema={schema}
|
||||
description={description}
|
||||
/>
|
||||
{schema?.isSensitive ? (
|
||||
<Badge variant="history" size="sm">
|
||||
敏感
|
||||
@@ -228,11 +235,9 @@ export const SettingsField: React.FC<SettingsFieldProps> = ({
|
||||
</div>
|
||||
|
||||
{description ? (
|
||||
<Tooltip content={description}>
|
||||
<p className="mb-3 inline-flex max-w-full text-xs leading-5 text-muted-text">
|
||||
{description}
|
||||
</p>
|
||||
</Tooltip>
|
||||
<p className="mb-3 max-w-full text-xs leading-5 text-muted-text">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { CircleHelp, ExternalLink, X } from 'lucide-react';
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
import type React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { SystemConfigFieldSchema } from '../../types/systemConfig';
|
||||
import { getSettingsHelpContent } from '../../locales/settingsHelp';
|
||||
import { cn } from '../../utils/cn';
|
||||
import { Tooltip } from '../common';
|
||||
|
||||
interface SettingsHelpButtonProps {
|
||||
fieldKey: string;
|
||||
title: string;
|
||||
schema?: SystemConfigFieldSchema;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const FOCUSABLE_SELECTOR = [
|
||||
'a[href]',
|
||||
'button:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'input:not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(',');
|
||||
|
||||
function getFocusableElements(container: HTMLElement): HTMLElement[] {
|
||||
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR));
|
||||
}
|
||||
|
||||
function hasItems<T>(items: T[] | undefined): items is T[] {
|
||||
return Boolean(items?.length);
|
||||
}
|
||||
|
||||
function HelpSection({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
if (!children) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-[0.16em] text-muted-text">{title}</h3>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function HelpList({ items }: { items?: string[] }) {
|
||||
if (!hasItems(items)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="space-y-1.5 text-sm leading-6 text-secondary-text">
|
||||
{items.map((item) => (
|
||||
<li className="flex gap-2" key={item}>
|
||||
<span className="mt-2 h-1.5 w-1.5 shrink-0 rounded-full bg-cyan/70" />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeExamples({ examples }: { examples?: string[] }) {
|
||||
if (!hasItems(examples)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{examples.map((example) => (
|
||||
<code
|
||||
className="block whitespace-pre-wrap break-words rounded-lg border border-border/70 bg-background/70 px-3 py-2 font-mono text-xs leading-5 text-foreground"
|
||||
key={example}
|
||||
>
|
||||
{example}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const SettingsHelpButton: React.FC<SettingsHelpButtonProps> = ({
|
||||
fieldKey,
|
||||
title,
|
||||
schema,
|
||||
description,
|
||||
}) => {
|
||||
const help = getSettingsHelpContent(schema?.helpKey, description);
|
||||
const [open, setOpen] = useState(false);
|
||||
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const dialogRef = useRef<HTMLDivElement | null>(null);
|
||||
const closeButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const titleId = useId();
|
||||
const examples = schema?.examples ?? [];
|
||||
const docs = schema?.docs?.length ? schema.docs : help?.docs ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
const focusDialogStart = () => {
|
||||
closeButtonRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== 'Tab') {
|
||||
return;
|
||||
}
|
||||
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) {
|
||||
return;
|
||||
}
|
||||
|
||||
const focusableElements = getFocusableElements(dialog);
|
||||
if (!focusableElements.length) {
|
||||
event.preventDefault();
|
||||
dialog.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const firstElement = focusableElements[0];
|
||||
const lastElement = focusableElements[focusableElements.length - 1];
|
||||
const activeElement = document.activeElement;
|
||||
|
||||
if (event.shiftKey) {
|
||||
if (!activeElement || !dialog.contains(activeElement) || activeElement === firstElement) {
|
||||
event.preventDefault();
|
||||
lastElement.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!activeElement || !dialog.contains(activeElement) || activeElement === lastElement) {
|
||||
event.preventDefault();
|
||||
firstElement.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
const triggerButton = buttonRef.current;
|
||||
document.body.style.overflow = 'hidden';
|
||||
focusDialogStart();
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = previousOverflow;
|
||||
triggerButton?.focus();
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (!help) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip content="查看配置说明">
|
||||
<span className="inline-flex">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-lg border border-transparent text-muted-text transition-colors hover:border-[var(--settings-border)] hover:bg-[var(--settings-surface-hover)] hover:text-foreground focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-cyan/15"
|
||||
aria-label={`查看 ${title} 配置说明`}
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? titleId : undefined}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<CircleHelp aria-hidden="true" className="h-4 w-4" />
|
||||
</button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
{open && typeof document !== 'undefined'
|
||||
? createPortal(
|
||||
<div className="fixed inset-0 z-[140] flex items-end bg-background/25 backdrop-blur-sm sm:items-center sm:justify-center">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 cursor-default"
|
||||
aria-label="关闭配置说明"
|
||||
tabIndex={-1}
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
tabIndex={-1}
|
||||
className={cn(
|
||||
'relative flex max-h-[88vh] w-full flex-col overflow-hidden rounded-t-2xl border border-border/80 bg-card shadow-soft-card-strong',
|
||||
'sm:max-w-2xl sm:rounded-2xl',
|
||||
)}
|
||||
>
|
||||
<div className="h-1 w-full bg-gradient-to-r from-cyan/80 via-primary/70 to-purple/70" />
|
||||
<div className="flex items-start justify-between gap-4 border-b border-border/60 px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-text">
|
||||
{fieldKey}
|
||||
</p>
|
||||
<h2 id={titleId} className="mt-1 text-lg font-semibold text-foreground">
|
||||
{help.title || title}
|
||||
</h2>
|
||||
{help.summary ? (
|
||||
<p className="mt-2 text-sm leading-6 text-secondary-text">{help.summary}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
ref={closeButtonRef}
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-border/70 bg-card/80 text-secondary-text transition-colors hover:bg-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-cyan/15"
|
||||
aria-label="关闭配置说明"
|
||||
>
|
||||
<X aria-hidden="true" className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5 overflow-y-auto px-5 py-5">
|
||||
<HelpSection title="用途">
|
||||
{help.usage ? <p className="text-sm leading-6 text-secondary-text">{help.usage}</p> : null}
|
||||
</HelpSection>
|
||||
|
||||
<HelpSection title="取值说明">
|
||||
<HelpList items={help.valueNotes} />
|
||||
</HelpSection>
|
||||
|
||||
<HelpSection title="配置样例">
|
||||
<CodeExamples examples={examples} />
|
||||
</HelpSection>
|
||||
|
||||
<HelpSection title="影响范围">
|
||||
<HelpList items={help.impact} />
|
||||
</HelpSection>
|
||||
|
||||
<HelpSection title="注意事项">
|
||||
<HelpList items={help.notes} />
|
||||
</HelpSection>
|
||||
|
||||
{hasItems(docs) ? (
|
||||
<HelpSection title="相关文档">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{docs.map((doc) => (
|
||||
<a
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border/70 bg-background/60 px-3 py-2 text-xs text-secondary-text transition-colors hover:bg-hover hover:text-foreground"
|
||||
href={doc.href}
|
||||
key={`${doc.label}-${doc.href}`}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span>{doc.label}</span>
|
||||
<ExternalLink aria-hidden="true" className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</HelpSection>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -82,4 +82,62 @@ describe('SettingsField', () => {
|
||||
expect(screen.getAllByRole('button', { name: '显示内容' })).toHaveLength(2);
|
||||
expect(screen.getAllByRole('button', { name: '删除' })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('opens detailed field help when help metadata is available', () => {
|
||||
render(
|
||||
<SettingsField
|
||||
item={{
|
||||
key: 'STOCK_LIST',
|
||||
value: '600519,300750',
|
||||
rawValueExists: true,
|
||||
isMasked: false,
|
||||
schema: {
|
||||
key: 'STOCK_LIST',
|
||||
category: 'base',
|
||||
dataType: 'array',
|
||||
uiControl: 'textarea',
|
||||
isSensitive: false,
|
||||
isRequired: false,
|
||||
isEditable: true,
|
||||
options: [],
|
||||
validation: {},
|
||||
displayOrder: 1,
|
||||
helpKey: 'settings.base.STOCK_LIST',
|
||||
examples: ['STOCK_LIST=600519,300750,002594'],
|
||||
docs: [
|
||||
{
|
||||
label: '完整指南',
|
||||
href: 'https://example.com/full-guide',
|
||||
},
|
||||
],
|
||||
warningCodes: [],
|
||||
},
|
||||
}}
|
||||
value="600519,300750"
|
||||
onChange={() => undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '查看 自选股列表 配置说明' }));
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '自选股列表' })).toBeInTheDocument();
|
||||
expect(screen.getByText('STOCK_LIST=600519,300750,002594')).toBeInTheDocument();
|
||||
const docLink = screen.getByRole('link', { name: /完整指南/ });
|
||||
expect(docLink).toHaveAttribute('href', 'https://example.com/full-guide');
|
||||
|
||||
const closeButtons = screen.getAllByRole('button', { name: '关闭配置说明' });
|
||||
expect(closeButtons[0].tabIndex).toBe(-1);
|
||||
const closeButton = closeButtons.find((button) => button.tabIndex !== -1);
|
||||
expect(closeButton).toBeDefined();
|
||||
|
||||
closeButton?.focus();
|
||||
fireEvent.keyDown(document, { key: 'Tab', shiftKey: true });
|
||||
expect(docLink).toHaveFocus();
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Tab' });
|
||||
expect(closeButton).toHaveFocus();
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(screen.queryByRole('dialog', { name: '自选股列表' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ export * from './ChangePasswordCard';
|
||||
export * from './IntelligentImport';
|
||||
export * from './NotificationTestPanel';
|
||||
export * from './SettingsField';
|
||||
export * from './SettingsHelpButton';
|
||||
export * from './SettingsLoading';
|
||||
export * from './SettingsSectionCard';
|
||||
export * from './SettingsCategoryNav';
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { SystemConfigDocLink } from '../types/systemConfig';
|
||||
|
||||
export interface SettingsHelpContent {
|
||||
title: string;
|
||||
summary?: string;
|
||||
usage?: string;
|
||||
valueNotes?: string[];
|
||||
impact?: string[];
|
||||
notes?: string[];
|
||||
docs?: SystemConfigDocLink[];
|
||||
}
|
||||
|
||||
type SettingsHelpMap = Record<string, SettingsHelpContent>;
|
||||
|
||||
const settingsHelpZhCN: SettingsHelpMap = {
|
||||
'settings.base.STOCK_LIST': {
|
||||
title: '自选股列表',
|
||||
summary: '配置需要分析的股票代码列表,是手动分析、定时任务和通知报告的基础输入。',
|
||||
usage: '多个股票代码使用英文逗号分隔。A 股可直接填写 6 位代码,港股可使用 hk 前缀,美股可填写 ticker。',
|
||||
valueNotes: [
|
||||
'定时模式每次触发前会重新读取当前保存的 STOCK_LIST。',
|
||||
'如果命令行临时传入 --stocks,只影响本次手动运行,不会锁定后续计划任务。',
|
||||
'邮件分组里的 STOCK_GROUP_N 应写成 STOCK_LIST 的子集,只影响邮件收件人,不改变分析范围。',
|
||||
],
|
||||
impact: [
|
||||
'影响主分析任务、市场报告中的个股范围、通知推送内容和历史报告记录。',
|
||||
],
|
||||
notes: [
|
||||
'股票代码之间不要使用中文逗号。',
|
||||
'修改后保存配置即可供后续任务读取。',
|
||||
],
|
||||
},
|
||||
'settings.ai_model.LITELLM_MODEL': {
|
||||
title: '主模型',
|
||||
summary: '指定普通分析流程默认使用的 LLM 模型。',
|
||||
usage: '推荐使用 provider/model 格式,例如 deepseek/deepseek-v4-flash、gemini/gemini-3.1-pro-preview 或 ollama/qwen3:8b。',
|
||||
valueNotes: [
|
||||
'系统配置优先级为 LITELLM_CONFIG > LLM_CHANNELS > legacy provider keys。',
|
||||
'如果留空,系统会尝试根据已配置的 API Key 或渠道声明自动推断。',
|
||||
'Agent 可通过 AGENT_LITELLM_MODEL 单独指定模型;留空时继承主模型。',
|
||||
],
|
||||
impact: [
|
||||
'影响普通个股分析、大盘复盘、报告生成,以及未单独覆盖模型的 Agent 调用。',
|
||||
],
|
||||
notes: [
|
||||
'无 provider 前缀时,LiteLLM 可能无法判断应该使用哪组 API Key。',
|
||||
'Ollama 本地模型应配合 OLLAMA_API_BASE 或 Ollama 渠道使用,不要误用 OPENAI_BASE_URL。',
|
||||
],
|
||||
},
|
||||
'settings.ai_model.LLM_CHANNELS': {
|
||||
title: 'LLM 渠道列表',
|
||||
summary: '声明多个模型渠道,用于多 provider、多 Key、备用模型和可视化渠道管理。',
|
||||
usage: '填写逗号分隔的渠道名,例如 deepseek,aihubmix;每个渠道再配置 LLM_<NAME>_BASE_URL、LLM_<NAME>_API_KEY(S)、LLM_<NAME>_MODELS 等字段。',
|
||||
valueNotes: [
|
||||
'启用渠道模式后,同层运行时优先读取渠道配置。',
|
||||
'在 Docker 或 GitHub Actions 中显式注入的环境变量会覆盖 Web 设置页写入的 .env。',
|
||||
'渠道编辑器保存时只更新本次提交的 key,不会静默迁移整个旧配置。',
|
||||
],
|
||||
impact: [
|
||||
'影响主模型、Agent 模型、fallback 模型和 Vision 模型的可选来源。',
|
||||
],
|
||||
notes: [
|
||||
'不要把极简 legacy key 和 Channels 混用后期待两边同时生效。',
|
||||
'自定义渠道名在 GitHub Actions 中通常还需要 workflow 显式映射对应环境变量。',
|
||||
],
|
||||
},
|
||||
'settings.notification.FEISHU_WEBHOOK_URL': {
|
||||
title: '飞书群机器人 Webhook',
|
||||
summary: '配置飞书自定义群机器人,用于把分析报告推送到指定飞书群。',
|
||||
usage: '在飞书群中添加自定义机器人后,复制 open-apis/bot/v2/hook 开头的 Webhook URL 到这里。',
|
||||
valueNotes: [
|
||||
'如果机器人开启“签名校验”,还需要填写 FEISHU_WEBHOOK_SECRET。',
|
||||
'如果机器人开启“关键词”,还需要填写 FEISHU_WEBHOOK_KEYWORD,系统会自动补到消息前。',
|
||||
'FEISHU_APP_ID / FEISHU_APP_SECRET 用于飞书应用、云文档或 Stream Bot,不会直接启用群 Webhook 推送。',
|
||||
],
|
||||
impact: [
|
||||
'影响飞书通知渠道;失败时不应拖垮主分析流程,只影响该渠道送达。',
|
||||
],
|
||||
notes: [
|
||||
'不要把 FEISHU_APP_SECRET 当作 FEISHU_WEBHOOK_SECRET 使用。',
|
||||
'如果飞书侧配置 IP 白名单,需要确认当前运行环境出口 IP 已加入白名单。',
|
||||
],
|
||||
},
|
||||
'settings.system.WEBUI_HOST': {
|
||||
title: 'WebUI 监听地址',
|
||||
summary: '控制 WebUI 服务绑定在哪个网络地址上。',
|
||||
usage: '本机访问通常使用 127.0.0.1;云服务器、Docker 或需要外部访问时通常使用 0.0.0.0。',
|
||||
valueNotes: [
|
||||
'.env 里的 WEBUI_HOST 在进程启动读取时优先级高于命令行 --host 参数。',
|
||||
'在设置页保存后,只会写入 .env 并重载运行时配置对象,不会让当前 WebUI/API 进程重新绑定监听地址。',
|
||||
'Docker Compose 中通常会在容器内使用 0.0.0.0,宿主机访问还取决于端口映射。',
|
||||
],
|
||||
impact: [
|
||||
'影响重启后浏览器能否从本机、局域网或公网访问 WebUI。',
|
||||
],
|
||||
notes: [
|
||||
'修改 WEBUI_HOST 后需要重启当前进程、Docker 容器或服务管理器才会生效。',
|
||||
'直连公网时建议同时启用 ADMIN_AUTH_ENABLED。',
|
||||
'如果部署在反向代理后面,登录限流与真实 IP 识别还需要评估 TRUST_X_FORWARDED_FOR。',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const settingsHelpEnUS: SettingsHelpMap = {
|
||||
'settings.base.STOCK_LIST': {
|
||||
title: 'Watchlist',
|
||||
summary: 'Defines the stock codes used by analysis jobs and notification reports.',
|
||||
usage: 'Separate symbols with commas. A-shares can use six-digit codes, HK stocks can use the hk prefix, and US stocks can use ticker symbols.',
|
||||
valueNotes: [
|
||||
'Scheduled mode rereads the saved STOCK_LIST before each run.',
|
||||
'A temporary --stocks argument only affects that manual run.',
|
||||
'STOCK_GROUP_N should be a subset of STOCK_LIST and only affects grouped email routing.',
|
||||
],
|
||||
impact: ['Affects analysis scope, notification content, and saved history reports.'],
|
||||
notes: ['Use English commas between symbols.', 'Save the setting before later tasks can read it.'],
|
||||
},
|
||||
'settings.ai_model.LITELLM_MODEL': {
|
||||
title: 'Primary Model',
|
||||
summary: 'Selects the default LLM model for regular analysis flows.',
|
||||
usage: 'Use provider/model format, such as deepseek/deepseek-v4-flash, gemini/gemini-3.1-pro-preview, or ollama/qwen3:8b.',
|
||||
valueNotes: [
|
||||
'Runtime priority is LITELLM_CONFIG > LLM_CHANNELS > legacy provider keys.',
|
||||
'When empty, the system tries to infer a model from available API keys or channels.',
|
||||
'Agent can use AGENT_LITELLM_MODEL; when empty, it inherits the primary model.',
|
||||
],
|
||||
impact: ['Affects regular stock analysis, market review, report generation, and Agent calls without a dedicated model.'],
|
||||
notes: [
|
||||
'Without a provider prefix, LiteLLM may not know which API key to use.',
|
||||
'For Ollama, use OLLAMA_API_BASE or an Ollama channel instead of OPENAI_BASE_URL.',
|
||||
],
|
||||
},
|
||||
'settings.ai_model.LLM_CHANNELS': {
|
||||
title: 'LLM Channels',
|
||||
summary: 'Declares model channels for multiple providers, keys, fallbacks, and visual channel management.',
|
||||
usage: 'Use comma-separated names such as deepseek,aihubmix; then configure LLM_<NAME>_BASE_URL, LLM_<NAME>_API_KEY(S), and LLM_<NAME>_MODELS for each channel.',
|
||||
valueNotes: [
|
||||
'Once channel mode is active, runtime selection reads channel configuration first.',
|
||||
'Environment variables injected by Docker or GitHub Actions can override values saved from the Web settings page.',
|
||||
'Saving in the channel editor updates submitted keys only and does not silently migrate all old config.',
|
||||
],
|
||||
impact: ['Affects available sources for primary, Agent, fallback, and Vision models.'],
|
||||
notes: [
|
||||
'Do not expect legacy keys and Channels to be active at the same time.',
|
||||
'Custom channel names in GitHub Actions usually need explicit workflow env mappings.',
|
||||
],
|
||||
},
|
||||
'settings.notification.FEISHU_WEBHOOK_URL': {
|
||||
title: 'Feishu Webhook URL',
|
||||
summary: 'Sends analysis reports to a Feishu group through a custom bot webhook.',
|
||||
usage: 'Create a custom bot in the target Feishu group and paste the open-apis/bot/v2/hook webhook URL here.',
|
||||
valueNotes: [
|
||||
'If signing is enabled, also set FEISHU_WEBHOOK_SECRET.',
|
||||
'If keyword protection is enabled, also set FEISHU_WEBHOOK_KEYWORD; the sender prepends it automatically.',
|
||||
'FEISHU_APP_ID / FEISHU_APP_SECRET are for app, cloud-doc, or Stream Bot modes and do not enable group webhook delivery.',
|
||||
],
|
||||
impact: ['Affects only the Feishu notification channel; delivery failure should not block the main analysis flow.'],
|
||||
notes: [
|
||||
'Do not use FEISHU_APP_SECRET as FEISHU_WEBHOOK_SECRET.',
|
||||
'If IP allowlisting is enabled in Feishu, add the outbound IP of your runtime environment.',
|
||||
],
|
||||
},
|
||||
'settings.system.WEBUI_HOST': {
|
||||
title: 'WebUI Host',
|
||||
summary: 'Controls the network address the WebUI service binds to.',
|
||||
usage: 'Use 127.0.0.1 for local-only access. Use 0.0.0.0 for cloud, Docker, or external access.',
|
||||
valueNotes: [
|
||||
'WEBUI_HOST in .env has higher priority than the --host command-line argument when the process starts.',
|
||||
'Saving it from the settings page writes .env and reloads runtime config objects, but the running WebUI/API process will not rebind its host.',
|
||||
'Docker Compose commonly binds 0.0.0.0 inside the container; host access also depends on port mapping.',
|
||||
],
|
||||
impact: ['Affects whether the WebUI can be reached locally, on the LAN, or from the public internet after restart.'],
|
||||
notes: [
|
||||
'Restart the process, Docker container, or service manager after changing WEBUI_HOST.',
|
||||
'Enable ADMIN_AUTH_ENABLED when exposing the service publicly.',
|
||||
'Behind a reverse proxy, also evaluate TRUST_X_FORWARDED_FOR for login rate limiting and real IP detection.',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
function getPreferredHelpMap(locale?: string | null): SettingsHelpMap {
|
||||
if (locale?.toLowerCase().startsWith('en')) {
|
||||
return settingsHelpEnUS;
|
||||
}
|
||||
return settingsHelpZhCN;
|
||||
}
|
||||
|
||||
export function getSettingsHelpContent(
|
||||
helpKey?: string | null,
|
||||
fallbackDescription?: string,
|
||||
locale?: string | null,
|
||||
): SettingsHelpContent | null {
|
||||
if (!helpKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const localized = getPreferredHelpMap(locale)[helpKey] ?? settingsHelpZhCN[helpKey];
|
||||
if (localized) {
|
||||
return localized;
|
||||
}
|
||||
|
||||
if (fallbackDescription) {
|
||||
return {
|
||||
title: '配置说明',
|
||||
summary: fallbackDescription,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -694,7 +694,14 @@ const SettingsPage: React.FC = () => {
|
||||
{toast ? (
|
||||
<div className="fixed bottom-5 right-5 z-50 w-[320px] max-w-[calc(100vw-24px)]">
|
||||
{toast.type === 'success'
|
||||
? <SettingsAlert title="操作成功" message={toast.message} variant="success" />
|
||||
? (
|
||||
<SettingsAlert
|
||||
title="操作成功"
|
||||
message={toast.message}
|
||||
variant="success"
|
||||
presentation="toast"
|
||||
/>
|
||||
)
|
||||
: <ApiErrorAlert error={toast.error} />}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -31,6 +31,11 @@ export interface SystemConfigOption {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface SystemConfigDocLink {
|
||||
label: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface SystemConfigFieldSchema {
|
||||
key: string;
|
||||
title?: string;
|
||||
@@ -45,6 +50,10 @@ export interface SystemConfigFieldSchema {
|
||||
options: Array<string | SystemConfigOption>;
|
||||
validation: Record<string, unknown>;
|
||||
displayOrder: number;
|
||||
helpKey?: string | null;
|
||||
examples?: string[];
|
||||
docs?: SystemConfigDocLink[];
|
||||
warningCodes?: string[];
|
||||
}
|
||||
|
||||
export interface SystemConfigCategorySchema {
|
||||
|
||||
+6
-1
@@ -711,11 +711,16 @@ User: "analyze TSLA and NVDA using trend strategy"
|
||||
return canonical_stock_code(unique_matches[0])
|
||||
return None
|
||||
|
||||
for candidate in _iter_candidates(text):
|
||||
candidates = _iter_candidates(text)
|
||||
|
||||
# Prefer deterministic local alias/partial-name matches before any
|
||||
# resolver path that may touch online market data providers.
|
||||
for candidate in candidates:
|
||||
partial = _unique_partial_match(candidate)
|
||||
if partial:
|
||||
return partial
|
||||
|
||||
for candidate in candidates:
|
||||
resolved = resolve_name_to_code(candidate)
|
||||
if resolved:
|
||||
return canonical_stock_code(resolved)
|
||||
|
||||
@@ -18,6 +18,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
- [修复] 修正 LLM 渠道测试中 `Your request was blocked` 等服务商或网关拦截错误被误报为网络异常的问题。
|
||||
- [chore] 清理仓库根目录:移除误入库的 `.codex`、`review.md` 跟踪记录,将 smoke 测试入口迁移到 `scripts/`、环境检查脚本迁移为 `scripts/check_env.py`,并将 LiteLLM YAML 示例迁移到 `docs/examples/`。
|
||||
- [新功能] Web 设置页新增通知渠道一键测试,支持临时配置、耗时与脱敏 attempts 展示。
|
||||
- [新功能] 系统设置页新增配置项帮助入口与多语言帮助文案基础设施,首批覆盖自选股、LLM 主模型、LLM 渠道、飞书 Webhook 与 WebUI 监听地址。
|
||||
- [改进] 设置项帮助窗口支持键盘焦点限制、Esc 关闭和关闭后焦点恢复,并移除短描述重复 hover tooltip。
|
||||
- [文档] 新增设置页配置帮助维护说明,明确帮助元数据字段、首批覆盖范围、事实源和多语言文案同步规则。
|
||||
- [测试] 补充设置项帮助元数据、API schema、前端弹窗交互测试,并修复 Bot 名称路由与调度时间 provider 测试的离线 CI 稳定性问题。
|
||||
|
||||
## [3.15.0] - 2026-05-05
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# 设置页配置帮助维护说明
|
||||
|
||||
设置页配置帮助用于把配置项的关键说明放到 WebUI 内部,减少用户在设置页和文档之间反复切换。页面上仍保留短描述,详细说明通过配置项标题旁的 help icon 打开。
|
||||
|
||||
本文只说明帮助系统的维护规则,不替代完整配置文档。配置语义、默认值、运行时优先级和排障细节仍以 `.env.example`、`docs/full-guide.md` 及对应专题文档为事实源。
|
||||
|
||||
## 数据结构
|
||||
|
||||
后端配置注册表在 `src/core/config_registry.py` 中为字段追加帮助元数据:
|
||||
|
||||
- `help_key`:前端多语言帮助文案的稳定 key。
|
||||
- `examples`:可直接展示的配置样例。敏感字段只能使用占位符,例如 `sk-xxxx`、`your_token`。
|
||||
- `docs`:相关文档链接,优先指向仓库内已有专题文档或完整指南。
|
||||
- `warning_codes`:面向前端或后续校验扩展的稳定提示 code。
|
||||
|
||||
前端长文案维护在 `apps/dsa-web/src/locales/settingsHelp.ts`:
|
||||
|
||||
- 默认展示中文文案。
|
||||
- 英文文案保留同样结构,便于后续扩展语言切换。
|
||||
- 文案应解释用途、取值说明、影响范围、注意事项和相关文档,不应复制完整专题文档。
|
||||
|
||||
## 首批覆盖范围
|
||||
|
||||
本轮先覆盖代表性配置项:
|
||||
|
||||
- `STOCK_LIST`
|
||||
- `LITELLM_MODEL`
|
||||
- `LLM_CHANNELS`
|
||||
- `FEISHU_WEBHOOK_URL`
|
||||
- `WEBUI_HOST`
|
||||
|
||||
后续 PR 可以按模块继续覆盖 AI 模型、数据源、搜索、通知、WebUI、认证、调度、Agent、回测、报告、代理、日志、数据库和桌面端相关配置。
|
||||
|
||||
## 事实源优先级
|
||||
|
||||
新增或修改帮助文案时,优先从以下位置核对:
|
||||
|
||||
1. `.env.example`:配置键名、默认值、样例格式和敏感占位符。
|
||||
2. `docs/full-guide.md`:主要配置说明、运行入口和部署上下文。
|
||||
3. `docs/LLM_CONFIG_GUIDE.md`、`docs/llm-providers.md`:LLM 优先级、Channels、provider/model、兼容边界和排障说明。
|
||||
4. 专题文档:例如 `docs/bot/feishu-bot-config.md`、`docs/deploy-webui-cloud.md`、`docs/desktop-package.md`。
|
||||
5. 代码实现和测试:当文档与代码不一致时,先以可执行实现为准,并同步修正文档。
|
||||
|
||||
## 维护边界
|
||||
|
||||
- 帮助文案不能改变配置保存、校验、运行时优先级、`.env` 写回或环境变量覆盖语义。
|
||||
- 不展示真实密钥、账号、token、Webhook 完整值或本机绝对路径。
|
||||
- LLM 相关示例如果写入具体 provider 前缀、模型名或 Base URL,必须能追溯到当前仓库文档或官方来源;否则应使用占位符或链接到事实源。
|
||||
- 对第三方模型/API 的可用性、LiteLLM 兼容窗口或 provider fallback 规则,不在设置帮助中单独承诺;需要变更时必须同步更新专题文档和 PR 兼容性说明。
|
||||
- 中英双语文案应保持同一语义范围。若只更新一种语言,需要在交付说明中写明原因。
|
||||
- 首屏短描述保持简洁,详细说明放在 help dialog 中,避免 hover tooltip 与常驻短描述重复。
|
||||
@@ -12,7 +12,7 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
from src.config import AGENT_MAX_STEPS_DEFAULT
|
||||
|
||||
SCHEMA_VERSION = "2026-03-29"
|
||||
SCHEMA_VERSION = "2026-05-05"
|
||||
|
||||
_CATEGORY_DEFINITIONS: List[Dict[str, Any]] = [
|
||||
{
|
||||
@@ -79,6 +79,22 @@ _FIELD_DEFINITIONS: Dict[str, Dict[str, Any]] = {
|
||||
"options": [],
|
||||
"validation": {"min_items": 1},
|
||||
"display_order": 10,
|
||||
"help_key": "settings.base.STOCK_LIST",
|
||||
"examples": [
|
||||
"STOCK_LIST=600519,300750,002594",
|
||||
"STOCK_LIST=600519,hk00700,AAPL",
|
||||
],
|
||||
"docs": [
|
||||
{
|
||||
"label": "完整指南:环境变量完整列表",
|
||||
"href": "https://github.com/ZhuLinsen/daily_stock_analysis/blob/main/docs/full-guide.md#环境变量完整列表",
|
||||
},
|
||||
{
|
||||
"label": "Tushare 股票列表指南",
|
||||
"href": "https://github.com/ZhuLinsen/daily_stock_analysis/blob/main/docs/TUSHARE_STOCK_LIST_GUIDE.md",
|
||||
},
|
||||
],
|
||||
"warning_codes": [],
|
||||
},
|
||||
# ------------------------------------------------------------------
|
||||
# AI Model – LiteLLM unified config
|
||||
@@ -96,6 +112,23 @@ _FIELD_DEFINITIONS: Dict[str, Dict[str, Any]] = {
|
||||
"options": [],
|
||||
"validation": {},
|
||||
"display_order": 1,
|
||||
"help_key": "settings.ai_model.LITELLM_MODEL",
|
||||
"examples": [
|
||||
"LITELLM_MODEL=deepseek/deepseek-v4-flash",
|
||||
"LITELLM_MODEL=gemini/gemini-3.1-pro-preview",
|
||||
"LITELLM_MODEL=ollama/qwen3:8b",
|
||||
],
|
||||
"docs": [
|
||||
{
|
||||
"label": "LLM 配置指南",
|
||||
"href": "https://github.com/ZhuLinsen/daily_stock_analysis/blob/main/docs/LLM_CONFIG_GUIDE.md",
|
||||
},
|
||||
{
|
||||
"label": "完整指南:AI 模型配置",
|
||||
"href": "https://github.com/ZhuLinsen/daily_stock_analysis/blob/main/docs/full-guide.md#ai-模型配置",
|
||||
},
|
||||
],
|
||||
"warning_codes": ["provider_prefix_required"],
|
||||
},
|
||||
"AGENT_LITELLM_MODEL": {
|
||||
"title": "Agent Primary Model",
|
||||
@@ -155,6 +188,24 @@ _FIELD_DEFINITIONS: Dict[str, Dict[str, Any]] = {
|
||||
"options": [],
|
||||
"validation": {},
|
||||
"display_order": 4,
|
||||
"help_key": "settings.ai_model.LLM_CHANNELS",
|
||||
"examples": [
|
||||
"LLM_CHANNELS=deepseek,aihubmix",
|
||||
"LLM_DEEPSEEK_BASE_URL=https://api.deepseek.com",
|
||||
"LLM_DEEPSEEK_API_KEY=sk-xxxx",
|
||||
"LLM_DEEPSEEK_MODELS=deepseek-v4-flash,deepseek-v4-pro",
|
||||
],
|
||||
"docs": [
|
||||
{
|
||||
"label": "LLM 配置指南:渠道模式",
|
||||
"href": "https://github.com/ZhuLinsen/daily_stock_analysis/blob/main/docs/LLM_CONFIG_GUIDE.md#方式二渠道channels模式配置适合进阶多模型",
|
||||
},
|
||||
{
|
||||
"label": "LLM 服务商配置速查",
|
||||
"href": "https://github.com/ZhuLinsen/daily_stock_analysis/blob/main/docs/llm-providers.md",
|
||||
},
|
||||
],
|
||||
"warning_codes": ["channels_override_legacy_keys"],
|
||||
},
|
||||
"LLM_TEMPERATURE": {
|
||||
"title": "Temperature",
|
||||
@@ -914,6 +965,23 @@ _FIELD_DEFINITIONS: Dict[str, Dict[str, Any]] = {
|
||||
"allowed_schemes": ["http", "https"],
|
||||
},
|
||||
"display_order": 12,
|
||||
"help_key": "settings.notification.FEISHU_WEBHOOK_URL",
|
||||
"examples": [
|
||||
"FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/your_hook_token",
|
||||
"FEISHU_WEBHOOK_SECRET=your_feishu_webhook_secret",
|
||||
"FEISHU_WEBHOOK_KEYWORD=股票日报",
|
||||
],
|
||||
"docs": [
|
||||
{
|
||||
"label": "完整指南:飞书通知配置",
|
||||
"href": "https://github.com/ZhuLinsen/daily_stock_analysis/blob/main/docs/full-guide.md#飞书",
|
||||
},
|
||||
{
|
||||
"label": "飞书机器人配置专题",
|
||||
"href": "https://github.com/ZhuLinsen/daily_stock_analysis/blob/main/docs/bot/feishu-bot-config.md",
|
||||
},
|
||||
],
|
||||
"warning_codes": ["feishu_webhook_not_app_secret"],
|
||||
},
|
||||
"FEISHU_WEBHOOK_SECRET": {
|
||||
"title": "Feishu Webhook Secret",
|
||||
@@ -1429,6 +1497,36 @@ _FIELD_DEFINITIONS: Dict[str, Dict[str, Any]] = {
|
||||
"validation": {"enum": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]},
|
||||
"display_order": 30,
|
||||
},
|
||||
"WEBUI_HOST": {
|
||||
"title": "Web UI Host",
|
||||
"description": "Host address for Web UI service binding.",
|
||||
"category": "system",
|
||||
"data_type": "string",
|
||||
"ui_control": "text",
|
||||
"is_sensitive": False,
|
||||
"is_required": False,
|
||||
"is_editable": True,
|
||||
"default_value": "127.0.0.1",
|
||||
"options": [],
|
||||
"validation": {},
|
||||
"display_order": 39,
|
||||
"help_key": "settings.system.WEBUI_HOST",
|
||||
"examples": [
|
||||
"WEBUI_HOST=127.0.0.1",
|
||||
"WEBUI_HOST=0.0.0.0",
|
||||
],
|
||||
"docs": [
|
||||
{
|
||||
"label": "云服务器访问 WebUI",
|
||||
"href": "https://github.com/ZhuLinsen/daily_stock_analysis/blob/main/docs/deploy-webui-cloud.md",
|
||||
},
|
||||
{
|
||||
"label": "完整指南:WebUI 与 API",
|
||||
"href": "https://github.com/ZhuLinsen/daily_stock_analysis/blob/main/docs/full-guide.md#webui-与-api-服务",
|
||||
},
|
||||
],
|
||||
"warning_codes": ["public_bind_requires_auth", "restart_required"],
|
||||
},
|
||||
"WEBUI_PORT": {
|
||||
"title": "Web UI Port",
|
||||
"description": "Port for Web UI service.",
|
||||
|
||||
@@ -1367,6 +1367,19 @@ class SystemConfigService:
|
||||
)
|
||||
)
|
||||
|
||||
startup_only_bind_keys = submitted_keys & {
|
||||
"WEBUI_HOST",
|
||||
"WEBUI_PORT",
|
||||
}
|
||||
if startup_only_bind_keys:
|
||||
warnings.append(
|
||||
(
|
||||
f"{', '.join(sorted(startup_only_bind_keys))} 已写入 .env。"
|
||||
"这些属于启动期监听配置:当前已运行的 WebUI/API 进程不会因为本次保存重新绑定监听地址或端口;"
|
||||
"请重启当前进程、Docker 容器或服务管理器后生效。"
|
||||
)
|
||||
)
|
||||
|
||||
return warnings
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -153,16 +153,24 @@ class TestCommandDispatcherAsync(unittest.IsolatedAsyncioTestCase):
|
||||
)
|
||||
|
||||
with patch("src.config.get_config", return_value=config):
|
||||
with patch.object(dispatcher, "_parse_intent_via_llm", new=AsyncMock(return_value={
|
||||
"intent": "analysis",
|
||||
"codes": [],
|
||||
"strategy": None,
|
||||
})):
|
||||
result = await dispatcher._try_nl_routing(_make_message("帮我分析茅台", mentioned=True))
|
||||
with patch(
|
||||
"src.services.name_to_code_resolver._get_akshare_name_to_code"
|
||||
) as mock_akshare:
|
||||
with patch.object(
|
||||
dispatcher,
|
||||
"_parse_intent_via_llm",
|
||||
new=AsyncMock(return_value={
|
||||
"intent": "analysis",
|
||||
"codes": [],
|
||||
"strategy": None,
|
||||
}),
|
||||
):
|
||||
result = await dispatcher._try_nl_routing(_make_message("帮我分析茅台", mentioned=True))
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.text, "ask-ok")
|
||||
ask_command.execute_async.assert_awaited_once()
|
||||
mock_akshare.assert_not_called()
|
||||
_, args = ask_command.execute_async.await_args.args
|
||||
self.assertEqual(args, ["600519"])
|
||||
|
||||
|
||||
@@ -145,6 +145,41 @@ class TestAstrBotFieldsRegistered(unittest.TestCase):
|
||||
self.assertIn(key, field_keys, f"{key} missing from schema response")
|
||||
|
||||
|
||||
class TestSettingsHelpMetadata(unittest.TestCase):
|
||||
"""Field help metadata should be available for the first settings help slice."""
|
||||
|
||||
_HELP_KEYS = (
|
||||
"STOCK_LIST",
|
||||
"LITELLM_MODEL",
|
||||
"LLM_CHANNELS",
|
||||
"FEISHU_WEBHOOK_URL",
|
||||
"WEBUI_HOST",
|
||||
)
|
||||
|
||||
def test_representative_fields_have_help_metadata(self):
|
||||
for key in self._HELP_KEYS:
|
||||
field = get_field_definition(key)
|
||||
self.assertTrue(field.get("help_key"), f"{key} missing help_key")
|
||||
self.assertTrue(field.get("examples"), f"{key} missing examples")
|
||||
self.assertTrue(field.get("docs"), f"{key} missing docs")
|
||||
|
||||
def test_webui_host_is_explicitly_registered(self):
|
||||
field = get_field_definition("WEBUI_HOST")
|
||||
self.assertEqual(field["category"], "system")
|
||||
self.assertNotEqual(field["display_order"], 9000)
|
||||
|
||||
def test_schema_response_includes_help_metadata(self):
|
||||
schema = build_schema_response()
|
||||
fields = {
|
||||
field["key"]: field
|
||||
for category in schema["categories"]
|
||||
for field in category["fields"]
|
||||
}
|
||||
|
||||
self.assertEqual(fields["STOCK_LIST"]["help_key"], "settings.base.STOCK_LIST")
|
||||
self.assertIn("docs/full-guide.md", fields["STOCK_LIST"]["docs"][0]["href"])
|
||||
|
||||
|
||||
class TestSensitiveFieldsUsePasswordControl(unittest.TestCase):
|
||||
"""Every is_sensitive field must use ui_control='password' to avoid
|
||||
leaking secrets in the Web settings page."""
|
||||
|
||||
@@ -294,7 +294,11 @@ class MainScheduleModeTestCase(unittest.TestCase):
|
||||
self.assertEqual(call_order, ["reload_env", "reset_instance", "get_config"])
|
||||
|
||||
def test_schedule_time_provider_propagates_config_read_failures(self) -> None:
|
||||
with patch(
|
||||
with patch.object(
|
||||
main,
|
||||
"_INITIAL_PROCESS_ENV",
|
||||
{},
|
||||
), patch(
|
||||
"src.core.config_manager.ConfigManager.read_config_map",
|
||||
side_effect=RuntimeError("boom"),
|
||||
):
|
||||
|
||||
@@ -64,6 +64,15 @@ class SystemConfigApiTestCase(unittest.TestCase):
|
||||
self.assertEqual(item_map["GEMINI_API_KEY"]["value"], "secret-key-value")
|
||||
self.assertFalse(item_map["GEMINI_API_KEY"]["is_masked"])
|
||||
|
||||
def test_get_config_schema_includes_help_metadata(self) -> None:
|
||||
payload = system_config.get_system_config(include_schema=True, service=self.service).model_dump(by_alias=True)
|
||||
item_map = {item["key"]: item for item in payload["items"]}
|
||||
stock_schema = item_map["STOCK_LIST"]["schema"]
|
||||
|
||||
self.assertEqual(stock_schema["help_key"], "settings.base.STOCK_LIST")
|
||||
self.assertTrue(stock_schema["examples"])
|
||||
self.assertTrue(stock_schema["docs"])
|
||||
|
||||
def test_get_setup_status_returns_readiness_payload(self) -> None:
|
||||
self.env_path.write_text(
|
||||
"\n".join(
|
||||
|
||||
@@ -1724,6 +1724,27 @@ class SystemConfigServiceTestCase(unittest.TestCase):
|
||||
self.assertIn("以 schedule 模式重新启动后生效", schedule_warning)
|
||||
self.assertNotIn("它属于启动期单次运行配置", schedule_warning)
|
||||
|
||||
def test_update_appends_webui_bind_restart_warning(self) -> None:
|
||||
response = self.service.update(
|
||||
config_version=self.manager.get_config_version(),
|
||||
items=[
|
||||
{"key": "WEBUI_HOST", "value": "0.0.0.0"},
|
||||
{"key": "WEBUI_PORT", "value": "18000"},
|
||||
],
|
||||
reload_now=True,
|
||||
)
|
||||
|
||||
self.assertTrue(response["success"])
|
||||
bind_warning = next(
|
||||
warning
|
||||
for warning in response["warnings"]
|
||||
if "WEBUI_HOST" in warning and "WEBUI_PORT" in warning
|
||||
)
|
||||
|
||||
self.assertIn("启动期监听配置", bind_warning)
|
||||
self.assertIn("不会因为本次保存重新绑定监听地址或端口", bind_warning)
|
||||
self.assertIn("重启当前进程、Docker 容器或服务管理器后生效", bind_warning)
|
||||
|
||||
def test_update_warns_when_runtime_model_references_are_cleared(self) -> None:
|
||||
self._rewrite_env(
|
||||
"STOCK_LIST=600519,000001",
|
||||
|
||||
Reference in New Issue
Block a user