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.
- package/README.md +1 -0
- package/dist/handlers/adminConfigHandler.js +30 -0
- package/dist/handlers/adminConfigHandler.js.map +1 -1
- package/dist/handlers/batchSummaryHandler.js +15 -8
- package/dist/handlers/batchSummaryHandler.js.map +1 -1
- package/dist/handlers/studentHandler.js +1 -1
- package/dist/handlers/studentHandler.js.map +1 -1
- package/dist/handlers/teachingSummaryHandler.js +38 -9
- package/dist/handlers/teachingSummaryHandler.js.map +1 -1
- package/dist/models/aiConfig.js +15 -2
- package/dist/models/aiConfig.js.map +1 -1
- package/dist/models/studentSummary.js +13 -0
- package/dist/models/studentSummary.js.map +1 -1
- package/dist/models/teachingSummary.js +2 -0
- package/dist/models/teachingSummary.js.map +1 -1
- package/dist/services/analyzers/errorClusterAnalyzer.js +1 -0
- package/dist/services/analyzers/errorClusterAnalyzer.js.map +1 -1
- package/dist/services/analyzers/findingConsolidator.js +172 -0
- package/dist/services/analyzers/findingConsolidator.js.map +1 -0
- package/dist/services/batchSummaryService.js +79 -38
- package/dist/services/batchSummaryService.js.map +1 -1
- package/dist/services/openaiClient.js +59 -28
- package/dist/services/openaiClient.js.map +1 -1
- package/dist/services/promptService.js +49 -24
- package/dist/services/promptService.js.map +1 -1
- package/dist/services/teachingAnalysisService.js +13 -2
- package/dist/services/teachingAnalysisService.js.map +1 -1
- package/dist/services/teachingSuggestionService.js +43 -44
- package/dist/services/teachingSuggestionService.js.map +1 -1
- package/frontend/admin/BudgetConfigForm.tsx +1 -1
- package/frontend/admin/ConfigPanel.tsx +53 -3
- package/frontend/admin/EndpointManager.tsx +1 -1
- package/frontend/admin/FeedbackForm.tsx +1 -1
- package/frontend/admin/JailbreakLogsViewer.tsx +1 -1
- package/frontend/admin/ScenarioModelSelector.tsx +238 -0
- package/frontend/admin/TelemetrySettings.tsx +1 -1
- package/frontend/admin/VersionBadge.tsx +1 -1
- package/frontend/admin/configTypes.ts +9 -0
- package/frontend/batchSummary/BatchSummaryPanel.tsx +29 -1
- package/frontend/batchSummary/StudentSummaryView.tsx +47 -14
- package/frontend/batchSummary/SummaryCard.tsx +1 -1
- package/frontend/batchSummary/useBatchSummary.ts +26 -8
- package/frontend/components/AIHelperDashboard.tsx +1 -1
- package/frontend/components/ErrorBoundary.tsx +1 -1
- package/frontend/components/ScoreboardTabContainer.tsx +1 -1
- package/frontend/generated/localeFallback.ts +1502 -0
- package/frontend/problem_detail.page.tsx +1 -1
- package/frontend/student/AIAssistantPanel.tsx +1 -1
- package/frontend/student/ChatInput.tsx +1 -1
- package/frontend/student/ChatMessageList.tsx +1 -1
- package/frontend/student/ThinkingBlock.tsx +1 -1
- package/frontend/student/hooks/useChatSession.ts +1 -1
- package/frontend/teacher/AnalyticsPage.tsx +1 -1
- package/frontend/teacher/ClassAnalyticsTable.tsx +1 -1
- package/frontend/teacher/ConversationDetail.tsx +1 -1
- package/frontend/teacher/ConversationDetailModal.tsx +1 -1
- package/frontend/teacher/ConversationList.tsx +1 -1
- package/frontend/teacher/CostDashboard.tsx +1 -1
- package/frontend/teacher/ExportDialog.tsx +1 -1
- package/frontend/teacher/MetricsPanel.tsx +1 -1
- package/frontend/teacher/ProblemAnalyticsTable.tsx +1 -1
- package/frontend/teacher/StudentAnalyticsTable.tsx +1 -1
- package/frontend/teacher/analyticsTypes.ts +1 -1
- package/frontend/teachingSummary/TeachingReviewPanel.tsx +1 -1
- package/frontend/teachingSummary/TeachingSummaryPanel.tsx +525 -175
- package/frontend/teachingSummary/useTeachingSummary.ts +10 -0
- package/frontend/utils/i18n.ts +32 -0
- package/frontend/utils/i18nFallbackCore.ts +30 -0
- package/locales/en.yaml +30 -6
- package/locales/zh.yaml +30 -6
- package/package.json +3 -1
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import React, { useState, useEffect, useCallback } from 'react';
|
|
2
|
-
import { i18n } from '
|
|
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:
|
|
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="
|
|
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>
|
|
@@ -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
|
+
};
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import React, { useState, useEffect, useRef } from 'react';
|
|
12
|
-
import { i18n } from '
|
|
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 '
|
|
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 '
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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 —
|
|
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
|
-
|
|
79
|
-
|
|
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
|
|
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={{
|