hydro-ai-helper 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +1 -0
  2. package/dist/handlers/adminConfigHandler.js +30 -0
  3. package/dist/handlers/adminConfigHandler.js.map +1 -1
  4. package/dist/handlers/batchSummaryHandler.js +15 -8
  5. package/dist/handlers/batchSummaryHandler.js.map +1 -1
  6. package/dist/handlers/studentHandler.js +1 -1
  7. package/dist/handlers/studentHandler.js.map +1 -1
  8. package/dist/handlers/teachingSummaryHandler.js +38 -9
  9. package/dist/handlers/teachingSummaryHandler.js.map +1 -1
  10. package/dist/models/aiConfig.js +15 -2
  11. package/dist/models/aiConfig.js.map +1 -1
  12. package/dist/models/studentSummary.js +13 -0
  13. package/dist/models/studentSummary.js.map +1 -1
  14. package/dist/models/teachingSummary.js +2 -0
  15. package/dist/models/teachingSummary.js.map +1 -1
  16. package/dist/services/analyzers/errorClusterAnalyzer.js +1 -0
  17. package/dist/services/analyzers/errorClusterAnalyzer.js.map +1 -1
  18. package/dist/services/analyzers/findingConsolidator.js +172 -0
  19. package/dist/services/analyzers/findingConsolidator.js.map +1 -0
  20. package/dist/services/batchSummaryService.js +79 -38
  21. package/dist/services/batchSummaryService.js.map +1 -1
  22. package/dist/services/openaiClient.js +59 -28
  23. package/dist/services/openaiClient.js.map +1 -1
  24. package/dist/services/promptService.js +49 -24
  25. package/dist/services/promptService.js.map +1 -1
  26. package/dist/services/teachingAnalysisService.js +13 -2
  27. package/dist/services/teachingAnalysisService.js.map +1 -1
  28. package/dist/services/teachingSuggestionService.js +43 -44
  29. package/dist/services/teachingSuggestionService.js.map +1 -1
  30. package/frontend/admin/BudgetConfigForm.tsx +1 -1
  31. package/frontend/admin/ConfigPanel.tsx +53 -3
  32. package/frontend/admin/EndpointManager.tsx +1 -1
  33. package/frontend/admin/FeedbackForm.tsx +1 -1
  34. package/frontend/admin/JailbreakLogsViewer.tsx +1 -1
  35. package/frontend/admin/ScenarioModelSelector.tsx +238 -0
  36. package/frontend/admin/TelemetrySettings.tsx +1 -1
  37. package/frontend/admin/VersionBadge.tsx +1 -1
  38. package/frontend/admin/configTypes.ts +9 -0
  39. package/frontend/batchSummary/BatchSummaryPanel.tsx +29 -1
  40. package/frontend/batchSummary/StudentSummaryView.tsx +47 -14
  41. package/frontend/batchSummary/SummaryCard.tsx +1 -1
  42. package/frontend/batchSummary/useBatchSummary.ts +26 -8
  43. package/frontend/components/AIHelperDashboard.tsx +1 -1
  44. package/frontend/components/ErrorBoundary.tsx +1 -1
  45. package/frontend/components/ScoreboardTabContainer.tsx +1 -1
  46. package/frontend/generated/localeFallback.ts +1502 -0
  47. package/frontend/problem_detail.page.tsx +1 -1
  48. package/frontend/student/AIAssistantPanel.tsx +1 -1
  49. package/frontend/student/ChatInput.tsx +1 -1
  50. package/frontend/student/ChatMessageList.tsx +1 -1
  51. package/frontend/student/ThinkingBlock.tsx +1 -1
  52. package/frontend/student/hooks/useChatSession.ts +1 -1
  53. package/frontend/teacher/AnalyticsPage.tsx +1 -1
  54. package/frontend/teacher/ClassAnalyticsTable.tsx +1 -1
  55. package/frontend/teacher/ConversationDetail.tsx +1 -1
  56. package/frontend/teacher/ConversationDetailModal.tsx +1 -1
  57. package/frontend/teacher/ConversationList.tsx +1 -1
  58. package/frontend/teacher/CostDashboard.tsx +1 -1
  59. package/frontend/teacher/ExportDialog.tsx +1 -1
  60. package/frontend/teacher/MetricsPanel.tsx +1 -1
  61. package/frontend/teacher/ProblemAnalyticsTable.tsx +1 -1
  62. package/frontend/teacher/StudentAnalyticsTable.tsx +1 -1
  63. package/frontend/teacher/analyticsTypes.ts +1 -1
  64. package/frontend/teachingSummary/TeachingReviewPanel.tsx +1 -1
  65. package/frontend/teachingSummary/TeachingSummaryPanel.tsx +525 -175
  66. package/frontend/teachingSummary/useTeachingSummary.ts +10 -0
  67. package/frontend/utils/i18n.ts +32 -0
  68. package/frontend/utils/i18nFallbackCore.ts +30 -0
  69. package/locales/en.yaml +30 -6
  70. package/locales/zh.yaml +30 -6
  71. package/package.json +3 -1
