Merge Yuxi teacher support with classroom discussions
This commit is contained in:
@@ -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')),
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user