Skip to content

Commit 2f75e83

Browse files
authored
feat: 添加 GenerationBackend Phase 1 抽象 (#1751)
* feat: add generation backend abstraction * fix: tighten generation backend phase1 contract * fix: preserve market review backend config errors
1 parent 1f23f37 commit 2f75e83

33 files changed

Lines changed: 1802 additions & 15 deletions

.env.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,14 @@ STOCK_INDEX_REMOTE_UPDATE_ENABLED=true
113113
# 【进阶】需要多模型 / 多平台 fallback → 配置下方「多渠道」或在 Web 设置页可视化管理。
114114
# ===================================
115115

116+
# 生成后端(Phase 1 仅支持 litellm)
117+
# 非 litellm 值会作为配置错误处理,不会静默回退到 LiteLLM。
118+
GENERATION_BACKEND=litellm
119+
# 后端级 fallback;litellm -> litellm 会被解析为 no-op,模型 fallback 仍由 LiteLLM 配置负责。
120+
GENERATION_FALLBACK_BACKEND=litellm
121+
# Agent Chat 后端;auto 在 Phase 1 中等价于现有 LiteLLM tool-calling 后端。
122+
AGENT_GENERATION_BACKEND=auto
123+
116124
# --- API Key(填一个即可)---
117125

118126
# Gemini(https://aistudio.google.com)

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

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,22 @@ function inferPasswordIconType(key: string): 'password' | 'key' {
4444
return key.toUpperCase().includes('PASSWORD') ? 'password' : 'key';
4545
}
4646

47+
function resolveDisplayValue(item: SystemConfigItem, value: string): string {
48+
const schema = item.schema;
49+
50+
if (
51+
schema?.uiControl === 'select'
52+
&& !value
53+
&& item.rawValueExists === false
54+
&& schema.defaultValue !== undefined
55+
&& schema.defaultValue !== null
56+
) {
57+
return schema.defaultValue;
58+
}
59+
60+
return value;
61+
}
62+
4763
interface SettingsFieldProps {
4864
item: SystemConfigItem;
4965
value: string;
@@ -216,6 +232,7 @@ export const SettingsField: React.FC<SettingsFieldProps> = ({
216232
const hasError = issues.some((issue) => issue.severity === 'error');
217233
const [isPasswordEditable, setIsPasswordEditable] = useState(false);
218234
const controlId = `setting-${item.key}`;
235+
const displayValue = resolveDisplayValue(item, value);
219236

220237
return (
221238
<div
@@ -256,7 +273,7 @@ export const SettingsField: React.FC<SettingsFieldProps> = ({
256273
<div>
257274
{renderFieldControl(
258275
item,
259-
value,
276+
displayValue,
260277
disabled,
261278
(nextValue) => onChange(item.key, nextValue),
262279
isPasswordEditable,

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

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,9 @@ export const SettingsHelpButton: React.FC<SettingsHelpButtonProps> = ({
106106
const dialogRef = useRef<HTMLDivElement | null>(null);
107107
const closeButtonRef = useRef<HTMLButtonElement | null>(null);
108108
const titleId = useId();
109-
const examples = providedExamples ?? schema?.examples ?? [];
109+
const examples = providedExamples ?? help?.examples ?? schema?.examples ?? [];
110110
const docs = providedDocs?.length ? providedDocs : schema?.docs?.length ? schema.docs : help?.docs ?? [];
111+
const showFieldKey = help?.showFieldKey ?? true;
111112
const helpButtonLabel = language === 'en'
112113
? `View ${title} configuration help`
113114
: `查看 ${title} 配置说明`;
@@ -220,9 +221,11 @@ export const SettingsHelpButton: React.FC<SettingsHelpButtonProps> = ({
220221
<div className="h-1 w-full bg-gradient-to-r from-cyan/80 via-primary/70 to-purple/70" />
221222
<div className="flex items-start justify-between gap-4 border-b border-border/60 px-5 py-4">
222223
<div className="min-w-0">
223-
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-text">
224-
{fieldKey}
225-
</p>
224+
{showFieldKey ? (
225+
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-text">
226+
{fieldKey}
227+
</p>
228+
) : null}
226229
<h2 id={titleId} className="mt-1 text-lg font-semibold text-foreground">
227230
{help.title || title}
228231
</h2>
@@ -250,9 +253,11 @@ export const SettingsHelpButton: React.FC<SettingsHelpButtonProps> = ({
250253
<HelpList items={help.valueNotes} />
251254
</HelpSection>
252255

253-
<HelpSection title={t('settings.helpExamples')}>
254-
<CodeExamples examples={examples} />
255-
</HelpSection>
256+
{hasItems(examples) ? (
257+
<HelpSection title={t('settings.helpExamples')}>
258+
<CodeExamples examples={examples} />
259+
</HelpSection>
260+
) : null}
256261

257262
<HelpSection title={t('settings.helpImpact')}>
258263
<HelpList items={help.impact} />

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

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,40 @@ describe('SettingsField', () => {
161161
expect(onChange).toHaveBeenCalledWith('NOTIFICATION_MIN_SEVERITY', '');
162162
});
163163

164+
it('shows the schema default for select fields when no explicit env value exists', () => {
165+
const onChange = vi.fn();
166+
167+
render(
168+
<SettingsField
169+
item={{
170+
key: 'GENERATION_BACKEND',
171+
value: '',
172+
rawValueExists: false,
173+
isMasked: false,
174+
schema: {
175+
key: 'GENERATION_BACKEND',
176+
title: 'Generation Backend',
177+
category: 'ai_model',
178+
dataType: 'string',
179+
uiControl: 'select',
180+
isSensitive: false,
181+
isRequired: false,
182+
isEditable: true,
183+
defaultValue: 'litellm',
184+
options: [{ label: 'Default model settings', value: 'litellm' }],
185+
validation: { enum: ['litellm'] },
186+
displayOrder: 1,
187+
},
188+
}}
189+
value=""
190+
onChange={onChange}
191+
/>
192+
);
193+
194+
expect(screen.getByLabelText('分析生成方式')).toHaveValue('litellm');
195+
expect(onChange).not.toHaveBeenCalled();
196+
});
197+
164198
it('renders localized labels for real system config select options', () => {
165199
const selectCases = [
166200
{
@@ -417,6 +451,90 @@ describe('SettingsField', () => {
417451
expect(screen.queryByRole('dialog', { name: '自选股列表' })).not.toBeInTheDocument();
418452
});
419453

454+
it('keeps generation channel help user-facing without env key or examples', () => {
455+
render(
456+
<SettingsField
457+
item={{
458+
key: 'GENERATION_BACKEND',
459+
value: 'litellm',
460+
rawValueExists: true,
461+
isMasked: false,
462+
schema: {
463+
key: 'GENERATION_BACKEND',
464+
title: 'Generation Backend',
465+
category: 'ai_model',
466+
dataType: 'string',
467+
uiControl: 'select',
468+
isSensitive: false,
469+
isRequired: false,
470+
isEditable: true,
471+
options: [{ label: 'Default model settings', value: 'litellm' }],
472+
validation: { enum: ['litellm'] },
473+
displayOrder: 1,
474+
helpKey: 'settings.ai_model.GENERATION_BACKEND',
475+
examples: ['GENERATION_BACKEND=litellm'],
476+
warningCodes: [],
477+
},
478+
}}
479+
value="litellm"
480+
onChange={() => undefined}
481+
/>
482+
);
483+
484+
fireEvent.click(screen.getByRole('button', { name: '查看 分析生成方式 配置说明' }));
485+
486+
const dialog = screen.getByRole('dialog', { name: '分析生成方式' });
487+
expect(dialog).toHaveTextContent('决定系统用哪种方式生成');
488+
expect(dialog).not.toHaveTextContent('GENERATION_BACKEND');
489+
expect(dialog).not.toHaveTextContent('配置样例');
490+
expect(dialog).not.toHaveTextContent('Phase 1');
491+
expect(dialog).toHaveTextContent('高级说明');
492+
expect(dialog).toHaveTextContent('LiteLLM');
493+
});
494+
495+
it('describes agent auto generation without exposing implementation labels as the primary UI copy', () => {
496+
render(
497+
<SettingsField
498+
item={{
499+
key: 'AGENT_GENERATION_BACKEND',
500+
value: 'auto',
501+
rawValueExists: true,
502+
isMasked: false,
503+
schema: {
504+
key: 'AGENT_GENERATION_BACKEND',
505+
title: 'Agent Generation Backend',
506+
category: 'agent',
507+
dataType: 'string',
508+
uiControl: 'select',
509+
isSensitive: false,
510+
isRequired: false,
511+
isEditable: true,
512+
options: [
513+
{ label: 'Auto', value: 'auto' },
514+
{ label: 'Default model tool calling', value: 'litellm' },
515+
],
516+
validation: { enum: ['auto', 'litellm'] },
517+
displayOrder: 1,
518+
helpKey: 'settings.agent.AGENT_GENERATION_BACKEND',
519+
examples: [],
520+
warningCodes: [],
521+
},
522+
}}
523+
value="auto"
524+
onChange={() => undefined}
525+
/>
526+
);
527+
528+
fireEvent.click(screen.getByRole('button', { name: '查看 问股生成方式 配置说明' }));
529+
530+
const dialog = screen.getByRole('dialog', { name: '问股生成方式' });
531+
expect(dialog).toHaveTextContent('系统会选择当前可用的模型工具调用方式');
532+
expect(dialog).toHaveTextContent('如果不确定,选择“自动”即可');
533+
expect(dialog).toHaveTextContent('高级说明');
534+
expect(dialog).toHaveTextContent('LiteLLM');
535+
expect(dialog).not.toHaveTextContent('优先选择当前可用');
536+
});
537+
420538
it('uses per-field schema titles even when helpKey is shared by multiple fields', () => {
421539
const restoreLanguage = localStorage.getItem(UI_LANGUAGE_STORAGE_KEY);
422540
localStorage.setItem(UI_LANGUAGE_STORAGE_KEY, 'en');

apps/dsa-web/src/locales/settingsHelp.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ export interface SettingsHelpContent {
77
valueNotes?: string[];
88
impact?: string[];
99
notes?: string[];
10+
examples?: string[];
11+
showFieldKey?: boolean;
1012
docs?: SystemConfigDocLink[];
1113
}
1214

@@ -30,6 +32,38 @@ const settingsHelpZhCN: SettingsHelpMap = {
3032
'修改后保存配置即可供后续任务读取。',
3133
],
3234
},
35+
'settings.ai_model.GENERATION_BACKEND': {
36+
title: '分析生成方式',
37+
showFieldKey: false,
38+
summary: '决定系统用哪种方式生成个股分析、大盘复盘和普通文本回复。',
39+
usage: '通常保持“默认模型配置”。系统会继续使用你在本页配置的主模型、备选模型、渠道和用量记录。',
40+
valueNotes: [
41+
'当前页面只开放“默认模型配置”作为可选方式,看到更多选项前无需调整。',
42+
'如果通过环境变量手动填写了其他值,系统会提示配置错误,避免误以为已经切换成功。',
43+
],
44+
impact: ['影响普通分析、大盘复盘和文本生成入口,不改变问股助手的工具执行规则。'],
45+
notes: [
46+
'想恢复默认行为,选择“默认模型配置”并保存配置。',
47+
'高级说明:当前默认模型配置内部由 LiteLLM 兼容层执行;普通使用无需了解或修改该内部值。',
48+
],
49+
examples: [],
50+
},
51+
'settings.ai_model.GENERATION_FALLBACK_BACKEND': {
52+
title: '备用生成方式(预留)',
53+
showFieldKey: false,
54+
summary: '为以后多个生成方式之间的备用切换预留;当前主要用于明确保持默认行为。',
55+
usage: '通常保持“默认模型配置”。主生成方式也是默认模型配置时,不会额外重复调用一次。',
56+
valueNotes: [
57+
'如果只是想设置主模型失败后的备用模型,请使用“备选模型”,不是这个字段。',
58+
'当前页面只开放“默认模型配置”,保存默认值即可。',
59+
],
60+
impact: ['不改变现有模型备用顺序,也不会影响渠道编辑器里的模型配置。'],
61+
notes: [
62+
'想恢复默认行为,选择“默认模型配置”并保存配置。',
63+
'高级说明:当前默认模型配置内部由 LiteLLM 兼容层执行;普通使用无需了解或修改该内部值。',
64+
],
65+
examples: [],
66+
},
3367
'settings.ai_model.LITELLM_MODEL': {
3468
title: '主模型',
3569
summary: '指定普通分析流程默认使用的 LLM 模型。',
@@ -700,6 +734,22 @@ const settingsHelpZhCN: SettingsHelpMap = {
700734
impact: ['影响个股分析流程、报告生成质量和 LLM 调用次数。'],
701735
notes: ['Agent 模式会消耗更多 token 和时间,适合需要深度推理的场景。'],
702736
},
737+
'settings.agent.AGENT_GENERATION_BACKEND': {
738+
title: '问股生成方式',
739+
showFieldKey: false,
740+
summary: '决定问股助手用哪种方式生成回复,并配合工具查询行情、新闻和历史数据。',
741+
usage: '通常保持“自动”。系统会选择当前可用的模型工具调用方式;如果没有明确要固定方式,无需调整。',
742+
valueNotes: [
743+
'如果不确定,选择“自动”即可。',
744+
'只有当你明确要固定使用当前默认模型工具调用时,才改为“默认模型工具调用”。',
745+
],
746+
impact: ['影响问股助手的回复生成和工具调用入口,不改变它能使用哪些工具。'],
747+
notes: [
748+
'想恢复默认行为,选择“自动”并保存配置。',
749+
'高级说明:当前默认模型工具调用内部由 LiteLLM 兼容层执行;普通使用无需了解或修改该内部值。',
750+
],
751+
examples: [],
752+
},
703753
'settings.agent.AGENT_MAX_STEPS': {
704754
title: 'Agent 最大推理步数',
705755
summary: '控制 Agent 推理链路的最大步数上限。',
@@ -1088,6 +1138,38 @@ const settingsHelpEnUS: SettingsHelpMap = {
10881138
impact: ['Affects analysis scope, notification content, and saved history reports.'],
10891139
notes: ['Use English commas between symbols.', 'Save the setting before later tasks can read it.'],
10901140
},
1141+
'settings.ai_model.GENERATION_BACKEND': {
1142+
title: 'Analysis Generation Method',
1143+
showFieldKey: false,
1144+
summary: 'Chooses how the system generates stock analysis, market reviews, and regular text responses.',
1145+
usage: 'Usually keep “Default model settings”. The system will continue to use the primary model, fallback models, channels, and usage tracking configured on this page.',
1146+
valueNotes: [
1147+
'The settings page currently exposes “Default model settings” as the available method, so most users do not need to change this.',
1148+
'If another value is set manually through environment variables, the system reports a configuration error instead of pretending the switch worked.',
1149+
],
1150+
impact: ['Affects regular analysis, market review, and text generation entry points. It does not change how the ask-stock assistant runs tools.'],
1151+
notes: [
1152+
'To restore the default behavior, choose “Default model settings” and save.',
1153+
'Advanced note: the default model settings are currently executed through the LiteLLM compatibility layer; regular users do not need to understand or change that internal value.',
1154+
],
1155+
examples: [],
1156+
},
1157+
'settings.ai_model.GENERATION_FALLBACK_BACKEND': {
1158+
title: 'Fallback Generation Method (reserved)',
1159+
showFieldKey: false,
1160+
summary: 'Reserved for switching between multiple generation methods later; today it mainly records the default behavior explicitly.',
1161+
usage: 'Usually keep “Default model settings”. When the main method is already the default model settings, the system does not make an extra duplicate call.',
1162+
valueNotes: [
1163+
'If you want a backup model after the primary model fails, use “Fallback models” instead of this field.',
1164+
'The settings page currently exposes “Default model settings” only, so saving the default is enough.',
1165+
],
1166+
impact: ['Does not change the current fallback model order or the model-channel editor configuration.'],
1167+
notes: [
1168+
'To restore the default behavior, choose “Default model settings” and save.',
1169+
'Advanced note: the default model settings are currently executed through the LiteLLM compatibility layer; regular users do not need to understand or change that internal value.',
1170+
],
1171+
examples: [],
1172+
},
10911173
'settings.ai_model.LITELLM_MODEL': {
10921174
title: 'Primary Model',
10931175
summary: 'Selects the default LLM model for regular analysis flows.',
@@ -1717,6 +1799,22 @@ const settingsHelpEnUS: SettingsHelpMap = {
17171799
impact: ['Affects stock analysis flow, report quality, and LLM call count.'],
17181800
notes: ['Agent mode consumes more tokens and time; best for scenarios requiring deep reasoning.'],
17191801
},
1802+
'settings.agent.AGENT_GENERATION_BACKEND': {
1803+
title: 'Ask-Stock Generation Method',
1804+
showFieldKey: false,
1805+
summary: 'Chooses how the ask-stock assistant generates replies and queries market, news, and history tools.',
1806+
usage: 'Usually keep Auto. The system chooses the currently available model tool-calling method; change it only when you need to pin the assistant method.',
1807+
valueNotes: [
1808+
'If you are unsure, choose Auto.',
1809+
'Choose “Default model tool calling” only when you explicitly want to pin the assistant to the current default model tool-calling method.',
1810+
],
1811+
impact: ['Affects the assistant reply path and tool entry point. It does not change which tools the assistant can use.'],
1812+
notes: [
1813+
'To restore the default behavior, choose Auto and save.',
1814+
'Advanced note: the current default model tool-calling method is executed through the LiteLLM compatibility layer; regular users do not need to understand or change that internal value.',
1815+
],
1816+
examples: [],
1817+
},
17201818
'settings.agent.AGENT_MAX_STEPS': {
17211819
title: 'Agent Max Steps',
17221820
summary: 'Controls the maximum reasoning-step limit for the Agent.',

0 commit comments

Comments
 (0)