hydro-ai-helper 2.0.7 → 2.2.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/dist/handlers/batchSummaryHandler.js +4 -2
- package/dist/handlers/batchSummaryHandler.js.map +1 -1
- package/dist/handlers/teachingSummaryHandler.js +3 -0
- package/dist/handlers/teachingSummaryHandler.js.map +1 -1
- package/dist/index.js +20 -1
- package/dist/index.js.map +1 -1
- package/dist/models/featureStats.js +58 -0
- package/dist/models/featureStats.js.map +1 -0
- package/dist/models/requestStats.js +14 -1
- package/dist/models/requestStats.js.map +1 -1
- package/dist/services/batchSummaryService.js +9 -1
- package/dist/services/batchSummaryService.js.map +1 -1
- package/dist/services/effectivenessService.js +37 -0
- package/dist/services/effectivenessService.js.map +1 -1
- package/dist/services/errorReporter.js +67 -0
- package/dist/services/errorReporter.js.map +1 -1
- package/dist/services/telemetryService.js +13 -2
- package/dist/services/telemetryService.js.map +1 -1
- package/frontend/student/ChatInput.tsx +118 -116
- package/frontend/student/ChatMessageList.tsx +98 -73
- package/frontend/student/ThinkingBlock.tsx +15 -12
- package/frontend/student/hooks/useTextSelection.ts +7 -21
- package/frontend/student/icons.tsx +81 -0
- package/locales/en.yaml +0 -1
- package/locales/zh.yaml +0 -1
- package/package.json +1 -1
|
@@ -1,9 +1,30 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { i18n } from '@hydrooj/ui-default';
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
import { TypeIcon, SendIcon, AttachIcon, RefreshIcon, RemoveIcon } from './icons';
|
|
4
|
+
|
|
5
|
+
// ── 方案 A · 克制蓝 调色 ───────────────────────────────────────────────
|
|
6
|
+
const A = {
|
|
7
|
+
border: '#eef1f5',
|
|
8
|
+
cardBorder: '#e3e8ef',
|
|
9
|
+
inputBg: '#f8fafc',
|
|
10
|
+
primary: '#2563eb',
|
|
11
|
+
gradient: 'linear-gradient(135deg, #2563eb, #5b8def)',
|
|
12
|
+
textPrimary: '#1e2536',
|
|
13
|
+
textSecondary: '#475569',
|
|
14
|
+
textMuted: '#64748b',
|
|
15
|
+
textFaint: '#94a3b8',
|
|
16
|
+
placeholder: '#aab4c2',
|
|
17
|
+
selBorder: '#2563eb',
|
|
18
|
+
selBg: '#f3f7ff',
|
|
19
|
+
idleBorder: '#eef1f5',
|
|
20
|
+
idleBg: '#fafbfd',
|
|
21
|
+
iconIdleBg: '#eef2f8',
|
|
22
|
+
success: '#10b981',
|
|
23
|
+
warning: '#f59e0b',
|
|
24
|
+
error: '#ef4444',
|
|
25
|
+
disabledBg: '#f1f5f9',
|
|
26
|
+
disabledText: '#cbd5e1',
|
|
27
|
+
};
|
|
7
28
|
|
|
8
29
|
interface QuestionType {
|
|
9
30
|
value: string;
|
|
@@ -42,12 +63,49 @@ export const ChatInput: React.FC<ChatInputProps> = ({
|
|
|
42
63
|
const isFollowUp = conversationHistoryLength > 0;
|
|
43
64
|
const canSubmit = isFirstConversation ? !!questionType : !!userThinking.trim();
|
|
44
65
|
|
|
66
|
+
// ── 问题类型卡片(2×2 网格,替换原 pill 行)─────────────────────────
|
|
67
|
+
const renderTypeCard = (type: QuestionType) => {
|
|
68
|
+
const isSelected = questionType === type.value;
|
|
69
|
+
return (
|
|
70
|
+
<label
|
|
71
|
+
key={type.value}
|
|
72
|
+
className="ai-type-card"
|
|
73
|
+
style={{
|
|
74
|
+
display: 'block', padding: '11px 12px', borderRadius: '12px', cursor: 'pointer',
|
|
75
|
+
border: `1.5px solid ${isSelected ? A.selBorder : A.idleBorder}`,
|
|
76
|
+
background: isSelected ? A.selBg : A.idleBg,
|
|
77
|
+
transition: 'all 160ms cubic-bezier(.4,0,.2,1)', userSelect: 'none',
|
|
78
|
+
}}
|
|
79
|
+
>
|
|
80
|
+
<input
|
|
81
|
+
type="radio" name="questionType" value={type.value}
|
|
82
|
+
checked={isSelected} onChange={(e) => onQuestionTypeChange(e.target.value)}
|
|
83
|
+
style={{ display: 'none' }}
|
|
84
|
+
/>
|
|
85
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '5px' }}>
|
|
86
|
+
<div style={{
|
|
87
|
+
width: '24px', height: '24px', borderRadius: '7px',
|
|
88
|
+
background: isSelected ? A.primary : A.iconIdleBg,
|
|
89
|
+
color: isSelected ? '#fff' : A.textMuted,
|
|
90
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
|
91
|
+
}}>
|
|
92
|
+
<TypeIcon type={type.value} size={14} />
|
|
93
|
+
</div>
|
|
94
|
+
<span style={{ fontSize: '13px', fontWeight: 600, color: A.textPrimary }}>{i18n(type.label)}</span>
|
|
95
|
+
</div>
|
|
96
|
+
{type.description && (
|
|
97
|
+
<div style={{ fontSize: '11px', color: A.textMuted, lineHeight: 1.4 }}>{i18n(type.description)}</div>
|
|
98
|
+
)}
|
|
99
|
+
</label>
|
|
100
|
+
);
|
|
101
|
+
};
|
|
102
|
+
|
|
45
103
|
const renderIncludeCodeCheckbox = (labelText: string) => (
|
|
46
104
|
<label
|
|
47
105
|
style={{
|
|
48
106
|
display: 'flex', alignItems: 'center',
|
|
49
107
|
cursor: questionType === 'optimize' ? 'not-allowed' : 'pointer',
|
|
50
|
-
fontSize: '
|
|
108
|
+
fontSize: '11.5px', color: questionType === 'optimize' ? A.disabledText : A.textMuted,
|
|
51
109
|
whiteSpace: 'nowrap', alignSelf: 'center',
|
|
52
110
|
}}
|
|
53
111
|
title={questionType === 'optimize' ? i18n('ai_helper_student_optimize_code_required') : undefined}
|
|
@@ -55,11 +113,11 @@ export const ChatInput: React.FC<ChatInputProps> = ({
|
|
|
55
113
|
<input
|
|
56
114
|
type="checkbox" checked={includeCode} disabled={questionType === 'optimize'}
|
|
57
115
|
onChange={(e) => onIncludeCodeChange(e.target.checked)}
|
|
58
|
-
style={{ marginRight: '6px', accentColor:
|
|
116
|
+
style={{ marginRight: '6px', accentColor: A.primary }}
|
|
59
117
|
/>
|
|
60
118
|
{labelText}
|
|
61
|
-
{questionType === 'optimize' && <span style={{ marginLeft: '4px', color:
|
|
62
|
-
{includeCode && code && questionType !== 'optimize' && <span style={{ marginLeft: '4px', color:
|
|
119
|
+
{questionType === 'optimize' && <span style={{ marginLeft: '4px', color: A.warning, fontSize: '11px' }}>({i18n('ai_helper_student_required')})</span>}
|
|
120
|
+
{includeCode && code && questionType !== 'optimize' && <span style={{ marginLeft: '4px', color: A.success, fontSize: '11px' }}>✓</span>}
|
|
63
121
|
</label>
|
|
64
122
|
);
|
|
65
123
|
|
|
@@ -69,14 +127,11 @@ export const ChatInput: React.FC<ChatInputProps> = ({
|
|
|
69
127
|
onChange={(e) => onUserThinkingChange(e.target.value)}
|
|
70
128
|
placeholder={isFirstConversation ? i18n('ai_helper_student_placeholder_first') : i18n('ai_helper_student_placeholder_followup')}
|
|
71
129
|
style={{
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
flex: 1, minHeight, maxHeight,
|
|
76
|
-
resize: 'none' as const, boxSizing: 'border-box' as const,
|
|
130
|
+
width: '100%', border: 'none', outline: 'none', boxShadow: 'none',
|
|
131
|
+
background: 'transparent', color: A.textPrimary,
|
|
132
|
+
fontSize: '12.5px', lineHeight: 1.6, fontFamily: 'inherit',
|
|
133
|
+
flex: 1, minHeight, maxHeight, resize: 'none', boxSizing: 'border-box', padding: 0,
|
|
77
134
|
}}
|
|
78
|
-
onFocus={() => {}}
|
|
79
|
-
onBlur={() => {}}
|
|
80
135
|
onKeyDown={(e) => {
|
|
81
136
|
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); onSubmit(); }
|
|
82
137
|
}}
|
|
@@ -85,137 +140,84 @@ export const ChatInput: React.FC<ChatInputProps> = ({
|
|
|
85
140
|
|
|
86
141
|
const disabledSubmit = isLoading || !canSubmit;
|
|
87
142
|
const submitStyle: React.CSSProperties = {
|
|
88
|
-
width: '
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
border: 'none',
|
|
94
|
-
display: 'flex',
|
|
95
|
-
alignItems: 'center',
|
|
96
|
-
justifyContent: 'center',
|
|
97
|
-
cursor: disabledSubmit ? 'not-allowed' : 'pointer',
|
|
98
|
-
transition: 'all 150ms ease',
|
|
143
|
+
width: '32px', height: '32px', borderRadius: '50%',
|
|
144
|
+
background: disabledSubmit ? A.disabledBg : A.gradient,
|
|
145
|
+
color: disabledSubmit ? A.disabledText : '#ffffff',
|
|
146
|
+
border: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
147
|
+
cursor: disabledSubmit ? 'not-allowed' : 'pointer', transition: 'all 150ms ease',
|
|
99
148
|
boxShadow: disabledSubmit ? 'none' : '0 2px 6px rgba(37, 99, 235, 0.3)',
|
|
100
|
-
flexShrink: 0,
|
|
101
|
-
|
|
102
|
-
|
|
149
|
+
flexShrink: 0, padding: 0,
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const pillButton: React.CSSProperties = {
|
|
153
|
+
display: 'flex', alignItems: 'center', gap: '5px', fontSize: '11.5px',
|
|
154
|
+
color: A.textSecondary, padding: '5px 11px', border: `1px solid ${A.cardBorder}`,
|
|
155
|
+
borderRadius: '999px', background: 'transparent', cursor: 'pointer', fontFamily: 'inherit',
|
|
103
156
|
};
|
|
104
157
|
|
|
105
158
|
return (
|
|
106
|
-
<div style={{ background:
|
|
159
|
+
<div style={{ background: '#fff', flexShrink: 0 }}>
|
|
160
|
+
<style>{`.ai-type-card:hover{border-color:#c5d4f5 !important}.ai-input-card:focus-within{border-color:#2563eb !important;box-shadow:0 0 0 3px rgba(37,99,235,.18)}`}</style>
|
|
107
161
|
{errorBanner}
|
|
108
162
|
|
|
109
|
-
{/*
|
|
163
|
+
{/* 问题类型卡片 — 首次对话 */}
|
|
110
164
|
{isFirstConversation && (
|
|
111
|
-
<div style={{ padding:
|
|
112
|
-
<div style={{ fontSize: '12px', color:
|
|
113
|
-
<div
|
|
114
|
-
{questionTypes.map(
|
|
115
|
-
const isSelected = questionType === type.value;
|
|
116
|
-
return (
|
|
117
|
-
<label
|
|
118
|
-
key={type.value}
|
|
119
|
-
style={{
|
|
120
|
-
...getPillStyle(isSelected),
|
|
121
|
-
userSelect: 'none' as const,
|
|
122
|
-
}}
|
|
123
|
-
>
|
|
124
|
-
<input
|
|
125
|
-
type="radio" name="questionType" value={type.value}
|
|
126
|
-
checked={isSelected} onChange={(e) => onQuestionTypeChange(e.target.value)}
|
|
127
|
-
style={{ display: 'none' }}
|
|
128
|
-
/>
|
|
129
|
-
{i18n(type.label)}
|
|
130
|
-
</label>
|
|
131
|
-
);
|
|
132
|
-
})}
|
|
165
|
+
<div style={{ padding: '12px 16px 0' }}>
|
|
166
|
+
<div style={{ fontSize: '12px', color: A.textFaint, marginBottom: '8px' }}>{i18n('ai_helper_student_select_type')}</div>
|
|
167
|
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
|
|
168
|
+
{questionTypes.map(renderTypeCard)}
|
|
133
169
|
</div>
|
|
134
|
-
{questionType && (
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
{i18n(descKey)}
|
|
140
|
-
{questionType === 'debug' && i18n('ai_helper_student_debug_auto_attach')}
|
|
141
|
-
</div>
|
|
142
|
-
) : null;
|
|
143
|
-
})()}
|
|
170
|
+
{questionType === 'debug' && (
|
|
171
|
+
<div style={{ fontSize: '11.5px', color: A.textMuted, marginTop: '8px' }}>
|
|
172
|
+
{i18n('ai_helper_student_debug_auto_attach')}
|
|
173
|
+
</div>
|
|
174
|
+
)}
|
|
144
175
|
</div>
|
|
145
176
|
)}
|
|
146
177
|
|
|
147
|
-
{/*
|
|
178
|
+
{/* 追问操作 — 后续对话 */}
|
|
148
179
|
{isFollowUp && (
|
|
149
|
-
<div style={{ display: 'flex', gap:
|
|
150
|
-
<button
|
|
151
|
-
|
|
152
|
-
style={{
|
|
153
|
-
...getButtonStyle('secondary'),
|
|
154
|
-
fontSize: '12px', padding: `${SPACING.xs} ${SPACING.md}`,
|
|
155
|
-
display: 'flex', alignItems: 'center', gap: SPACING.xs,
|
|
156
|
-
}}
|
|
157
|
-
>
|
|
158
|
-
📎 {includeCode ? i18n('ai_helper_student_code_attached') : i18n('ai_helper_student_attach_code')}
|
|
180
|
+
<div style={{ display: 'flex', gap: '8px', padding: '10px 16px 0' }}>
|
|
181
|
+
<button type="button" onClick={onRefreshCode} style={pillButton}>
|
|
182
|
+
<AttachIcon size={12} /> {includeCode ? i18n('ai_helper_student_code_attached') : i18n('ai_helper_student_attach_code')}
|
|
159
183
|
</button>
|
|
160
|
-
<button
|
|
161
|
-
|
|
162
|
-
style={{
|
|
163
|
-
...getButtonStyle('ghost'),
|
|
164
|
-
fontSize: '12px', padding: `${SPACING.xs} ${SPACING.md}`,
|
|
165
|
-
border: `1px solid ${COLORS.border}`,
|
|
166
|
-
}}
|
|
167
|
-
>
|
|
168
|
-
🔄 {i18n('ai_helper_student_new_conversation')}
|
|
184
|
+
<button type="button" onClick={onNewConversation} style={pillButton}>
|
|
185
|
+
<RefreshIcon size={12} /> {i18n('ai_helper_student_new_conversation')}
|
|
169
186
|
</button>
|
|
170
187
|
</div>
|
|
171
188
|
)}
|
|
172
189
|
|
|
190
|
+
{/* 已附带代码预览 */}
|
|
173
191
|
{isFollowUp && includeCode && code && (
|
|
174
|
-
<div style={{
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
<span style={{ color: COLORS.textSecondary }}>📝 {i18n('ai_helper_student_code_attached')} ({code.length} {i18n('ai_helper_student_chars')})</span>
|
|
180
|
-
<button
|
|
181
|
-
type="button" onClick={onCodeClear}
|
|
182
|
-
style={{ background: 'none', border: 'none', color: COLORS.error, cursor: 'pointer', fontSize: '11px', padding: '2px 4px' }}
|
|
183
|
-
>
|
|
184
|
-
✕ {i18n('ai_helper_student_remove')}
|
|
192
|
+
<div style={{ background: A.inputBg, border: `1px solid ${A.cardBorder}`, borderRadius: '10px', padding: '8px', margin: '8px 16px 0', fontSize: '11px' }}>
|
|
193
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '4px' }}>
|
|
194
|
+
<span style={{ color: A.textMuted }}>📝 {i18n('ai_helper_student_code_attached')} ({code.length} {i18n('ai_helper_student_chars')})</span>
|
|
195
|
+
<button type="button" onClick={onCodeClear} style={{ display: 'flex', alignItems: 'center', gap: '3px', background: 'none', border: 'none', color: A.error, cursor: 'pointer', fontSize: '11px', padding: '2px 4px', fontFamily: 'inherit' }}>
|
|
196
|
+
<RemoveIcon size={11} /> {i18n('ai_helper_student_remove')}
|
|
185
197
|
</button>
|
|
186
198
|
</div>
|
|
187
|
-
<pre style={{
|
|
188
|
-
margin: 0, fontFamily: 'Consolas, Monaco, "Courier New", monospace',
|
|
189
|
-
whiteSpace: 'pre-wrap', wordBreak: 'break-all', color: COLORS.textPrimary, maxHeight: '60px', overflow: 'auto',
|
|
190
|
-
}}>
|
|
199
|
+
<pre style={{ margin: 0, fontFamily: 'Consolas, Monaco, "Courier New", monospace', whiteSpace: 'pre-wrap', wordBreak: 'break-all', color: A.textPrimary, maxHeight: '60px', overflow: 'auto' }}>
|
|
191
200
|
{code.length > 200 ? code.substring(0, 200) + '...' : code}
|
|
192
201
|
</pre>
|
|
193
202
|
</div>
|
|
194
203
|
)}
|
|
195
204
|
|
|
196
|
-
{/*
|
|
197
|
-
<div className="
|
|
198
|
-
margin: '12px', padding: '
|
|
199
|
-
border: `1px solid ${
|
|
200
|
-
|
|
205
|
+
{/* 统一输入卡片 */}
|
|
206
|
+
<div className="ai-input-card" style={{
|
|
207
|
+
margin: '12px 16px', padding: '11px 12px', borderRadius: '16px',
|
|
208
|
+
border: `1px solid ${A.cardBorder}`, background: A.inputBg,
|
|
209
|
+
transition: 'all 200ms ease',
|
|
201
210
|
}}>
|
|
202
|
-
{renderTextarea(isFirstConversation ? '
|
|
203
|
-
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '
|
|
204
|
-
{isFirstConversation ? renderIncludeCodeCheckbox(
|
|
211
|
+
{renderTextarea(isFirstConversation ? '40px' : '24px', '120px')}
|
|
212
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '10px' }}>
|
|
213
|
+
{isFirstConversation ? renderIncludeCodeCheckbox(i18n('ai_helper_student_attach_current_code')) : <div />}
|
|
205
214
|
{isLoading ? (
|
|
206
|
-
<button
|
|
207
|
-
onClick={onCancel}
|
|
208
|
-
style={{ ...getButtonStyle('danger'), whiteSpace: 'nowrap', borderRadius: RADIUS.full, padding: '6px 16px', fontSize: '13px' }}
|
|
209
|
-
>
|
|
215
|
+
<button onClick={onCancel} style={{ background: A.error, color: '#fff', border: 'none', borderRadius: '999px', padding: '6px 16px', fontSize: '13px', cursor: 'pointer', whiteSpace: 'nowrap', fontFamily: 'inherit' }}>
|
|
210
216
|
{i18n('ai_helper_student_cancel')}
|
|
211
217
|
</button>
|
|
212
218
|
) : (
|
|
213
|
-
<button
|
|
214
|
-
|
|
215
|
-
style={submitStyle}
|
|
216
|
-
title={i18n('ai_helper_student_send_shortcut')}
|
|
217
|
-
>
|
|
218
|
-
➤
|
|
219
|
+
<button onClick={onSubmit} disabled={disabledSubmit} style={submitStyle} title={i18n('ai_helper_student_send_shortcut')}>
|
|
220
|
+
<SendIcon size={15} />
|
|
219
221
|
</button>
|
|
220
222
|
)}
|
|
221
223
|
</div>
|
|
@@ -1,10 +1,27 @@
|
|
|
1
|
-
import React from 'react';
|
|
1
|
+
import React, { useMemo } from 'react';
|
|
2
2
|
import { i18n } from '@hydrooj/ui-default';
|
|
3
3
|
import { renderMarkdown as renderMarkdownSafe, renderStreamingMarkdown } from '../utils/markdown';
|
|
4
|
-
import {
|
|
4
|
+
import { ZINDEX } from '../utils/styles';
|
|
5
5
|
import { ThinkingBlock } from './ThinkingBlock';
|
|
6
|
+
import { AIMark } from './icons';
|
|
6
7
|
import type { Message, ProblemInfo } from './types';
|
|
7
8
|
|
|
9
|
+
// ── 方案 A · 克制蓝 调色(局部常量,避免改动共享的 styles.ts)──────────────
|
|
10
|
+
const A = {
|
|
11
|
+
border: '#eef1f5',
|
|
12
|
+
problemBg: '#f7f9fc',
|
|
13
|
+
pillBg: '#e7eeff',
|
|
14
|
+
pillText: '#2563eb',
|
|
15
|
+
textPrimary: '#1e2536',
|
|
16
|
+
textSecondary: '#64748b',
|
|
17
|
+
textMuted: '#94a3b8',
|
|
18
|
+
textFaint: '#aab4c2',
|
|
19
|
+
aiBubbleBg: '#f4f8ff',
|
|
20
|
+
aiBubbleBorder: '#e2ecfb',
|
|
21
|
+
userBubbleBg: '#2563eb',
|
|
22
|
+
mono: "'JetBrains Mono', ui-monospace, 'SFMono-Regular', monospace",
|
|
23
|
+
};
|
|
24
|
+
|
|
8
25
|
interface ParsedContent {
|
|
9
26
|
content: string;
|
|
10
27
|
isThinkingStreaming: boolean;
|
|
@@ -16,12 +33,10 @@ function parseMessageContent(text: string): ParsedContent {
|
|
|
16
33
|
|
|
17
34
|
const thinkEnd = text.indexOf('</think>');
|
|
18
35
|
if (thinkEnd === -1) {
|
|
19
|
-
// Think tag opened but not closed — still streaming thinking
|
|
20
36
|
const content = text.substring(0, thinkStart);
|
|
21
37
|
return { content, isThinkingStreaming: true };
|
|
22
38
|
}
|
|
23
39
|
|
|
24
|
-
// Strip the entire <think>...</think> block (content is just a placeholder)
|
|
25
40
|
const content = text.substring(0, thinkStart) + text.substring(thinkEnd + 8);
|
|
26
41
|
return { content: content.trim(), isThinkingStreaming: false };
|
|
27
42
|
}
|
|
@@ -49,7 +64,7 @@ const renderMarkdown = (text: string, streaming?: boolean) => {
|
|
|
49
64
|
<div
|
|
50
65
|
className="markdown-body"
|
|
51
66
|
dangerouslySetInnerHTML={{ __html: html }}
|
|
52
|
-
style={{ fontSize: '13px', lineHeight: '1.
|
|
67
|
+
style={{ fontSize: '13px', lineHeight: '1.7' }}
|
|
53
68
|
/>
|
|
54
69
|
);
|
|
55
70
|
};
|
|
@@ -63,11 +78,11 @@ export const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
|
|
63
78
|
const renderProblemInfoCard = () => {
|
|
64
79
|
if (problemInfo) {
|
|
65
80
|
return (
|
|
66
|
-
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '
|
|
67
|
-
<span style={{ fontSize: '11px', fontWeight:
|
|
81
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '9px 16px', background: A.problemBg, borderBottom: `1px solid ${A.border}` }}>
|
|
82
|
+
<span style={{ fontSize: '11px', fontWeight: 700, padding: '2px 7px', background: A.pillBg, color: A.pillText, borderRadius: '5px', fontFamily: A.mono, flexShrink: 0 }}>
|
|
68
83
|
{problemInfo.problemId}
|
|
69
84
|
</span>
|
|
70
|
-
<span style={{ fontSize: '
|
|
85
|
+
<span style={{ fontSize: '12.5px', color: A.textSecondary, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 }}>
|
|
71
86
|
{problemInfo.title}
|
|
72
87
|
</span>
|
|
73
88
|
</div>
|
|
@@ -75,19 +90,14 @@ export const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
|
|
75
90
|
}
|
|
76
91
|
if (problemInfoError) {
|
|
77
92
|
return (
|
|
78
|
-
<div style={{
|
|
79
|
-
|
|
80
|
-
}}>
|
|
81
|
-
<span style={{ fontSize: '12px', color: COLORS.warningText, whiteSpace: 'nowrap' }}>⚠️ {i18n('ai_helper_student_cannot_get_problem')}</span>
|
|
93
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '9px 16px', background: '#fffbeb', borderBottom: '1px solid #fde68a' }}>
|
|
94
|
+
<span style={{ fontSize: '12px', color: '#92400e', whiteSpace: 'nowrap' }}>⚠ {i18n('ai_helper_student_cannot_get_problem')}</span>
|
|
82
95
|
<input
|
|
83
96
|
type="text"
|
|
84
97
|
placeholder={i18n('ai_helper_student_manual_title_placeholder')}
|
|
85
98
|
value={manualTitle}
|
|
86
99
|
onChange={(e) => onManualTitleChange(e.target.value)}
|
|
87
|
-
style={{
|
|
88
|
-
flex: 1, padding: SPACING.xs, border: `1px solid ${COLORS.warningBorder}`,
|
|
89
|
-
borderRadius: RADIUS.sm, fontSize: '12px', boxSizing: 'border-box'
|
|
90
|
-
}}
|
|
100
|
+
style={{ flex: 1, padding: '4px 8px', border: '1px solid #fde68a', borderRadius: '6px', fontSize: '12px', boxSizing: 'border-box' }}
|
|
91
101
|
/>
|
|
92
102
|
</div>
|
|
93
103
|
);
|
|
@@ -98,16 +108,16 @@ export const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
|
|
98
108
|
const renderEmptyState = () => (
|
|
99
109
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', flex: 1, padding: '40px 24px', textAlign: 'center' }}>
|
|
100
110
|
<div style={{
|
|
101
|
-
width: '
|
|
102
|
-
background: 'linear-gradient(135deg, #2563eb, #
|
|
111
|
+
width: '64px', height: '64px', borderRadius: '18px',
|
|
112
|
+
background: 'linear-gradient(135deg, #2563eb, #5b8def)',
|
|
103
113
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
104
|
-
fontSize: '
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}}
|
|
108
|
-
<div style={{ fontSize: '
|
|
109
|
-
<div style={{ fontSize: '
|
|
110
|
-
{i18n('ai_helper_student_welcome_desc_line1')}<br/>{i18n('ai_helper_student_welcome_desc_line2')}
|
|
114
|
+
fontFamily: A.mono, fontSize: '24px', fontWeight: 700, color: '#fff',
|
|
115
|
+
letterSpacing: '-1px', marginBottom: '18px',
|
|
116
|
+
boxShadow: '0 8px 20px rgba(37, 99, 235, 0.25)',
|
|
117
|
+
}}>AI</div>
|
|
118
|
+
<div style={{ fontSize: '17px', fontWeight: 700, color: A.textPrimary, marginBottom: '6px' }}>{i18n('ai_helper_student_welcome_title')}</div>
|
|
119
|
+
<div style={{ fontSize: '12.5px', color: A.textMuted, lineHeight: '1.7' }}>
|
|
120
|
+
{i18n('ai_helper_student_welcome_desc_line1')}<br />{i18n('ai_helper_student_welcome_desc_line2')}
|
|
111
121
|
</div>
|
|
112
122
|
</div>
|
|
113
123
|
);
|
|
@@ -116,20 +126,18 @@ export const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
|
|
116
126
|
const parsed = msg.role === 'ai' ? parseMessageContent(msg.content) : null;
|
|
117
127
|
const isStudent = msg.role === 'student';
|
|
118
128
|
return (
|
|
119
|
-
<div key={idx} style={{ display: 'flex', flexDirection: isStudent ? 'row-reverse' : 'row', gap:
|
|
120
|
-
{/* Avatar
|
|
121
|
-
|
|
122
|
-
width: '28px', height: '28px', borderRadius:
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
</div>
|
|
130
|
-
<div style={{ maxWidth: '80%', display: 'flex', flexDirection: 'column', gap: '2px' }}>
|
|
129
|
+
<div key={idx} style={{ display: 'flex', flexDirection: isStudent ? 'row-reverse' : 'row', gap: '8px', alignItems: 'flex-start' }}>
|
|
130
|
+
{/* Avatar */}
|
|
131
|
+
{isStudent ? (
|
|
132
|
+
<div style={{ width: '28px', height: '28px', borderRadius: '50%', flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '12px', fontWeight: 600, background: A.userBubbleBg, color: '#fff' }}>
|
|
133
|
+
{i18n('ai_helper_student_me')}
|
|
134
|
+
</div>
|
|
135
|
+
) : (
|
|
136
|
+
<AIMark size={28} radius={8} fontSize={10} />
|
|
137
|
+
)}
|
|
138
|
+
<div style={{ maxWidth: '82%', display: 'flex', flexDirection: 'column', gap: '3px' }}>
|
|
131
139
|
{/* Speaker label */}
|
|
132
|
-
<div style={{ fontSize: '
|
|
140
|
+
<div style={{ fontSize: '10.5px', color: A.textFaint, textAlign: isStudent ? 'right' : 'left' }}>
|
|
133
141
|
{isStudent ? i18n('ai_helper_student_me') : i18n('ai_helper_student_ai_assistant')}
|
|
134
142
|
</div>
|
|
135
143
|
{/* Bubble */}
|
|
@@ -137,13 +145,12 @@ export const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
|
|
137
145
|
data-ai-message={msg.role === 'ai' ? 'true' : undefined}
|
|
138
146
|
data-message-id={msg.role === 'ai' ? msg.id : undefined}
|
|
139
147
|
style={{
|
|
140
|
-
padding:
|
|
141
|
-
borderRadius: isStudent ? '
|
|
142
|
-
background: isStudent ?
|
|
143
|
-
color: isStudent ? '#ffffff' :
|
|
144
|
-
fontSize: '13px', lineHeight: '1.
|
|
145
|
-
border: isStudent ? 'none' :
|
|
146
|
-
boxShadow: SHADOWS.sm,
|
|
148
|
+
padding: '10px 13px',
|
|
149
|
+
borderRadius: isStudent ? '14px 14px 4px 14px' : '14px 14px 14px 4px',
|
|
150
|
+
background: isStudent ? A.userBubbleBg : A.aiBubbleBg,
|
|
151
|
+
color: isStudent ? '#ffffff' : A.textPrimary,
|
|
152
|
+
fontSize: '13px', lineHeight: '1.7',
|
|
153
|
+
border: isStudent ? 'none' : `1px solid ${A.aiBubbleBorder}`,
|
|
147
154
|
}}
|
|
148
155
|
>
|
|
149
156
|
{msg.role === 'ai' && parsed ? (
|
|
@@ -152,18 +159,12 @@ export const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
|
|
152
159
|
<div style={{ whiteSpace: 'pre-wrap' }}>{msg.content}</div>
|
|
153
160
|
)}
|
|
154
161
|
</div>
|
|
155
|
-
{/* Attached code
|
|
162
|
+
{/* Attached code (student) */}
|
|
156
163
|
{isStudent && msg.code && (
|
|
157
|
-
<div style={{
|
|
158
|
-
|
|
159
|
-
borderRadius: RADIUS.md, padding: SPACING.sm, fontSize: '12px',
|
|
160
|
-
maxWidth: '100%', overflow: 'hidden',
|
|
161
|
-
}}>
|
|
162
|
-
<div style={{ fontSize: '11px', color: COLORS.textMuted, marginBottom: SPACING.xs, display: 'flex', alignItems: 'center', gap: SPACING.xs }}>
|
|
163
|
-
📝 {i18n('ai_helper_student_attached_code')}
|
|
164
|
-
</div>
|
|
164
|
+
<div style={{ background: '#f8fafc', border: `1px solid ${A.border}`, borderRadius: '10px', padding: '8px', fontSize: '12px', maxWidth: '100%', overflow: 'hidden' }}>
|
|
165
|
+
<div style={{ fontSize: '11px', color: A.textMuted, marginBottom: '4px' }}>📝 {i18n('ai_helper_student_attached_code')}</div>
|
|
165
166
|
<div className="markdown-body" dangerouslySetInnerHTML={{
|
|
166
|
-
__html: renderMarkdownSafe(`\`\`\`\n${msg.code.length > 500 ? msg.code.substring(0, 500) + `\n// ... ${i18n('ai_helper_student_code_truncated')}` : msg.code}\n\`\`\``)
|
|
167
|
+
__html: renderMarkdownSafe(`\`\`\`\n${msg.code.length > 500 ? msg.code.substring(0, 500) + `\n// ... ${i18n('ai_helper_student_code_truncated')}` : msg.code}\n\`\`\``),
|
|
167
168
|
}} />
|
|
168
169
|
</div>
|
|
169
170
|
)}
|
|
@@ -172,36 +173,55 @@ export const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
|
|
172
173
|
);
|
|
173
174
|
};
|
|
174
175
|
|
|
176
|
+
// Memoize message nodes on `messages`. Selecting text only changes popup
|
|
177
|
+
// state, so without this the whole list re-renders on every selection — and
|
|
178
|
+
// in the live runtime that re-render coincided with the AI bubble's rendered
|
|
179
|
+
// DOM being replaced (observed via MutationObserver), which detached the
|
|
180
|
+
// user's selection and collapsed the highlight. Stable element references
|
|
181
|
+
// make React bail out of these subtrees, so the selection's DOM (and the
|
|
182
|
+
// native highlight) survive; it also avoids re-rendering completed messages
|
|
183
|
+
// on every keystroke. (renderMarkdown itself is deterministic — verified
|
|
184
|
+
// end-to-end — so this is not about unstable HTML output.)
|
|
185
|
+
const messageNodes = useMemo(
|
|
186
|
+
() => messages.map((msg, idx) => renderMessage(msg, idx)),
|
|
187
|
+
[messages],
|
|
188
|
+
);
|
|
189
|
+
|
|
175
190
|
return (
|
|
176
191
|
<div
|
|
177
192
|
ref={chatContainerRef}
|
|
178
193
|
onMouseUp={onTextSelection}
|
|
179
|
-
style={{ flex: 1, overflowY: 'auto', padding:
|
|
194
|
+
style={{ flex: 1, overflowY: 'auto', padding: '18px 16px', display: 'flex', flexDirection: 'column', gap: '16px' }}
|
|
180
195
|
>
|
|
181
|
-
{/*
|
|
182
|
-
{
|
|
196
|
+
{/* dot-pulse keyframes for streaming/loading */}
|
|
197
|
+
<style>{`@keyframes dotpulse{0%,80%,100%{opacity:.25;transform:translateY(0)}40%{opacity:1;transform:translateY(-3px)}}`}</style>
|
|
198
|
+
|
|
199
|
+
{/* Problem info breadcrumb (full-bleed: negate the body padding) */}
|
|
200
|
+
<div style={{ margin: '-18px -16px 0' }}>{renderProblemInfoCard()}</div>
|
|
183
201
|
|
|
184
202
|
{/* Empty state */}
|
|
185
203
|
{messages.length === 0 && !isStreaming && !isLoading && renderEmptyState()}
|
|
186
204
|
|
|
187
|
-
{/* Messages */}
|
|
188
|
-
{
|
|
205
|
+
{/* Messages (memoized — see messageNodes above) */}
|
|
206
|
+
{messageNodes}
|
|
189
207
|
|
|
190
208
|
{/* Streaming output */}
|
|
191
209
|
{isStreaming && streamingContent && (() => {
|
|
192
210
|
const parsed = parseMessageContent(streamingContent);
|
|
193
211
|
return (
|
|
194
|
-
<div style={{ display: 'flex', flexDirection: 'row', gap:
|
|
212
|
+
<div style={{ display: 'flex', flexDirection: 'row', gap: '8px', alignItems: 'flex-start' }}>
|
|
213
|
+
<AIMark size={28} radius={8} fontSize={10} />
|
|
195
214
|
<div style={{
|
|
196
|
-
maxWidth: '
|
|
197
|
-
background:
|
|
215
|
+
maxWidth: '82%', padding: '11px 14px', borderRadius: '14px 14px 14px 4px',
|
|
216
|
+
background: A.aiBubbleBg, border: `1px solid ${A.aiBubbleBorder}`,
|
|
217
|
+
color: A.textPrimary, fontSize: '13px', lineHeight: '1.7',
|
|
198
218
|
}}>
|
|
199
219
|
<ThinkingBlock isStreaming={parsed.isThinkingStreaming} />
|
|
200
220
|
{(!parsed.isThinkingStreaming && parsed.content) && renderMarkdown(parsed.content, true)}
|
|
201
221
|
{!parsed.isThinkingStreaming && (
|
|
202
222
|
<span style={{
|
|
203
|
-
display: 'inline-block', width: '6px', height: '14px', background:
|
|
204
|
-
marginLeft: '2px', animation: 'blink 1s step-end infinite', verticalAlign: 'text-bottom'
|
|
223
|
+
display: 'inline-block', width: '6px', height: '14px', background: A.pillText,
|
|
224
|
+
marginLeft: '2px', animation: 'blink 1s step-end infinite', verticalAlign: 'text-bottom',
|
|
205
225
|
}} />
|
|
206
226
|
)}
|
|
207
227
|
</div>
|
|
@@ -209,12 +229,17 @@ export const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
|
|
209
229
|
);
|
|
210
230
|
})()}
|
|
211
231
|
|
|
212
|
-
{/* Loading
|
|
232
|
+
{/* Loading (pre-stream) */}
|
|
213
233
|
{isLoading && !isStreaming && (
|
|
214
|
-
<div style={{ display: 'flex', gap:
|
|
215
|
-
<
|
|
216
|
-
|
|
217
|
-
<span
|
|
234
|
+
<div style={{ display: 'flex', gap: '8px', alignItems: 'flex-start' }}>
|
|
235
|
+
<AIMark size={28} radius={8} fontSize={10} />
|
|
236
|
+
<div style={{ padding: '12px 14px', borderRadius: '14px 14px 14px 4px', background: A.aiBubbleBg, border: `1px solid ${A.aiBubbleBorder}`, color: A.textSecondary, fontSize: '13px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
237
|
+
<span>{i18n('ai_helper_student_loading')}</span>
|
|
238
|
+
<span style={{ display: 'inline-flex', gap: '3px' }}>
|
|
239
|
+
<span style={{ width: '5px', height: '5px', borderRadius: '50%', background: A.pillText, animation: 'dotpulse 1.2s infinite' }} />
|
|
240
|
+
<span style={{ width: '5px', height: '5px', borderRadius: '50%', background: A.pillText, animation: 'dotpulse 1.2s infinite 0.2s' }} />
|
|
241
|
+
<span style={{ width: '5px', height: '5px', borderRadius: '50%', background: A.pillText, animation: 'dotpulse 1.2s infinite 0.4s' }} />
|
|
242
|
+
</span>
|
|
218
243
|
</div>
|
|
219
244
|
</div>
|
|
220
245
|
)}
|
|
@@ -225,9 +250,9 @@ export const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
|
|
225
250
|
style={{
|
|
226
251
|
position: 'fixed', left: popupPosition.x, top: popupPosition.y,
|
|
227
252
|
transform: 'translateX(-50%)', zIndex: ZINDEX.dropdown,
|
|
228
|
-
background:
|
|
229
|
-
borderRadius:
|
|
230
|
-
boxShadow:
|
|
253
|
+
background: A.textPrimary, color: '#ffffff', padding: '6px 12px',
|
|
254
|
+
borderRadius: '8px', fontSize: '12px', cursor: 'pointer',
|
|
255
|
+
boxShadow: '0 4px 12px rgba(0,0,0,.18)', whiteSpace: 'nowrap',
|
|
231
256
|
}}
|
|
232
257
|
onMouseDown={(e) => e.preventDefault()}
|
|
233
258
|
onClick={onDontUnderstand}
|