@@ -1,7 +1,8 @@
1
1
  import React, { useState, useEffect, useCallback } from 'react';
2
- import { i18n } from '@hydrooj/ui-default';
2
+ import { i18n } from '../utils/i18n';
3
3
  import { VersionBadge } from './VersionBadge';
4
4
  import { EndpointManager } from './EndpointManager';
5
+ import { ScenarioModelSelector } from './ScenarioModelSelector';
5
6
  import { BudgetConfigForm } from './BudgetConfigForm';
6
7
  import { JailbreakLogsViewer } from './JailbreakLogsViewer';
7
8
  import { TelemetrySettings } from './TelemetrySettings';
@@ -13,8 +14,21 @@ import {
13
14
  } from '../utils/styles';
14
15
  import type {
15
16
  Endpoint, ConfigState, JailbreakLogPagination, APIConfigResponse, TelemetryStatus,
17
+ AIScenarioKey, SelectedModel, ScenarioModelsState,
16
18
  } from './configTypes';
17
19
 
20
+ const EMPTY_SCENARIO_MODELS: ScenarioModelsState = {
21
+ studentChat: [], learningSummary: [], teachingAnalysis: [],
22
+ };
23
+
24
+ function parseScenarioModels(raw?: Partial<Record<AIScenarioKey, SelectedModel[]>>): ScenarioModelsState {
25
+ return {
26
+ studentChat: raw?.studentChat || [],
27
+ learningSummary: raw?.learningSummary || [],
28
+ teachingAnalysis: raw?.teachingAnalysis || [],
29
+ };
30
+ }
31
+
18
32
  interface ConfigPanelProps {
19
33
  embedded?: boolean;
20
34
  }
@@ -57,6 +71,7 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({ embedded = false }) =>
57
71
  if (json.config == null) {
58
72
  setConfig({
59
73
  endpoints: [], selectedModels: [],
74
+ scenarioModels: { ...EMPTY_SCENARIO_MODELS },
60
75
  apiBaseUrl: '', modelName: '',
61
76
  rateLimitPerMinute: 5, timeoutSeconds: 30,
62
77
  systemPromptTemplate: '', extraJailbreakPatternsText: '',
@@ -67,6 +82,7 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({ embedded = false }) =>
67
82
  setConfig({
68
83
  endpoints: (json.config.endpoints || []).map((ep) => ({ ...ep, newApiKey: '' })),
69
84
  selectedModels: json.config.selectedModels || [],
85
+ scenarioModels: parseScenarioModels(json.config.scenarioModels),
70
86
  apiBaseUrl: json.config.apiBaseUrl || '',
71
87
  modelName: json.config.modelName || '',
72
88
  rateLimitPerMinute: json.config.rateLimitPerMinute ?? 5,
@@ -131,6 +147,7 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({ embedded = false }) =>
131
147
  apiKey: ep.newApiKey || undefined, models: ep.models, enabled: ep.enabled,
132
148
  }));
133
149
  body.selectedModels = config.selectedModels;
150
+ body.scenarioModels = config.scenarioModels;
134
151
  } else {
135
152
  body.apiBaseUrl = config.apiBaseUrl.trim();
136
153
  body.modelName = config.modelName.trim();
@@ -152,6 +169,7 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({ embedded = false }) =>
152
169
  setConfig({
153
170
  endpoints: (json.config.endpoints || []).map((ep) => ({ ...ep, newApiKey: '' })),
154
171
  selectedModels: json.config.selectedModels || [],
172
+ scenarioModels: parseScenarioModels(json.config.scenarioModels),
155
173
  apiBaseUrl: json.config.apiBaseUrl || '',
156
174
  modelName: json.config.modelName || '',
157
175
  rateLimitPerMinute: json.config.rateLimitPerMinute ?? 5,
@@ -245,10 +263,17 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({ embedded = false }) =>
245
263
  setConfig(prev => {
246
264
  if (!prev) return prev;
247
265
  const ep = prev.endpoints[index];
266
+ const dropEndpoint = (models: typeof prev.selectedModels) =>
267
+ ep?.id ? models.filter(sm => sm.endpointId !== ep.id) : models;
248
268
  return {
249
269
  ...prev,
250
270
  endpoints: prev.endpoints.filter((_, i) => i !== index),
251
- selectedModels: ep?.id ? prev.selectedModels.filter(sm => sm.endpointId !== ep.id) : prev.selectedModels,
271
+ selectedModels: dropEndpoint(prev.selectedModels),
272
+ scenarioModels: {
273
+ studentChat: dropEndpoint(prev.scenarioModels.studentChat),
274
+ learningSummary: dropEndpoint(prev.scenarioModels.learningSummary),
275
+ teachingAnalysis: dropEndpoint(prev.scenarioModels.teachingAnalysis),
276
+ },
252
277
  };
253
278
  });
254
279
  }, []);
@@ -288,6 +313,13 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({ embedded = false }) =>
288
313
  });
