Merge Yuxi teacher support with classroom discussions
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled

This commit is contained in:
鲨鱼辣椒
2026-09-24 10:27:36 +08:00
34 changed files with 2333 additions and 105 deletions

View File

@@ -4,6 +4,7 @@ import type {
TeacherAvailability,
TeacherCheckInInput,
TeacherCheckInResult,
TeacherCatalog,
TeacherDefinition,
TeacherDiscussionAction,
TeacherSend,
@@ -17,6 +18,7 @@ export function teacherTopicsPath(projectId: string, sourceId: string, role?: Co
: `/api/coding/projects/${encodeURIComponent(projectId)}/conversations/${encodeURIComponent(sourceId)}/teacher-topics`;
}
export const teacherApi = {
catalog: () => hostApiFetch<TeacherCatalog>('/api/coding/teacher/teachers'),
checkIn: (projectId: string, input: TeacherCheckInInput) =>
hostApiFetch<TeacherCheckInResult>(`/api/coding/projects/${encodeURIComponent(projectId)}/teacher-check-in`, {
method: 'POST',
@@ -31,10 +33,10 @@ export const teacherApi = {
'/api/coding/teacher-preview?draftRevision=' + draftRevision
),
list: (base: string) => hostApiFetch<TeacherTopicList>(base),
create: (base: string, draftRevision?: number, sampleContext?: string) =>
create: (base: string, draftRevision?: number, sampleContext?: string, teacherVersion?: number) =>
hostApiFetch<TeacherTopic>(base, {
method: 'POST',
body: JSON.stringify({ draftRevision, sampleContext }),
body: JSON.stringify({ draftRevision, sampleContext, teacherVersion }),
}),
read: (base: string, id: string, select = true) =>
hostApiFetch<TeacherTopic>(base + '/' + encodeURIComponent(id) + (select ? '' : '?select=false')),

View File

@@ -8,6 +8,7 @@ import { teacherApi, teacherTopicsPath } from '@/lib/coding-teacher';
import type {
ConsultationRole,
TeacherDefinition,
TeacherCatalog,
TeacherReference,
TeacherSend,
TeacherTopic,
@@ -78,6 +79,13 @@ export function TeacherChatPanel({
const [error, setError] = useState('');
const [busy, setBusy] = useState(true);
const [enabled, setEnabled] = useState(false);
const [legacyEnabled, setLegacyEnabled] = useState(false);
const [teachers, setTeachers] = useState<TeacherCatalog['items']>([]);
const [selectedTeacherVersion, setSelectedTeacherVersion] = useState<number>();
const topicEnabled = !topic ? enabled
: topic.definition.config_id
? teachers.some((item) => item.teacher_id === topic.definition.config_id)
: legacyEnabled;
const pending = useRef<TeacherSend | null>(saved.pending ?? null);
useEffect(() => {
persistDraft(text, references, pending.current);
@@ -135,6 +143,19 @@ export function TeacherChatPanel({
if (alive) {
setDefinition(config.definition);
setEnabled(config.enabled);
setLegacyEnabled(config.enabled);
}
if (!draftRevision) {
const catalog = await teacherApi.catalog();
if (alive) {
setTeachers(catalog.items);
const selected = catalog.items.find((item) => item.is_default) ?? catalog.items[0];
if (role !== 'friend') {
setSelectedTeacherVersion(selected?.version);
setEnabled(catalog.items.length > 0 || (config.enabled && !config.definition?.config_id));
if (selected) setDefinition(selected.definition);
}
}
}
} catch (e) {
if (alive) setError(e instanceof Error ? e.message : `${label}配置暂不可用`);
@@ -227,7 +248,12 @@ export function TeacherChatPanel({
setBusy(true);
setError('');
try {
const next = await teacherApi.create(base, draftRevision, sampleContext);
const next = await teacherApi.create(
base,
draftRevision,
sampleContext,
selectedTeacherVersion
);
if (version === generation.current) {
consume(next);
setDefinition(next.definition);
@@ -244,16 +270,22 @@ export function TeacherChatPanel({
const send = async (action?: Pick<TeacherSend, 'text' | 'intent' | 'discussion'>) => {
const question = action?.text ?? text;
const questionReferences = action ? [] : references;
if (!question.trim() || busy || !enabled || topicRef.current?.requests.some((request) => ['preparing', 'running'].includes(request.status))) return;
if (!question.trim() || busy || !topicEnabled || topicRef.current?.requests.some((request) => ['preparing', 'running'].includes(request.status))) return;
const version = generation.current;
setBusy(true);
setError('');
let current = topicRef.current;
try {
if (!current) {
current = await teacherApi.create(base, draftRevision, sampleContext);
current = await teacherApi.create(
base,
draftRevision,
sampleContext,
selectedTeacherVersion
);
if (version !== generation.current) return;
consume(current);
setDefinition(current.definition);
}
const presentation = studentTeacher && (!action?.intent || action.intent === 'question' || action.intent === 'guided-help') ? 'discussion-v1' as const : undefined;
const discussion = presentation && current.discussion?.status === 'active'
@@ -300,7 +332,7 @@ export function TeacherChatPanel({
const running = topic?.requests.find(
(request) => request.status === 'preparing' || request.status === 'running'
);
const helpUnavailable = busy || !enabled || Boolean(running);
const helpUnavailable = busy || !topicEnabled || Boolean(running);
const updateDiscussion = async (action: TeacherDiscussionAction['action'], itemId?: string) => {
const current = topicRef.current;
if (!current?.discussion || helpUnavailable) return;
@@ -341,10 +373,22 @@ export function TeacherChatPanel({
<header className="flex h-[68px] shrink-0 items-center gap-3 px-5">
<img className="h-9 w-9 rounded-xl [image-rendering:pixelated]" src={avatar} alt="" />
<div className="min-w-0 flex-1"><h2 className="truncate text-sm font-semibold">{definition?.name ?? (role === 'friend' ? '小麦' : '麦洛老师')}</h2>
<p className="mt-1 text-[11px] text-muted-foreground">{draftRevision ? '运营草稿试聊' : role === 'friend' ? '陪你体验,也听你说' : '一起想清楚,再动手'}</p></div>
<p className="mt-1 text-[11px] text-muted-foreground">{draftRevision ? '运营草稿试聊' : role === 'friend' ? '陪你体验,也听你说' : !role ? '结合当前会话和项目文件答疑' : '一起想清楚,再动手'}</p></div>
{onExpand && <button className="consult-icon" aria-label={expanded ? '恢复咨询栏宽度' : '展开咨询栏'} onClick={onExpand}><Maximize2 className="h-4 w-4" /></button>}
{onClose && <button className="consult-icon" aria-label={`关闭${label}`} onClick={onClose}><X className="h-4 w-4" /></button>}
</header>
{!draftRevision && role !== 'friend' && teachers.length > 1 && (
<label className="px-5 pb-2 text-[11px] text-muted-foreground">
新话题使用的老师
<select aria-label="新话题使用的老师" value={selectedTeacherVersion ?? ''} disabled={busy}
onChange={(event) => setSelectedTeacherVersion(Number(event.target.value))}
className="mt-1 w-full rounded-lg border bg-background p-2 text-xs text-foreground">
{teachers.map((item) => <option key={item.teacher_id} value={item.version}>
{item.definition.name}{item.is_default ? '(默认)' : ''}
</option>)}
</select>
</label>
)}
{(topics.items.length > 0 || draftRevision) && <div className="px-5 pt-2">
<button className="text-[11px] text-muted-foreground" onClick={() => setHistoryOpen(!historyOpen)} aria-expanded={historyOpen}>以往讨论 <ChevronDown className="inline h-3 w-3" /></button>
{(historyOpen || !role) && <div className="mt-2 flex gap-2">
@@ -392,6 +436,8 @@ export function TeacherChatPanel({
{request.suggestedQuestions?.map(question => <button type="button" key={question} disabled={helpUnavailable} onClick={() => void send({ text: question })} className="consultation-suggestion rounded-xl border px-3 py-2 text-left text-xs leading-6">{question}<ChevronRight className="ml-1 inline h-3 w-3" /></button>)}
</div>}
{request.discussionError && <p className="text-xs leading-6 text-muted-foreground">{request.discussionError}</p>}
{request.progress && ['running', 'preparing'].includes(request.status) && <p role="status" className="text-xs text-muted-foreground">{request.progress}</p>}
{(request.truncatedMessages ?? 0) > 0 && <p className="text-xs text-muted-foreground">{draftRevision ? '较长的上下文已节选。' : '较长的上下文已节选,老师可按需读取原文。'}</p>}
{request.omittedMessages > 0 && <p className="text-[10px] text-muted-foreground">本轮参考了较近的讨论,省略了 {request.omittedMessages} 条较早内容。</p>}
{request.error && <p className="text-xs text-destructive">{request.error}</p>}
{!role && onBringBack && request.response && request.intent !== 'suggestions' && request.status === 'completed' && <div className="text-[11px] text-muted-foreground">
@@ -401,7 +447,7 @@ export function TeacherChatPanel({
</div>
{error && <p role="alert" className="px-5 py-2 text-xs leading-5 text-destructive">{error}</p>}
{topic?.unsaved && <div className="px-5 py-2 text-xs text-destructive">回复尚未保存,请复制或重试保存。<button className="ml-2 underline" onClick={() => void teacherApi.save(base, topic.id).then(consume).catch(() => setError('保存失败,请先复制回复。'))}>重试保存</button></div>}
{!enabled && !error && !busy && <p className="px-5 pb-2 text-xs text-muted-foreground">{label}暂未开放,历史仍可查看。</p>}
{!topicEnabled && !error && !busy && <p className="px-5 pb-2 text-xs text-muted-foreground">{label}暂未开放,历史仍可查看。</p>}
<form className="px-4 pb-4 pt-2" onSubmit={(e) => { e.preventDefault(); void send(); }}>
{references.length > 0 && <div className="mb-2 rounded-lg bg-white p-3 text-xs"><p className="line-clamp-3 whitespace-pre-wrap">{references.map((ref) => ref.text).join('\n')}</p><button type="button" className="mt-1 underline" onClick={removeReferences}>移除引用</button></div>}
<div className="consultation-composer rounded-2xl border bg-white p-3 focus-within:ring-2 focus-within:ring-black/5">
@@ -413,7 +459,7 @@ export function TeacherChatPanel({
{studentTeacher ? <button type="button" disabled={helpUnavailable} onClick={askForSuggestions} className="consultation-help-prompt flex min-h-9 cursor-pointer items-center gap-1.5 rounded-full px-3 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-50">老师帮我看看<ChevronRight className="h-3 w-3" aria-hidden="true" /></button>
: <span className="text-[10px] text-muted-foreground">{role === 'friend' ? '聊感受,也聊你的新发现' : ''}</span>}
{running ? <button type="button" className="rounded-lg border px-3 py-2 text-xs" onClick={() => void teacherApi.cancel(base, topic!.id, running.id).then(consume).catch(() => setError('暂时无法停止,请稍后重试。'))}>停止回复</button>
: <button type="submit" aria-label={role === 'friend' ? '发送给朋友' : '提问'} title={busy ? '正在发送…' : '发送'} disabled={busy || !enabled || !text.trim()} className="consultation-send flex h-8 w-8 items-center justify-center rounded-full text-white disabled:opacity-30"><ArrowUp className="h-4 w-4" /></button>}
: <button type="submit" aria-label={role === 'friend' ? '发送给朋友' : '提问'} title={busy ? '正在发送…' : '发送'} disabled={busy || !topicEnabled || !text.trim()} className="consultation-send flex h-8 w-8 items-center justify-center rounded-full text-white disabled:opacity-30"><ArrowUp className="h-4 w-4" /></button>}
</div>
</div>
</form>