289
314
  }, []);
290
315
 
316
+ const updateScenarioModels = useCallback((scenario: AIScenarioKey, chain: SelectedModel[]) => {
317
+ setConfig(prev => {
318
+ if (!prev) return prev;
319
+ return { ...prev, scenarioModels: { ...prev.scenarioModels, [scenario]: chain } };
320
+ });
321
+ }, []);
322
+
291
323
  const changePage = (newPage: number) => {
292
324
  if (newPage < 1 || newPage > logPagination.totalPages) return;
293
325
  loadJailbreakLogs(newPage);
@@ -390,6 +422,18 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({ embedded = false }) =>
390
422
  />
391
423
  </div>
392
424
 
425
+ {config.endpoints.length > 0 && (
426
+ <div style={dsCardStyle}>
427
+ <ScenarioModelSelector
428
+ endpoints={config.endpoints}
429
+ globalModels={config.selectedModels}
430
+ scenarioModels={config.scenarioModels}
431
+ onChange={updateScenarioModels}
432
+ disabled={isBusy}
433
+ />
434
+ </div>
435
+ )}
436
+
393
437
  <div style={dsCardStyle}>
394
438
  <h2 style={cardTitleStyle}>{i18n('ai_helper_admin_general_settings')}</h2>
395
439
  <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '15px' }}>
@@ -401,6 +445,9 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({ embedded = false }) =>
401
445
  onChange={(e) => setConfig({ ...config, timeoutSeconds: e.target.value === '' ? '' : Number(e.target.value) })}
402
446
  placeholder="30" min="1" disabled={isBusy} style={getInputStyle()}
403
447
  />
448
+ <p style={{ fontSize: '12px', color: COLORS.textMuted, margin: `${SPACING.xs} 0 0` }}>
449
+ {i18n('ai_helper_config_timeout_hint')}
450
+ </p>
404
451
  </div>
405
452
  <div>
406
453
  <label style={{ display: 'block', marginBottom: SPACING.xs, fontWeight: 500, color: COLORS.textPrimary }}>{i18n('ai_helper_config_rate_limit_per_minute')}</label>
@@ -408,8 +455,11 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({ embedded = false }) =>
408
455
  type="number"
409
456
  value={config.rateLimitPerMinute}
410
457
  onChange={(e) => setConfig({ ...config, rateLimitPerMinute: e.target.value === '' ? '' : Number(e.target.value) })}
411
- placeholder="5" min="1" disabled={isBusy} style={getInputStyle()}
458
+ placeholder="5" min="0" disabled={isBusy} style={getInputStyle()}
412
459
  />
460
+ <p style={{ fontSize: '12px', color: COLORS.textMuted, margin: `${SPACING.xs} 0 0` }}>
461
+ {i18n('ai_helper_config_rate_limit_hint')}
462
+ </p>
413
463
  </div>
414
464
  </div>
415
465
  </div>
@@ -1,5 +1,5 @@
1
1
  import React, { useState } from 'react';
2
- import { i18n } from '@hydrooj/ui-default';
2
+ import { i18n } from '../utils/i18n';
3
3
  import {
4
4
  COLORS, SPACING, RADIUS, SHADOWS, TRANSITIONS,
5
5
  getInputStyle, getButtonStyle, getBadgeStyle,
@@ -1,5 +1,5 @@
1
1
  import React, { useState } from 'react';
2
- import { i18n } from '@hydrooj/ui-default';
2
+ import { i18n } from '../utils/i18n';
3
3
  import {
4
4
  COLORS, SPACING, RADIUS, TYPOGRAPHY,
5
5
  getInputStyle, getButtonStyle,
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { i18n } from '@hydrooj/ui-default';
2
+ import { i18n } from '../utils/i18n';
3
3
  import {
4
4
  COLORS, SPACING, RADIUS, TYPOGRAPHY,
5
5
  cardStyle, getButtonStyle,
@@ -0,0 +1,238 @@
1
+ import React from 'react';
2
+ import { i18n } from '../utils/i18n';
3
+ import {
4
+ COLORS, SPACING, RADIUS,
5
+ getInputStyle, getButtonStyle, getBadgeStyle,
6
+ } from '../utils/styles';
7
+ import type { Endpoint, SelectedModel, AIScenarioKey, ScenarioModelsState } from './configTypes';
8
+ import { AI_SCENARIO_KEYS } from './configTypes';
9
+
10
+ interface ScenarioModelSelectorProps {
11
+ endpoints: Endpoint[];
12
+ /** 全局默认模型链(用于展示"跟随全局"场景当前实际生效的模型) */
13
+ globalModels: SelectedModel[];
14
+ scenarioModels: ScenarioModelsState;
15
+ onChange: (scenario: AIScenarioKey, chain: SelectedModel[]) => void;
16
+ disabled: boolean;
17
+ }
18
+
19
+ /** 把模型链概括成 "a → b → c" 的短文本(最多 3 个,多余折叠) */
20
+ function summarizeChain(chain: SelectedModel[]): string {
21
+ const names = chain.map(sm => sm.modelName);
22
+ const shown = names.slice(0, 3).join(' → ');
23
+ return names.length > 3 ? `${shown} → +${names.length - 3}` : shown;
24
+ }
25
+
26
+ const SCENARIO_META: Record<AIScenarioKey, { labelKey: string; descKey: string; icon: string }> = {
27
+ studentChat: {
28
+ labelKey: 'ai_helper_admin_scenario_student_chat',
29
+ descKey: 'ai_helper_admin_scenario_student_chat_desc',
30
+ icon: '💬',
31
+ },
32
+ learningSummary: {
33
+ labelKey: 'ai_helper_admin_scenario_learning_summary',
34
+ descKey: 'ai_helper_admin_scenario_learning_summary_desc',
35
+ icon: '📝',
36
+ },
37
+ teachingAnalysis: {
38
+ labelKey: 'ai_helper_admin_scenario_teaching_analysis',
39
+ descKey: 'ai_helper_admin_scenario_teaching_analysis_desc',
40
+ icon: '📊',
41
+ },
42
+ };
43
+
44
+ const OPTION_SEPARATOR = '::';
45
+
46
+ export const ScenarioModelSelector: React.FC<ScenarioModelSelectorProps> = ({
47
+ endpoints, globalModels, scenarioModels, onChange, disabled,
48
+ }) => {
49
+ // 可供选择的 端点×模型 组合(未保存端点的临时 ID 会在保存时由后端重映射为真实 ID)
50
+ const modelOptions: Array<{ endpointId: string; endpointName: string; modelName: string }> = [];
51
+ for (const ep of endpoints) {
52
+ if (!ep.id || !ep.enabled) continue;
53
+ for (const model of ep.models) {
54
+ modelOptions.push({ endpointId: ep.id, endpointName: ep.name, modelName: model });
55
+ }
56
+ }
57
+
58
+ const endpointName = (endpointId: string) =>
59
+ endpoints.find(e => e.id === endpointId)?.name || i18n('ai_helper_admin_endpoint_unknown');
60
+
61
+ const addModel = (scenario: AIScenarioKey, optionValue: string) => {
62
+ if (!optionValue) return;
63
+ const sepIndex = optionValue.indexOf(OPTION_SEPARATOR);
64
+ if (sepIndex <= 0) return;
65
+ const endpointId = optionValue.slice(0, sepIndex);
66
+ const modelName = optionValue.slice(sepIndex + OPTION_SEPARATOR.length);
67
+ const chain = scenarioModels[scenario];
68
+ if (chain.some(sm => sm.endpointId === endpointId && sm.modelName === modelName)) return;
69
+ onChange(scenario, [...chain, { endpointId, modelName }]);
70
+ };
71
+
72
+ const removeModel = (scenario: AIScenarioKey, index: number) => {
73
+ onChange(scenario, scenarioModels[scenario].filter((_, i) => i !== index));
74
+ };
75
+
76
+ const moveModel = (scenario: AIScenarioKey, index: number, direction: 'up' | 'down') => {
77
+ const chain = [...scenarioModels[scenario]];
78
+ const ni = direction === 'up' ? index - 1 : index + 1;
79
+ if (ni < 0 || ni >= chain.length) return;
80
+ [chain[index], chain[ni]] = [chain[ni], chain[index]];
81
+ onChange(scenario, chain);
82
+ };
83
+
84
+ return (
85
+ <div>
86
+ <h2 style={{ marginTop: 0, marginBottom: SPACING.sm, fontSize: '18px', color: COLORS.textPrimary }}>
87
+ {i18n('ai_helper_admin_scenario_title')}
88
+ </h2>
89
+ <p style={{ fontSize: '13px', color: COLORS.textMuted, marginTop: 0, marginBottom: SPACING.base }}>
90
+ {i18n('ai_helper_admin_scenario_desc')}
91
+ </p>
92
+
93
+ <div style={{ display: 'flex', flexDirection: 'column', gap: SPACING.md }}>
94
+ {AI_SCENARIO_KEYS.map((scenario) => {
95
+ const meta = SCENARIO_META[scenario];
96
+ const chain = scenarioModels[scenario];
97
+ const isDefault = chain.length === 0;
98
+
99
+ return (
100
+ <div
101
+ key={scenario}
102
+ style={{
103
+ padding: SPACING.base, backgroundColor: COLORS.bgCard,
104
+ borderRadius: RADIUS.md, border: `1px solid ${COLORS.border}`,
105
+ }}
106
+ >
107
+ <div style={{ display: 'flex', alignItems: 'center', gap: SPACING.sm, marginBottom: SPACING.xs, flexWrap: 'wrap' }}>
108
+ <span style={{ fontSize: '16px' }}>{meta.icon}</span>
109
+ <span style={{ fontSize: '14px', fontWeight: 600, color: COLORS.textPrimary }}>{i18n(meta.labelKey)}</span>
110
+ {isDefault ? (
111
+ <span style={getBadgeStyle('info')}>{i18n('ai_helper_admin_scenario_follow_global')}</span>
112
+ ) : (
113
+ <span style={getBadgeStyle('success')}>{i18n('ai_helper_admin_scenario_custom')}</span>
114
+ )}
115
+ {!isDefault && (
116
+ <button
117
+ onClick={() => onChange(scenario, [])}
118
+ disabled={disabled}
119
+ style={{
120
+ ...getButtonStyle('ghost'),
121
+ padding: '2px 8px', fontSize: '12px', marginLeft: 'auto',
122
+ cursor: disabled ? 'not-allowed' : 'pointer',
123
+ }}
124
+ >
125
+ {i18n('ai_helper_admin_scenario_reset')}
126
+ </button>
127
+ )}
128
+ </div>
129
+ <p style={{ fontSize: '12px', color: COLORS.textMuted, margin: `0 0 ${SPACING.sm}` }}>
130
+ {i18n(meta.descKey)}
131
+ </p>
132
+
133
+ {isDefault && (
134
+ <div style={{
135
+ fontSize: '12px', marginBottom: SPACING.sm,
136
+ color: globalModels.length > 0 ? COLORS.textSecondary : COLORS.warningText,
137
+ }}>
138
+ {globalModels.length > 0
139
+ ? i18n('ai_helper_admin_scenario_effective_global', summarizeChain(globalModels))
140
+ : i18n('ai_helper_admin_scenario_global_empty')}
141
+ </div>
142
+ )}
143
+
144
+ {!isDefault && (
145
+ <div style={{ display: 'flex', flexDirection: 'column', gap: SPACING.xs, marginBottom: SPACING.sm }}>
146
+ {chain.map((sm, index) => (
147
+ <div
148
+ key={`${sm.endpointId}-${sm.modelName}`}
149
+ style={{
150
+ display: 'flex', alignItems: 'center', padding: `${SPACING.xs} ${SPACING.md}`,
151
+ backgroundColor: COLORS.bgPage, borderRadius: RADIUS.sm, border: `1px solid ${COLORS.border}`,
152
+ }}
153
+ >
154
+ <span style={{
155
+ width: '20px', height: '20px', borderRadius: '50%',
156
+ backgroundColor: COLORS.primaryLight, color: COLORS.primary,
157
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
158
+ fontSize: '11px', fontWeight: 600, marginRight: SPACING.sm, flexShrink: 0,
159
+ }}>
160
+ {index + 1}
161
+ </span>
162
+ <div style={{ flex: 1, minWidth: 0 }}>
163
+ <span style={{ fontSize: '13px', fontWeight: 500, color: COLORS.textPrimary }}>{sm.modelName}</span>
164
+ <span style={{ fontSize: '12px', color: COLORS.textMuted, marginLeft: SPACING.sm }}>{endpointName(sm.endpointId)}</span>
165
+ </div>
166
+ <div style={{ display: 'flex', gap: '4px' }}>
167
+ <button
168
+ onClick={() => moveModel(scenario, index, 'up')}
169
+ disabled={disabled || index === 0}
170
+ style={{
171
+ ...getButtonStyle('ghost'), padding: '2px 6px', fontSize: '12px',
172
+ cursor: (disabled || index === 0) ? 'not-allowed' : 'pointer',
173
+ opacity: (disabled || index === 0) ? 0.5 : 1,
174
+ }}
175
+ >
176
+
177
+ </button>
178
+ <button
179
+ onClick={() => moveModel(scenario, index, 'down')}
180
+ disabled={disabled || index === chain.length - 1}
181
+ style={{
182
+ ...getButtonStyle('ghost'), padding: '2px 6px', fontSize: '12px',
183
+ cursor: (disabled || index === chain.length - 1) ? 'not-allowed' : 'pointer',
184
+ opacity: (disabled || index === chain.length - 1) ? 0.5 : 1,
185
+ }}
186
+ >
187
+
188
+ </button>
189
+ <button
190
+ onClick={() => removeModel(scenario, index)}
191
+ disabled={disabled}
192
+ style={{
193
+ ...getButtonStyle('danger'), padding: '2px 6px', fontSize: '12px',
194
+ cursor: disabled ? 'not-allowed' : 'pointer',
195
+ }}
196
+ >
197
+ ×
198
+ </button>
199
+ </div>
200
+ </div>
201
+ ))}
202
+ </div>
203
+ )}
204
+
205
+ {modelOptions.length > 0 ? (
206
+ <select
207
+ value=""
208
+ onChange={(e) => addModel(scenario, e.target.value)}
209
+ disabled={disabled}
210
+ style={{ ...getInputStyle(), maxWidth: '420px', cursor: disabled ? 'not-allowed' : 'pointer' }}
211
+ >
212
+ <option value="">
213
+ {isDefault
214
+ ? i18n('ai_helper_admin_scenario_add_model_override')
215
+ : i18n('ai_helper_admin_scenario_add_model')}
216
+ </option>
217
+ {modelOptions.map(opt => (
218
+ <option
219
+ key={`${opt.endpointId}${OPTION_SEPARATOR}${opt.modelName}`}
220
+ value={`${opt.endpointId}${OPTION_SEPARATOR}${opt.modelName}`}
221
+ disabled={chain.some(sm => sm.endpointId === opt.endpointId && sm.modelName === opt.modelName)}
222
+ >
223
+ {opt.modelName} — {opt.endpointName}
224
+ </option>
225
+ ))}
226
+ </select>
227
+ ) : (
228
+ <div style={{ fontSize: '12px', color: COLORS.textMuted }}>
229
+ {i18n('ai_helper_admin_scenario_no_models')}
230
+ </div>
231
+ )}
232
+ </div>
233
+ );
234
+ })}
235
+ </div>
236
+ </div>
237
+ );
238
+ };
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { i18n } from '@hydrooj/ui-default';
2
+ import { i18n } from '../utils/i18n';
3
3
  import {
4
4
  COLORS, SPACING, RADIUS, TYPOGRAPHY,
5
5
  } from '../utils/styles';
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import React, { useState, useEffect, useRef } from 'react';
12
- import { i18n } from '@hydrooj/ui-default';
12
+ import { i18n } from '../utils/i18n';
13
13
  import { buildApiUrl } from '../utils/domainUtils';
14
14
  import {
15
15
  COLORS, SPACING, RADIUS, SHADOWS, TRANSITIONS, ANIMATIONS, TYPOGRAPHY, FONT_FAMILY,
@@ -16,6 +16,13 @@ export interface SelectedModel {
16
16
  modelName: string;
17
17
  }
18
18
 
19
+ export type AIScenarioKey = 'studentChat' | 'learningSummary' | 'teachingAnalysis';
20
+
21
+ export const AI_SCENARIO_KEYS: readonly AIScenarioKey[] = ['studentChat', 'learningSummary', 'teachingAnalysis'] as const;
22
+
23
+ /** 每个场景的专属模型链;空数组 = 跟随全局 selectedModels */
24
+ export type ScenarioModelsState = Record<AIScenarioKey, SelectedModel[]>;
25
+
19
26
  export interface BudgetConfigState {
20
27
  dailyTokenLimitPerUser: number | '';
21
28
  dailyTokenLimitPerDomain: number | '';
@@ -26,6 +33,7 @@ export interface BudgetConfigState {
26
33
  export interface ConfigState {
27
34
  endpoints: Endpoint[];
28
35
  selectedModels: SelectedModel[];
36
+ scenarioModels: ScenarioModelsState;
29
37
  apiBaseUrl: string;
30
38
  modelName: string;
31
39
  rateLimitPerMinute: number | '';
@@ -73,6 +81,7 @@ export interface APIConfigResponse {
73
81
  config: {
74
82
  endpoints?: Array<Omit<Endpoint, 'newApiKey' | 'isNew'> & { apiKeyMasked?: string; hasApiKey?: boolean }>;
75
83
  selectedModels?: SelectedModel[];
84
+ scenarioModels?: Partial<Record<AIScenarioKey, SelectedModel[]>>;
76
85
  apiBaseUrl?: string;
77
86
  modelName?: string;
78
87
  rateLimitPerMinute?: number;
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react';
8
- import { i18n } from '@hydrooj/ui-default';
8
+ import { i18n } from '../utils/i18n';
9
9
  import { COLORS, SPACING, RADIUS, SHADOWS, LAYOUT, getButtonStyle, getAlertStyle, markdownTheme } from '../utils/styles';
10
10
 
11
11
  /** i18n with hardcoded Chinese fallback for keys that may not yet be in lang-*.js */
@@ -29,6 +29,9 @@ const I18N_FALLBACK: Record<string, string> = {
29
29
  ai_helper_batch_summary_stats_failed: '失败',
30
30
  ai_helper_batch_summary_stats_not_generated: '未生成',
31
31
  ai_helper_batch_summary_no_new_students: '所有学生已生成总结,无需补充',
32
+ ai_helper_batch_summary_publish_done: '已发布 {0} 份总结',
33
+ ai_helper_batch_summary_publish_skipped_failed: '{0} 名学生生成失败,未发布',
34
+ ai_helper_batch_summary_publish_skipped_pending: '{0} 名学生尚未生成,未发布',
32
35
  };
33
36
  function t(key: string): string {
34
37
  const val = i18n(key);
@@ -227,6 +230,24 @@ export const BatchSummaryPanel: React.FC<BatchSummaryPanelProps> = ({
227
230
  publishAll();
228
231
  }, [publishAll]);
229
232
 
233
+ // Publish outcome notice — warns when publishAll skipped failed/pending
234
+ // students (they would otherwise see a blank tab with no one the wiser).
235
+ const publishNotice = useMemo(() => {
236
+ const pr = state.publishResult;
237
+ if (!pr) return null;
238
+ const parts = [t('ai_helper_batch_summary_publish_done').replace('{0}', String(pr.published))];
239
+ if (pr.skippedFailed > 0) {
240
+ parts.push(t('ai_helper_batch_summary_publish_skipped_failed').replace('{0}', String(pr.skippedFailed)));
241
+ }
242
+ if (pr.skippedPending > 0) {
243
+ parts.push(t('ai_helper_batch_summary_publish_skipped_pending').replace('{0}', String(pr.skippedPending)));
244
+ }
245
+ return {
246
+ text: parts.join(' · '),
247
+ variant: (pr.skippedFailed + pr.skippedPending > 0 ? 'warning' : 'success') as 'warning' | 'success',
248
+ };
249
+ }, [state.publishResult]);
250
+
230
251
  // ── Publish single ──────────────────────────────────────────────────────────
231
252
 
232
253
  const handlePublishOne = useCallback(async (userId: number) => {
@@ -453,6 +474,13 @@ export const BatchSummaryPanel: React.FC<BatchSummaryPanelProps> = ({
453
474
  </div>
454
475
  )}
455
476
 
477
+ {/* Publish outcome */}
478
+ {publishNotice && (
479
+ <div style={{ ...getAlertStyle(publishNotice.variant), marginBottom: SPACING.base }}>
480
+ {publishNotice.text}
481
+ </div>
482
+ )}
483
+
456
484
  {/* Error */}
457
485
  {state.error && (
458
486
  <div style={{ ...getAlertStyle('error'), marginBottom: SPACING.base }}>
@@ -2,17 +2,26 @@
2
2
  * StudentSummaryView — read-only view of a student's published AI learning summary.
3
3
  * Fetches the current user's published summary for a given contest and renders markdown.
4
4
  * Polls every 30s until a published summary is found, then stops.
5
+ * While nothing is published (or the request fails) it renders an explanatory
6
+ * placeholder instead of nothing — a blank tab is indistinguishable from a bug
7
+ * for students, which is exactly how the "无法看到 AI 学习总结" report read.
5
8
  */
6
9
 
7
10
  import React, { useEffect, useState, useRef, useCallback } from 'react';
8
- import { i18n } from '@hydrooj/ui-default';
9
- import { COLORS, SPACING, RADIUS, SHADOWS, LAYOUT, markdownTheme } from '../utils/styles';
11
+ import { i18n } from '../utils/i18n';
12
+ import { COLORS, SPACING, RADIUS, SHADOWS, LAYOUT, markdownTheme, emptyStateStyle } from '../utils/styles';
10
13
  import { renderMarkdown } from '../utils/markdown';
11
14
 
15
+ /** i18n with hardcoded Chinese fallback for keys that may not yet be in lang-*.js */
16
+ const I18N_FALLBACK: Record<string, string> = {
17
+ ai_helper_batch_summary_my_title: 'AI 学习总结',
18
+ ai_helper_batch_summary_my_empty: '你的学习总结尚未发布。老师发布后会自动显示在这里。',
19
+ ai_helper_batch_summary_my_load_failed: '学习总结加载失败,正在自动重试…',
20
+ };
21
+
12
22
  function t(key: string): string {
13
23
  const val = i18n(key);
14
- if (val === key && key === 'ai_helper_batch_summary_my_title') return 'AI 学习总结';
15
- return val;
24
+ return val === key ? (I18N_FALLBACK[key] || val) : val;
16
25
  }
17
26
 
18
27
  interface StudentSummaryViewProps {
@@ -40,22 +49,29 @@ function buildUrl(domainId: string, path: string): string {
40
49
 
41
50
  const POLL_INTERVAL = 30000; // 30 seconds
42
51
 
52
+ /** Fetch outcome: `failed` distinguishes request errors from "not published yet" */
53
+ interface FetchResult {
54
+ failed: boolean;
55
+ summary: string | null;
56
+ }
57
+
43
58
  export const StudentSummaryView: React.FC<StudentSummaryViewProps> = ({ domainId, contestId }) => {
44
59
  const [summary, setSummary] = useState<string | null>(null);
45
60
  const [loading, setLoading] = useState(true);
61
+ const [loadFailed, setLoadFailed] = useState(false);
46
62
  const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
47
63
 
48
- const fetchSummary = useCallback(async () => {
64
+ const fetchSummary = useCallback(async (): Promise<FetchResult> => {
49
65
  try {
50
66
  const res = await fetch(buildUrl(domainId, `/my-summary?contestId=${contestId}`), {
51
67
  credentials: 'include',
52
68
  headers: { 'X-Requested-With': 'XMLHttpRequest' },
53
69
  });
54
- if (!res.ok) return null;
70
+ if (!res.ok) return { failed: true, summary: null };
55
71
  const data = await res.json();
56
- return data.summary?.summary || null;
72
+ return { failed: false, summary: data.summary?.summary || null };
57
73
  } catch {
58
- return null;
74
+ return { failed: true, summary: null };
59
75
  }
60
76
  }, [domainId, contestId]);
61
77
 
@@ -66,17 +82,19 @@ export const StudentSummaryView: React.FC<StudentSummaryViewProps> = ({ domainId
66
82
  const result = await fetchSummary();
67
83
  if (cancelled) return;
68
84
  setLoading(false);
69
- if (result) {
70
- setSummary(result);
85
+ setLoadFailed(result.failed);
86
+ if (result.summary) {
87
+ setSummary(result.summary);
71
88
  return; // Already have summary, no need to poll
72
89
  }
73
90
 
74
- // No published summary yet — start polling until one appears
91
+ // No published summary yet (or the request failed) poll until one appears
75
92
  timerRef.current = setInterval(async () => {
76
93
  const polled = await fetchSummary();
77
94
  if (cancelled) return;
78
- if (polled) {
79
- setSummary(polled);
95
+ setLoadFailed(polled.failed);
96
+ if (polled.summary) {
97
+ setSummary(polled.summary);
80
98
  // Stop polling once we have the summary
81
99
  if (timerRef.current) {
82
100
  clearInterval(timerRef.current);
@@ -95,7 +113,22 @@ export const StudentSummaryView: React.FC<StudentSummaryViewProps> = ({ domainId
95
113
  };
96
114
  }, [fetchSummary]);
97
115
 
98
- if (loading || !summary) return null;
116
+ if (loading) return null;
117
+
118
+ if (!summary) {
119
+ return (
120
+ <div style={{
121
+ maxWidth: LAYOUT.contentMaxWidth,
122
+ margin: '0 auto',
123
+ width: '100%',
124
+ marginBottom: SPACING.base,
125
+ }}>
126
+ <div style={emptyStateStyle}>
127
+ {t(loadFailed ? 'ai_helper_batch_summary_my_load_failed' : 'ai_helper_batch_summary_my_empty')}
128
+ </div>
129
+ </div>
130
+ );
131
+ }
99
132
 
100
133
  return (
101
134
  <div style={{
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import React, { useState } from 'react';
8
- import { i18n } from '@hydrooj/ui-default';
8
+ import { i18n } from '../utils/i18n';
9
9
  import {
10
10
  COLORS, SPACING, RADIUS, SHADOWS, getButtonStyle,
11
11
  } from '../utils/styles';